mirror of
https://github.com/papra-hq/papra
synced 2026-04-21 13:37:23 +00:00
Compare commits
104 commits
@papra/app
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7854f6b1a1 | ||
|
|
2b0c258f8a | ||
|
|
988383ea57 | ||
|
|
a574fb7206 | ||
|
|
6a8ade711b | ||
|
|
e05e6e86f0 | ||
|
|
fb8cc381cc | ||
|
|
4f25ecd9d2 | ||
|
|
ad3fabf564 | ||
|
|
123b7c1784 | ||
|
|
4fe1731323 | ||
|
|
ff2ae64abe | ||
|
|
791d08c486 | ||
|
|
612fcb7563 | ||
|
|
488997f580 | ||
|
|
9ad1c16b45 | ||
|
|
c5ccac53c2 | ||
|
|
5d55e41c3b | ||
|
|
401efe2fa4 | ||
|
|
366e4eead1 | ||
|
|
6c1e45acf1 | ||
|
|
b154d2f363 | ||
|
|
5f8e2f6375 | ||
|
|
9c6985b51f | ||
|
|
2fcbe0bc89 | ||
|
|
5b3a85795c | ||
|
|
133d235ccd | ||
|
|
5bdf0dab1f | ||
|
|
015bb53498 | ||
|
|
07d7109a46 | ||
|
|
f0b346760d | ||
|
|
8984179a76 | ||
|
|
1548535f1e | ||
|
|
ad5e42d445 | ||
|
|
cee14d96a5 | ||
|
|
4a10ace601 | ||
|
|
927c7d6b31 | ||
|
|
59a62cbf6a | ||
|
|
5b0d747d29 | ||
|
|
db988e990d | ||
|
|
ef89ea6751 | ||
|
|
da86b93439 | ||
|
|
e11fb5352a | ||
|
|
f713a9801a | ||
|
|
fe724fbb06 | ||
|
|
ac11aa50a4 | ||
|
|
f248b283dc | ||
|
|
b29396a197 | ||
|
|
c252a5945f | ||
|
|
7d22a31511 | ||
|
|
327eda0001 | ||
|
|
17b501a996 | ||
|
|
9ee776f1c5 | ||
|
|
884d470410 | ||
|
|
9039b4806e | ||
|
|
4b77e41242 | ||
|
|
00cfbf88df | ||
|
|
8fe222bb8f | ||
|
|
7d34241bbf | ||
|
|
f336b842c6 | ||
|
|
5555934847 | ||
|
|
2ca5634166 | ||
|
|
882fee6e73 | ||
|
|
31e0813429 | ||
|
|
1c1e904ba1 | ||
|
|
ce1e891ec1 | ||
|
|
30bd0778b3 | ||
|
|
595b5a9149 | ||
|
|
5900674083 | ||
|
|
74828e8ad6 | ||
|
|
812e7c317e | ||
|
|
b900a1d947 | ||
|
|
d47d6b29a6 | ||
|
|
87d80af2ac | ||
|
|
7c2b2d27cd | ||
|
|
db6badbc3c | ||
|
|
ec740ed168 | ||
|
|
725eaff4b0 | ||
|
|
27fe78501d | ||
|
|
c7089610ac | ||
|
|
31e27d5e1e | ||
|
|
3bde35bb07 | ||
|
|
a7b18cec6b | ||
|
|
e1314a42f2 | ||
|
|
305921e779 | ||
|
|
87a94ab567 | ||
|
|
f11c2b9064 | ||
|
|
69ab83c504 | ||
|
|
62e9e66638 | ||
|
|
69f8f0e13b | ||
|
|
c4645eae9c | ||
|
|
6be6beae90 | ||
|
|
41e9f33b06 | ||
|
|
01e4540927 | ||
|
|
077e4294fb | ||
|
|
f29ebc5de7 | ||
|
|
105353ff00 | ||
|
|
65c2bea4c3 | ||
|
|
1a0a900fd9 | ||
|
|
aca98084ca | ||
|
|
548608be39 | ||
|
|
21e5c313a6 | ||
|
|
a096360eeb | ||
|
|
2143728157 |
406 changed files with 17014 additions and 3497 deletions
5
.changeset/eight-papayas-exist.md
Normal file
5
.changeset/eight-papayas-exist.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Removed logging of a polluting empty error cause (with stack trace) when an error is thrown without a cause.
|
||||
5
.changeset/free-lemur-yummy.md
Normal file
5
.changeset/free-lemur-yummy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Synchronized the document pagination of the home page in query params to permit sharing and navigation.
|
||||
5
.changeset/happy-donkeys-prove.md
Normal file
5
.changeset/happy-donkeys-prove.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Added content preview for yaml files
|
||||
5
.changeset/hip-corners-rhyme.md
Normal file
5
.changeset/hip-corners-rhyme.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Removed weird shadows on ui components in light mode
|
||||
9
.changeset/new-dolls-wink.md
Normal file
9
.changeset/new-dolls-wink.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Added some size limits on the webhooks creation and update API endpoints parameters.
|
||||
|
||||
- Names are limited to 128 characters.
|
||||
- Secret keys are limited to 256 characters.
|
||||
- URLs are limited to 2048 characters.
|
||||
5
.changeset/pink-baths-worry.md
Normal file
5
.changeset/pink-baths-worry.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
When reopening the quick search modal with existing query, the input content is automatically selected to allow easy replacement or editing.
|
||||
5
.changeset/proud-things-rest.md
Normal file
5
.changeset/proud-things-rest.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Changed the server endpoint validation library from `zod` to `valibot`, and improved some validation schemas in the process.
|
||||
5
.changeset/reserved-jaguar-cry.md
Normal file
5
.changeset/reserved-jaguar-cry.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Removed native clear button of search bar in safari.
|
||||
5
.changeset/smooth-peaches-wash.md
Normal file
5
.changeset/smooth-peaches-wash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Updated pnpm to 10.33.0.
|
||||
5
.changeset/tiny-hairs-drive.md
Normal file
5
.changeset/tiny-hairs-drive.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Increased the sidebar collapsing breakpoint to improve the UX on tablets and small laptops.
|
||||
5
.changeset/versatile-sparrow-clean.md
Normal file
5
.changeset/versatile-sparrow-clean.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Removed useless close button in the small-screen sidebar sheet.
|
||||
5
.changeset/violet-geese-sit.md
Normal file
5
.changeset/violet-geese-sit.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Prevented the users and organizations tables from forcing horizontal scrolling in the admin panels.
|
||||
5
.changeset/whole-socks-drop.md
Normal file
5
.changeset/whole-socks-drop.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@papra/app": patch
|
||||
---
|
||||
|
||||
Prevented long documents name from pushing the right columns out of the container.
|
||||
|
|
@ -1 +0,0 @@
|
|||
packages/docker/.dockerignore
|
||||
45
.dockerignore
Normal file
45
.dockerignore
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
.git
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.*
|
||||
*.log
|
||||
*.local
|
||||
|
||||
.dockerignore
|
||||
.env
|
||||
**/.env
|
||||
Dockerfile
|
||||
fly.toml
|
||||
|
||||
.wrangler
|
||||
coverage
|
||||
cache
|
||||
.zed
|
||||
*.vars
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
dist-app
|
||||
dist-node
|
||||
dist-cloudflare
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
*.md
|
||||
|
||||
.eslintcache
|
||||
.claude
|
||||
.opencode
|
||||
AGENTS.md
|
||||
24
README.md
24
README.md
|
|
@ -39,7 +39,7 @@ A live demo of the platform is available at [demo.papra.app](https://demo.papra.
|
|||
|
||||
## Project Status
|
||||
|
||||
Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly.
|
||||
Papra is under active development, the core functionalities are stable and usable. With lots of features and improvements added regularly.
|
||||
|
||||
Feedback and bug reports are highly appreciated to help us improve the platform.
|
||||
|
||||
|
|
@ -61,16 +61,17 @@ Feedback and bug reports are highly appreciated to help us improve the platform.
|
|||
- **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.
|
||||
- _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
|
||||
## Support
|
||||
|
||||
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.
|
||||
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](https://papra.app/contact) to discuss a sponsorship.
|
||||
|
||||
## Self-hosting
|
||||
|
||||
|
|
@ -138,6 +139,7 @@ This project would not have been possible without the inspiration and work of ot
|
|||
|
||||
- **[Paperless-ngx](https://paperless-ngx.com/)**: A full-featured document management system.
|
||||
|
||||
## Contact Information
|
||||
## Sponsors
|
||||
|
||||
Please use the issue tracker on GitHub for any questions or feedback.
|
||||
Shout-out to our current sponsors:
|
||||

|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"dependencies": {
|
||||
"@astrojs/solid-js": "^5.1.0",
|
||||
"@astrojs/starlight": "^0.34.3",
|
||||
"@valibot/to-json-schema": "^1.6.0",
|
||||
"astro": "catalog:",
|
||||
"sharp": "^0.32.5",
|
||||
"shiki": "^3.4.2",
|
||||
|
|
@ -26,19 +27,16 @@
|
|||
"starlight-theme-rapide": "^0.5.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "catalog:",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
"valibot": "catalog:",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "catalog:",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@unocss/reset": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^3.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"figue": "catalog:",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
|
||||
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
|
||||
|
||||
import { configDefinition } from '../../papra-server/src/modules/config/config';
|
||||
import { renderMarkdown } from './markdown';
|
||||
|
|
@ -28,10 +27,14 @@ function formatDoc(doc: string | undefined): string {
|
|||
return `${coerced}.`;
|
||||
}
|
||||
|
||||
function getIsEmptyDefaultValue(defaultValue: unknown): boolean {
|
||||
return defaultValue === undefined || defaultValue === null || defaultValue === '' || (Array.isArray(defaultValue) && defaultValue.length === 0);
|
||||
}
|
||||
|
||||
const rows = configDetails
|
||||
.filter(({ path }) => path[0] !== 'env')
|
||||
.filter(({ showInDocumentation }) => showInDocumentation !== false)
|
||||
.map(({ doc, default: defaultValue, env, path }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
const isEmptyDefaultValue = getIsEmptyDefaultValue(defaultValue);
|
||||
|
||||
const rawDocumentation = formatDoc(doc);
|
||||
|
||||
|
|
@ -47,7 +50,7 @@ const rows = configDetails
|
|||
});
|
||||
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
|
||||
const envs = castArray(env);
|
||||
const envs = Array.isArray(env) ? env : [env];
|
||||
const [firstEnv, ...restEnvs] = envs;
|
||||
|
||||
return `
|
||||
|
|
@ -84,8 +87,8 @@ function wrapText(text: string, maxLength = 75) {
|
|||
}
|
||||
|
||||
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
|
||||
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
|
||||
const envs = castArray(env);
|
||||
const isEmptyDefaultValue = getIsEmptyDefaultValue(defaultValue);
|
||||
const envs = Array.isArray(env) ? env : [env];
|
||||
const [firstEnv] = envs;
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -87,11 +87,11 @@ The code for the Email Worker proxy is available in the [papra-hq/email-proxy](h
|
|||
|
||||
# Tell your Papra instance that it can generate any email address from the
|
||||
# domain you setup in the Email Worker as it's a wildcard redirection
|
||||
INTAKE_EMAILS_DRIVER=random-username
|
||||
INTAKE_EMAILS_DRIVER=catch-all
|
||||
|
||||
# This is the domain from which the intake email will be generated
|
||||
# eg. `domain.com`
|
||||
INTAKE_EMAILS_EMAIL_GENERATION_DOMAIN=papra.email
|
||||
INTAKE_EMAILS_CATCH_ALL_DOMAIN=papra.email
|
||||
|
||||
# This is the secret key to authenticate the webhook requests
|
||||
# set the same as the `WEBHOOK_SECRET` variable in the Email Worker
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import EncryptionKeyGenerator from '../../../components/encryption-key-generator
|
|||
Document encryption is available in Papra v0.9.0 and above.
|
||||
</Aside>
|
||||
|
||||
Document encryption in Papra provides end-to-end protection for your stored documents using industry-standard AES-256-GCM encryption. This guide will walk you through enabling encryption, understanding how it works, and managing encryption keys.
|
||||
Document encryption in Papra provides protection for your stored documents using industry-standard AES-256-GCM encryption. This guide will walk you through enabling encryption, understanding how it works, and managing encryption keys.
|
||||
|
||||
## How Encryption Works
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ Use filters to search specific document properties. Current supported filters:
|
|||
- `content:` - Search in document content
|
||||
- `tag:` - Search by tag name or ID
|
||||
- `created:` - Filter by document creation date
|
||||
- `date:` - Filter by document date (the date associated with the document)
|
||||
- `has:tags` - Check if documents have any tags, it can be negated with `-has:tags` or `NOT has:tags` to find untagged documents
|
||||
- `has:date` - Check if documents have a date set, it can be negated with `-has:date` or `NOT has:date` to find documents without a date
|
||||
|
||||
### Filter Examples
|
||||
|
||||
|
|
@ -69,6 +71,8 @@ The `has:` filter checks whether documents have certain properties:
|
|||
|
||||
- `has:tags` - Documents with at least one tag
|
||||
- `-has:tags` or `NOT has:tags` - Documents without any tags
|
||||
- `has:date` - Documents with a date set
|
||||
- `-has:date` or `NOT has:date` - Documents without a date
|
||||
|
||||
#### Existence Filter Examples
|
||||
|
||||
|
|
@ -84,11 +88,17 @@ has:tags invoice
|
|||
|
||||
# Find tagged documents that are not archived
|
||||
has:tags NOT tag:archived
|
||||
|
||||
# Find documents without a date set
|
||||
-has:date
|
||||
|
||||
# Find documents that have a date and a specific tag
|
||||
has:date tag:invoice
|
||||
```
|
||||
|
||||
### Date Filter
|
||||
|
||||
The `created:` filter supports date comparisons with the following operators:
|
||||
The `created:` and `date:` filters support date comparisons with the following operators:
|
||||
|
||||
- `=` - Exact date match
|
||||
- `>` - After the date
|
||||
|
|
@ -127,6 +137,13 @@ created:<=2024-06-30
|
|||
|
||||
# Finds invoices created only in 2024
|
||||
tag:invoice created:>2024 created:<2025
|
||||
|
||||
# Finds documents with a document date after January 1st, 2024
|
||||
date:>2024
|
||||
|
||||
# Finds documents with a document date on or before now
|
||||
date:<=now
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
|
|
|||
255
apps/docs/src/content/docs/03-guides/09-storage-key-patterns.mdx
Normal file
255
apps/docs/src/content/docs/03-guides/09-storage-key-patterns.mdx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
---
|
||||
title: Document Storage Key Patterns
|
||||
description: Guide to configuring how Papra generates storage keys (file paths) for your documents, including pattern syntax, available expressions, transformers, and conflict resolution.
|
||||
slug: guides/storage-key-patterns
|
||||
---
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
<Aside type="note">
|
||||
This feature is available starting from Papra v26.3.0
|
||||
</Aside>
|
||||
|
||||
When a document is uploaded to Papra, the system generates a **storage key** that determines where and how the file is stored in the underlying storage backend (filesystem, S3, Azure Blob Storage, etc.). By default, Papra uses a legacy system that generates opaque storage keys based on the document ID. With storage key patterns, you can define a human-readable, customizable naming scheme for your stored files.
|
||||
|
||||
## How It Works
|
||||
|
||||
A **storage key pattern** is a string template containing **expressions** enclosed in double curly braces (`{{ }}`). When a document is uploaded, each expression is evaluated using the document's metadata and replaced with the resulting value.
|
||||
|
||||
For example, the default pattern:
|
||||
|
||||
```
|
||||
{{organization.id}}/{{document.name}}
|
||||
```
|
||||
|
||||
For a document named `invoice-2025.pdf` in organization `org_123456789012345678901234`, this produces:
|
||||
|
||||
```
|
||||
org_123456789012345678901234/invoice-2025.pdf
|
||||
```
|
||||
|
||||
### Storage Key Persistence
|
||||
|
||||
The generated storage key is stored in the database alongside each document record. This means that **changing the pattern configuration does not affect existing documents**, only newly uploaded documents will use the updated pattern. Existing documents retain their original storage keys and continue to be served from their original location.
|
||||
|
||||
<Aside type="tip">
|
||||
A migration tool to reprocess storage keys for existing documents (allowing you to move files from an old pattern to a new one, or even to a different storage backend) will be available in a future release.
|
||||
</Aside>
|
||||
|
||||
## Enabling Storage Key Patterns
|
||||
|
||||
At the moment, Papra uses the legacy storage key system for backward compatibility. To enable the new pattern-based system, set the following environment variable:
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=false
|
||||
```
|
||||
|
||||
Then configure your desired pattern:
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
If you switch from the legacy system to the pattern-based system on an existing instance, only new documents will use the new pattern. Existing documents will continue to use their legacy storage keys (`{{organization.id}}/originals/{{document.id}}`) and remain accessible without any changes needed.
|
||||
</Aside>
|
||||
|
||||
### Docker Compose Example
|
||||
|
||||
```yaml title="docker-compose.yml" ins={9-10}
|
||||
services:
|
||||
papra:
|
||||
container_name: papra
|
||||
image: ghcr.io/papra-hq/papra:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# ... other environment variables ...
|
||||
- DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=false
|
||||
- DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
|
||||
volumes:
|
||||
- ./app-data:/app/app-data
|
||||
ports:
|
||||
- "1221:1221"
|
||||
```
|
||||
|
||||
## Pattern Syntax
|
||||
|
||||
A pattern is a string that can contain:
|
||||
|
||||
- **Literal text**: Any text outside of `{{ }}` is kept as-is (e.g., path separators `/`, prefixes, etc.)
|
||||
- **Expressions**: Placeholders enclosed in `{{` and `}}` that are evaluated at upload time
|
||||
- **Transformers**: Optional functions applied to expression values using the pipe `|` operator
|
||||
|
||||
The general syntax for an expression with transformers is:
|
||||
|
||||
```
|
||||
{{expression | transformer1 | transformer2 arg1 arg2}}
|
||||
```
|
||||
|
||||
Multiple transformers can be chained, and each transformer receives the output of the previous one.
|
||||
|
||||
## Available Expressions
|
||||
|
||||
| Expression | Description | Example Output |
|
||||
|-----------|-------------|----------------|
|
||||
| `document.id` | The unique document identifier | `doc_123456789012345678901234` |
|
||||
| `document.name` | The original file name (sanitized for safe filesystem use) | `invoice-2025.pdf` |
|
||||
| `organization.id` | The organization identifier | `org_123456789012345678901234` |
|
||||
| `currentDate` | The current date and time in ISO 8601 format | `2025-06-15T14:30:00.000Z` |
|
||||
| `currentDate.yyyy` | Current year (4 digits) | `2025` |
|
||||
| `currentDate.MM` | Current month (2 digits, zero-padded) | `06` |
|
||||
| `currentDate.dd` | Current day of month (2 digits, zero-padded) | `15` |
|
||||
| `currentDate.HH` | Current hour (2 digits, 24-hour, zero-padded) | `14` |
|
||||
| `currentDate.mm` | Current minute (2 digits, zero-padded) | `30` |
|
||||
| `currentDate.ss` | Current second (2 digits, zero-padded) | `00` |
|
||||
| `currentDate.SSS` | Current millisecond (3 digits, zero-padded) | `000` |
|
||||
| `random` | A random 8-character alphanumeric string | `k9x2m4pq` |
|
||||
|
||||
Example:
|
||||
```
|
||||
{{organization.id}}/{{currentDate.yyyy}}/{{currentDate.MM}}/{{document.name}}
|
||||
|
||||
# Result: org_123456789012345678901234/2025/06/invoice-2025.pdf
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
The `document.name` expression automatically sanitizes the file name by replacing unsafe filesystem characters with underscores (`_`).
|
||||
</Aside>
|
||||
|
||||
## Available Transformers
|
||||
|
||||
Transformers modify the value of an expression. They are applied using the pipe (`|`) operator after the expression name.
|
||||
|
||||
| Transformer | Arguments | Description | Example |
|
||||
|------------|-----------|-------------|---------|
|
||||
| `uppercase` | None | Converts the value to uppercase | `{{document.name \| uppercase}}` |
|
||||
| `lowercase` | None | Converts the value to lowercase | `{{document.name \| lowercase}}` |
|
||||
| `formatDate` | Optional format string (default: `{yyyy}-{MM}-{dd}`) | Formats a date value | `{{currentDate \| formatDate {yyyy}/{MM}}}` |
|
||||
| `padStart` | Target length, optional pad character (default: space) | Pads the start of the value | `{{currentDate.MM \| padStart 2 0}}` |
|
||||
| `padEnd` | Target length, optional pad character (default: space) | Pads the end of the value | `{{document.id \| padEnd 20 _}}` |
|
||||
|
||||
### Transformer Chaining
|
||||
|
||||
Transformers can be chained to apply multiple transformations in sequence:
|
||||
|
||||
```
|
||||
{{document.name | lowercase | padEnd 20 _}}
|
||||
```
|
||||
|
||||
### Transformer Arguments
|
||||
|
||||
Arguments are space-separated after the transformer name. If an argument contains spaces, wrap it in double quotes:
|
||||
|
||||
```
|
||||
{{currentDate | formatDate {yyyy}-{MM}-{dd}}}
|
||||
```
|
||||
|
||||
## Pattern Examples
|
||||
|
||||
Here are some common patterns for different use cases:
|
||||
|
||||
### Organize by Organization and Document Name (Default)
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
|
||||
# Result: org_abc123/invoice-2025.pdf
|
||||
```
|
||||
|
||||
### Organize by Date
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_KEY_PATTERN={{currentDate.yyyy}}/{{currentDate.MM}}/{{document.name}}
|
||||
# Result: 2025/06/invoice-2025.pdf
|
||||
```
|
||||
|
||||
### Organize by Date with Unique ID
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{currentDate.yyyy}}/{{currentDate.MM}}/{{document.id}}-{{document.name}}
|
||||
# Result: org_123456789012345678901234/2025/06/doc_123456789012345678901234-invoice-2025.pdf
|
||||
```
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
When using patterns that don't guarantee uniqueness (e.g., patterns based on `document.name` alone), two documents could generate the same storage key. Papra handles this automatically to prevent data loss using a two-stage conflict resolution mechanism.
|
||||
|
||||
### Stage 1: Incremental Suffix
|
||||
|
||||
When a storage key already exists, Papra appends an incrementing numeric suffix before the file extension:
|
||||
|
||||
```
|
||||
org_abc123/invoice.pdf # original (already exists)
|
||||
org_abc123/invoice_1.pdf # first conflict
|
||||
org_abc123/invoice_2.pdf # second conflict
|
||||
org_abc123/invoice_3.pdf # third conflict
|
||||
...
|
||||
org_abc123/invoice_9.pdf # ninth conflict (default max)
|
||||
```
|
||||
|
||||
The suffix is inserted before the file extension and after the file name, separated by an underscore. For files without an extension, the suffix is appended at the end (e.g., `README` becomes `README_1`).
|
||||
|
||||
By default, up to **9 incremental suffix attempts** are made (configurable, see below). This means a total of **10 possible slots** for a given storage key: the original plus 9 suffixed variants.
|
||||
|
||||
### Stage 2: Random Suffix Fallback
|
||||
|
||||
If all incremental suffix attempts are exhausted (i.e., all 10 slots are taken), Papra falls back to appending a random 8-character alphanumeric string as a suffix:
|
||||
|
||||
```
|
||||
org_abc123/invoice_k9x2m4pq.pdf # random suffix fallback
|
||||
```
|
||||
|
||||
This provides a very high probability of finding an available key (with 8-character random suffix, need more than 17 million files with same name and path to have a 50% chance of collision). If even this random suffix collides (extremely unlikely), the upload will fail with an error rather than overwriting existing data.
|
||||
|
||||
<Aside type="tip">
|
||||
If you frequently encounter conflicts, consider adding `{{document.id}}` or `{{random}}` to your pattern to guarantee uniqueness.
|
||||
</Aside>
|
||||
|
||||
### Conflict Resolution Configuration
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DOCUMENT_STORAGE_PATTERN_MAX_INCREMENTAL_SUFFIX_ATTEMPTS` | How many incremental suffixes to try (e.g., `_1`, `_2`, ...). Set to `0` to skip incremental suffixes entirely. | `9` |
|
||||
| `DOCUMENT_STORAGE_PATTERN_ENABLE_RANDOM_SUFFIX_FALLBACK` | Whether to try a random suffix if all incremental attempts are exhausted | `true` |
|
||||
|
||||
### Disabling Conflict Resolution
|
||||
|
||||
If you want to disable conflict resolution, and want to reject uploads that would cause a storage key collision (not sure why you would want this, but it's possible), set:
|
||||
```bash
|
||||
DOCUMENT_STORAGE_PATTERN_MAX_INCREMENTAL_SUFFIX_ATTEMPTS=0
|
||||
DOCUMENT_STORAGE_PATTERN_ENABLE_RANDOM_SUFFIX_FALLBACK=false
|
||||
```
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM` | Use the legacy storage key format (`{orgId}/originals/{docId}.{ext}`). Set to `false` to enable patterns. | `true` |
|
||||
| `DOCUMENT_STORAGE_KEY_PATTERN` | The pattern template for generating storage keys | `{{organization.id}}/{{document.name}}` |
|
||||
| `DOCUMENT_STORAGE_PATTERN_MAX_INCREMENTAL_SUFFIX_ATTEMPTS` | Maximum number of incremental suffix attempts for conflict resolution | `9` |
|
||||
| `DOCUMENT_STORAGE_PATTERN_ENABLE_RANDOM_SUFFIX_FALLBACK` | Enable random suffix fallback when all incremental suffixes are exhausted | `true` |
|
||||
|
||||
## Legacy System
|
||||
|
||||
The legacy storage key system generates keys in the format:
|
||||
|
||||
```
|
||||
{{organization.id}}/originals/{{document.id}}
|
||||
```
|
||||
|
||||
For example: `org_abc123/originals/doc_123456789012345678901234`
|
||||
|
||||
Since the legacy system uses the unique document ID as the file name, conflicts are impossible and no suffix mechanism is needed. At the moment, this system remains the default while the new pattern-based system is being developed and tested.
|
||||
|
||||
To keep using the legacy system, either leave the configuration unchanged or explicitly set:
|
||||
|
||||
```bash
|
||||
DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=true
|
||||
```
|
||||
|
||||
## Pattern Validation
|
||||
|
||||
Storage key patterns are validated at application startup. If a pattern contains an invalid expression or an unknown transformer, Papra will refuse to start and report the error. This prevents misconfigured patterns from causing issues at upload time.
|
||||
|
||||
A pattern is invalid if:
|
||||
- It references an expression that does not exist (e.g., `{{unknown.field}}`)
|
||||
- It uses a transformer that does not exist (e.g., `{{document.name | nonexistent}}`)
|
||||
- It ends with a `/` (trailing slashes are not allowed)
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
---
|
||||
title: Migrate Document Storage
|
||||
description: Move documents between storage backends or change storage settings using the storage migration maintenance script.
|
||||
slug: guides/migrate-document-storage
|
||||
---
|
||||
import { Steps } from '@astrojs/starlight/components';
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
||||
|
||||
<Aside type="note">
|
||||
This migration script is available starting from Papra v26.3.0
|
||||
</Aside>
|
||||
|
||||
The storage migration script copies every document from one storage backend to another, streaming files one at a time to keep memory usage flat. Encryption is handled transparently — documents are decrypted on the way out of the source and re-encrypted (or left plain) on the way into the destination, depending on each side's configuration.
|
||||
|
||||
## When to Use This Script
|
||||
|
||||
- **Changing storage key pattern** — after updating `DOCUMENT_STORAGE_KEY_PATTERN` or switching off the legacy key system, existing documents keep their old storage keys; use this script to re-copy them so they land under the new pattern (see [Storage Key Patterns](/guides/storage-key-patterns))
|
||||
- **Moving to cloud storage** — migrating from a local filesystem to S3, Azure Blob Storage, or another S3-compatible provider
|
||||
- **Switching cloud providers** — moving files between S3 and Azure Blob, or between S3-compatible services
|
||||
- **Changing storage root** — relocating the filesystem storage directory to a different path or volume
|
||||
|
||||
<Aside type="note">
|
||||
If you only want to encrypt existing documents without changing anything else, use the dedicated [`maintenance:encrypt-all-documents`](/guides/document-encryption#migrating-existing-documents-to-encrypted-format) script instead. The storage migration script will fail if source and destination resolve to the same path for the same key, because storage drivers refuse to overwrite existing files.
|
||||
</Aside>
|
||||
|
||||
## How It Works
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Source and destination configs are built** from the base environment variables, with your `--from` and `--to` overrides applied on top. Database configuration always comes from the running environment — only storage settings are overridden.
|
||||
|
||||
2. **Documents are iterated in batches** (including soft-deleted ones), so the script can handle large document collections without loading everything into memory.
|
||||
|
||||
3. **Each document is streamed** from the source storage (decrypting if the source has encryption enabled), then written to the destination (encrypting if the destination has encryption enabled).
|
||||
|
||||
4. **The new storage key is computed** using the destination's pattern configuration (via `DOCUMENT_STORAGE_KEY_PATTERN`), and both the storage key and encryption metadata are updated in the database. This is what allows a no-args invocation to re-key all documents after a pattern change.
|
||||
|
||||
5. **If `--delete-source` is passed**, the original file is removed from the source backend after a successful copy.
|
||||
|
||||
6. **Errors are per-document** — a single failed document is logged and skipped; the rest of the migration continues.
|
||||
|
||||
</Steps>
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
maintenance:migrate-document-storage [options]
|
||||
|
||||
Options:
|
||||
--from <KEY=VALUE> Source storage environment variable (repeatable)
|
||||
--to <KEY=VALUE> Destination storage environment variable (repeatable)
|
||||
--delete-source Delete source files after a successful copy
|
||||
--dry-run Preview what would be migrated without touching any files
|
||||
-h, --help Show this help message
|
||||
```
|
||||
|
||||
`--from` and `--to` accept any `DOCUMENT_STORAGE_*` environment variable. Values are merged on top of the current environment, so you only need to specify the settings that differ from your running configuration.
|
||||
|
||||
## Running the Migration
|
||||
|
||||
<Aside type="caution">
|
||||
Back up your documents and database before running the migration. While the script is non-destructive by default (it copies, not moves), a backup is the safest way to recover from unexpected issues.
|
||||
</Aside>
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Run a dry run first**
|
||||
|
||||
The dry run logs every document that would be migrated without reading or writing any files.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Docker Compose">
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
|
||||
--dry-run
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Docker">
|
||||
```bash
|
||||
docker exec -it papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
|
||||
--dry-run
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Source Installation">
|
||||
```bash
|
||||
pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
|
||||
--dry-run
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
2. **Run the actual migration**
|
||||
|
||||
Once you are happy with the dry run output, run without `--dry-run`. Add `--delete-source` if you want source files removed after each successful copy (USE WITH CAUTION).
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Docker Compose">
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Docker">
|
||||
```bash
|
||||
docker exec -it papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Source Installation">
|
||||
```bash
|
||||
pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
3. **Update your Papra configuration**
|
||||
|
||||
Point your running Papra instance to the new storage backend and restart it.
|
||||
|
||||
4. **Verify the migration**
|
||||
|
||||
Open Papra and check that documents are accessible and downloadable. If anything looks wrong, your original files are still in the source backend (unless you used `--delete-source`).
|
||||
|
||||
</Steps>
|
||||
|
||||
## Examples
|
||||
|
||||
### Filesystem → S3
|
||||
|
||||
Move documents from a local directory to an S3 bucket, using AWS credentials already in the environment:
|
||||
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--from DOCUMENT_STORAGE_FILESYSTEM_ROOT=./app-data/documents \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-papra-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1
|
||||
```
|
||||
|
||||
### S3 → Azure Blob Storage
|
||||
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--from DOCUMENT_STORAGE_S3_BUCKET_NAME=old-bucket \
|
||||
--from DOCUMENT_STORAGE_S3_REGION=us-east-1 \
|
||||
--to DOCUMENT_STORAGE_DRIVER=azure-blob \
|
||||
--to DOCUMENT_STORAGE_AZURE_BLOB_ACCOUNT_NAME=myaccount \
|
||||
--to DOCUMENT_STORAGE_AZURE_BLOB_CONTAINER_NAME=papra-documents
|
||||
```
|
||||
|
||||
### S3 → S3-Compatible (e.g. Cloudflare R2, MinIO)
|
||||
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--from DOCUMENT_STORAGE_S3_BUCKET_NAME=old-bucket \
|
||||
--from DOCUMENT_STORAGE_S3_REGION=us-east-1 \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=new-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=auto
|
||||
```
|
||||
|
||||
### Add Encryption While Moving to S3
|
||||
|
||||
Move from an unencrypted filesystem to an encrypted S3 backend in one step:
|
||||
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_DRIVER=s3 \
|
||||
--to DOCUMENT_STORAGE_S3_BUCKET_NAME=my-papra-bucket \
|
||||
--to DOCUMENT_STORAGE_S3_REGION=us-east-1 \
|
||||
--to DOCUMENT_STORAGE_ENCRYPTION_IS_ENABLED=true \
|
||||
--to DOCUMENT_STORAGE_DOCUMENT_KEY_ENCRYPTION_KEYS=<your-encryption-key>
|
||||
```
|
||||
|
||||
After running this, update your Papra environment variables to include the encryption settings so new documents are also encrypted.
|
||||
|
||||
### Change Storage Key Pattern
|
||||
|
||||
Each document's storage key is saved in the database when it is uploaded, so Papra always knows where to find a file regardless of what the current pattern setting is. Changing `DOCUMENT_STORAGE_KEY_PATTERN` (or disabling the legacy key system) only affects new uploads — existing documents keep their old keys.
|
||||
|
||||
To re-key existing documents, first update your Papra configuration to the desired pattern, then run the migration script without any arguments. The script will read each document from its stored key and write it back under the key your new pattern produces, updating the database record in the process.
|
||||
|
||||
```bash
|
||||
# 1. Update your Papra config (e.g. in .env or docker-compose.yml):
|
||||
# DOCUMENT_STORAGE_USE_LEGACY_STORAGE_KEY_DEFINITION_SYSTEM=false
|
||||
# DOCUMENT_STORAGE_KEY_PATTERN={{organization.id}}/{{document.name}}
|
||||
|
||||
# 2. Run the migration (no --from / --to needed when staying on the same backend):
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage
|
||||
```
|
||||
|
||||
The `--from` / `--to` overrides are only necessary when you are also changing the backend driver or root path at the same time.
|
||||
|
||||
### Change Filesystem Root Path
|
||||
|
||||
If you are moving the storage directory to a new volume, override only the path:
|
||||
|
||||
```bash
|
||||
docker compose exec papra pnpm maintenance:migrate-document-storage \
|
||||
--from DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--from DOCUMENT_STORAGE_FILESYSTEM_ROOT=/old-volume/documents \
|
||||
--to DOCUMENT_STORAGE_DRIVER=filesystem \
|
||||
--to DOCUMENT_STORAGE_FILESYSTEM_ROOT=/new-volume/documents
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"File already exists" or overwrite error**
|
||||
|
||||
The source and destination resolve to the same storage location. This happens when you pass the same driver and path on both sides. Use the `maintenance:encrypt-all-documents` script for in-place encryption instead.
|
||||
|
||||
**Individual documents fail but migration continues**
|
||||
|
||||
Failed documents are logged with their ID, name, and error message. The migration reports a final `succeeded / failed` count. After fixing the underlying issue (permissions, connectivity, missing credentials), you can re-run the script — already-migrated files will fail to overwrite, so re-run with the source pointing to the correct set of remaining files.
|
||||
|
||||
**Migration is slow**
|
||||
|
||||
The script streams one document at a time. Performance depends on document size and the latency between source and destination storage. For large collections, consider running the migration during a maintenance window.
|
||||
|
||||
**Container exits before migration finishes**
|
||||
|
||||
For long-running migrations in Docker, make sure no timeout is configured on `docker exec`.
|
||||
|
|
@ -155,7 +155,7 @@ List all documents in the organization.
|
|||
- Query parameters
|
||||
- `pageIndex`: (optional, default: 0) The page index to start from.
|
||||
- `pageSize`: (optional, default: 100) The number of documents to return.
|
||||
- `tags`: (optional) The tags IDs to filter by.
|
||||
- `searchQuery`: (optional) Search query, can reference [Filters](https://docs.papra.app/guides/advanced-search/#filters) for a more defined search
|
||||
- Response (JSON)
|
||||
- `documents`: The list of documents.
|
||||
- `documentsCount`: The total number of documents.
|
||||
|
|
@ -236,7 +236,7 @@ Get the statistics (number of documents and total size) of the documents in the
|
|||
Change the name or content (for search purposes) of a document.
|
||||
|
||||
- Required API key permissions: `documents:update`
|
||||
- Body (form-data)
|
||||
- Body (JSON)
|
||||
- `name`: (optional) The document name.
|
||||
- `content`: (optional) The document content.
|
||||
- Response (JSON)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ export const sidebar = [
|
|||
label: 'Document Encryption',
|
||||
slug: 'guides/document-encryption',
|
||||
},
|
||||
{
|
||||
label: 'Migrate Document Storage',
|
||||
slug: 'guides/migrate-document-storage',
|
||||
},
|
||||
{
|
||||
label: 'Tagging Rules',
|
||||
slug: 'guides/tagging-rules',
|
||||
|
|
@ -53,6 +57,10 @@ export const sidebar = [
|
|||
label: 'Advanced Search',
|
||||
slug: 'guides/advanced-search',
|
||||
},
|
||||
{
|
||||
label: 'Storage Key Patterns',
|
||||
slug: 'guides/storage-key-patterns',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ const defaultCommand = `mkdir -p ./app-data/{db,documents} && docker compose up
|
|||
<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>
|
||||
<option value="catch-all" class="bg-background">Cloudflare Email Worker</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -294,7 +294,7 @@ function getDockerComposeYml() {
|
|||
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}`,
|
||||
intakeEmailEnabled && intakeDriver === 'catch-all' && cfEmailDomainInput.value && `INTAKE_EMAILS_CATCH_ALL_DOMAIN=${cfEmailDomainInput.value}`,
|
||||
].flat().filter(Boolean);
|
||||
|
||||
const volumes = [
|
||||
|
|
@ -426,7 +426,7 @@ function handleIntakeDriverChange() {
|
|||
owlrelayConfig.style.display = driver === 'owlrelay' ? 'block' : 'none';
|
||||
}
|
||||
if (cfWorkerConfig) {
|
||||
cfWorkerConfig.style.display = driver === 'random-username' ? 'block' : 'none';
|
||||
cfWorkerConfig.style.display = driver === 'catch-all' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
updateDockerCompose();
|
||||
|
|
|
|||
|
|
@ -1,46 +1,32 @@
|
|||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { toJsonSchema } from '@valibot/to-json-schema';
|
||||
import * as v from 'valibot';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }) {
|
||||
const schema: any = mapValues(configDefinition, (config) => {
|
||||
if (typeof config === 'object' && config !== null && 'schema' in config && 'doc' in config) {
|
||||
return config.schema;
|
||||
function buildConfigSchema({ configDefinition }: { configDefinition: ConfigDefinition }): v.GenericSchema {
|
||||
const entries: Record<string, v.GenericSchema> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(configDefinition)) {
|
||||
if ('schema' in value) {
|
||||
entries[key] = v.optional(value.schema as v.GenericSchema);
|
||||
} else {
|
||||
return buildConfigSchema({
|
||||
configDefinition: config as ConfigDefinition,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return z.object(schema);
|
||||
}
|
||||
|
||||
function stripRequired(schema: any) {
|
||||
if (schema.type === 'object') {
|
||||
schema.required = [];
|
||||
for (const key in schema.properties) {
|
||||
stripRequired(schema.properties[key]);
|
||||
entries[key] = v.optional(buildConfigSchema({ configDefinition: value }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addSchema(schema: any) {
|
||||
schema.properties.$schema = {
|
||||
type: 'string',
|
||||
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
|
||||
};
|
||||
return v.object(entries);
|
||||
}
|
||||
|
||||
function getConfigSchema() {
|
||||
const schema = buildConfigSchema({ configDefinition });
|
||||
const jsonSchema = zodToJsonSchema(schema, { pipeStrategy: 'output' });
|
||||
const jsonSchema = toJsonSchema(schema, { typeMode: 'input', errorMode: 'ignore' });
|
||||
|
||||
(jsonSchema.properties ??= {}).$schema = {
|
||||
type: 'string',
|
||||
description: 'The schema of the configuration file, to be used by IDEs to provide autocompletion and validation',
|
||||
};
|
||||
|
||||
stripRequired(jsonSchema);
|
||||
addSchema(jsonSchema);
|
||||
return jsonSchema;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { defineConfig } from 'vitest/config';
|
|||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
isolate: false,
|
||||
env: {
|
||||
TZ: 'UTC',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -66,14 +66,16 @@
|
|||
|
||||
<style>.sr-only {position: absolute;width: 1px;height: 1px;padding: 0;margin: -1px;overflow: hidden;clip: rect(0, 0, 0, 0);white-space: nowrap;border-width: 0;}</style>
|
||||
|
||||
<!-- Prevent flash of wrong theme on load -->
|
||||
<!-- Prevent FOUC, default to dark theme -->
|
||||
<script>
|
||||
(function () {
|
||||
const stored = localStorage?.getItem('papra_color_mode') ?? 'dark';
|
||||
try{
|
||||
const stored = localStorage?.getItem('papra_color_mode');
|
||||
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isLight = stored === 'light' || (stored === 'system' && !systemPrefersDark);
|
||||
|
||||
if (stored === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
}
|
||||
document.documentElement.setAttribute('data-kb-theme', isLight ? 'light' : 'dark');
|
||||
} catch {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"dependencies": {
|
||||
"@branchlet/core": "^1.0.0",
|
||||
"@corentinth/chisels": "catalog:",
|
||||
"@corvu/calendar": "^0.1.2",
|
||||
"@corvu/otp-field": "^0.1.4",
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
|
|
|
|||
|
|
@ -72,4 +72,7 @@
|
|||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
/* @refresh reload */
|
||||
|
||||
import type { ConfigColorMode } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { Router } from '@solidjs/router';
|
||||
import { QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
|
|
@ -12,8 +10,10 @@ import { isDemoMode } from './modules/config/config';
|
|||
import { ConfigProvider } from './modules/config/config.provider';
|
||||
import { RenameDocumentDialogProvider } from './modules/documents/components/rename-document-button.component';
|
||||
import { I18nProvider } from './modules/i18n/i18n.provider';
|
||||
import { AboutDialogProvider } from './modules/shared/components/about-dialog';
|
||||
import { ConfirmModalProvider } from './modules/shared/confirm';
|
||||
import { queryClient } from './modules/shared/query/query-client';
|
||||
import { ThemeProvider } from './modules/theme/theme.provider';
|
||||
import { IdentifyUser } from './modules/tracking/components/identify-user.component';
|
||||
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
|
||||
import { Toaster } from './modules/ui/components/sonner';
|
||||
|
|
@ -27,10 +27,6 @@ const DemoIndicator = isDemoMode
|
|||
|
||||
render(
|
||||
() => {
|
||||
const initialColorMode: ConfigColorMode = 'dark';
|
||||
const colorModeStorageKey = 'papra_color_mode';
|
||||
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router
|
||||
|
|
@ -41,23 +37,22 @@ render(
|
|||
<IdentifyUser />
|
||||
<I18nProvider>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
>
|
||||
<ThemeProvider>
|
||||
<CommandPaletteProvider>
|
||||
<ConfigProvider>
|
||||
<RenameDocumentDialogProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
</RenameDocumentDialogProvider>
|
||||
{DemoIndicator && <DemoIndicator />}
|
||||
<AboutDialogProvider>
|
||||
<RenameDocumentDialogProvider>
|
||||
<div class="min-h-screen font-sans text-sm font-400">
|
||||
{props.children}
|
||||
</div>
|
||||
</RenameDocumentDialogProvider>
|
||||
{DemoIndicator && <DemoIndicator />}
|
||||
</AboutDialogProvider>
|
||||
</ConfigProvider>
|
||||
|
||||
<Toaster />
|
||||
</CommandPaletteProvider>
|
||||
</ColorModeProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
</ConfirmModalProvider>
|
||||
</I18nProvider>
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Erstellt am',
|
||||
'documents.info.updated-at': 'Aktualisiert am',
|
||||
'documents.info.never': 'Nie',
|
||||
'documents.info.document-date': 'Datum',
|
||||
'documents.info.no-date': 'Kein Datum',
|
||||
'documents.info.today': 'Heute',
|
||||
|
||||
'custom-properties.types.text': 'Text',
|
||||
'custom-properties.types.number': 'Zahl',
|
||||
'custom-properties.types.date': 'Datum',
|
||||
'custom-properties.types.boolean': 'Boolescher Wert',
|
||||
'custom-properties.types.select': 'Auswahl',
|
||||
'custom-properties.types.multi_select': 'Mehrfachauswahl',
|
||||
'custom-properties.types.user_relation': 'Benutzer',
|
||||
'custom-properties.types.document_relation': 'Dokument',
|
||||
|
||||
'custom-properties.list.title': 'Benutzerdefinierte Eigenschaften',
|
||||
'custom-properties.list.description': 'Definieren Sie benutzerdefinierte Metadatenfelder für Ihre Dokumente. Eigenschaften können Text, Zahlen, Datum, Wahrheitswerte oder Auswahllisten sein.',
|
||||
'custom-properties.list.create-button': 'Eigenschaft erstellen',
|
||||
'custom-properties.list.empty.title': 'Benutzerdefinierte Eigenschaften',
|
||||
'custom-properties.list.empty.description': 'Mit benutzerdefinierten Eigenschaften können Sie strukturierte Metadaten zu Ihren Dokumenten hinzufügen, z. B. Ablaufdaten, Firmennamen oder Beträge.',
|
||||
'custom-properties.list.table.name': 'Name',
|
||||
'custom-properties.list.table.type': 'Typ',
|
||||
'custom-properties.list.table.description': 'Beschreibung',
|
||||
'custom-properties.list.table.created': 'Erstellt',
|
||||
'custom-properties.list.table.actions': 'Aktionen',
|
||||
'custom-properties.list.table.no-description': 'Keine Beschreibung',
|
||||
'custom-properties.list.delete.confirm-title': 'Benutzerdefinierte Eigenschaft löschen',
|
||||
'custom-properties.list.delete.confirm-message': 'Möchten Sie die benutzerdefinierte Eigenschaft „{{ name }}” wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.',
|
||||
'custom-properties.list.delete.confirm-button': 'Löschen',
|
||||
'custom-properties.list.delete.success': 'Benutzerdefinierte Eigenschaft erfolgreich gelöscht',
|
||||
'custom-properties.list.delete.error': 'Benutzerdefinierte Eigenschaft konnte nicht gelöscht werden',
|
||||
|
||||
'custom-properties.create.title': 'Benutzerdefinierte Eigenschaft erstellen',
|
||||
'custom-properties.create.submit': 'Eigenschaft erstellen',
|
||||
'custom-properties.create.success': 'Benutzerdefinierte Eigenschaft erfolgreich erstellt',
|
||||
'custom-properties.create.error': 'Benutzerdefinierte Eigenschaft konnte nicht erstellt werden',
|
||||
|
||||
'custom-properties.update.title': 'Benutzerdefinierte Eigenschaft bearbeiten',
|
||||
'custom-properties.update.submit': 'Änderungen speichern',
|
||||
'custom-properties.update.success': 'Benutzerdefinierte Eigenschaft erfolgreich aktualisiert',
|
||||
'custom-properties.update.error': 'Benutzerdefinierte Eigenschaft konnte nicht aktualisiert werden',
|
||||
|
||||
'custom-properties.form.name.label': 'Name',
|
||||
'custom-properties.form.name.placeholder': 'z. B. Rechnungsbetrag',
|
||||
'custom-properties.form.name.required': 'Name ist erforderlich',
|
||||
'custom-properties.form.name.max-length': 'Der Name darf höchstens 255 Zeichen lang sein',
|
||||
'custom-properties.form.description.label': 'Beschreibung',
|
||||
'custom-properties.form.description.optional': '(optional)',
|
||||
'custom-properties.form.description.placeholder': 'Beschreiben Sie, wofür diese Eigenschaft verwendet wird',
|
||||
'custom-properties.form.description.max-length': 'Die Beschreibung darf höchstens 1000 Zeichen lang sein',
|
||||
'custom-properties.form.type.label': 'Typ',
|
||||
'custom-properties.form.type.immutable': 'Der Eigenschaftstyp kann nach der Erstellung nicht geändert werden.',
|
||||
'custom-properties.form.options.title': 'Optionen',
|
||||
'custom-properties.form.options.description': 'Definieren Sie die verfügbaren Auswahlmöglichkeiten für diese Eigenschaft.',
|
||||
'custom-properties.form.options.name.placeholder': 'Optionsname',
|
||||
'custom-properties.form.options.name.required': 'Optionsname ist erforderlich',
|
||||
'custom-properties.form.options.name.max-length': 'Der Optionsname darf höchstens 255 Zeichen lang sein',
|
||||
'custom-properties.form.options.validation.required': 'Bitte fügen Sie mindestens eine Option hinzu',
|
||||
'custom-properties.form.options.add': 'Option hinzufügen',
|
||||
'custom-properties.form.cancel': 'Abbrechen',
|
||||
'custom-properties.form.save-error': 'Beim Speichern der Eigenschaftsdefinition ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Eigenschaften',
|
||||
'documents.custom-properties.no-value': 'Nicht festgelegt',
|
||||
'documents.custom-properties.text-placeholder': 'Wert eingeben...',
|
||||
'documents.custom-properties.save': 'Speichern',
|
||||
'documents.custom-properties.clear': 'Löschen',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Dokumente suchen...',
|
||||
'documents.custom-properties.user-relation-manage': 'Benutzer verwalten',
|
||||
'documents.custom-properties.document-relation-manage': 'Dokumente verwalten',
|
||||
'documents.custom-properties.no-results': 'Keine Ergebnisse',
|
||||
|
||||
'documents.rename.title': 'Dokument umbenennen',
|
||||
'documents.rename.form.name.label': 'Name',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'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.name.max-length': 'Der Name darf maximal 128 Zeichen lang sein',
|
||||
'webhooks.create.form.url.label': 'Webhook-URL',
|
||||
'webhooks.create.form.url.placeholder': 'Webhook-URL eingeben',
|
||||
'webhooks.create.form.url.required': 'URL ist erforderlich',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Startseite',
|
||||
'layout.menu.documents': 'Dokumente',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Benutzerdefinierte Eigenschaften',
|
||||
'layout.menu.tagging-rules': 'Tagging-Regeln',
|
||||
'layout.menu.deleted-documents': 'Gelöschte Dokumente',
|
||||
'layout.menu.organization-settings': 'Einstellungen',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API-Schlüssel',
|
||||
'user-menu.invitations': 'Einladungen',
|
||||
'user-menu.language': 'Sprache',
|
||||
'user-menu.theme': 'Design',
|
||||
'user-menu.about': 'Über Papra',
|
||||
'user-menu.logout': 'Abmelden',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.',
|
||||
'api-errors.organization.has_active_subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden. Bitte kündigen Sie zuerst Ihr Abonnement über die Schaltfläche Abonnement verwalten oben.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'Die angegebene URL ist nicht erlaubt. Webhook-URLs dürfen nicht auf private oder reservierte IP-Adressen verweisen.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Fehler beim Erstellen des Benutzers',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Ημερομηνία δημιουργίας',
|
||||
'documents.info.updated-at': 'Ημερομηνία ενημέρωσης',
|
||||
'documents.info.never': 'Ποτέ',
|
||||
'documents.info.document-date': 'Ημερομηνία',
|
||||
'documents.info.no-date': 'Χωρίς ημερομηνία',
|
||||
'documents.info.today': 'Σήμερα',
|
||||
|
||||
'custom-properties.types.text': 'Κείμενο',
|
||||
'custom-properties.types.number': 'Αριθμός',
|
||||
'custom-properties.types.date': 'Ημερομηνία',
|
||||
'custom-properties.types.boolean': 'Λογική τιμή',
|
||||
'custom-properties.types.select': 'Επιλογή',
|
||||
'custom-properties.types.multi_select': 'Πολλαπλή επιλογή',
|
||||
'custom-properties.types.user_relation': 'Χρήστης',
|
||||
'custom-properties.types.document_relation': 'Έγγραφο',
|
||||
|
||||
'custom-properties.list.title': 'Προσαρμοσμένες ιδιότητες',
|
||||
'custom-properties.list.description': 'Ορίστε προσαρμοσμένα πεδία μεταδεδομένων για τα έγγραφά σας. Οι ιδιότητες μπορεί να είναι κείμενο, αριθμοί, ημερομηνίες, λογικές τιμές ή λίστες επιλογών.',
|
||||
'custom-properties.list.create-button': 'Δημιουργία ιδιότητας',
|
||||
'custom-properties.list.empty.title': 'Προσαρμοσμένες ιδιότητες',
|
||||
'custom-properties.list.empty.description': 'Οι προσαρμοσμένες ιδιότητες σάς επιτρέπουν να προσθέτετε δομημένα μεταδεδομένα στα έγγραφά σας, όπως ημερομηνίες λήξης, ονόματα εταιρειών ή ποσά.',
|
||||
'custom-properties.list.table.name': 'Όνομα',
|
||||
'custom-properties.list.table.type': 'Τύπος',
|
||||
'custom-properties.list.table.description': 'Περιγραφή',
|
||||
'custom-properties.list.table.created': 'Δημιουργήθηκε',
|
||||
'custom-properties.list.table.actions': 'Ενέργειες',
|
||||
'custom-properties.list.table.no-description': 'Χωρίς περιγραφή',
|
||||
'custom-properties.list.delete.confirm-title': 'Διαγραφή προσαρμοσμένης ιδιότητας',
|
||||
'custom-properties.list.delete.confirm-message': 'Είστε βέβαιοι ότι θέλετε να διαγράψετε την προσαρμοσμένη ιδιότητα «{{ name }}»; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.',
|
||||
'custom-properties.list.delete.confirm-button': 'Διαγραφή',
|
||||
'custom-properties.list.delete.success': 'Η προσαρμοσμένη ιδιότητα διαγράφηκε με επιτυχία',
|
||||
'custom-properties.list.delete.error': 'Αποτυχία διαγραφής προσαρμοσμένης ιδιότητας',
|
||||
|
||||
'custom-properties.create.title': 'Δημιουργία προσαρμοσμένης ιδιότητας',
|
||||
'custom-properties.create.submit': 'Δημιουργία ιδιότητας',
|
||||
'custom-properties.create.success': 'Η προσαρμοσμένη ιδιότητα δημιουργήθηκε με επιτυχία',
|
||||
'custom-properties.create.error': 'Αποτυχία δημιουργίας προσαρμοσμένης ιδιότητας',
|
||||
|
||||
'custom-properties.update.title': 'Επεξεργασία προσαρμοσμένης ιδιότητας',
|
||||
'custom-properties.update.submit': 'Αποθήκευση αλλαγών',
|
||||
'custom-properties.update.success': 'Η προσαρμοσμένη ιδιότητα ενημερώθηκε με επιτυχία',
|
||||
'custom-properties.update.error': 'Αποτυχία ενημέρωσης προσαρμοσμένης ιδιότητας',
|
||||
|
||||
'custom-properties.form.name.label': 'Όνομα',
|
||||
'custom-properties.form.name.placeholder': 'π.χ. Ποσό τιμολογίου',
|
||||
'custom-properties.form.name.required': 'Το όνομα είναι υποχρεωτικό',
|
||||
'custom-properties.form.name.max-length': 'Το όνομα πρέπει να έχει το πολύ 255 χαρακτήρες',
|
||||
'custom-properties.form.description.label': 'Περιγραφή',
|
||||
'custom-properties.form.description.optional': '(προαιρετικό)',
|
||||
'custom-properties.form.description.placeholder': 'Περιγράψτε για τι χρησιμοποιείται αυτή η ιδιότητα',
|
||||
'custom-properties.form.description.max-length': 'Η περιγραφή πρέπει να έχει το πολύ 1000 χαρακτήρες',
|
||||
'custom-properties.form.type.label': 'Τύπος',
|
||||
'custom-properties.form.type.immutable': 'Ο τύπος ιδιότητας δεν μπορεί να αλλάξει μετά τη δημιουργία.',
|
||||
'custom-properties.form.options.title': 'Επιλογές',
|
||||
'custom-properties.form.options.description': 'Ορίστε τις διαθέσιμες επιλογές για αυτή την ιδιότητα.',
|
||||
'custom-properties.form.options.name.placeholder': 'Όνομα επιλογής',
|
||||
'custom-properties.form.options.name.required': 'Το όνομα επιλογής είναι υποχρεωτικό',
|
||||
'custom-properties.form.options.name.max-length': 'Το όνομα επιλογής πρέπει να έχει το πολύ 255 χαρακτήρες',
|
||||
'custom-properties.form.options.validation.required': 'Προσθέστε τουλάχιστον μία επιλογή',
|
||||
'custom-properties.form.options.add': 'Προσθήκη επιλογής',
|
||||
'custom-properties.form.cancel': 'Ακύρωση',
|
||||
'custom-properties.form.save-error': 'Παρουσιάστηκε σφάλμα κατά την αποθήκευση του ορισμού ιδιότητας. Δοκιμάστε ξανά.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Ιδιότητες',
|
||||
'documents.custom-properties.no-value': 'Μη ορισμένο',
|
||||
'documents.custom-properties.text-placeholder': 'Εισάγετε μια τιμή...',
|
||||
'documents.custom-properties.save': 'Αποθήκευση',
|
||||
'documents.custom-properties.clear': 'Εκκαθάριση',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Αναζήτηση εγγράφων...',
|
||||
'documents.custom-properties.user-relation-manage': 'Διαχείριση χρηστών',
|
||||
'documents.custom-properties.document-relation-manage': 'Διαχείριση εγγράφων',
|
||||
'documents.custom-properties.no-results': 'Δεν βρέθηκαν αποτελέσματα',
|
||||
|
||||
'documents.rename.title': 'Μετονομασία εγγράφου',
|
||||
'documents.rename.form.name.label': 'Όνομα',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Όνομα webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Εισαγάγετε όνομα',
|
||||
'webhooks.create.form.name.required': 'Το όνομα είναι υποχρεωτικό',
|
||||
'webhooks.create.form.name.max-length': 'Το όνομα πρέπει να έχει το πολύ 128 χαρακτήρες',
|
||||
'webhooks.create.form.url.label': 'URL Webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Εισαγάγετε URL',
|
||||
'webhooks.create.form.url.required': 'Το URL είναι υποχρεωτικό',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Αρχική',
|
||||
'layout.menu.documents': 'Έγγραφα',
|
||||
'layout.menu.tags': 'Ετικέτες',
|
||||
'layout.menu.custom-properties': 'Προσαρμοσμένες ιδιότητες',
|
||||
'layout.menu.tagging-rules': 'Κανόνες ετικετοποίησης',
|
||||
'layout.menu.deleted-documents': 'Διαγεγραμμένα έγγραφα',
|
||||
'layout.menu.organization-settings': 'Ρυθμίσεις',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API keys',
|
||||
'user-menu.invitations': 'Προσκλήσεις',
|
||||
'user-menu.language': 'Γλώσσα',
|
||||
'user-menu.theme': 'Θέμα',
|
||||
'user-menu.about': 'Σχετικά με το Papra',
|
||||
'user-menu.logout': 'Αποσύνδεση',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Μη έγκυρη προέλευση εφαρμογής. Βεβαιωθείτε ότι το APP_BASE_URL ταιριάζει με το τρέχον URL. Δείτε https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Φτάσατε το μέγιστο αριθμό μελών/προσκλήσεων. Αναβαθμίστε το πλάνο σας.',
|
||||
'api-errors.organization.has_active_subscription': 'Δεν είναι δυνατή η διαγραφή με ενεργή συνδρομή. Ακυρώστε πρώτα μέσω "Διαχείριση συνδρομής".',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'Η παρεχόμενη URL δεν επιτρέπεται. Οι URL webhook δεν πρέπει να δείχνουν σε ιδιωτικές ή δεσμευμένες διευθύνσεις IP.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Ο χρήστης δεν βρέθηκε',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Αποτυχία δημιουργίας χρήστη',
|
||||
|
|
|
|||
|
|
@ -352,6 +352,75 @@ export const translations = {
|
|||
'documents.info.created-at': 'Created',
|
||||
'documents.info.updated-at': 'Updated',
|
||||
'documents.info.never': 'Never',
|
||||
'documents.info.document-date': 'Date',
|
||||
'documents.info.no-date': 'No date',
|
||||
'documents.info.today': 'Today',
|
||||
|
||||
'custom-properties.types.text': 'Text',
|
||||
'custom-properties.types.number': 'Number',
|
||||
'custom-properties.types.date': 'Date',
|
||||
'custom-properties.types.boolean': 'Boolean',
|
||||
'custom-properties.types.select': 'Select',
|
||||
'custom-properties.types.multi_select': 'Multi-select',
|
||||
'custom-properties.types.user_relation': 'User',
|
||||
'custom-properties.types.document_relation': 'Document',
|
||||
|
||||
'custom-properties.list.title': 'Custom Properties',
|
||||
'custom-properties.list.description': 'Define custom metadata fields for your documents. Properties can be text, numbers, dates, booleans, or selection lists.',
|
||||
'custom-properties.list.create-button': 'Create property',
|
||||
'custom-properties.list.empty.title': 'Custom Properties',
|
||||
'custom-properties.list.empty.description': 'Custom properties let you add structured metadata to your documents, such as expiration dates, company names, or amounts.',
|
||||
'custom-properties.list.table.name': 'Name',
|
||||
'custom-properties.list.table.type': 'Type',
|
||||
'custom-properties.list.table.description': 'Description',
|
||||
'custom-properties.list.table.created': 'Created',
|
||||
'custom-properties.list.table.actions': 'Actions',
|
||||
'custom-properties.list.table.no-description': 'No description',
|
||||
'custom-properties.list.delete.confirm-title': 'Delete custom property',
|
||||
'custom-properties.list.delete.confirm-message': 'Are you sure you want to delete the custom property "{{ name }}"? This action cannot be undone.',
|
||||
'custom-properties.list.delete.confirm-button': 'Delete',
|
||||
'custom-properties.list.delete.success': 'Custom property deleted successfully',
|
||||
'custom-properties.list.delete.error': 'Failed to delete custom property',
|
||||
|
||||
'custom-properties.create.title': 'Create Custom Property',
|
||||
'custom-properties.create.submit': 'Create property',
|
||||
'custom-properties.create.success': 'Custom property created successfully',
|
||||
'custom-properties.create.error': 'Failed to create custom property',
|
||||
|
||||
'custom-properties.update.title': 'Update Custom Property',
|
||||
'custom-properties.update.submit': 'Save changes',
|
||||
'custom-properties.update.success': 'Custom property updated successfully',
|
||||
'custom-properties.update.error': 'Failed to update custom property',
|
||||
|
||||
'custom-properties.form.name.label': 'Name',
|
||||
'custom-properties.form.name.placeholder': 'e.g. Invoice Amount',
|
||||
'custom-properties.form.name.required': 'Name is required',
|
||||
'custom-properties.form.name.max-length': 'Name must be at most 255 characters',
|
||||
'custom-properties.form.description.label': 'Description',
|
||||
'custom-properties.form.description.optional': '(optional)',
|
||||
'custom-properties.form.description.placeholder': 'Describe what this property is used for',
|
||||
'custom-properties.form.description.max-length': 'Description must be at most 1000 characters',
|
||||
'custom-properties.form.type.label': 'Type',
|
||||
'custom-properties.form.type.immutable': 'Property type cannot be changed after creation.',
|
||||
'custom-properties.form.options.title': 'Options',
|
||||
'custom-properties.form.options.description': 'Define the choices available for this property.',
|
||||
'custom-properties.form.options.name.placeholder': 'Option name',
|
||||
'custom-properties.form.options.name.required': 'Option name is required',
|
||||
'custom-properties.form.options.name.max-length': 'Option name must be at most 255 characters',
|
||||
'custom-properties.form.options.validation.required': 'Please add at least one option',
|
||||
'custom-properties.form.options.add': 'Add option',
|
||||
'custom-properties.form.cancel': 'Cancel',
|
||||
'custom-properties.form.save-error': 'An error occurred while saving the property definition. Please try again.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Properties',
|
||||
'documents.custom-properties.no-value': 'Not set',
|
||||
'documents.custom-properties.text-placeholder': 'Enter a value...',
|
||||
'documents.custom-properties.save': 'Save',
|
||||
'documents.custom-properties.clear': 'Clear',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Search documents...',
|
||||
'documents.custom-properties.user-relation-manage': 'Manage users',
|
||||
'documents.custom-properties.document-relation-manage': 'Manage documents',
|
||||
'documents.custom-properties.no-results': 'No results',
|
||||
|
||||
'documents.rename.title': 'Rename document',
|
||||
'documents.rename.form.name.label': 'Name',
|
||||
|
|
@ -672,6 +741,7 @@ export const translations = {
|
|||
'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.name.max-length': 'Name must be at most 128 characters',
|
||||
'webhooks.create.form.url.label': 'Webhook URL',
|
||||
'webhooks.create.form.url.placeholder': 'Enter webhook URL',
|
||||
'webhooks.create.form.url.required': 'URL is required',
|
||||
|
|
@ -706,6 +776,7 @@ export const translations = {
|
|||
'layout.menu.home': 'Home',
|
||||
'layout.menu.documents': 'Documents',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Custom Properties',
|
||||
'layout.menu.tagging-rules': 'Tagging rules',
|
||||
'layout.menu.deleted-documents': 'Deleted documents',
|
||||
'layout.menu.organization-settings': 'Settings',
|
||||
|
|
@ -735,6 +806,7 @@ export const translations = {
|
|||
'user-menu.api-keys': 'API keys',
|
||||
'user-menu.invitations': 'Invitations',
|
||||
'user-menu.language': 'Language',
|
||||
'user-menu.theme': 'Theme',
|
||||
'user-menu.about': 'About Papra',
|
||||
'user-menu.logout': 'Logout',
|
||||
|
||||
|
|
@ -765,6 +837,7 @@ export const translations = {
|
|||
'api-errors.auth.invalid_origin': 'Invalid application origin. If you are self-hosting Papra, ensure your APP_BASE_URL environment variable matches your current url. For more details see https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'The maximum number of members and pending invitations for this organization has been reached. Please upgrade your plan to add more members.',
|
||||
'api-errors.organization.has_active_subscription': 'Cannot delete organization with an active subscription. Please cancel your subscription first using the Manage Subscription button above.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'The provided URL is not allowed. Webhook URLs must not point to private or reserved IP addresses.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'User not found',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Failed to create user',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Creado el',
|
||||
'documents.info.updated-at': 'Actualizado el',
|
||||
'documents.info.never': 'Nunca',
|
||||
'documents.info.document-date': 'Fecha',
|
||||
'documents.info.no-date': 'Sin fecha',
|
||||
'documents.info.today': 'Hoy',
|
||||
|
||||
'custom-properties.types.text': 'Texto',
|
||||
'custom-properties.types.number': 'Número',
|
||||
'custom-properties.types.date': 'Fecha',
|
||||
'custom-properties.types.boolean': 'Booleano',
|
||||
'custom-properties.types.select': 'Selección',
|
||||
'custom-properties.types.multi_select': 'Selección múltiple',
|
||||
'custom-properties.types.user_relation': 'Usuario',
|
||||
'custom-properties.types.document_relation': 'Documento',
|
||||
|
||||
'custom-properties.list.title': 'Propiedades personalizadas',
|
||||
'custom-properties.list.description': 'Define campos de metadatos personalizados para tus documentos. Las propiedades pueden ser texto, números, fechas, valores booleanos o listas de selección.',
|
||||
'custom-properties.list.create-button': 'Crear propiedad',
|
||||
'custom-properties.list.empty.title': 'Propiedades personalizadas',
|
||||
'custom-properties.list.empty.description': 'Las propiedades personalizadas te permiten añadir metadatos estructurados a tus documentos, como fechas de vencimiento, nombres de empresa o importes.',
|
||||
'custom-properties.list.table.name': 'Nombre',
|
||||
'custom-properties.list.table.type': 'Tipo',
|
||||
'custom-properties.list.table.description': 'Descripción',
|
||||
'custom-properties.list.table.created': 'Creado',
|
||||
'custom-properties.list.table.actions': 'Acciones',
|
||||
'custom-properties.list.table.no-description': 'Sin descripción',
|
||||
'custom-properties.list.delete.confirm-title': 'Eliminar propiedad personalizada',
|
||||
'custom-properties.list.delete.confirm-message': '¿Estás seguro de que deseas eliminar la propiedad personalizada "{{ name }}"? Esta acción no se puede deshacer.',
|
||||
'custom-properties.list.delete.confirm-button': 'Eliminar',
|
||||
'custom-properties.list.delete.success': 'Propiedad personalizada eliminada correctamente',
|
||||
'custom-properties.list.delete.error': 'Error al eliminar la propiedad personalizada',
|
||||
|
||||
'custom-properties.create.title': 'Crear propiedad personalizada',
|
||||
'custom-properties.create.submit': 'Crear propiedad',
|
||||
'custom-properties.create.success': 'Propiedad personalizada creada correctamente',
|
||||
'custom-properties.create.error': 'Error al crear la propiedad personalizada',
|
||||
|
||||
'custom-properties.update.title': 'Editar propiedad personalizada',
|
||||
'custom-properties.update.submit': 'Guardar cambios',
|
||||
'custom-properties.update.success': 'Propiedad personalizada actualizada correctamente',
|
||||
'custom-properties.update.error': 'Error al actualizar la propiedad personalizada',
|
||||
|
||||
'custom-properties.form.name.label': 'Nombre',
|
||||
'custom-properties.form.name.placeholder': 'p. ej. Importe de factura',
|
||||
'custom-properties.form.name.required': 'El nombre es obligatorio',
|
||||
'custom-properties.form.name.max-length': 'El nombre debe tener como máximo 255 caracteres',
|
||||
'custom-properties.form.description.label': 'Descripción',
|
||||
'custom-properties.form.description.optional': '(opcional)',
|
||||
'custom-properties.form.description.placeholder': 'Describe para qué se usa esta propiedad',
|
||||
'custom-properties.form.description.max-length': 'La descripción debe tener como máximo 1000 caracteres',
|
||||
'custom-properties.form.type.label': 'Tipo',
|
||||
'custom-properties.form.type.immutable': 'El tipo de propiedad no puede modificarse después de su creación.',
|
||||
'custom-properties.form.options.title': 'Opciones',
|
||||
'custom-properties.form.options.description': 'Define las opciones disponibles para esta propiedad.',
|
||||
'custom-properties.form.options.name.placeholder': 'Nombre de la opción',
|
||||
'custom-properties.form.options.name.required': 'El nombre de la opción es obligatorio',
|
||||
'custom-properties.form.options.name.max-length': 'El nombre de la opción debe tener como máximo 255 caracteres',
|
||||
'custom-properties.form.options.validation.required': 'Por favor, añade al menos una opción',
|
||||
'custom-properties.form.options.add': 'Añadir opción',
|
||||
'custom-properties.form.cancel': 'Cancelar',
|
||||
'custom-properties.form.save-error': 'Se produjo un error al guardar la definición de la propiedad. Por favor, inténtalo de nuevo.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Propiedades',
|
||||
'documents.custom-properties.no-value': 'No definido',
|
||||
'documents.custom-properties.text-placeholder': 'Introduce un valor...',
|
||||
'documents.custom-properties.save': 'Guardar',
|
||||
'documents.custom-properties.clear': 'Limpiar',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Buscar documentos...',
|
||||
'documents.custom-properties.user-relation-manage': 'Gestionar usuarios',
|
||||
'documents.custom-properties.document-relation-manage': 'Gestionar documentos',
|
||||
'documents.custom-properties.no-results': 'Sin resultados',
|
||||
|
||||
'documents.rename.title': 'Renombrar documento',
|
||||
'documents.rename.form.name.label': 'Nombre',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nombre del webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Ingresa el nombre del webhook',
|
||||
'webhooks.create.form.name.required': 'El nombre es obligatorio',
|
||||
'webhooks.create.form.name.max-length': 'El nombre debe tener como máximo 128 caracteres',
|
||||
'webhooks.create.form.url.label': 'URL del webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Ingresa la URL del webhook',
|
||||
'webhooks.create.form.url.required': 'La URL es obligatoria',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Inicio',
|
||||
'layout.menu.documents': 'Documentos',
|
||||
'layout.menu.tags': 'Etiquetas',
|
||||
'layout.menu.custom-properties': 'Propiedades personalizadas',
|
||||
'layout.menu.tagging-rules': 'Reglas de etiquetado',
|
||||
'layout.menu.deleted-documents': 'Documentos eliminados',
|
||||
'layout.menu.organization-settings': 'Configuración',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Claves API',
|
||||
'user-menu.invitations': 'Invitaciones',
|
||||
'user-menu.language': 'Idioma',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Acerca de Papra',
|
||||
'user-menu.logout': 'Cerrar sesión',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Origen de la aplicación inválido. Si estás alojando Papra, asegúrate de que la variable de entorno APP_BASE_URL coincida con tu URL actual. Para más detalles, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Se ha alcanzado el número máximo de miembros e invitaciones pendientes para esta organización. Por favor, actualiza tu plan para añadir más miembros.',
|
||||
'api-errors.organization.has_active_subscription': 'No se puede eliminar la organización con una suscripción activa. Por favor, cancela tu suscripción primero usando el botón Gestionar Suscripción arriba.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'La URL proporcionada no está permitida. Las URLs de webhook no deben apuntar a direcciones IP privadas o reservadas.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Usuario no encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Error al crear usuario',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Créé le',
|
||||
'documents.info.updated-at': 'Mis à jour le',
|
||||
'documents.info.never': 'Jamais',
|
||||
'documents.info.document-date': 'Date',
|
||||
'documents.info.no-date': 'Pas de date',
|
||||
'documents.info.today': 'Aujourd\'hui',
|
||||
|
||||
'custom-properties.types.text': 'Texte',
|
||||
'custom-properties.types.number': 'Nombre',
|
||||
'custom-properties.types.date': 'Date',
|
||||
'custom-properties.types.boolean': 'Booléen',
|
||||
'custom-properties.types.select': 'Sélection',
|
||||
'custom-properties.types.multi_select': 'Sélection multiple',
|
||||
'custom-properties.types.user_relation': 'Utilisateur',
|
||||
'custom-properties.types.document_relation': 'Document',
|
||||
|
||||
'custom-properties.list.title': 'Propriétés personnalisées',
|
||||
'custom-properties.list.description': 'Définissez des champs de métadonnées personnalisés pour vos documents. Les propriétés peuvent être du texte, des nombres, des dates, des booléens ou des listes de sélection.',
|
||||
'custom-properties.list.create-button': 'Créer une propriété',
|
||||
'custom-properties.list.empty.title': 'Propriétés personnalisées',
|
||||
'custom-properties.list.empty.description': 'Les propriétés personnalisées vous permettent d\'ajouter des métadonnées structurées à vos documents, comme des dates d\'expiration, des noms d\'entreprise ou des montants.',
|
||||
'custom-properties.list.table.name': 'Nom',
|
||||
'custom-properties.list.table.type': 'Type',
|
||||
'custom-properties.list.table.description': 'Description',
|
||||
'custom-properties.list.table.created': 'Créé le',
|
||||
'custom-properties.list.table.actions': 'Actions',
|
||||
'custom-properties.list.table.no-description': 'Aucune description',
|
||||
'custom-properties.list.delete.confirm-title': 'Supprimer la propriété personnalisée',
|
||||
'custom-properties.list.delete.confirm-message': 'Êtes-vous sûr de vouloir supprimer la propriété personnalisée « {{ name }} » ? Cette action est irréversible.',
|
||||
'custom-properties.list.delete.confirm-button': 'Supprimer',
|
||||
'custom-properties.list.delete.success': 'Propriété personnalisée supprimée avec succès',
|
||||
'custom-properties.list.delete.error': 'Échec de la suppression de la propriété personnalisée',
|
||||
|
||||
'custom-properties.create.title': 'Créer une propriété personnalisée',
|
||||
'custom-properties.create.submit': 'Créer la propriété',
|
||||
'custom-properties.create.success': 'Propriété personnalisée créée avec succès',
|
||||
'custom-properties.create.error': 'Échec de la création de la propriété personnalisée',
|
||||
|
||||
'custom-properties.update.title': 'Modifier la propriété personnalisée',
|
||||
'custom-properties.update.submit': 'Enregistrer les modifications',
|
||||
'custom-properties.update.success': 'Propriété personnalisée mise à jour avec succès',
|
||||
'custom-properties.update.error': 'Échec de la mise à jour de la propriété personnalisée',
|
||||
|
||||
'custom-properties.form.name.label': 'Nom',
|
||||
'custom-properties.form.name.placeholder': 'ex. Montant de la facture',
|
||||
'custom-properties.form.name.required': 'Le nom est requis',
|
||||
'custom-properties.form.name.max-length': 'Le nom ne peut pas dépasser 255 caractères',
|
||||
'custom-properties.form.description.label': 'Description',
|
||||
'custom-properties.form.description.optional': '(facultatif)',
|
||||
'custom-properties.form.description.placeholder': 'Décrivez à quoi sert cette propriété',
|
||||
'custom-properties.form.description.max-length': 'La description ne peut pas dépasser 1000 caractères',
|
||||
'custom-properties.form.type.label': 'Type',
|
||||
'custom-properties.form.type.immutable': 'Le type de propriété ne peut pas être modifié après la création.',
|
||||
'custom-properties.form.options.title': 'Options',
|
||||
'custom-properties.form.options.description': 'Définissez les choix disponibles pour cette propriété.',
|
||||
'custom-properties.form.options.name.placeholder': 'Nom de l\'option',
|
||||
'custom-properties.form.options.name.required': 'Le nom de l\'option est requis',
|
||||
'custom-properties.form.options.name.max-length': 'Le nom de l\'option ne peut pas dépasser 255 caractères',
|
||||
'custom-properties.form.options.validation.required': 'Veuillez ajouter au moins une option',
|
||||
'custom-properties.form.options.add': 'Ajouter une option',
|
||||
'custom-properties.form.cancel': 'Annuler',
|
||||
'custom-properties.form.save-error': 'Une erreur s\'est produite lors de l\'enregistrement de la propriété. Veuillez réessayer.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Propriétés',
|
||||
'documents.custom-properties.no-value': 'Non défini',
|
||||
'documents.custom-properties.text-placeholder': 'Saisir une valeur...',
|
||||
'documents.custom-properties.save': 'Enregistrer',
|
||||
'documents.custom-properties.clear': 'Effacer',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Rechercher des documents...',
|
||||
'documents.custom-properties.user-relation-manage': 'Gérer les utilisateurs',
|
||||
'documents.custom-properties.document-relation-manage': 'Gérer les documents',
|
||||
'documents.custom-properties.no-results': 'Aucun résultat',
|
||||
|
||||
'documents.rename.title': 'Renommer le document',
|
||||
'documents.rename.form.name.label': 'Nom',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'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.name.max-length': 'Le nom doit contenir au maximum 128 caractères',
|
||||
'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',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Accueil',
|
||||
'layout.menu.documents': 'Documents',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Propriétés personnalisées',
|
||||
'layout.menu.tagging-rules': 'Règles de catégorisation',
|
||||
'layout.menu.deleted-documents': 'Documents supprimés',
|
||||
'layout.menu.organization-settings': 'Paramètres',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Clés d\'API',
|
||||
'user-menu.invitations': 'Invitations',
|
||||
'user-menu.language': 'Langue',
|
||||
'user-menu.theme': 'Thème',
|
||||
'user-menu.about': 'À propos de Papra',
|
||||
'user-menu.logout': 'Déconnexion',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Origine de l\'application invalide. Si vous hébergez Papra, assurez-vous que la variable d\'environnement APP_BASE_URL correspond à votre URL actuelle. Pour plus de détails, consultez https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Le nombre maximum de membres et d\'invitations en attente pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour ajouter plus de membres.',
|
||||
'api-errors.organization.has_active_subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif. Veuillez d\'abord annuler votre abonnement en utilisant le bouton Gérer l\'abonnement ci-dessus.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'L\'URL fournie n\'est pas autorisée. Les URLs de webhook ne doivent pas pointer vers des adresses IP privées ou réservées.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utilisateur introuvable',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Échec de la création de l\'utilisateur',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Creato il',
|
||||
'documents.info.updated-at': 'Aggiornato il',
|
||||
'documents.info.never': 'Mai',
|
||||
'documents.info.document-date': 'Data',
|
||||
'documents.info.no-date': 'Nessuna data',
|
||||
'documents.info.today': 'Oggi',
|
||||
|
||||
'custom-properties.types.text': 'Testo',
|
||||
'custom-properties.types.number': 'Numero',
|
||||
'custom-properties.types.date': 'Data',
|
||||
'custom-properties.types.boolean': 'Booleano',
|
||||
'custom-properties.types.select': 'Selezione',
|
||||
'custom-properties.types.multi_select': 'Selezione multipla',
|
||||
'custom-properties.types.user_relation': 'Utente',
|
||||
'custom-properties.types.document_relation': 'Documento',
|
||||
|
||||
'custom-properties.list.title': 'Proprietà personalizzate',
|
||||
'custom-properties.list.description': 'Definisci campi di metadati personalizzati per i tuoi documenti. Le proprietà possono essere testo, numeri, date, valori booleani o liste di selezione.',
|
||||
'custom-properties.list.create-button': 'Crea proprietà',
|
||||
'custom-properties.list.empty.title': 'Proprietà personalizzate',
|
||||
'custom-properties.list.empty.description': 'Le proprietà personalizzate ti permettono di aggiungere metadati strutturati ai tuoi documenti, come date di scadenza, nomi aziendali o importi.',
|
||||
'custom-properties.list.table.name': 'Nome',
|
||||
'custom-properties.list.table.type': 'Tipo',
|
||||
'custom-properties.list.table.description': 'Descrizione',
|
||||
'custom-properties.list.table.created': 'Creato',
|
||||
'custom-properties.list.table.actions': 'Azioni',
|
||||
'custom-properties.list.table.no-description': 'Nessuna descrizione',
|
||||
'custom-properties.list.delete.confirm-title': 'Elimina proprietà personalizzata',
|
||||
'custom-properties.list.delete.confirm-message': 'Sei sicuro di voler eliminare la proprietà personalizzata "{{ name }}"? Questa azione non può essere annullata.',
|
||||
'custom-properties.list.delete.confirm-button': 'Elimina',
|
||||
'custom-properties.list.delete.success': 'Proprietà personalizzata eliminata con successo',
|
||||
'custom-properties.list.delete.error': 'Impossibile eliminare la proprietà personalizzata',
|
||||
|
||||
'custom-properties.create.title': 'Crea proprietà personalizzata',
|
||||
'custom-properties.create.submit': 'Crea proprietà',
|
||||
'custom-properties.create.success': 'Proprietà personalizzata creata con successo',
|
||||
'custom-properties.create.error': 'Impossibile creare la proprietà personalizzata',
|
||||
|
||||
'custom-properties.update.title': 'Modifica proprietà personalizzata',
|
||||
'custom-properties.update.submit': 'Salva modifiche',
|
||||
'custom-properties.update.success': 'Proprietà personalizzata aggiornata con successo',
|
||||
'custom-properties.update.error': 'Impossibile aggiornare la proprietà personalizzata',
|
||||
|
||||
'custom-properties.form.name.label': 'Nome',
|
||||
'custom-properties.form.name.placeholder': 'es. Importo fattura',
|
||||
'custom-properties.form.name.required': 'Il nome è obbligatorio',
|
||||
'custom-properties.form.name.max-length': 'Il nome deve contenere al massimo 255 caratteri',
|
||||
'custom-properties.form.description.label': 'Descrizione',
|
||||
'custom-properties.form.description.optional': '(facoltativo)',
|
||||
'custom-properties.form.description.placeholder': 'Descrivi a cosa serve questa proprietà',
|
||||
'custom-properties.form.description.max-length': 'La descrizione deve contenere al massimo 1000 caratteri',
|
||||
'custom-properties.form.type.label': 'Tipo',
|
||||
'custom-properties.form.type.immutable': 'Il tipo di proprietà non può essere modificato dopo la creazione.',
|
||||
'custom-properties.form.options.title': 'Opzioni',
|
||||
'custom-properties.form.options.description': 'Definisci le scelte disponibili per questa proprietà.',
|
||||
'custom-properties.form.options.name.placeholder': 'Nome opzione',
|
||||
'custom-properties.form.options.name.required': 'Il nome dell\'opzione è obbligatorio',
|
||||
'custom-properties.form.options.name.max-length': 'Il nome dell\'opzione deve contenere al massimo 255 caratteri',
|
||||
'custom-properties.form.options.validation.required': 'Aggiungi almeno un\'opzione',
|
||||
'custom-properties.form.options.add': 'Aggiungi opzione',
|
||||
'custom-properties.form.cancel': 'Annulla',
|
||||
'custom-properties.form.save-error': 'Si è verificato un errore durante il salvataggio della definizione della proprietà. Riprova.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Proprietà',
|
||||
'documents.custom-properties.no-value': 'Non impostato',
|
||||
'documents.custom-properties.text-placeholder': 'Inserisci un valore...',
|
||||
'documents.custom-properties.save': 'Salva',
|
||||
'documents.custom-properties.clear': 'Cancella',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Cerca documenti...',
|
||||
'documents.custom-properties.user-relation-manage': 'Gestisci utenti',
|
||||
'documents.custom-properties.document-relation-manage': 'Gestisci documenti',
|
||||
'documents.custom-properties.no-results': 'Nessun risultato',
|
||||
|
||||
'documents.rename.title': 'Rinomina documento',
|
||||
'documents.rename.form.name.label': 'Nome',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nome webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Inserisci nome webhook',
|
||||
'webhooks.create.form.name.required': 'Il nome è obbligatorio',
|
||||
'webhooks.create.form.name.max-length': 'Il nome deve contenere al massimo 128 caratteri',
|
||||
'webhooks.create.form.url.label': 'URL webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Inserisci URL webhook',
|
||||
'webhooks.create.form.url.required': 'L\'URL è obbligatorio',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Home',
|
||||
'layout.menu.documents': 'Documenti',
|
||||
'layout.menu.tags': 'Tag',
|
||||
'layout.menu.custom-properties': 'Proprietà personalizzate',
|
||||
'layout.menu.tagging-rules': 'Regole di tagging',
|
||||
'layout.menu.deleted-documents': 'Documenti eliminati',
|
||||
'layout.menu.organization-settings': 'Impostazioni',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Chiavi API',
|
||||
'user-menu.invitations': 'Inviti',
|
||||
'user-menu.language': 'Lingua',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Informazioni su Papra',
|
||||
'user-menu.logout': 'Esci',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Origine dell\'applicazione non valida. Se stai ospitando Papra, assicurati che la variabile di ambiente APP_BASE_URL corrisponda all\'URL corrente. Per maggiori dettagli, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'È stato raggiunto il numero massimo di membri e inviti in sospeso per questa organizzazione. Aggiorna il tuo piano per aggiungere altri membri.',
|
||||
'api-errors.organization.has_active_subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo. Si prega di annullare prima l\'abbonamento utilizzando il pulsante Gestisci abbonamento sopra.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'L\'URL fornito non è consentito. Gli URL dei webhook non devono puntare a indirizzi IP privati o riservati.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utente non trovato',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Impossibile creare l\'utente',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Aangemaakt op',
|
||||
'documents.info.updated-at': 'Bijgewerkt op',
|
||||
'documents.info.never': 'Nooit',
|
||||
'documents.info.document-date': 'Datum',
|
||||
'documents.info.no-date': 'Geen datum',
|
||||
'documents.info.today': 'Vandaag',
|
||||
|
||||
'custom-properties.types.text': 'Tekst',
|
||||
'custom-properties.types.number': 'Getal',
|
||||
'custom-properties.types.date': 'Datum',
|
||||
'custom-properties.types.boolean': 'Booleaans',
|
||||
'custom-properties.types.select': 'Selectie',
|
||||
'custom-properties.types.multi_select': 'Meervoudige selectie',
|
||||
'custom-properties.types.user_relation': 'Gebruiker',
|
||||
'custom-properties.types.document_relation': 'Document',
|
||||
|
||||
'custom-properties.list.title': 'Aangepaste eigenschappen',
|
||||
'custom-properties.list.description': 'Definieer aangepaste metadatavelden voor uw documenten. Eigenschappen kunnen tekst, getallen, datums, booleaanse waarden of selectielijsten zijn.',
|
||||
'custom-properties.list.create-button': 'Eigenschap aanmaken',
|
||||
'custom-properties.list.empty.title': 'Aangepaste eigenschappen',
|
||||
'custom-properties.list.empty.description': 'Met aangepaste eigenschappen kunt u gestructureerde metadata aan uw documenten toevoegen, zoals vervaldatums, bedrijfsnamen of bedragen.',
|
||||
'custom-properties.list.table.name': 'Naam',
|
||||
'custom-properties.list.table.type': 'Type',
|
||||
'custom-properties.list.table.description': 'Beschrijving',
|
||||
'custom-properties.list.table.created': 'Aangemaakt',
|
||||
'custom-properties.list.table.actions': 'Acties',
|
||||
'custom-properties.list.table.no-description': 'Geen beschrijving',
|
||||
'custom-properties.list.delete.confirm-title': 'Aangepaste eigenschap verwijderen',
|
||||
'custom-properties.list.delete.confirm-message': 'Weet u zeker dat u de aangepaste eigenschap "{{ name }}" wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
|
||||
'custom-properties.list.delete.confirm-button': 'Verwijderen',
|
||||
'custom-properties.list.delete.success': 'Aangepaste eigenschap succesvol verwijderd',
|
||||
'custom-properties.list.delete.error': 'Verwijderen van aangepaste eigenschap mislukt',
|
||||
|
||||
'custom-properties.create.title': 'Aangepaste eigenschap aanmaken',
|
||||
'custom-properties.create.submit': 'Eigenschap aanmaken',
|
||||
'custom-properties.create.success': 'Aangepaste eigenschap succesvol aangemaakt',
|
||||
'custom-properties.create.error': 'Aanmaken van aangepaste eigenschap mislukt',
|
||||
|
||||
'custom-properties.update.title': 'Aangepaste eigenschap bewerken',
|
||||
'custom-properties.update.submit': 'Wijzigingen opslaan',
|
||||
'custom-properties.update.success': 'Aangepaste eigenschap succesvol bijgewerkt',
|
||||
'custom-properties.update.error': 'Bijwerken van aangepaste eigenschap mislukt',
|
||||
|
||||
'custom-properties.form.name.label': 'Naam',
|
||||
'custom-properties.form.name.placeholder': 'bijv. Factuurbedrag',
|
||||
'custom-properties.form.name.required': 'Naam is verplicht',
|
||||
'custom-properties.form.name.max-length': 'De naam mag maximaal 255 tekens bevatten',
|
||||
'custom-properties.form.description.label': 'Beschrijving',
|
||||
'custom-properties.form.description.optional': '(optioneel)',
|
||||
'custom-properties.form.description.placeholder': 'Beschrijf waarvoor deze eigenschap wordt gebruikt',
|
||||
'custom-properties.form.description.max-length': 'De beschrijving mag maximaal 1000 tekens bevatten',
|
||||
'custom-properties.form.type.label': 'Type',
|
||||
'custom-properties.form.type.immutable': 'Het type eigenschap kan na aanmaken niet worden gewijzigd.',
|
||||
'custom-properties.form.options.title': 'Opties',
|
||||
'custom-properties.form.options.description': 'Definieer de beschikbare keuzes voor deze eigenschap.',
|
||||
'custom-properties.form.options.name.placeholder': 'Optienaam',
|
||||
'custom-properties.form.options.name.required': 'Optienaam is verplicht',
|
||||
'custom-properties.form.options.name.max-length': 'De optienaam mag maximaal 255 tekens bevatten',
|
||||
'custom-properties.form.options.validation.required': 'Voeg ten minste één optie toe',
|
||||
'custom-properties.form.options.add': 'Optie toevoegen',
|
||||
'custom-properties.form.cancel': 'Annuleren',
|
||||
'custom-properties.form.save-error': 'Er is een fout opgetreden bij het opslaan van de eigenschapsdefinitie. Probeer het opnieuw.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Eigenschappen',
|
||||
'documents.custom-properties.no-value': 'Niet ingesteld',
|
||||
'documents.custom-properties.text-placeholder': 'Voer een waarde in...',
|
||||
'documents.custom-properties.save': 'Opslaan',
|
||||
'documents.custom-properties.clear': 'Wissen',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Documenten zoeken...',
|
||||
'documents.custom-properties.user-relation-manage': 'Gebruikers beheren',
|
||||
'documents.custom-properties.document-relation-manage': 'Documenten beheren',
|
||||
'documents.custom-properties.no-results': 'Geen resultaten',
|
||||
|
||||
'documents.rename.title': 'Document hernoemen',
|
||||
'documents.rename.form.name.label': 'Naam',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Webhooknaam',
|
||||
'webhooks.create.form.name.placeholder': 'Voer een webhooknaam in',
|
||||
'webhooks.create.form.name.required': 'Naam is verplicht',
|
||||
'webhooks.create.form.name.max-length': 'De naam mag maximaal 128 tekens bevatten',
|
||||
'webhooks.create.form.url.label': 'Webhook-URL',
|
||||
'webhooks.create.form.url.placeholder': 'Voer een webhook-URL in',
|
||||
'webhooks.create.form.url.required': 'URL is verplicht',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Start',
|
||||
'layout.menu.documents': 'Documenten',
|
||||
'layout.menu.tags': 'Labels',
|
||||
'layout.menu.custom-properties': 'Aangepaste eigenschappen',
|
||||
'layout.menu.tagging-rules': 'Labelregels',
|
||||
'layout.menu.deleted-documents': 'Verwijderde documenten',
|
||||
'layout.menu.organization-settings': 'Instellingen',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API-sleutels',
|
||||
'user-menu.invitations': 'Uitnodigingen',
|
||||
'user-menu.language': 'Taal',
|
||||
'user-menu.theme': 'Thema',
|
||||
'user-menu.about': 'Over Papra',
|
||||
'user-menu.logout': 'Uitloggen',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Ongeldige applicatiebron. Als u Papra zelf host, zorg ervoor dat uw APP_BASE_URL-omgeving variabele overeenkomt met uw huidige URL. Zie https://docs.papra.app/resources/troubleshooting/#invalid-application-origin voor meer informatie.',
|
||||
'api-errors.organization.max_members_count_reached': 'Het maximale aantal leden en openstaande uitnodigingen voor deze organisatie is bereikt. Upgrade uw plan om meer leden toe te voegen.',
|
||||
'api-errors.organization.has_active_subscription': 'Kan de organisatie niet verwijderen vanwege een actief abonnement. Annuleer eerst uw abonnement via de knop Abonnement beheren.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'De opgegeven URL is niet toegestaan. Webhook-URL\'s mogen niet verwijzen naar privé- of gereserveerde IP-adressen.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Gebruiker niet gevonden',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Kan gebruiker niet aanmaken',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Utworzono',
|
||||
'documents.info.updated-at': 'Zaktualizowano',
|
||||
'documents.info.never': 'Nigdy',
|
||||
'documents.info.document-date': 'Data',
|
||||
'documents.info.no-date': 'Brak daty',
|
||||
'documents.info.today': 'Dzisiaj',
|
||||
|
||||
'custom-properties.types.text': 'Tekst',
|
||||
'custom-properties.types.number': 'Liczba',
|
||||
'custom-properties.types.date': 'Data',
|
||||
'custom-properties.types.boolean': 'Wartość logiczna',
|
||||
'custom-properties.types.select': 'Wybór',
|
||||
'custom-properties.types.multi_select': 'Wybór wielokrotny',
|
||||
'custom-properties.types.user_relation': 'Użytkownik',
|
||||
'custom-properties.types.document_relation': 'Dokument',
|
||||
|
||||
'custom-properties.list.title': 'Właściwości niestandardowe',
|
||||
'custom-properties.list.description': 'Zdefiniuj niestandardowe pola metadanych dla swoich dokumentów. Właściwości mogą być tekstem, liczbami, datami, wartościami logicznymi lub listami wyboru.',
|
||||
'custom-properties.list.create-button': 'Utwórz właściwość',
|
||||
'custom-properties.list.empty.title': 'Właściwości niestandardowe',
|
||||
'custom-properties.list.empty.description': 'Właściwości niestandardowe pozwalają dodawać ustrukturyzowane metadane do dokumentów, takie jak daty wygaśnięcia, nazwy firm lub kwoty.',
|
||||
'custom-properties.list.table.name': 'Nazwa',
|
||||
'custom-properties.list.table.type': 'Typ',
|
||||
'custom-properties.list.table.description': 'Opis',
|
||||
'custom-properties.list.table.created': 'Utworzono',
|
||||
'custom-properties.list.table.actions': 'Działania',
|
||||
'custom-properties.list.table.no-description': 'Brak opisu',
|
||||
'custom-properties.list.delete.confirm-title': 'Usuń właściwość niestandardową',
|
||||
'custom-properties.list.delete.confirm-message': 'Czy na pewno chcesz usunąć właściwość niestandardową „{{ name }}”? Tej operacji nie można cofnąć.',
|
||||
'custom-properties.list.delete.confirm-button': 'Usuń',
|
||||
'custom-properties.list.delete.success': 'Właściwość niestandardowa została pomyślnie usunięta',
|
||||
'custom-properties.list.delete.error': 'Nie udało się usunąć właściwości niestandardowej',
|
||||
|
||||
'custom-properties.create.title': 'Utwórz właściwość niestandardową',
|
||||
'custom-properties.create.submit': 'Utwórz właściwość',
|
||||
'custom-properties.create.success': 'Właściwość niestandardowa została pomyślnie utworzona',
|
||||
'custom-properties.create.error': 'Nie udało się utworzyć właściwości niestandardowej',
|
||||
|
||||
'custom-properties.update.title': 'Edytuj właściwość niestandardową',
|
||||
'custom-properties.update.submit': 'Zapisz zmiany',
|
||||
'custom-properties.update.success': 'Właściwość niestandardowa została pomyślnie zaktualizowana',
|
||||
'custom-properties.update.error': 'Nie udało się zaktualizować właściwości niestandardowej',
|
||||
|
||||
'custom-properties.form.name.label': 'Nazwa',
|
||||
'custom-properties.form.name.placeholder': 'np. Kwota faktury',
|
||||
'custom-properties.form.name.required': 'Nazwa jest wymagana',
|
||||
'custom-properties.form.name.max-length': 'Nazwa może mieć co najwyżej 255 znaków',
|
||||
'custom-properties.form.description.label': 'Opis',
|
||||
'custom-properties.form.description.optional': '(opcjonalnie)',
|
||||
'custom-properties.form.description.placeholder': 'Opisz, do czego służy ta właściwość',
|
||||
'custom-properties.form.description.max-length': 'Opis może mieć co najwyżej 1000 znaków',
|
||||
'custom-properties.form.type.label': 'Typ',
|
||||
'custom-properties.form.type.immutable': 'Typ właściwości nie może zostać zmieniony po jej utworzeniu.',
|
||||
'custom-properties.form.options.title': 'Opcje',
|
||||
'custom-properties.form.options.description': 'Zdefiniuj dostępne opcje dla tej właściwości.',
|
||||
'custom-properties.form.options.name.placeholder': 'Nazwa opcji',
|
||||
'custom-properties.form.options.name.required': 'Nazwa opcji jest wymagana',
|
||||
'custom-properties.form.options.name.max-length': 'Nazwa opcji może mieć co najwyżej 255 znaków',
|
||||
'custom-properties.form.options.validation.required': 'Dodaj co najmniej jedną opcję',
|
||||
'custom-properties.form.options.add': 'Dodaj opcję',
|
||||
'custom-properties.form.cancel': 'Anuluj',
|
||||
'custom-properties.form.save-error': 'Wystąpił błąd podczas zapisywania definicji właściwości. Spróbuj ponownie.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Właściwości',
|
||||
'documents.custom-properties.no-value': 'Nie ustawiono',
|
||||
'documents.custom-properties.text-placeholder': 'Wprowadź wartość...',
|
||||
'documents.custom-properties.save': 'Zapisz',
|
||||
'documents.custom-properties.clear': 'Wyczyść',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Szukaj dokumentów...',
|
||||
'documents.custom-properties.user-relation-manage': 'Zarządzaj użytkownikami',
|
||||
'documents.custom-properties.document-relation-manage': 'Zarządzaj dokumentami',
|
||||
'documents.custom-properties.no-results': 'Brak wyników',
|
||||
|
||||
'documents.rename.title': 'Zmień nazwę dokumentu',
|
||||
'documents.rename.form.name.label': 'Nazwa',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nazwa webhooka',
|
||||
'webhooks.create.form.name.placeholder': 'Wprowadź nazwę webhooka',
|
||||
'webhooks.create.form.name.required': 'Nazwa jest wymagana',
|
||||
'webhooks.create.form.name.max-length': 'Nazwa może mieć maksymalnie 128 znaków',
|
||||
'webhooks.create.form.url.label': 'URL webhooka',
|
||||
'webhooks.create.form.url.placeholder': 'Wprowadź URL webhooka',
|
||||
'webhooks.create.form.url.required': 'URL jest wymagany',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Strona główna',
|
||||
'layout.menu.documents': 'Dokumenty',
|
||||
'layout.menu.tags': 'Tagi',
|
||||
'layout.menu.custom-properties': 'Właściwości niestandardowe',
|
||||
'layout.menu.tagging-rules': 'Zasady tagowania',
|
||||
'layout.menu.deleted-documents': 'Usunięte dokumenty',
|
||||
'layout.menu.organization-settings': 'Ustawienia',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Klucze API',
|
||||
'user-menu.invitations': 'Zaproszenia',
|
||||
'user-menu.language': 'Język',
|
||||
'user-menu.theme': 'Motyw',
|
||||
'user-menu.about': 'O Papra',
|
||||
'user-menu.logout': 'Wyloguj',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Nieprawidłowa lokalizacja aplikacji. Jeśli hostujesz Papra, upewnij się, że zmienna środowiskowa APP_BASE_URL odpowiada bieżącemu adresowi URL. Aby uzyskać więcej informacji, zobacz https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Osiągnięto maksymalną liczbę członków i oczekujących zaproszeń dla tej organizacji. Zaktualizuj swój plan, aby dodać więcej członków.',
|
||||
'api-errors.organization.has_active_subscription': 'Nie można usunąć organizacji z aktywną subskrypcją. Proszę najpierw anulować subskrypcję za pomocą przycisku Zarządzaj subskrypcją powyżej.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'Podany adres URL jest niedozwolony. Adresy URL webhooków nie mogą wskazywać na prywatne lub zarezerwowane adresy IP.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Nie znaleziono użytkownika',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Nie udało się utworzyć użytkownika',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Criado em',
|
||||
'documents.info.updated-at': 'Atualizado em',
|
||||
'documents.info.never': 'Nunca',
|
||||
'documents.info.document-date': 'Data',
|
||||
'documents.info.no-date': 'Sem data',
|
||||
'documents.info.today': 'Hoje',
|
||||
|
||||
'custom-properties.types.text': 'Texto',
|
||||
'custom-properties.types.number': 'Número',
|
||||
'custom-properties.types.date': 'Data',
|
||||
'custom-properties.types.boolean': 'Booleano',
|
||||
'custom-properties.types.select': 'Seleção',
|
||||
'custom-properties.types.multi_select': 'Seleção múltipla',
|
||||
'custom-properties.types.user_relation': 'Usuário',
|
||||
'custom-properties.types.document_relation': 'Documento',
|
||||
|
||||
'custom-properties.list.title': 'Propriedades personalizadas',
|
||||
'custom-properties.list.description': 'Defina campos de metadados personalizados para seus documentos. As propriedades podem ser texto, números, datas, booleanos ou listas de seleção.',
|
||||
'custom-properties.list.create-button': 'Criar propriedade',
|
||||
'custom-properties.list.empty.title': 'Propriedades personalizadas',
|
||||
'custom-properties.list.empty.description': 'As propriedades personalizadas permitem adicionar metadados estruturados aos seus documentos, como datas de vencimento, nomes de empresas ou valores.',
|
||||
'custom-properties.list.table.name': 'Nome',
|
||||
'custom-properties.list.table.type': 'Tipo',
|
||||
'custom-properties.list.table.description': 'Descrição',
|
||||
'custom-properties.list.table.created': 'Criado em',
|
||||
'custom-properties.list.table.actions': 'Ações',
|
||||
'custom-properties.list.table.no-description': 'Sem descrição',
|
||||
'custom-properties.list.delete.confirm-title': 'Excluir propriedade personalizada',
|
||||
'custom-properties.list.delete.confirm-message': 'Tem certeza que deseja excluir a propriedade personalizada "{{ name }}"? Esta ação não pode ser desfeita.',
|
||||
'custom-properties.list.delete.confirm-button': 'Excluir',
|
||||
'custom-properties.list.delete.success': 'Propriedade personalizada excluída com sucesso',
|
||||
'custom-properties.list.delete.error': 'Falha ao excluir a propriedade personalizada',
|
||||
|
||||
'custom-properties.create.title': 'Criar propriedade personalizada',
|
||||
'custom-properties.create.submit': 'Criar propriedade',
|
||||
'custom-properties.create.success': 'Propriedade personalizada criada com sucesso',
|
||||
'custom-properties.create.error': 'Falha ao criar a propriedade personalizada',
|
||||
|
||||
'custom-properties.update.title': 'Editar propriedade personalizada',
|
||||
'custom-properties.update.submit': 'Salvar alterações',
|
||||
'custom-properties.update.success': 'Propriedade personalizada atualizada com sucesso',
|
||||
'custom-properties.update.error': 'Falha ao atualizar a propriedade personalizada',
|
||||
|
||||
'custom-properties.form.name.label': 'Nome',
|
||||
'custom-properties.form.name.placeholder': 'ex.: Valor da fatura',
|
||||
'custom-properties.form.name.required': 'O nome é obrigatório',
|
||||
'custom-properties.form.name.max-length': 'O nome deve ter no máximo 255 caracteres',
|
||||
'custom-properties.form.description.label': 'Descrição',
|
||||
'custom-properties.form.description.optional': '(opcional)',
|
||||
'custom-properties.form.description.placeholder': 'Descreva para que esta propriedade é usada',
|
||||
'custom-properties.form.description.max-length': 'A descrição deve ter no máximo 1000 caracteres',
|
||||
'custom-properties.form.type.label': 'Tipo',
|
||||
'custom-properties.form.type.immutable': 'O tipo de propriedade não pode ser alterado após a criação.',
|
||||
'custom-properties.form.options.title': 'Opções',
|
||||
'custom-properties.form.options.description': 'Defina as escolhas disponíveis para esta propriedade.',
|
||||
'custom-properties.form.options.name.placeholder': 'Nome da opção',
|
||||
'custom-properties.form.options.name.required': 'O nome da opção é obrigatório',
|
||||
'custom-properties.form.options.name.max-length': 'O nome da opção deve ter no máximo 255 caracteres',
|
||||
'custom-properties.form.options.validation.required': 'Adicione pelo menos uma opção',
|
||||
'custom-properties.form.options.add': 'Adicionar opção',
|
||||
'custom-properties.form.cancel': 'Cancelar',
|
||||
'custom-properties.form.save-error': 'Ocorreu um erro ao salvar a definição da propriedade. Tente novamente.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Propriedades',
|
||||
'documents.custom-properties.no-value': 'Não definido',
|
||||
'documents.custom-properties.text-placeholder': 'Digite um valor...',
|
||||
'documents.custom-properties.save': 'Salvar',
|
||||
'documents.custom-properties.clear': 'Limpar',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Pesquisar documentos...',
|
||||
'documents.custom-properties.user-relation-manage': 'Gerenciar usuários',
|
||||
'documents.custom-properties.document-relation-manage': 'Gerenciar documentos',
|
||||
'documents.custom-properties.no-results': 'Nenhum resultado',
|
||||
|
||||
'documents.rename.title': 'Renomear documento',
|
||||
'documents.rename.form.name.label': 'Nome',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nome do webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Insira o nome do webhook',
|
||||
'webhooks.create.form.name.required': 'O nome é obrigatório',
|
||||
'webhooks.create.form.name.max-length': 'O nome deve ter no máximo 128 caracteres',
|
||||
'webhooks.create.form.url.label': 'URL do Webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Insira a URL do webhook',
|
||||
'webhooks.create.form.url.required': 'A URL é obrigatória',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Início',
|
||||
'layout.menu.documents': 'Documentos',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Propriedades personalizadas',
|
||||
'layout.menu.tagging-rules': 'Regras de marcação',
|
||||
'layout.menu.deleted-documents': 'Documentos excluídos',
|
||||
'layout.menu.organization-settings': 'Configurações',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Chaves de API',
|
||||
'user-menu.invitations': 'Convites',
|
||||
'user-menu.language': 'Idioma',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Sobre o Papra',
|
||||
'user-menu.logout': 'Sair',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize seu plano para adicionar mais membros.',
|
||||
'api-errors.organization.has_active_subscription': 'Não é possível excluir a organização com uma assinatura ativa. Por favor, cancele sua assinatura primeiro usando o botão Gerenciar Assinatura acima.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'A URL fornecida não é permitida. URLs de webhook não devem apontar para endereços IP privados ou reservados.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Usuário não encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar usuário',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Criado em',
|
||||
'documents.info.updated-at': 'Atualizado em',
|
||||
'documents.info.never': 'Nunca',
|
||||
'documents.info.document-date': 'Data',
|
||||
'documents.info.no-date': 'Sem data',
|
||||
'documents.info.today': 'Hoje',
|
||||
|
||||
'custom-properties.types.text': 'Texto',
|
||||
'custom-properties.types.number': 'Número',
|
||||
'custom-properties.types.date': 'Data',
|
||||
'custom-properties.types.boolean': 'Booleano',
|
||||
'custom-properties.types.select': 'Seleção',
|
||||
'custom-properties.types.multi_select': 'Seleção múltipla',
|
||||
'custom-properties.types.user_relation': 'Utilizador',
|
||||
'custom-properties.types.document_relation': 'Documento',
|
||||
|
||||
'custom-properties.list.title': 'Propriedades personalizadas',
|
||||
'custom-properties.list.description': 'Defina campos de metadados personalizados para os seus documentos. As propriedades podem ser texto, números, datas, booleanos ou listas de seleção.',
|
||||
'custom-properties.list.create-button': 'Criar propriedade',
|
||||
'custom-properties.list.empty.title': 'Propriedades personalizadas',
|
||||
'custom-properties.list.empty.description': 'As propriedades personalizadas permitem adicionar metadados estruturados aos seus documentos, como datas de validade, nomes de empresas ou montantes.',
|
||||
'custom-properties.list.table.name': 'Nome',
|
||||
'custom-properties.list.table.type': 'Tipo',
|
||||
'custom-properties.list.table.description': 'Descrição',
|
||||
'custom-properties.list.table.created': 'Criado',
|
||||
'custom-properties.list.table.actions': 'Ações',
|
||||
'custom-properties.list.table.no-description': 'Sem descrição',
|
||||
'custom-properties.list.delete.confirm-title': 'Eliminar propriedade personalizada',
|
||||
'custom-properties.list.delete.confirm-message': 'Tem a certeza de que pretende eliminar a propriedade personalizada "{{ name }}"? Esta ação não pode ser desfeita.',
|
||||
'custom-properties.list.delete.confirm-button': 'Eliminar',
|
||||
'custom-properties.list.delete.success': 'Propriedade personalizada eliminada com sucesso',
|
||||
'custom-properties.list.delete.error': 'Falha ao eliminar a propriedade personalizada',
|
||||
|
||||
'custom-properties.create.title': 'Criar propriedade personalizada',
|
||||
'custom-properties.create.submit': 'Criar propriedade',
|
||||
'custom-properties.create.success': 'Propriedade personalizada criada com sucesso',
|
||||
'custom-properties.create.error': 'Falha ao criar a propriedade personalizada',
|
||||
|
||||
'custom-properties.update.title': 'Editar propriedade personalizada',
|
||||
'custom-properties.update.submit': 'Guardar alterações',
|
||||
'custom-properties.update.success': 'Propriedade personalizada atualizada com sucesso',
|
||||
'custom-properties.update.error': 'Falha ao atualizar a propriedade personalizada',
|
||||
|
||||
'custom-properties.form.name.label': 'Nome',
|
||||
'custom-properties.form.name.placeholder': 'ex.: Montante da fatura',
|
||||
'custom-properties.form.name.required': 'O nome é obrigatório',
|
||||
'custom-properties.form.name.max-length': 'O nome deve ter no máximo 255 caracteres',
|
||||
'custom-properties.form.description.label': 'Descrição',
|
||||
'custom-properties.form.description.optional': '(opcional)',
|
||||
'custom-properties.form.description.placeholder': 'Descreva para que serve esta propriedade',
|
||||
'custom-properties.form.description.max-length': 'A descrição deve ter no máximo 1000 caracteres',
|
||||
'custom-properties.form.type.label': 'Tipo',
|
||||
'custom-properties.form.type.immutable': 'O tipo de propriedade não pode ser alterado após a criação.',
|
||||
'custom-properties.form.options.title': 'Opções',
|
||||
'custom-properties.form.options.description': 'Defina as escolhas disponíveis para esta propriedade.',
|
||||
'custom-properties.form.options.name.placeholder': 'Nome da opção',
|
||||
'custom-properties.form.options.name.required': 'O nome da opção é obrigatório',
|
||||
'custom-properties.form.options.name.max-length': 'O nome da opção deve ter no máximo 255 caracteres',
|
||||
'custom-properties.form.options.validation.required': 'Adicione pelo menos uma opção',
|
||||
'custom-properties.form.options.add': 'Adicionar opção',
|
||||
'custom-properties.form.cancel': 'Cancelar',
|
||||
'custom-properties.form.save-error': 'Ocorreu um erro ao guardar a definição da propriedade. Tente novamente.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Propriedades',
|
||||
'documents.custom-properties.no-value': 'Não definido',
|
||||
'documents.custom-properties.text-placeholder': 'Introduzir um valor...',
|
||||
'documents.custom-properties.save': 'Guardar',
|
||||
'documents.custom-properties.clear': 'Limpar',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Pesquisar documentos...',
|
||||
'documents.custom-properties.user-relation-manage': 'Gerir utilizadores',
|
||||
'documents.custom-properties.document-relation-manage': 'Gerir documentos',
|
||||
'documents.custom-properties.no-results': 'Sem resultados',
|
||||
|
||||
'documents.rename.title': 'Renomear documento',
|
||||
'documents.rename.form.name.label': 'Nome',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nome do webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Insira o nome do webhook',
|
||||
'webhooks.create.form.name.required': 'O nome é obrigatório',
|
||||
'webhooks.create.form.name.max-length': 'O nome deve ter no máximo 128 caracteres',
|
||||
'webhooks.create.form.url.label': 'URL do Webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Insira o URL do webhook',
|
||||
'webhooks.create.form.url.required': 'O URL é obrigatória',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Início',
|
||||
'layout.menu.documents': 'Documentos',
|
||||
'layout.menu.tags': 'Tags',
|
||||
'layout.menu.custom-properties': 'Propriedades personalizadas',
|
||||
'layout.menu.tagging-rules': 'Regras de etiquetagem',
|
||||
'layout.menu.deleted-documents': 'Documentos eliminados',
|
||||
'layout.menu.organization-settings': 'Definições',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Chaves API',
|
||||
'user-menu.invitations': 'Convites',
|
||||
'user-menu.language': 'Linguagem',
|
||||
'user-menu.theme': 'Tema',
|
||||
'user-menu.about': 'Acerca do Papra',
|
||||
'user-menu.logout': 'Sair',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize o seu plano para adicionar mais membros.',
|
||||
'api-errors.organization.has_active_subscription': 'Não é possível eliminar a organização com uma subscrição ativa. Por favor, cancele a sua subscrição primeiro usando o botão Gerir Subscrição acima.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'O URL fornecido não é permitido. Os URLs de webhook não devem apontar para endereços IP privados ou reservados.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utilizador não encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar utilizador',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Creat la',
|
||||
'documents.info.updated-at': 'Actualizat la',
|
||||
'documents.info.never': 'Niciodată',
|
||||
'documents.info.document-date': 'Data',
|
||||
'documents.info.no-date': 'Fără dată',
|
||||
'documents.info.today': 'Astăzi',
|
||||
|
||||
'custom-properties.types.text': 'Text',
|
||||
'custom-properties.types.number': 'Număr',
|
||||
'custom-properties.types.date': 'Dată',
|
||||
'custom-properties.types.boolean': 'Boolean',
|
||||
'custom-properties.types.select': 'Selectare',
|
||||
'custom-properties.types.multi_select': 'Selectare multiplă',
|
||||
'custom-properties.types.user_relation': 'Utilizator',
|
||||
'custom-properties.types.document_relation': 'Document',
|
||||
|
||||
'custom-properties.list.title': 'Proprietăți personalizate',
|
||||
'custom-properties.list.description': 'Definiți câmpuri de metadate personalizate pentru documentele dvs. Proprietățile pot fi text, numere, date, valori booleene sau liste de selectare.',
|
||||
'custom-properties.list.create-button': 'Creare proprietate',
|
||||
'custom-properties.list.empty.title': 'Proprietăți personalizate',
|
||||
'custom-properties.list.empty.description': 'Proprietățile personalizate vă permit să adăugați metadate structurate la documentele dvs., precum date de expirare, nume de companii sau sume.',
|
||||
'custom-properties.list.table.name': 'Nume',
|
||||
'custom-properties.list.table.type': 'Tip',
|
||||
'custom-properties.list.table.description': 'Descriere',
|
||||
'custom-properties.list.table.created': 'Creat',
|
||||
'custom-properties.list.table.actions': 'Acțiuni',
|
||||
'custom-properties.list.table.no-description': 'Fără descriere',
|
||||
'custom-properties.list.delete.confirm-title': 'Ștergere proprietate personalizată',
|
||||
'custom-properties.list.delete.confirm-message': 'Sigur doriți să ștergeți proprietatea personalizată „{{ name }}”? Această acțiune nu poate fi anulată.',
|
||||
'custom-properties.list.delete.confirm-button': 'Șterge',
|
||||
'custom-properties.list.delete.success': 'Proprietatea personalizată a fost ștearsă cu succes',
|
||||
'custom-properties.list.delete.error': 'Ștergerea proprietății personalizate a eșuat',
|
||||
|
||||
'custom-properties.create.title': 'Creare proprietate personalizată',
|
||||
'custom-properties.create.submit': 'Creare proprietate',
|
||||
'custom-properties.create.success': 'Proprietatea personalizată a fost creată cu succes',
|
||||
'custom-properties.create.error': 'Crearea proprietății personalizate a eșuat',
|
||||
|
||||
'custom-properties.update.title': 'Editare proprietate personalizată',
|
||||
'custom-properties.update.submit': 'Salvare modificări',
|
||||
'custom-properties.update.success': 'Proprietatea personalizată a fost actualizată cu succes',
|
||||
'custom-properties.update.error': 'Actualizarea proprietății personalizate a eșuat',
|
||||
|
||||
'custom-properties.form.name.label': 'Nume',
|
||||
'custom-properties.form.name.placeholder': 'ex.: Suma facturii',
|
||||
'custom-properties.form.name.required': 'Numele este obligatoriu',
|
||||
'custom-properties.form.name.max-length': 'Numele trebuie să aibă cel mult 255 de caractere',
|
||||
'custom-properties.form.description.label': 'Descriere',
|
||||
'custom-properties.form.description.optional': '(opțional)',
|
||||
'custom-properties.form.description.placeholder': 'Descrieți la ce servește această proprietate',
|
||||
'custom-properties.form.description.max-length': 'Descrierea trebuie să aibă cel mult 1000 de caractere',
|
||||
'custom-properties.form.type.label': 'Tip',
|
||||
'custom-properties.form.type.immutable': 'Tipul proprietății nu poate fi modificat după creare.',
|
||||
'custom-properties.form.options.title': 'Opțiuni',
|
||||
'custom-properties.form.options.description': 'Definiți opțiunile disponibile pentru această proprietate.',
|
||||
'custom-properties.form.options.name.placeholder': 'Denumire opțiune',
|
||||
'custom-properties.form.options.name.required': 'Denumirea opțiunii este obligatorie',
|
||||
'custom-properties.form.options.name.max-length': 'Denumirea opțiunii trebuie să aibă cel mult 255 de caractere',
|
||||
'custom-properties.form.options.validation.required': 'Adăugați cel puțin o opțiune',
|
||||
'custom-properties.form.options.add': 'Adăugare opțiune',
|
||||
'custom-properties.form.cancel': 'Anulare',
|
||||
'custom-properties.form.save-error': 'A apărut o eroare la salvarea definiției proprietății. Vă rugăm să încercați din nou.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Proprietăți',
|
||||
'documents.custom-properties.no-value': 'Nesetat',
|
||||
'documents.custom-properties.text-placeholder': 'Introduceți o valoare...',
|
||||
'documents.custom-properties.save': 'Salvare',
|
||||
'documents.custom-properties.clear': 'Golire',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Căutare documente...',
|
||||
'documents.custom-properties.user-relation-manage': 'Gestionare utilizatori',
|
||||
'documents.custom-properties.document-relation-manage': 'Gestionare documente',
|
||||
'documents.custom-properties.no-results': 'Niciun rezultat',
|
||||
|
||||
'documents.rename.title': 'Redenumește documentul',
|
||||
'documents.rename.form.name.label': 'Nume',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Nume webhook',
|
||||
'webhooks.create.form.name.placeholder': 'Introdu numele webhook-ului',
|
||||
'webhooks.create.form.name.required': 'Numele este obligatoriu',
|
||||
'webhooks.create.form.name.max-length': 'Numele trebuie să aibă cel mult 128 de caractere',
|
||||
'webhooks.create.form.url.label': 'URL webhook',
|
||||
'webhooks.create.form.url.placeholder': 'Introdu URL-ul webhook-ului',
|
||||
'webhooks.create.form.url.required': 'URL-ul este obligatoriu',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Acasă',
|
||||
'layout.menu.documents': 'Documente',
|
||||
'layout.menu.tags': 'Etichete',
|
||||
'layout.menu.custom-properties': 'Proprietăți personalizate',
|
||||
'layout.menu.tagging-rules': 'Reguli de etichetare',
|
||||
'layout.menu.deleted-documents': 'Documente șterse',
|
||||
'layout.menu.organization-settings': 'Setări organizație',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'Chei API',
|
||||
'user-menu.invitations': 'Invitații',
|
||||
'user-menu.language': 'Limbă',
|
||||
'user-menu.theme': 'Temă',
|
||||
'user-menu.about': 'Despre Papra',
|
||||
'user-menu.logout': 'Deconectare',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Origine invalidă a aplicației. Dacă hospedezi Papra, asigură-te că variabila de mediu APP_BASE_URL corespunde URL-ului actual. Pentru mai multe detalii, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Numărul maxim de membri și invitații în așteptare pentru această organizație a fost atins. Te rugăm să îți actualizezi planul pentru a adăuga mai mulți membri.',
|
||||
'api-errors.organization.has_active_subscription': 'Nu se poate șterge organizația cu un abonament activ. Vă rugăm să anulați mai întâi abonamentul folosind butonul Gestionați abonamentul de mai sus.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'URL-ul furnizat nu este permis. URL-urile webhook nu trebuie să indice spre adrese IP private sau rezervate.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utilizatorul nu a fost găsit',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Eroare la crearea utilizatorului',
|
||||
|
|
|
|||
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': 'Создан',
|
||||
'documents.info.updated-at': 'Обновлён',
|
||||
'documents.info.never': 'Никогда',
|
||||
'documents.info.document-date': 'Дата',
|
||||
'documents.info.no-date': 'Нет даты',
|
||||
'documents.info.today': 'Сегодня',
|
||||
|
||||
'custom-properties.types.text': 'Текст',
|
||||
'custom-properties.types.number': 'Число',
|
||||
'custom-properties.types.date': 'Дата',
|
||||
'custom-properties.types.boolean': 'Логическое значение',
|
||||
'custom-properties.types.select': 'Выбор',
|
||||
'custom-properties.types.multi_select': 'Множественный выбор',
|
||||
'custom-properties.types.user_relation': 'Пользователь',
|
||||
'custom-properties.types.document_relation': 'Документ',
|
||||
|
||||
'custom-properties.list.title': 'Пользовательские свойства',
|
||||
'custom-properties.list.description': 'Задайте пользовательские поля метаданных для ваших документов. Свойства могут быть текстом, числами, датами, логическими значениями или списками выбора.',
|
||||
'custom-properties.list.create-button': 'Создать свойство',
|
||||
'custom-properties.list.empty.title': 'Пользовательские свойства',
|
||||
'custom-properties.list.empty.description': 'Пользовательские свойства позволяют добавлять структурированные метаданные к документам, например даты истечения срока, названия компаний или суммы.',
|
||||
'custom-properties.list.table.name': 'Название',
|
||||
'custom-properties.list.table.type': 'Тип',
|
||||
'custom-properties.list.table.description': 'Описание',
|
||||
'custom-properties.list.table.created': 'Создано',
|
||||
'custom-properties.list.table.actions': 'Действия',
|
||||
'custom-properties.list.table.no-description': 'Нет описания',
|
||||
'custom-properties.list.delete.confirm-title': 'Удалить пользовательское свойство',
|
||||
'custom-properties.list.delete.confirm-message': 'Вы уверены, что хотите удалить пользовательское свойство «{{ name }}»? Это действие нельзя отменить.',
|
||||
'custom-properties.list.delete.confirm-button': 'Удалить',
|
||||
'custom-properties.list.delete.success': 'Пользовательское свойство успешно удалено',
|
||||
'custom-properties.list.delete.error': 'Не удалось удалить пользовательское свойство',
|
||||
|
||||
'custom-properties.create.title': 'Создание пользовательского свойства',
|
||||
'custom-properties.create.submit': 'Создать свойство',
|
||||
'custom-properties.create.success': 'Пользовательское свойство успешно создано',
|
||||
'custom-properties.create.error': 'Не удалось создать пользовательское свойство',
|
||||
|
||||
'custom-properties.update.title': 'Изменение пользовательского свойства',
|
||||
'custom-properties.update.submit': 'Сохранить изменения',
|
||||
'custom-properties.update.success': 'Пользовательское свойство успешно обновлено',
|
||||
'custom-properties.update.error': 'Не удалось обновить пользовательское свойство',
|
||||
|
||||
'custom-properties.form.name.label': 'Название',
|
||||
'custom-properties.form.name.placeholder': 'например, Сумма счёта',
|
||||
'custom-properties.form.name.required': 'Название обязательно',
|
||||
'custom-properties.form.name.max-length': 'Название должно содержать не более 255 символов',
|
||||
'custom-properties.form.description.label': 'Описание',
|
||||
'custom-properties.form.description.optional': '(необязательно)',
|
||||
'custom-properties.form.description.placeholder': 'Опишите, для чего используется это свойство',
|
||||
'custom-properties.form.description.max-length': 'Описание должно содержать не более 1000 символов',
|
||||
'custom-properties.form.type.label': 'Тип',
|
||||
'custom-properties.form.type.immutable': 'Тип свойства нельзя изменить после создания.',
|
||||
'custom-properties.form.options.title': 'Варианты',
|
||||
'custom-properties.form.options.description': 'Задайте доступные варианты для этого свойства.',
|
||||
'custom-properties.form.options.name.placeholder': 'Название варианта',
|
||||
'custom-properties.form.options.name.required': 'Название варианта обязательно',
|
||||
'custom-properties.form.options.name.max-length': 'Название варианта должно содержать не более 255 символов',
|
||||
'custom-properties.form.options.validation.required': 'Добавьте хотя бы один вариант',
|
||||
'custom-properties.form.options.add': 'Добавить вариант',
|
||||
'custom-properties.form.cancel': 'Отмена',
|
||||
'custom-properties.form.save-error': 'Произошла ошибка при сохранении определения свойства. Пожалуйста, попробуйте ещё раз.',
|
||||
|
||||
'documents.custom-properties.section-title': 'Свойства',
|
||||
'documents.custom-properties.no-value': 'Не задано',
|
||||
'documents.custom-properties.text-placeholder': 'Введите значение...',
|
||||
'documents.custom-properties.save': 'Сохранить',
|
||||
'documents.custom-properties.clear': 'Очистить',
|
||||
'documents.custom-properties.document-relation-search-placeholder': 'Поиск документов...',
|
||||
'documents.custom-properties.user-relation-manage': 'Управление пользователями',
|
||||
'documents.custom-properties.document-relation-manage': 'Управление документами',
|
||||
'documents.custom-properties.no-results': 'Нет результатов',
|
||||
|
||||
'documents.rename.title': 'Переименовывание документа',
|
||||
'documents.rename.form.name.label': 'Имя',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Название вебхука',
|
||||
'webhooks.create.form.name.placeholder': 'Введите название вебхука',
|
||||
'webhooks.create.form.name.required': 'Название обязательно',
|
||||
'webhooks.create.form.name.max-length': 'Название должно содержать не более 128 символов',
|
||||
'webhooks.create.form.url.label': 'URL вебхука',
|
||||
'webhooks.create.form.url.placeholder': 'Введите URL вебхука',
|
||||
'webhooks.create.form.url.required': 'URL обязателен',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': 'Главная',
|
||||
'layout.menu.documents': 'Документы',
|
||||
'layout.menu.tags': 'Теги',
|
||||
'layout.menu.custom-properties': 'Пользовательские свойства',
|
||||
'layout.menu.tagging-rules': 'Правила тегирования',
|
||||
'layout.menu.deleted-documents': 'Удалённые документы',
|
||||
'layout.menu.organization-settings': 'Настройки',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API ключи',
|
||||
'user-menu.invitations': 'Приглашения',
|
||||
'user-menu.language': 'Язык',
|
||||
'user-menu.theme': 'Тема',
|
||||
'user-menu.about': 'Информация',
|
||||
'user-menu.logout': 'Выйти',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': 'Неверный origin приложения. Если вы используете Papra на собственном хостинге, убедитесь, что переменная окружения APP_BASE_URL соответствует вашему текущему URL. Для получения дополнительной информации см. https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Достигнуто максимальное количество участников и ожидающих приглашений для этой организации. Пожалуйста, обновите ваш план, чтобы добавить больше участников.',
|
||||
'api-errors.organization.has_active_subscription': 'Невозможно удалить организацию с активной подпиской. Пожалуйста, сначала отмените подписку, используя кнопку "Управление подпиской" выше.',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': 'Указанный URL не разрешён. URL-адреса вебхуков не должны указывать на частные или зарезервированные IP-адреса.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Пользователь не найден',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Не удалось создать пользователя',
|
||||
|
|
|
|||
1092
apps/papra-client/src/locales/sv.dictionary.ts
Normal file
1092
apps/papra-client/src/locales/sv.dictionary.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -354,6 +354,75 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'documents.info.created-at': '创建时间',
|
||||
'documents.info.updated-at': '更新时间',
|
||||
'documents.info.never': '从不',
|
||||
'documents.info.document-date': '日期',
|
||||
'documents.info.no-date': '无日期',
|
||||
'documents.info.today': '今天',
|
||||
|
||||
'custom-properties.types.text': '文本',
|
||||
'custom-properties.types.number': '数字',
|
||||
'custom-properties.types.date': '日期',
|
||||
'custom-properties.types.boolean': '布尔值',
|
||||
'custom-properties.types.select': '单选',
|
||||
'custom-properties.types.multi_select': '多选',
|
||||
'custom-properties.types.user_relation': '用户',
|
||||
'custom-properties.types.document_relation': '文档',
|
||||
|
||||
'custom-properties.list.title': '自定义属性',
|
||||
'custom-properties.list.description': '为您的文档定义自定义元数据字段。属性可以是文本、数字、日期、布尔值或选择列表。',
|
||||
'custom-properties.list.create-button': '创建属性',
|
||||
'custom-properties.list.empty.title': '自定义属性',
|
||||
'custom-properties.list.empty.description': '自定义属性让您可以为文档添加结构化元数据,如到期日期、公司名称或金额。',
|
||||
'custom-properties.list.table.name': '名称',
|
||||
'custom-properties.list.table.type': '类型',
|
||||
'custom-properties.list.table.description': '描述',
|
||||
'custom-properties.list.table.created': '创建时间',
|
||||
'custom-properties.list.table.actions': '操作',
|
||||
'custom-properties.list.table.no-description': '无描述',
|
||||
'custom-properties.list.delete.confirm-title': '删除自定义属性',
|
||||
'custom-properties.list.delete.confirm-message': '确定要删除自定义属性"{{ name }}"吗?此操作无法撤销。',
|
||||
'custom-properties.list.delete.confirm-button': '删除',
|
||||
'custom-properties.list.delete.success': '自定义属性已成功删除',
|
||||
'custom-properties.list.delete.error': '删除自定义属性失败',
|
||||
|
||||
'custom-properties.create.title': '创建自定义属性',
|
||||
'custom-properties.create.submit': '创建属性',
|
||||
'custom-properties.create.success': '自定义属性已成功创建',
|
||||
'custom-properties.create.error': '创建自定义属性失败',
|
||||
|
||||
'custom-properties.update.title': '编辑自定义属性',
|
||||
'custom-properties.update.submit': '保存更改',
|
||||
'custom-properties.update.success': '自定义属性已成功更新',
|
||||
'custom-properties.update.error': '更新自定义属性失败',
|
||||
|
||||
'custom-properties.form.name.label': '名称',
|
||||
'custom-properties.form.name.placeholder': '例如:发票金额',
|
||||
'custom-properties.form.name.required': '名称为必填项',
|
||||
'custom-properties.form.name.max-length': '名称最多为 255 个字符',
|
||||
'custom-properties.form.description.label': '描述',
|
||||
'custom-properties.form.description.optional': '(可选)',
|
||||
'custom-properties.form.description.placeholder': '描述此属性的用途',
|
||||
'custom-properties.form.description.max-length': '描述最多为 1000 个字符',
|
||||
'custom-properties.form.type.label': '类型',
|
||||
'custom-properties.form.type.immutable': '属性类型创建后不可更改。',
|
||||
'custom-properties.form.options.title': '选项',
|
||||
'custom-properties.form.options.description': '定义此属性的可选值。',
|
||||
'custom-properties.form.options.name.placeholder': '选项名称',
|
||||
'custom-properties.form.options.name.required': '选项名称为必填项',
|
||||
'custom-properties.form.options.name.max-length': '选项名称最多为 255 个字符',
|
||||
'custom-properties.form.options.validation.required': '请至少添加一个选项',
|
||||
'custom-properties.form.options.add': '添加选项',
|
||||
'custom-properties.form.cancel': '取消',
|
||||
'custom-properties.form.save-error': '保存属性定义时发生错误,请重试。',
|
||||
|
||||
'documents.custom-properties.section-title': '属性',
|
||||
'documents.custom-properties.no-value': '未设置',
|
||||
'documents.custom-properties.text-placeholder': '输入值...',
|
||||
'documents.custom-properties.save': '保存',
|
||||
'documents.custom-properties.clear': '清除',
|
||||
'documents.custom-properties.document-relation-search-placeholder': '搜索文档...',
|
||||
'documents.custom-properties.user-relation-manage': '管理用户',
|
||||
'documents.custom-properties.document-relation-manage': '管理文档',
|
||||
'documents.custom-properties.no-results': '无结果',
|
||||
|
||||
'documents.rename.title': '重命名文档',
|
||||
'documents.rename.form.name.label': '名称',
|
||||
|
|
@ -674,6 +743,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'webhooks.create.form.name.label': 'Webhook 名称',
|
||||
'webhooks.create.form.name.placeholder': '请输入 Webhook 名称',
|
||||
'webhooks.create.form.name.required': '名称为必填项',
|
||||
'webhooks.create.form.name.max-length': '名称最多不能超过128个字符',
|
||||
'webhooks.create.form.url.label': 'Webhook URL',
|
||||
'webhooks.create.form.url.placeholder': '请输入 Webhook URL',
|
||||
'webhooks.create.form.url.required': 'URL 为必填项',
|
||||
|
|
@ -708,6 +778,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'layout.menu.home': '首页',
|
||||
'layout.menu.documents': '文档',
|
||||
'layout.menu.tags': '标签',
|
||||
'layout.menu.custom-properties': '自定义属性',
|
||||
'layout.menu.tagging-rules': '标签规则',
|
||||
'layout.menu.deleted-documents': '已删除文档',
|
||||
'layout.menu.organization-settings': '设置',
|
||||
|
|
@ -737,6 +808,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'user-menu.api-keys': 'API 密钥',
|
||||
'user-menu.invitations': '邀请',
|
||||
'user-menu.language': '语言',
|
||||
'user-menu.theme': '主题',
|
||||
'user-menu.about': '关于 Papra',
|
||||
'user-menu.logout': '登出',
|
||||
|
||||
|
|
@ -767,6 +839,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||
'api-errors.auth.invalid_origin': '应用来源无效。如果您自托管 Papra,请确保 APP_BASE_URL 环境变量与当前 URL 匹配。详情见 https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': '该组织的成员和待处理邀请数量已达上限。请升级方案以添加更多成员。',
|
||||
'api-errors.organization.has_active_subscription': '无法删除有有效订阅的组织。请先通过上方“管理订阅”取消订阅。',
|
||||
'api-errors.webhooks.ssrf_unsafe_url': '提供的 URL 不被允许。Webhook URL 不能指向私有或保留的 IP 地址。',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': '未找到用户',
|
||||
'api-errors.FAILED_TO_CREATE_USER': '创建用户失败',
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const AdminLayout: ParentComponent = (props) => {
|
|||
<div class="w-280px flex-shrink-0 hidden md:block">
|
||||
{sidenav()}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<header class="h-14 flex items-center px-4 justify-between">
|
||||
<Sheet>
|
||||
<SheetTrigger>
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@ export const AdminListOrganizationsPage: Component = () => {
|
|||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex flex-col items-center justify-between mt-4 md:flex-row">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{t('admin.organizations.pagination.info', {
|
||||
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,
|
||||
|
|
|
|||
|
|
@ -161,7 +161,7 @@ export const AdminListUsersPage: Component = () => {
|
|||
</Table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex flex-col items-center justify-between mt-4 md:flex-row">
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{t('admin.users.pagination.info', {
|
||||
start: table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1,
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ import { coerceDates } from '../shared/http/http-client.models';
|
|||
export async function createApiKey({
|
||||
name,
|
||||
permissions,
|
||||
organizationIds,
|
||||
allOrganizations,
|
||||
expiresAt,
|
||||
// organizationIds,
|
||||
// allOrganizations,
|
||||
// expiresAt,
|
||||
}: {
|
||||
name: string;
|
||||
permissions: string[];
|
||||
organizationIds: string[];
|
||||
allOrganizations: boolean;
|
||||
expiresAt?: Date;
|
||||
// organizationIds: string[];
|
||||
// allOrganizations: boolean;
|
||||
// expiresAt?: Date;
|
||||
}) {
|
||||
const { apiKey, token } = await apiClient<{
|
||||
apiKey: ApiKey;
|
||||
|
|
@ -24,9 +24,9 @@ export async function createApiKey({
|
|||
body: {
|
||||
name,
|
||||
permissions,
|
||||
organizationIds,
|
||||
allOrganizations,
|
||||
expiresAt,
|
||||
// organizationIds,
|
||||
// allOrganizations,
|
||||
// expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ export const CreateApiKeyPage: Component = () => {
|
|||
const { token } = await createApiKey({
|
||||
name,
|
||||
permissions,
|
||||
organizationIds: [],
|
||||
allOrganizations: false,
|
||||
// organizationIds: [],
|
||||
// allOrganizations: false,
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useI18n } from '../i18n/i18n.provider';
|
|||
import { cn } from '../shared/style/cn';
|
||||
import { toArrayIf } from '../shared/utils/array';
|
||||
import { debounce } from '../shared/utils/timing';
|
||||
import { useThemeStore } from '../theme/theme.store';
|
||||
import { useTheme } from '../theme/theme.provider';
|
||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
|
||||
|
||||
const CommandPaletteContext = createContext<{
|
||||
|
|
@ -52,8 +52,19 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||
document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
createEffect(on(
|
||||
getIsCommandPaletteOpen,
|
||||
(isOpen) => {
|
||||
if (isOpen && getSearchQuery().length > 0) {
|
||||
setTimeout(() => inputRef?.select(), 0);
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { setColorMode } = useThemeStore();
|
||||
const { setThemePreference } = useTheme();
|
||||
|
||||
const searchDocs = debounce(async ({ searchQuery }: { searchQuery: string }) => {
|
||||
const [result] = await safely(fetchOrganizationDocuments({ searchQuery, organizationId: params.organizationId, pageIndex: 0, pageSize: 5 }));
|
||||
|
|
@ -108,17 +119,17 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||
{
|
||||
label: t('layout.theme.light'),
|
||||
icon: 'i-tabler-sun',
|
||||
action: () => setColorMode({ mode: 'light' }),
|
||||
action: () => setThemePreference('light'),
|
||||
},
|
||||
{
|
||||
label: t('layout.theme.dark'),
|
||||
icon: 'i-tabler-moon',
|
||||
action: () => setColorMode({ mode: 'dark' }),
|
||||
action: () => setThemePreference('dark'),
|
||||
},
|
||||
{
|
||||
label: t('layout.theme.system'),
|
||||
icon: 'i-tabler-device-laptop',
|
||||
action: () => setColorMode({ mode: 'system' }),
|
||||
action: () => setThemePreference('system'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -144,6 +155,7 @@ export const CommandPaletteProvider: ParentComponent = (props) => {
|
|||
>
|
||||
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={getSearchQuery()}
|
||||
onValueChange={setSearchQuery}
|
||||
placeholder={t('command-palette.search.placeholder')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
import type { Component, JSX } from 'solid-js';
|
||||
import type { CustomPropertyDefinition, CustomPropertyType } from '../custom-properties.types';
|
||||
import { getValue, insert, remove, setValue } from '@modular-forms/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
|
||||
import { TextArea } from '@/modules/ui/components/textarea';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { PROPERTY_TYPE_LABEL_I18N_KEYS } from '../custom-properties.constants';
|
||||
|
||||
const PROPERTY_TYPES: CustomPropertyType[] = ['text', 'number', 'date', 'boolean', 'select', 'multi_select', 'user_relation', 'document_relation'];
|
||||
const SELECT_LIKE_TYPES: CustomPropertyType[] = ['select', 'multi_select'];
|
||||
|
||||
type OptionDraft = { id?: string; name: string };
|
||||
export type PropertyDefinitionDraft = { name: string; description?: string; type: CustomPropertyType; options?: OptionDraft[] };
|
||||
|
||||
export const CustomPropertyDefinitionForm: Component<{
|
||||
onSubmit: (args: { propertyDefinition: PropertyDefinitionDraft }) => Promise<void> | void;
|
||||
organizationId: string;
|
||||
propertyDefinition?: CustomPropertyDefinition;
|
||||
submitButton: JSX.Element;
|
||||
}> = (props) => {
|
||||
const hasExistingType = () => Boolean(props.propertyDefinition?.type);
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { form, Form, Field, FieldArray, createFormError } = createForm({
|
||||
onSubmit: async ({ name, description, type, options }) => {
|
||||
// TODO: make a discriminated union of the form values based on the type to avoid having to do this kind of validation
|
||||
if ((!options || options.length === 0) && SELECT_LIKE_TYPES.includes(type)) {
|
||||
throw createFormError({ message: t('custom-properties.form.options.validation.required') });
|
||||
}
|
||||
|
||||
try {
|
||||
await props.onSubmit({
|
||||
propertyDefinition: {
|
||||
name,
|
||||
description,
|
||||
type,
|
||||
options,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessage({ error, defaultMessage: t('custom-properties.form.save-error') });
|
||||
throw createFormError({ message });
|
||||
}
|
||||
},
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minLength(1, t('custom-properties.form.name.required')),
|
||||
v.maxLength(255, t('custom-properties.form.name.max-length')),
|
||||
),
|
||||
description: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.maxLength(1000, t('custom-properties.form.description.max-length')),
|
||||
),
|
||||
type: v.picklist(PROPERTY_TYPES),
|
||||
options: v.optional(
|
||||
v.array(v.object({
|
||||
id: v.optional(v.string()),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minLength(1, t('custom-properties.form.options.name.required')),
|
||||
v.maxLength(255, t('custom-properties.form.options.name.max-length')),
|
||||
),
|
||||
})),
|
||||
[],
|
||||
),
|
||||
}),
|
||||
initialValues: {
|
||||
name: props.propertyDefinition?.name ?? '',
|
||||
description: props.propertyDefinition?.description ?? '',
|
||||
type: props.propertyDefinition?.type ?? 'text',
|
||||
options: props.propertyDefinition?.options?.map(o => ({ id: o.id, name: o.name })) ?? [{ name: '' }],
|
||||
},
|
||||
});
|
||||
|
||||
const currentType = () => getValue(form, 'type');
|
||||
const isSelectLike = () => SELECT_LIKE_TYPES.includes(currentType() as CustomPropertyType);
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="name">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1">
|
||||
<TextFieldLabel for="name">{t('custom-properties.form.name.label')}</TextFieldLabel>
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
placeholder={t('custom-properties.form.name.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="description">
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot class="flex flex-col gap-1 mt-6">
|
||||
<TextFieldLabel for="description">
|
||||
{t('custom-properties.form.description.label')}
|
||||
<span class="text-muted-foreground font-normal ml-1">{t('custom-properties.form.description.optional')}</span>
|
||||
</TextFieldLabel>
|
||||
<TextArea
|
||||
id="description"
|
||||
placeholder={t('custom-properties.form.description.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="type">
|
||||
{field => (
|
||||
<div class="flex flex-col gap-1 mt-6">
|
||||
<label class="text-sm font-medium" for="type">{t('custom-properties.form.type.label')}</label>
|
||||
<Select
|
||||
id="type"
|
||||
defaultValue={field.value ?? 'text'}
|
||||
onChange={value => value && setValue(form, 'type', value as CustomPropertyType)}
|
||||
options={PROPERTY_TYPES}
|
||||
itemComponent={itemProps => (
|
||||
<SelectItem item={itemProps.item}>{t(PROPERTY_TYPE_LABEL_I18N_KEYS[itemProps.item.rawValue as CustomPropertyType])}</SelectItem>
|
||||
)}
|
||||
disabled={hasExistingType()}
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue<CustomPropertyType>>{state => t(PROPERTY_TYPE_LABEL_I18N_KEYS[state.selectedOption()])}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Show when={hasExistingType()}>
|
||||
<p class="text-xs text-muted-foreground">{t('custom-properties.form.type.immutable')}</p>
|
||||
</Show>
|
||||
|
||||
<Show when={isSelectLike()}>
|
||||
<p class="mb-1 font-medium mt-6">{t('custom-properties.form.options.title')}</p>
|
||||
<p class="mb-3 text-sm text-muted-foreground">{t('custom-properties.form.options.description')}</p>
|
||||
|
||||
<FieldArray name="options">
|
||||
{fieldArray => (
|
||||
<div class="flex flex-col gap-3">
|
||||
<For each={fieldArray.items}>
|
||||
{(_, index) => (
|
||||
<Field name={`options.${index()}.id`}>
|
||||
{() => (
|
||||
<div class="flex gap-2 items-start">
|
||||
<div class="flex-1">
|
||||
<Field name={`options.${index()}.name`}>
|
||||
{(field, inputProps) => (
|
||||
<TextFieldRoot>
|
||||
<TextField
|
||||
placeholder={t('custom-properties.form.options.name.placeholder')}
|
||||
{...inputProps}
|
||||
value={field.value}
|
||||
aria-invalid={Boolean(field.error)}
|
||||
/>
|
||||
{field.error && <div class="text-red-500 text-xs mt-1">{field.error}</div>}
|
||||
</TextFieldRoot>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="shrink-0"
|
||||
onClick={() => remove(form, 'options', { at: index() })}
|
||||
>
|
||||
<div class="i-tabler-x size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
class="gap-2 mt-1 self-start"
|
||||
onClick={() => insert(form, 'options', { value: { name: '' } })}
|
||||
>
|
||||
<div class="i-tabler-plus size-4" />
|
||||
{t('custom-properties.form.options.add')}
|
||||
</Button>
|
||||
|
||||
{fieldArray.error && <div class="text-red-500 text-sm">{fieldArray.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</FieldArray>
|
||||
</Show>
|
||||
|
||||
<div class="flex justify-end mt-8 gap-2">
|
||||
<Button variant="outline" as={A} href={`/organizations/${props.organizationId}/custom-properties`}>
|
||||
{t('custom-properties.form.cancel')}
|
||||
</Button>
|
||||
{props.submitButton}
|
||||
</div>
|
||||
|
||||
<div class="text-red-500 text-sm mt-4">{form.response.message}</div>
|
||||
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,809 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { CustomPropertyDefinition } from '../custom-properties.types';
|
||||
import type { Document } from '@/modules/documents/documents.types';
|
||||
import Calendar from '@corvu/calendar';
|
||||
import { A } from '@solidjs/router';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createMemo, createSignal, For, Match, Show, Switch as SolidSwitch, Suspense } from 'solid-js';
|
||||
import { fetchOrganizationDocuments } from '@/modules/documents/documents.services';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganizationMembers } from '@/modules/organizations/organizations.services';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { useDebounce } from '@/modules/shared/utils/timing';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { CalendarGrid } from '@/modules/ui/components/calendar';
|
||||
import { CalendarMonthYearHeader } from '@/modules/ui/components/calendar-month-year-header';
|
||||
import { NumberField, NumberFieldDecrementTrigger, NumberFieldGroup, NumberFieldIncrementTrigger, NumberFieldInput } from '@/modules/ui/components/number-field';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/modules/ui/components/popover';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { Switch, SwitchControl, SwitchThumb } from '@/modules/ui/components/switch';
|
||||
import { TextField, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { rawPropertyValueAsOption, rawPropertyValueAsOptionArray, rawPropertyValueAsRelatedDocumentArray, rawPropertyValueAsUserArray } from '../custom-properties.models';
|
||||
import { deleteDocumentCustomPropertyValue, setDocumentCustomPropertyValue } from '../custom-properties.services';
|
||||
|
||||
type SelectOption = { optionId: string; name: string };
|
||||
|
||||
function getDateValue(value: unknown): Date | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const TextPropertyEditor: Component<{
|
||||
value: string | null;
|
||||
onSave: (value: string | null) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [draft, setDraft] = createSignal('');
|
||||
|
||||
const handleOpen = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setDraft(props.value ?? '');
|
||||
}
|
||||
setOpen(isOpen);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const val = draft().trim();
|
||||
props.onSave(val === '' ? null : val);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
props.onSave(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={handleOpen} placement="bottom-start">
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<Show
|
||||
when={props.value}
|
||||
keyed
|
||||
fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}
|
||||
>
|
||||
{v => <span>{v}</span>}
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-72 p-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<TextFieldRoot>
|
||||
<TextField
|
||||
value={draft()}
|
||||
onInput={e => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSave()}
|
||||
placeholder={t('documents.custom-properties.text-placeholder')}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear}>
|
||||
{t('documents.custom-properties.clear')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{t('documents.custom-properties.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const NumberPropertyEditor: Component<{
|
||||
value: number | null;
|
||||
onSave: (value: number | null) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [draft, setDraft] = createSignal<number | undefined>(undefined);
|
||||
|
||||
const handleOpen = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setDraft(props.value ?? undefined);
|
||||
}
|
||||
setOpen(isOpen);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const val = draft();
|
||||
props.onSave(val != null && Number.isFinite(val) ? val : null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
props.onSave(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onChange = (value: number) => {
|
||||
setDraft(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={handleOpen} placement="bottom-start">
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<Show when={props.value != null} fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
|
||||
<span>{props.value}</span>
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-56 p-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<NumberField rawValue={draft()} onRawValueChange={onChange}>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldInput onKeyDown={e => e.key === 'Enter' && handleSave()} />
|
||||
<NumberFieldDecrementTrigger />
|
||||
<NumberFieldIncrementTrigger />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear}>
|
||||
{t('documents.custom-properties.clear')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
{t('documents.custom-properties.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const DatePropertyEditor: Component<{
|
||||
value: Date | null;
|
||||
onSave: (value: Date | null) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
const { t, formatDate } = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
const handleSave = (date: Date | null) => {
|
||||
props.onSave(date);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<Show when={props.value} keyed fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
|
||||
{d => formatDate(d, { dateStyle: 'medium' })}
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-3">
|
||||
<Calendar
|
||||
mode="single"
|
||||
value={props.value ?? null}
|
||||
onValueChange={handleSave}
|
||||
fixedWeeks
|
||||
>
|
||||
{() => (
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-2">
|
||||
<CalendarMonthYearHeader />
|
||||
<CalendarGrid />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 min-w-28 ml-2 border-l pl-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start text-sm"
|
||||
onClick={() => handleSave(new Date())}
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<div class="i-tabler-calendar-event size-4 mr-2 text-muted-foreground" />
|
||||
{t('documents.info.today')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start text-sm"
|
||||
onClick={() => handleSave(null)}
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<div class="i-tabler-x size-4 mr-2 text-muted-foreground" />
|
||||
{t('documents.custom-properties.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Calendar>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const BooleanPropertyEditor: Component<{
|
||||
value: boolean | null;
|
||||
onSave: (value: boolean) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
return (
|
||||
<Switch
|
||||
checked={props.value ?? false}
|
||||
onChange={checked => props.onSave(checked)}
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<SwitchControl>
|
||||
<SwitchThumb />
|
||||
</SwitchControl>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
const SelectPropertyEditor: Component<{
|
||||
value: SelectOption | null;
|
||||
options: { id: string; name: string }[];
|
||||
onSave: (value: string | null) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
const handleSelect = (optionId: string) => {
|
||||
props.onSave(optionId);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
props.onSave(null);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<Show when={props.value?.name} keyed fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
|
||||
{name => <span>{name}</span>}
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-48 p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={props.options}>
|
||||
{option => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full${props.value?.optionId === option.id ? ' bg-accent' : ''}`}
|
||||
onClick={() => handleSelect(option.id)}
|
||||
>
|
||||
{option.name}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.value !== null}>
|
||||
<Separator class="my-1" />
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<div class="i-tabler-x size-4" />
|
||||
{t('documents.custom-properties.clear')}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const MultiSelectPropertyEditor: Component<{
|
||||
value: SelectOption[];
|
||||
options: { id: string; name: string }[];
|
||||
onSave: (value: string[]) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
const selectedIds = createMemo(() => (props.value ?? []).map(v => v.optionId));
|
||||
const displayText = createMemo(() => {
|
||||
const selected = props.value ?? [];
|
||||
return selected.length === 0 ? null : selected.map(v => v.name ?? v.optionId).join(', ');
|
||||
});
|
||||
|
||||
const toggleOption = (optionId: string) => {
|
||||
const current = selectedIds();
|
||||
const next = current.includes(optionId)
|
||||
? current.filter(id => id !== optionId)
|
||||
: [...current, optionId];
|
||||
props.onSave(next);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
props.onSave([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<Show when={displayText()} keyed fallback={<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>}>
|
||||
{text => <span class="max-w-40 truncate">{text}</span>}
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-52 p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={props.options}>
|
||||
{(option) => {
|
||||
const isSelected = () => selectedIds().includes(option.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
|
||||
onClick={() => toggleOption(option.id)}
|
||||
>
|
||||
<div class={`size-4 rounded border flex-shrink-0 flex items-center justify-center ${isSelected() ? 'bg-primary border-primary' : 'border-input'}`}>
|
||||
<Show when={isSelected()}>
|
||||
<div class="i-tabler-check size-3 text-primary-foreground" />
|
||||
</Show>
|
||||
</div>
|
||||
{option.name}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={selectedIds().length > 0}>
|
||||
<Separator class="my-1" />
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<div class="i-tabler-x size-4" />
|
||||
{t('documents.custom-properties.clear')}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
type UserRelationValue = { userId: string; name: string | null; email: string };
|
||||
|
||||
const UserRelationPropertyEditor: Component<{
|
||||
value: UserRelationValue[];
|
||||
organizationId: string;
|
||||
onSave: (value: string[]) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
const membersQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', props.organizationId, 'members'],
|
||||
queryFn: () => fetchOrganizationMembers({ organizationId: props.organizationId }),
|
||||
enabled: open(),
|
||||
}));
|
||||
|
||||
const members = () => membersQuery.data?.members ?? [];
|
||||
const selectedUserIds = createMemo(() => props.value.map(u => u.userId));
|
||||
|
||||
const toggleUser = (userId: string) => {
|
||||
const current = selectedUserIds();
|
||||
const next = current.includes(userId)
|
||||
? current.filter(id => id !== userId)
|
||||
: [...current, userId];
|
||||
props.onSave(next);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
props.onSave([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open()} onOpenChange={setOpen} placement="bottom-start">
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<Show when={props.value.length > 0}>
|
||||
<For each={props.value}>
|
||||
{user => (
|
||||
<span class="text-sm truncate">
|
||||
{user.name ?? user.email}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-1 group bg-transparent! p-0 h-auto text-left w-fit"
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<Show
|
||||
when={props.value.length === 0}
|
||||
fallback={<span class="text-xs text-muted-foreground group-hover:text-foreground transition-colors">{t('documents.custom-properties.user-relation-manage')}</span>}
|
||||
>
|
||||
<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-3.5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent class="w-64 p-2">
|
||||
<Suspense>
|
||||
<div class="flex flex-col gap-1">
|
||||
<For each={members()}>
|
||||
{(member) => {
|
||||
const userId = member.user?.id ?? '';
|
||||
const isSelected = () => selectedUserIds().includes(userId);
|
||||
const displayName = member.user?.name ?? member.user?.email ?? userId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
|
||||
onClick={() => toggleUser(userId)}
|
||||
>
|
||||
<div class={`size-4 rounded border flex-shrink-0 flex items-center justify-center ${isSelected() ? 'bg-primary border-primary' : 'border-input'}`}>
|
||||
<Show when={isSelected()}>
|
||||
<div class="i-tabler-check size-3 text-primary-foreground" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="truncate">{displayName}</span>
|
||||
<Show when={member.user?.name}>
|
||||
<span class="text-xs text-muted-foreground truncate">{member.user?.email}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={selectedUserIds().length > 0}>
|
||||
<Separator class="my-1" />
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<div class="i-tabler-x size-4" />
|
||||
{t('documents.custom-properties.clear')}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</Suspense>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
type RelatedDocumentValue = { documentId: string; name: string };
|
||||
|
||||
const DocumentRelationPropertyEditor: Component<{
|
||||
value: RelatedDocumentValue[];
|
||||
organizationId: string;
|
||||
onSave: (value: string[]) => void;
|
||||
isPending: boolean;
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = createSignal(false);
|
||||
const [search, setSearch] = createSignal('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
|
||||
const isSearchActive = () => debouncedSearch().length > 0;
|
||||
|
||||
const documentsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', props.organizationId, 'documents', { searchQuery: debouncedSearch() }],
|
||||
queryFn: () => fetchOrganizationDocuments({ organizationId: props.organizationId, searchQuery: debouncedSearch(), pageIndex: 0, pageSize: 10 }),
|
||||
enabled: open() && isSearchActive(),
|
||||
}));
|
||||
|
||||
const searchResults = () => documentsQuery.data?.documents ?? [];
|
||||
const selectedDocumentIds = createMemo(() => props.value.map(d => d.documentId));
|
||||
|
||||
const toggleDocument = (documentId: string) => {
|
||||
const current = selectedDocumentIds();
|
||||
const next = current.includes(documentId)
|
||||
? current.filter(id => id !== documentId)
|
||||
: [...current, documentId];
|
||||
props.onSave(next);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
props.onSave([]);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open()}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSearch('');
|
||||
}
|
||||
}}
|
||||
placement="bottom-start"
|
||||
>
|
||||
<div class="flex flex-col gap-0.5 min-w-0">
|
||||
<Show when={props.value.length > 0}>
|
||||
<For each={props.value}>
|
||||
{doc => (
|
||||
<A
|
||||
href={`/organizations/${props.organizationId}/documents/${doc.documentId}`}
|
||||
class="text-sm hover:underline truncate"
|
||||
>
|
||||
{doc.name}
|
||||
</A>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-1 group bg-transparent! p-0 h-auto text-left w-fit"
|
||||
disabled={props.isPending}
|
||||
>
|
||||
<Show
|
||||
when={props.value.length === 0}
|
||||
fallback={<span class="text-xs text-muted-foreground group-hover:text-foreground transition-colors">{t('documents.custom-properties.document-relation-manage')}</span>}
|
||||
>
|
||||
<span class="text-muted-foreground">{t('documents.custom-properties.no-value')}</span>
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-3.5 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent class="w-72 p-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<TextFieldRoot class="mb-1">
|
||||
<TextField
|
||||
value={search()}
|
||||
onInput={e => setSearch(e.currentTarget.value)}
|
||||
placeholder={t('documents.custom-properties.document-relation-search-placeholder')}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
<Suspense>
|
||||
<Show
|
||||
when={isSearchActive()}
|
||||
fallback={(
|
||||
<Show
|
||||
when={props.value.length > 0}
|
||||
fallback={<span class="text-sm text-muted-foreground px-2 py-1.5">{t('documents.custom-properties.no-results')}</span>}
|
||||
>
|
||||
<For each={props.value}>
|
||||
{doc => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
|
||||
onClick={() => toggleDocument(doc.documentId)}
|
||||
>
|
||||
<div class="size-4 rounded border flex-shrink-0 flex items-center justify-center bg-primary border-primary">
|
||||
<div class="i-tabler-check size-3 text-primary-foreground" />
|
||||
</div>
|
||||
<span class="truncate">{doc.name}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)}
|
||||
>
|
||||
<Show
|
||||
when={searchResults().length > 0}
|
||||
fallback={<span class="text-sm text-muted-foreground px-2 py-1.5">{t('documents.custom-properties.no-results')}</span>}
|
||||
>
|
||||
<For each={searchResults()}>
|
||||
{(doc) => {
|
||||
const isSelected = () => selectedDocumentIds().includes(doc.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full"
|
||||
onClick={() => toggleDocument(doc.id)}
|
||||
>
|
||||
<div class={`size-4 rounded border flex-shrink-0 flex items-center justify-center ${isSelected() ? 'bg-primary border-primary' : 'border-input'}`}>
|
||||
<Show when={isSelected()}>
|
||||
<div class="i-tabler-check size-3 text-primary-foreground" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="truncate">{doc.name}</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={selectedDocumentIds().length > 0}>
|
||||
<Separator class="my-1" />
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-accent transition-colors text-left w-full text-muted-foreground"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<div class="i-tabler-x size-4" />
|
||||
{t('documents.custom-properties.clear')}
|
||||
</button>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const PropertyValueEditor: Component<{
|
||||
definition: CustomPropertyDefinition;
|
||||
rawValue: unknown;
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
}> = (props) => {
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
|
||||
const mutation = useMutation(() => ({
|
||||
mutationFn: (value: string | number | boolean | string[] | null) => {
|
||||
if (value === null) {
|
||||
return deleteDocumentCustomPropertyValue({
|
||||
organizationId: props.organizationId,
|
||||
documentId: props.documentId,
|
||||
propertyDefinitionId: props.definition.id,
|
||||
});
|
||||
}
|
||||
return setDocumentCustomPropertyValue({
|
||||
organizationId: props.organizationId,
|
||||
documentId: props.documentId,
|
||||
propertyDefinitionId: props.definition.id,
|
||||
value,
|
||||
});
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', props.organizationId, 'documents', props.documentId],
|
||||
}),
|
||||
onError: (error: unknown) => {
|
||||
createToast({ message: getErrorMessage({ error }), type: 'error' });
|
||||
},
|
||||
}));
|
||||
|
||||
const save = (value: string | number | boolean | string[] | null) => {
|
||||
mutation.mutate(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<SolidSwitch>
|
||||
<Match when={props.definition.type === 'text'}>
|
||||
<TextPropertyEditor
|
||||
value={typeof props.rawValue === 'string' ? props.rawValue : null}
|
||||
onSave={save}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.definition.type === 'number'}>
|
||||
<NumberPropertyEditor
|
||||
value={typeof props.rawValue === 'number' ? props.rawValue : null}
|
||||
onSave={save}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.definition.type === 'date'}>
|
||||
<DatePropertyEditor
|
||||
value={getDateValue(props.rawValue)}
|
||||
onSave={date => save(date ? date.toISOString() : null)}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.definition.type === 'boolean'}>
|
||||
<BooleanPropertyEditor
|
||||
value={typeof props.rawValue === 'boolean' ? props.rawValue : null}
|
||||
onSave={save}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.definition.type === 'select'}>
|
||||
<SelectPropertyEditor
|
||||
value={rawPropertyValueAsOption(props.rawValue)}
|
||||
options={props.definition.options}
|
||||
onSave={optionId => save(optionId)}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.definition.type === 'multi_select'}>
|
||||
<MultiSelectPropertyEditor
|
||||
value={rawPropertyValueAsOptionArray(props.rawValue)}
|
||||
options={props.definition.options}
|
||||
onSave={ids => save(ids)}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.definition.type === 'user_relation'}>
|
||||
<UserRelationPropertyEditor
|
||||
value={rawPropertyValueAsUserArray(props.rawValue)}
|
||||
organizationId={props.organizationId}
|
||||
onSave={ids => save(ids)}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.definition.type === 'document_relation'}>
|
||||
<DocumentRelationPropertyEditor
|
||||
value={rawPropertyValueAsRelatedDocumentArray(props.rawValue)}
|
||||
organizationId={props.organizationId}
|
||||
onSave={ids => save(ids)}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</Match>
|
||||
</SolidSwitch>
|
||||
);
|
||||
};
|
||||
|
||||
export const DocumentCustomPropertiesPanel: Component<{
|
||||
document: Document;
|
||||
organizationId: string;
|
||||
propertyDefinitions: CustomPropertyDefinition[];
|
||||
}> = (props) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const definitions = createMemo(() => props.propertyDefinitions.toSorted((a, b) => a.displayOrder - b.displayOrder));
|
||||
const getPropertyValueByKey = createMemo(() => Object.fromEntries(props.document.customProperties?.map(p => [p.key, p.value]) ?? []));
|
||||
const getPropertyValue = (key: string) => getPropertyValueByKey()[key] ?? null;
|
||||
|
||||
return (
|
||||
<Show when={definitions().length > 0}>
|
||||
<>
|
||||
<div class="col-span-2 mt-4">
|
||||
<Separator class="mb-3" />
|
||||
<p class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
|
||||
{t('documents.custom-properties.section-title')}
|
||||
</p>
|
||||
</div>
|
||||
<For each={definitions()}>
|
||||
{definition => (
|
||||
<>
|
||||
<div class="py-1 pr-2 text-sm text-muted-foreground flex items-start">
|
||||
<div class="flex items-center gap-2 whitespace-nowrap">
|
||||
<div class="i-tabler-tag size-4" />
|
||||
{definition.name}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="py-1 pl-2 text-sm">
|
||||
<PropertyValueEditor
|
||||
definition={definition}
|
||||
rawValue={getPropertyValue(definition.key)}
|
||||
documentId={props.document.id}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import type { TranslationsDictionary } from '../i18n/locales.types';
|
||||
import type { CustomPropertyType } from './custom-properties.types';
|
||||
|
||||
export const PROPERTY_TYPE_LABEL_I18N_KEYS: Record<CustomPropertyType, keyof TranslationsDictionary> = {
|
||||
text: 'custom-properties.types.text',
|
||||
number: 'custom-properties.types.number',
|
||||
date: 'custom-properties.types.date',
|
||||
boolean: 'custom-properties.types.boolean',
|
||||
select: 'custom-properties.types.select',
|
||||
multi_select: 'custom-properties.types.multi_select',
|
||||
user_relation: 'custom-properties.types.user_relation',
|
||||
document_relation: 'custom-properties.types.document_relation',
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
export function rawPropertyValueAsOption(value: unknown): { optionId: string; name: string } | null {
|
||||
if (
|
||||
typeof value === 'object'
|
||||
&& value !== null
|
||||
&& 'optionId' in value
|
||||
&& typeof value.optionId === 'string'
|
||||
&& 'name' in value
|
||||
&& typeof value.name === 'string'
|
||||
) {
|
||||
return { optionId: value.optionId, name: value.name };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rawPropertyValueAsOptionArray(value: unknown): { optionId: string; name: string }[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(rawPropertyValueAsOption).filter(v => v !== null);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function rawPropertyValueAsUserArray(value: unknown): { userId: string; name: string | null; email: string }[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter(
|
||||
(v): v is { userId: string; name: string | null; email: string } =>
|
||||
typeof v === 'object'
|
||||
&& v !== null
|
||||
&& 'userId' in v
|
||||
&& typeof v.userId === 'string'
|
||||
&& 'email' in v
|
||||
&& typeof v.email === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export function rawPropertyValueAsRelatedDocumentArray(value: unknown): { documentId: string; name: string }[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter(
|
||||
(v): v is { documentId: string; name: string } =>
|
||||
typeof v === 'object'
|
||||
&& v !== null
|
||||
&& 'documentId' in v
|
||||
&& typeof v.documentId === 'string'
|
||||
&& 'name' in v
|
||||
&& typeof v.name === 'string',
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import type { AsDto } from '../shared/http/http-client.types';
|
||||
import type { CustomPropertyDefinition, CustomPropertyType } from './custom-properties.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
import { coerceDates } from '../shared/http/http-client.models';
|
||||
|
||||
export async function fetchCustomPropertyDefinitions({ organizationId }: { organizationId: string }) {
|
||||
const { propertyDefinitions } = await apiClient<{ propertyDefinitions: AsDto<CustomPropertyDefinition>[] }>({
|
||||
path: `/api/organizations/${organizationId}/custom-properties`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
propertyDefinitions: propertyDefinitions.map(coerceDates),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCustomPropertyDefinition({ organizationId, propertyDefinitionId }: { organizationId: string; propertyDefinitionId: string }) {
|
||||
const { definition } = await apiClient<{ definition: AsDto<CustomPropertyDefinition> }>({
|
||||
path: `/api/organizations/${organizationId}/custom-properties/${propertyDefinitionId}`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
definition: coerceDates(definition),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCustomPropertyDefinition({
|
||||
organizationId,
|
||||
propertyDefinition,
|
||||
}: {
|
||||
organizationId: string;
|
||||
propertyDefinition: {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: CustomPropertyType;
|
||||
options?: { name: string }[];
|
||||
};
|
||||
}) {
|
||||
const { propertyDefinition: created } = await apiClient<{ propertyDefinition: AsDto<CustomPropertyDefinition> }>({
|
||||
path: `/api/organizations/${organizationId}/custom-properties`,
|
||||
method: 'POST',
|
||||
body: propertyDefinition,
|
||||
});
|
||||
|
||||
return {
|
||||
propertyDefinition: coerceDates(created),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateCustomPropertyDefinition({
|
||||
organizationId,
|
||||
propertyDefinitionId,
|
||||
propertyDefinition,
|
||||
}: {
|
||||
organizationId: string;
|
||||
propertyDefinitionId: string;
|
||||
propertyDefinition: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
options?: { id?: string; name: string }[];
|
||||
};
|
||||
}) {
|
||||
const { propertyDefinition: updated } = await apiClient<{ propertyDefinition: AsDto<CustomPropertyDefinition> }>({
|
||||
path: `/api/organizations/${organizationId}/custom-properties/${propertyDefinitionId}`,
|
||||
method: 'PUT',
|
||||
body: propertyDefinition,
|
||||
});
|
||||
|
||||
return {
|
||||
propertyDefinition: coerceDates(updated),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteCustomPropertyDefinition({
|
||||
organizationId,
|
||||
propertyDefinitionId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
propertyDefinitionId: string;
|
||||
}) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/custom-properties/${propertyDefinitionId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function setDocumentCustomPropertyValue({
|
||||
organizationId,
|
||||
documentId,
|
||||
propertyDefinitionId,
|
||||
value,
|
||||
}: {
|
||||
organizationId: string;
|
||||
documentId: string;
|
||||
propertyDefinitionId: string;
|
||||
value: string | number | boolean | string[];
|
||||
}) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}/custom-properties/${propertyDefinitionId}`,
|
||||
method: 'PUT',
|
||||
body: { value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteDocumentCustomPropertyValue({
|
||||
organizationId,
|
||||
documentId,
|
||||
propertyDefinitionId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
documentId: string;
|
||||
propertyDefinitionId: string;
|
||||
}) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}/custom-properties/${propertyDefinitionId}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export type CustomPropertyType = 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi_select' | 'user_relation' | 'document_relation';
|
||||
|
||||
export type DocumentCustomProperty = {
|
||||
key: string;
|
||||
name: string;
|
||||
type: CustomPropertyType;
|
||||
displayOrder: number;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type CustomPropertySelectOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
displayOrder: number;
|
||||
};
|
||||
|
||||
export type CustomPropertyDefinition = {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string | null;
|
||||
type: CustomPropertyType;
|
||||
displayOrder: number;
|
||||
options: CustomPropertySelectOption[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { PropertyDefinitionDraft } from '../components/custom-property-definition-form.component';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { CustomPropertyDefinitionForm } from '../components/custom-property-definition-form.component';
|
||||
import { createCustomPropertyDefinition } from '../custom-properties.services';
|
||||
|
||||
export const CreateCustomPropertyPage: Component = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const createMutation = useMutation(() => ({
|
||||
mutationFn: async ({ propertyDefinition }: { propertyDefinition: PropertyDefinitionDraft }) => {
|
||||
await createCustomPropertyDefinition({ organizationId: params.organizationId, propertyDefinition });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'custom-properties'] });
|
||||
|
||||
createToast({
|
||||
message: t('custom-properties.create.success'),
|
||||
type: 'success',
|
||||
});
|
||||
navigate(`/organizations/${params.organizationId}/custom-properties`);
|
||||
},
|
||||
onError: () => {
|
||||
createToast({
|
||||
message: t('custom-properties.create.error'),
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-4">
|
||||
<div class="border-b mb-6 pb-4">
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('custom-properties.create.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<CustomPropertyDefinitionForm
|
||||
organizationId={params.organizationId}
|
||||
onSubmit={({ propertyDefinition }) => createMutation.mutateAsync({ propertyDefinition })}
|
||||
submitButton={(
|
||||
<Button type="submit" isLoading={createMutation.isPending}>
|
||||
{t('custom-properties.create.submit')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { CustomPropertyType } from '../custom-properties.types';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getSortedRowModel } from '@tanstack/solid-table';
|
||||
import { For, Show, Suspense } from 'solid-js';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { PROPERTY_TYPE_LABEL_I18N_KEYS } from '../custom-properties.constants';
|
||||
import {
|
||||
deleteCustomPropertyDefinition,
|
||||
fetchCustomPropertyDefinitions,
|
||||
} from '../custom-properties.services';
|
||||
|
||||
const TYPE_ICON: Record<CustomPropertyType, string> = {
|
||||
text: 'i-tabler-text-size',
|
||||
number: 'i-tabler-123',
|
||||
date: 'i-tabler-calendar',
|
||||
boolean: 'i-tabler-toggle-left',
|
||||
select: 'i-tabler-list',
|
||||
multi_select: 'i-tabler-list-check',
|
||||
user_relation: 'i-tabler-user',
|
||||
document_relation: 'i-tabler-file-symlink',
|
||||
};
|
||||
|
||||
export const DeleteCustomPropertyButton: Component<{ organizationId: string; propertyDefinitionId: string; propertyDefinitionName: string }> = (props) => {
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
|
||||
const deleteMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
await deleteCustomPropertyDefinition({ organizationId: props.organizationId, propertyDefinitionId: props.propertyDefinitionId });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', props.organizationId, 'custom-properties'] });
|
||||
|
||||
createToast({
|
||||
message: t('custom-properties.list.delete.success'),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
createToast({
|
||||
message: t('custom-properties.list.delete.error'),
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const handleDelete = async () => {
|
||||
const isConfirmed = await confirm({
|
||||
title: t('custom-properties.list.delete.confirm-title'),
|
||||
message: t('custom-properties.list.delete.confirm-message', { name: props.propertyDefinitionName }),
|
||||
confirmButton: {
|
||||
text: t('custom-properties.list.delete.confirm-button'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
deleteMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button size="icon" variant="outline" class="size-7 text-red" onClick={handleDelete} disabled={deleteMutation.isPending} aria-label={`Delete custom property ${props.propertyDefinitionName}`}>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomPropertiesPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'custom-properties'],
|
||||
queryFn: () => fetchCustomPropertyDefinitions({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
return query.data?.propertyDefinitions ?? [];
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: () => t('custom-properties.list.table.name'),
|
||||
accessorKey: 'name',
|
||||
sortingFn: 'alphanumeric',
|
||||
cell: data => (
|
||||
<A href={`/organizations/${params.organizationId}/custom-properties/${data.row.original.id}`} class="font-medium hover:underline">{data.getValue<string>()}</A>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => t('custom-properties.list.table.type'),
|
||||
accessorKey: 'type',
|
||||
sortingFn: 'alphanumeric',
|
||||
cell: (data) => {
|
||||
const type = data.getValue<CustomPropertyType>();
|
||||
return (
|
||||
<div class="flex items-center gap-1.5 w-fit px-2 py-0.5 rounded bg-muted text-xs font-medium">
|
||||
<div class={`${TYPE_ICON[type]} size-3.5 text-muted-foreground`} />
|
||||
{t(PROPERTY_TYPE_LABEL_I18N_KEYS[type])}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: () => t('custom-properties.list.table.description'),
|
||||
accessorKey: 'description',
|
||||
sortingFn: 'alphanumeric',
|
||||
cell: data => (
|
||||
<span class="text-wrap">
|
||||
{data.getValue<string | null>() || <span class="text-muted-foreground">{t('custom-properties.list.table.no-description')}</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => t('custom-properties.list.table.created'),
|
||||
accessorKey: 'createdAt',
|
||||
sortingFn: 'datetime',
|
||||
cell: data => (
|
||||
<RelativeTime class="text-muted-foreground" date={data.getValue<Date>()} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span class="text-right">{t('custom-properties.list.table.actions')}</span>,
|
||||
cell: data => (
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
as={A}
|
||||
href={`/organizations/${params.organizationId}/custom-properties/${data.row.original.id}`}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
class="size-7"
|
||||
aria-label={`Edit custom property ${data.row.original.name}`}
|
||||
>
|
||||
<div class="i-tabler-pencil size-4" />
|
||||
</Button>
|
||||
<DeleteCustomPropertyButton
|
||||
organizationId={params.organizationId}
|
||||
propertyDefinitionId={data.row.original.id}
|
||||
propertyDefinitionName={data.row.original.name}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
initialState: {
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 mx-auto max-w-5xl">
|
||||
<Suspense>
|
||||
<Show when={query.data?.propertyDefinitions}>
|
||||
{getPropertyDefinitions => (
|
||||
<Show
|
||||
when={getPropertyDefinitions().length > 0}
|
||||
fallback={(
|
||||
<EmptyState
|
||||
title={t('custom-properties.list.empty.title')}
|
||||
icon="i-tabler-forms"
|
||||
description={t('custom-properties.list.empty.description')}
|
||||
cta={(
|
||||
<Button as={A} href={`/organizations/${params.organizationId}/custom-properties/create`}>
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('custom-properties.list.create-button')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div class="flex justify-between sm:items-center pb-6 gap-4 flex-col sm:flex-row">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">
|
||||
{t('custom-properties.list.title')}
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground mt-1">
|
||||
{t('custom-properties.list.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<Button as={A} href={`/organizations/${params.organizationId}/custom-properties/create`} class="w-full">
|
||||
<div class="i-tabler-plus size-4 mr-2" />
|
||||
{t('custom-properties.list.create-button')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<For each={table.getHeaderGroups()}>
|
||||
{headerGroup => (
|
||||
<TableRow>
|
||||
<For each={headerGroup.headers}>
|
||||
{header => (
|
||||
<TableHead>
|
||||
<Show
|
||||
when={header.column.getCanSort()}
|
||||
fallback={flexRender(header.column.columnDef.header, header.getContext())}
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 cursor-pointer select-none"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<Show when={header.column.getIsSorted() === 'asc'}>
|
||||
<div class="i-tabler-arrow-down size-3.5" />
|
||||
</Show>
|
||||
<Show when={header.column.getIsSorted() === 'desc'}>
|
||||
<div class="i-tabler-arrow-up size-3.5" />
|
||||
</Show>
|
||||
<Show when={!header.column.getIsSorted()}>
|
||||
<div class="i-tabler-arrows-sort size-3.5 opacity-40" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
</TableHead>
|
||||
)}
|
||||
</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={table.getRowModel().rows}>
|
||||
{row => (
|
||||
<TableRow>
|
||||
<For each={row.getVisibleCells()}>
|
||||
{cell => (
|
||||
<TableCell>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
)}
|
||||
</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { PropertyDefinitionDraft } from '../components/custom-property-definition-form.component';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { Show } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { CustomPropertyDefinitionForm } from '../components/custom-property-definition-form.component';
|
||||
import { fetchCustomPropertyDefinition, updateCustomPropertyDefinition } from '../custom-properties.services';
|
||||
|
||||
export const UpdateCustomPropertyPage: Component = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'custom-properties', params.propertyDefinitionId],
|
||||
queryFn: () => fetchCustomPropertyDefinition({ organizationId: params.organizationId, propertyDefinitionId: params.propertyDefinitionId }),
|
||||
}));
|
||||
|
||||
const updateMutation = useMutation(() => ({
|
||||
mutationFn: async ({ propertyDefinition }: { propertyDefinition: PropertyDefinitionDraft }) => {
|
||||
// Cannot update the type
|
||||
const { type: _, ...definition } = propertyDefinition;
|
||||
|
||||
await updateCustomPropertyDefinition({
|
||||
organizationId: params.organizationId,
|
||||
propertyDefinitionId: params.propertyDefinitionId,
|
||||
propertyDefinition: definition,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', params.organizationId, 'custom-properties'] });
|
||||
|
||||
createToast({
|
||||
message: t('custom-properties.update.success'),
|
||||
type: 'success',
|
||||
});
|
||||
navigate(`/organizations/${params.organizationId}/custom-properties`);
|
||||
},
|
||||
onError: () => {
|
||||
createToast({
|
||||
message: t('custom-properties.update.error'),
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div class="p-6 max-w-screen-md mx-auto mt-4">
|
||||
<div class="border-b mb-6 pb-4">
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('custom-properties.update.title')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Show when={query.data?.definition}>
|
||||
{getDefinition => (
|
||||
<CustomPropertyDefinitionForm
|
||||
organizationId={params.organizationId}
|
||||
propertyDefinition={getDefinition()}
|
||||
onSubmit={({ propertyDefinition }) => updateMutation.mutateAsync({ propertyDefinition })}
|
||||
submitButton={(
|
||||
<Button type="submit" isLoading={updateMutation.isPending}>
|
||||
{t('custom-properties.update.submit')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { CustomPropertyDefinition } from '../custom-properties/custom-properties.types';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import type { Webhook } from '../webhooks/webhooks.types';
|
||||
import type {
|
||||
DocumentCustomPropertyValueStorage,
|
||||
DocumentFile,
|
||||
} from './demo.storage';
|
||||
import { FetchError } from 'ofetch';
|
||||
|
|
@ -11,6 +13,8 @@ import { defineHandler } from './demo-api-mock.models';
|
|||
import { createId, randomString } from './demo.models';
|
||||
import {
|
||||
apiKeyStorage,
|
||||
customPropertyDefinitionStorage,
|
||||
documentCustomPropertyValueStorage,
|
||||
documentFileStorage,
|
||||
documentStorage,
|
||||
organizationStorage,
|
||||
|
|
@ -20,7 +24,7 @@ import {
|
|||
webhooksStorage,
|
||||
} from './demo.storage';
|
||||
import { findMany, getValues } from './demo.storage.models';
|
||||
import { searchDemoDocuments } from './search/demo.search.services';
|
||||
import { generatePropertyKey, searchDemoDocuments } from './search/demo.search.services';
|
||||
import { demoUser } from './seed/users.fixtures';
|
||||
|
||||
function assert(condition: unknown, { message = 'Error', status }: { message?: string; status?: number } = {}): asserts condition {
|
||||
|
|
@ -65,6 +69,61 @@ async function deserializeFile(storageInfo: DocumentFile): Promise<File> {
|
|||
return new File([await fromBase64(base64Content)], name, { type });
|
||||
}
|
||||
|
||||
function hydratePropertyValue({ value, definition }: { value: unknown; definition: CustomPropertyDefinition }): unknown {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (definition.type === 'select') {
|
||||
const optionId = String(value);
|
||||
const option = definition.options.find(o => o.id === optionId || o.key === optionId);
|
||||
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { optionId: option.id, name: option.name };
|
||||
}
|
||||
|
||||
if (definition.type === 'multi_select') {
|
||||
const ids = Array.isArray(value) ? value.map(String) : [String(value)];
|
||||
|
||||
return ids
|
||||
.map((id) => {
|
||||
const option = definition.options.find(o => o.id === id || o.key === id);
|
||||
|
||||
return option ? { optionId: option.id, name: option.name } : null;
|
||||
})
|
||||
.filter(v => v !== null);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildCustomPropertiesResponse({
|
||||
definitions,
|
||||
storedValues,
|
||||
documentId,
|
||||
}: {
|
||||
definitions: CustomPropertyDefinition[];
|
||||
storedValues: DocumentCustomPropertyValueStorage[];
|
||||
documentId: string;
|
||||
}) {
|
||||
const documentValues = storedValues.filter(v => v.documentId === documentId);
|
||||
const valuesByDefinitionId = Object.fromEntries(documentValues.map(v => [v.propertyDefinitionId, v.value]));
|
||||
|
||||
return definitions
|
||||
.toSorted((a, b) => a.displayOrder - b.displayOrder)
|
||||
.map(def => ({
|
||||
propertyDefinitionId: def.id,
|
||||
key: def.key,
|
||||
name: def.name,
|
||||
type: def.type,
|
||||
displayOrder: def.displayOrder,
|
||||
value: hydratePropertyValue({ value: valuesByDefinitionId[def.id] ?? null, definition: def }),
|
||||
}));
|
||||
}
|
||||
|
||||
const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
...defineHandler({
|
||||
path: '/api/config',
|
||||
|
|
@ -166,23 +225,32 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
assert(organization, { status: 403 });
|
||||
|
||||
const searchQuery = rawSearchQuery.trim();
|
||||
const [organizationDocuments, allTags, tagDocuments] = await Promise.all([
|
||||
const [organizationDocuments, allTags, tagDocuments, allDefinitions, allPropertyValues] = await Promise.all([
|
||||
findMany(documentStorage, document => document?.organizationId === organizationId && !document?.deletedAt),
|
||||
getValues(tagStorage),
|
||||
getValues(tagDocumentStorage),
|
||||
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
|
||||
getValues(documentCustomPropertyValueStorage),
|
||||
]);
|
||||
|
||||
const documentsWithTags = organizationDocuments.map((document) => {
|
||||
const documentsWithTagsAndProperties = organizationDocuments.map((document) => {
|
||||
const documentTagDocuments = tagDocuments.filter(tagDocument => tagDocument?.documentId === document?.id);
|
||||
const tags = allTags.filter(tag => documentTagDocuments.some(tagDocument => tagDocument?.tagId === tag?.id));
|
||||
|
||||
const customProperties = buildCustomPropertiesResponse({
|
||||
definitions: allDefinitions,
|
||||
storedValues: allPropertyValues,
|
||||
documentId: document.id,
|
||||
});
|
||||
|
||||
return {
|
||||
...document,
|
||||
tags,
|
||||
customProperties,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredDocuments = searchDemoDocuments({ query: searchQuery, documents: documentsWithTags as Document[] });
|
||||
const filteredDocuments = searchDemoDocuments({ query: searchQuery, documents: documentsWithTagsAndProperties as unknown as Document[] });
|
||||
|
||||
const paginatedDocuments = filteredDocuments
|
||||
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
|
|
@ -223,13 +291,25 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
|
||||
assert(document, { status: 404 });
|
||||
|
||||
const tagDocuments = await findMany(tagDocumentStorage, tagDocument => tagDocument.documentId === documentId);
|
||||
const [tagDocuments, allDefinitions, allPropertyValues] = await Promise.all([
|
||||
findMany(tagDocumentStorage, tagDocument => tagDocument.documentId === documentId),
|
||||
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
|
||||
findMany(documentCustomPropertyValueStorage, v => v.documentId === documentId),
|
||||
]);
|
||||
|
||||
const tags = await findMany(tagStorage, tag => tagDocuments.some(tagDocument => tagDocument.tagId === tag.id));
|
||||
|
||||
const customProperties = buildCustomPropertiesResponse({
|
||||
definitions: allDefinitions,
|
||||
storedValues: allPropertyValues,
|
||||
documentId,
|
||||
});
|
||||
|
||||
return {
|
||||
document: {
|
||||
...document,
|
||||
tags,
|
||||
customProperties,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -749,16 +829,17 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
|
||||
assert(document, { status: 404 });
|
||||
|
||||
const { name, content } = body as { name?: string; content?: string };
|
||||
const { name, content, documentDate } = body as { name?: string; content?: string; documentDate?: string };
|
||||
|
||||
const newDocument = {
|
||||
...document,
|
||||
...(name !== undefined && { name }),
|
||||
...(content !== undefined && { content }),
|
||||
...(documentDate !== undefined && { documentDate: documentDate === null ? null : new Date(documentDate) }),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await documentStorage.setItem(`${organizationId}:${documentId}`, newDocument);
|
||||
await documentStorage.setItem(`${organizationId}:${documentId}`, newDocument as Document); // TODO: introduce a storage/serialized type
|
||||
|
||||
return { document: newDocument };
|
||||
},
|
||||
|
|
@ -861,6 +942,173 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
|||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/custom-properties',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const propertyDefinitions = await findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId);
|
||||
|
||||
return { propertyDefinitions };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/custom-properties',
|
||||
method: 'POST',
|
||||
handler: async ({ params: { organizationId }, body }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const existingDefinitions = await findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId);
|
||||
|
||||
const propertyDefinition = {
|
||||
id: createId({ prefix: 'cpd' }),
|
||||
organizationId,
|
||||
name: get(body, ['name']) as string,
|
||||
key: generatePropertyKey({ name: get(body, ['name']) as string }),
|
||||
description: (get(body, ['description']) ?? null) as string | null,
|
||||
type: get(body, ['type']) as string,
|
||||
displayOrder: existingDefinitions.length,
|
||||
options: (get(body, ['options']) as { name: string }[] ?? []).map((option, index) => ({
|
||||
id: createId({ prefix: 'opt' }),
|
||||
name: option.name,
|
||||
key: generatePropertyKey({ name: option.name }),
|
||||
displayOrder: index,
|
||||
})),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await customPropertyDefinitionStorage.setItem(propertyDefinition.id, propertyDefinition as any);
|
||||
|
||||
return { propertyDefinition };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId, propertyDefinitionId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const definition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
|
||||
|
||||
assert(definition, { status: 404 });
|
||||
|
||||
return { definition };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
|
||||
method: 'PUT',
|
||||
handler: async ({ params: { organizationId, propertyDefinitionId }, body }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const propertyDefinition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
|
||||
|
||||
assert(propertyDefinition, { status: 404 });
|
||||
|
||||
const updatedDefinition = Object.assign(propertyDefinition, body, { updatedAt: new Date() });
|
||||
|
||||
await customPropertyDefinitionStorage.setItem(propertyDefinitionId, updatedDefinition);
|
||||
|
||||
return { propertyDefinition: updatedDefinition };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/custom-properties/:propertyDefinitionId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { organizationId, propertyDefinitionId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
await customPropertyDefinitionStorage.removeItem(propertyDefinitionId);
|
||||
|
||||
// Remove all values associated with this definition
|
||||
const values = await findMany(documentCustomPropertyValueStorage, v => v.propertyDefinitionId === propertyDefinitionId);
|
||||
|
||||
await Promise.all(values.map(v => documentCustomPropertyValueStorage.removeItem(`${v.documentId}:${propertyDefinitionId}`)));
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId, documentId } }) => {
|
||||
const key = `${organizationId}:${documentId}`;
|
||||
const document = await documentStorage.getItem(key);
|
||||
|
||||
assert(document, { status: 404 });
|
||||
|
||||
const [allDefinitions, allValues] = await Promise.all([
|
||||
findMany(customPropertyDefinitionStorage, def => def.organizationId === organizationId),
|
||||
findMany(documentCustomPropertyValueStorage, v => v.documentId === documentId),
|
||||
]);
|
||||
|
||||
const customProperties = buildCustomPropertiesResponse({
|
||||
definitions: allDefinitions,
|
||||
storedValues: allValues,
|
||||
documentId,
|
||||
});
|
||||
|
||||
return { customProperties };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties/:propertyDefinitionId',
|
||||
method: 'PUT',
|
||||
handler: async ({ params: { organizationId, documentId, propertyDefinitionId }, body }) => {
|
||||
const docKey = `${organizationId}:${documentId}`;
|
||||
const document = await documentStorage.getItem(docKey);
|
||||
|
||||
assert(document, { status: 404 });
|
||||
|
||||
const definition = await customPropertyDefinitionStorage.getItem(propertyDefinitionId);
|
||||
|
||||
assert(definition, { status: 404 });
|
||||
|
||||
const valueKey = `${documentId}:${propertyDefinitionId}`;
|
||||
const existing = await documentCustomPropertyValueStorage.getItem(valueKey);
|
||||
|
||||
const value = get(body, ['value']);
|
||||
|
||||
await documentCustomPropertyValueStorage.setItem(valueKey, {
|
||||
id: existing?.id ?? createId({ prefix: 'dcpv' }),
|
||||
documentId,
|
||||
propertyDefinitionId,
|
||||
value,
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/documents/:documentId/custom-properties/:propertyDefinitionId',
|
||||
method: 'DELETE',
|
||||
handler: async ({ params: { organizationId, documentId, propertyDefinitionId } }) => {
|
||||
const docKey = `${organizationId}:${documentId}`;
|
||||
const document = await documentStorage.getItem(docKey);
|
||||
|
||||
assert(document, { status: 404 });
|
||||
|
||||
const valueKey = `${documentId}:${propertyDefinitionId}`;
|
||||
|
||||
await documentCustomPropertyValueStorage.removeItem(valueKey);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { CustomPropertyDefinition } from '../custom-properties/custom-properties.types';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import type { Organization } from '../organizations/organizations.types';
|
||||
import type { TaggingRule } from '../tagging-rules/tagging-rules.types';
|
||||
|
|
@ -9,6 +10,7 @@ import localStorageDriver from 'unstorage/drivers/localstorage';
|
|||
import { trackingServices } from '../tracking/tracking.services';
|
||||
import { DEMO_IS_SEEDED_KEY } from './demo.constants';
|
||||
import { createId } from './demo.models';
|
||||
import { customPropertyDefinitionsFixtures } from './seed/custom-property-definitions.fixtures';
|
||||
import { documentFixtures } from './seed/documents.fixtures';
|
||||
import { tagsFixtures } from './seed/tags.fixtures';
|
||||
|
||||
|
|
@ -20,6 +22,13 @@ export type DocumentFileStoredFile = { name: string; size: number; type: string;
|
|||
export type DocumentFileRemoteFile = { name: string; path: string };
|
||||
export type DocumentFile = DocumentFileStoredFile | DocumentFileRemoteFile;
|
||||
|
||||
export type DocumentCustomPropertyValueStorage = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
propertyDefinitionId: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export const organizationStorage = prefixStorage<Organization>(storage, 'organizations');
|
||||
export const documentStorage = prefixStorage<Document>(storage, 'documents');
|
||||
export const documentFileStorage = prefixStorage<DocumentFile>(storage, 'documentFiles');
|
||||
|
|
@ -28,6 +37,8 @@ export const tagDocumentStorage = prefixStorage<{ documentId: string; tagId: str
|
|||
export const taggingRuleStorage = prefixStorage<TaggingRule>(storage, 'taggingRules');
|
||||
export const apiKeyStorage = prefixStorage<ApiKey>(storage, 'apiKeys');
|
||||
export const webhooksStorage = prefixStorage<Webhook>(storage, 'webhooks');
|
||||
export const customPropertyDefinitionStorage = prefixStorage<CustomPropertyDefinition>(storage, 'customPropertyDefinitions');
|
||||
export const documentCustomPropertyValueStorage = prefixStorage<DocumentCustomPropertyValueStorage>(storage, 'documentCustomPropertyValues');
|
||||
|
||||
export async function clearDemoStorage() {
|
||||
await storage.clear();
|
||||
|
|
@ -90,6 +101,24 @@ export async function seedDemoStorage() {
|
|||
|
||||
const tagsPromises = tagStorage.setItems(tags.map(tag => ({ key: tag.id, value: tag })));
|
||||
|
||||
// Create custom property definitions
|
||||
const customPropertyDefinitions = customPropertyDefinitionsFixtures.map((fixture, index) => ({
|
||||
id: createId({ prefix: 'cpd' }),
|
||||
organizationId,
|
||||
name: fixture.name,
|
||||
key: fixture.key,
|
||||
description: fixture.description ?? null,
|
||||
type: fixture.type,
|
||||
displayOrder: index,
|
||||
options: fixture.options ?? [],
|
||||
createdAt: lastMonth,
|
||||
updatedAt: lastMonth,
|
||||
}));
|
||||
|
||||
const customPropertyDefinitionsPromises = customPropertyDefinitionStorage.setItems(
|
||||
customPropertyDefinitions.map(def => ({ key: def.id, value: def })),
|
||||
);
|
||||
|
||||
const documentsPromises = documentFixtures.flatMap((fixture) => {
|
||||
const documentId = createId({ prefix: 'doc' });
|
||||
|
||||
|
|
@ -126,11 +155,30 @@ export async function seedDemoStorage() {
|
|||
};
|
||||
}));
|
||||
|
||||
return [documentPromise, documentFilePromise, tagDocumentPromise];
|
||||
const customPropertyValuePromises = (fixture.customProperties ?? []).map((prop) => {
|
||||
const definition = customPropertyDefinitions.find(def => def.key === prop.key);
|
||||
|
||||
if (!definition) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const id = createId({ prefix: 'dcpv' });
|
||||
const valueKey = `${documentId}:${definition.id}`;
|
||||
|
||||
return documentCustomPropertyValueStorage.setItem(valueKey, {
|
||||
id,
|
||||
documentId,
|
||||
propertyDefinitionId: definition.id,
|
||||
value: prop.value,
|
||||
});
|
||||
});
|
||||
|
||||
return [documentPromise, documentFilePromise, tagDocumentPromise, ...customPropertyValuePromises];
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
tagsPromises,
|
||||
customPropertyDefinitionsPromises,
|
||||
...documentsPromises,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ describe('demo search services', () => {
|
|||
{ id: 'doc_4', name: 'Grocery List', content: 'Eggs, milk, bread, and butter.', tags: [tags.cooking, tags.personal], createdAt: new Date('2023-04-05') },
|
||||
{ id: 'doc_5', name: 'Project Plan', content: 'Outline project goals and milestones.', tags: [tags.work], createdAt: new Date('2023-05-20') },
|
||||
{ id: 'doc_6', name: 'Vacation Ideas', content: 'Consider visiting the beach or mountains.', tags: [], createdAt: new Date('2023-06-15') },
|
||||
{ id: 'doc_7', name: 'Invoice 001', content: 'Invoice for services.', tags: [tags.work], createdAt: new Date('2023-07-01'), customProperties: [{ key: 'invoicenumber', type: 'text', value: 'INV-001' }] },
|
||||
] as unknown as Document[];
|
||||
|
||||
const queries = [
|
||||
|
|
@ -26,15 +27,18 @@ describe('demo search services', () => {
|
|||
{ query: 'pancakes flour', expectedIds: ['doc_1'] },
|
||||
{ query: 'tag:cooking', expectedIds: ['doc_1', 'doc_4'] },
|
||||
{ query: 'tag:cooking butter', expectedIds: ['doc_4'] },
|
||||
{ query: 'tag:work created:>2023-03-01', expectedIds: ['doc_5'] },
|
||||
{ query: 'tag:work created:>2023-03-01', expectedIds: ['doc_5', 'doc_7'] },
|
||||
{ query: '-tag:work', expectedIds: ['doc_1', 'doc_3', 'doc_4', 'doc_6'] },
|
||||
{ query: 'has:tags', expectedIds: ['doc_1', 'doc_2', 'doc_3', 'doc_4', 'doc_5'] },
|
||||
{ query: 'has:tags', expectedIds: ['doc_1', 'doc_2', 'doc_3', 'doc_4', 'doc_5', 'doc_7'] },
|
||||
{ query: '-has:tags', expectedIds: ['doc_6'] },
|
||||
{ query: 'NOT has:tags', expectedIds: ['doc_6'] },
|
||||
{ query: '-has:tags OR tag:personal', expectedIds: ['doc_3', 'doc_4', 'doc_6'] },
|
||||
{ query: 'ncakes', expectedIds: [] },
|
||||
{ query: 'name:ncakes', expectedIds: [] },
|
||||
{ query: 'content:ncakes', expectedIds: [] },
|
||||
{ query: 'InvoiceNumber:INV', expectedIds: ['doc_7'] },
|
||||
{ query: 'invoicenumber:INV', expectedIds: ['doc_7'] },
|
||||
{ query: 'INVOICENUMBER:INV', expectedIds: ['doc_7'] },
|
||||
];
|
||||
|
||||
for (const { query, expectedIds } of queries) {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,16 @@ import type { AndExpression, Expression, FilterExpression, NotExpression, OrExpr
|
|||
import type { Document } from '../../documents/documents.types';
|
||||
import { parseSearchQuery } from '@papra/search-parser';
|
||||
|
||||
export function generatePropertyKey({ name }: { name: string }): string {
|
||||
return name
|
||||
.replace(/[^\p{L}\p{N}]/gu, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
type DocumentCondition = (params: { document: Document }) => boolean;
|
||||
|
||||
const falseCondition: DocumentCondition = () => false;
|
||||
const trueCondition: DocumentCondition = () => true;
|
||||
|
||||
export function someCorpusTokenStartsWith({ corpus, prefix }: { corpus: string | string []; prefix: string }): boolean {
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
|
|
@ -75,7 +82,7 @@ function buildContentFilterCondition({ expression }: { expression: FilterExpress
|
|||
|
||||
function buildCreatedFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
|
||||
const { value, operator } = expression;
|
||||
const dateValue = new Date(value);
|
||||
const dateValue = getDateValue({ value });
|
||||
|
||||
if (Number.isNaN(dateValue.getTime())) {
|
||||
return () => false;
|
||||
|
|
@ -105,6 +112,160 @@ function buildHasTagsFilter({ expression }: { expression: FilterExpression }): D
|
|||
return ({ document }) => document.tags.length > 0;
|
||||
}
|
||||
|
||||
function getDateValue({ value, now = new Date() }: { value: string; now?: Date }): Date {
|
||||
if (value === 'now') {
|
||||
return now;
|
||||
}
|
||||
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
function buildDateFilterCondition({ expression }: { expression: FilterExpression }): DocumentCondition {
|
||||
const { value, operator } = expression;
|
||||
const dateValue = getDateValue({ value });
|
||||
|
||||
if (Number.isNaN(dateValue.getTime())) {
|
||||
return () => false;
|
||||
}
|
||||
|
||||
return ({ document }) => {
|
||||
if (!document.documentDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const docDate = new Date(document.documentDate);
|
||||
|
||||
switch (operator) {
|
||||
case '=': return docDate.toDateString() === dateValue.toDateString();
|
||||
case '<': return docDate < dateValue;
|
||||
case '<=': return docDate <= dateValue;
|
||||
case '>': return docDate > dateValue;
|
||||
case '>=': return docDate >= dateValue;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildHasDateFilter({ expression }: { expression: FilterExpression }): DocumentCondition {
|
||||
const { operator } = expression;
|
||||
|
||||
if (operator !== '=') {
|
||||
return falseCondition;
|
||||
}
|
||||
|
||||
return ({ document }) => document.documentDate != null;
|
||||
}
|
||||
|
||||
function buildHasCustomPropertyFilter({ propertyKey }: { propertyKey: string }): DocumentCondition {
|
||||
const normalizedKey = generatePropertyKey({ name: propertyKey });
|
||||
|
||||
return ({ document }) => {
|
||||
const prop = document.customProperties?.find(p => p.key === normalizedKey);
|
||||
|
||||
return prop !== undefined && prop.value != null;
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomPropertyFilterCondition({ field, expression }: { field: string; expression: FilterExpression }): DocumentCondition {
|
||||
const { value, operator } = expression;
|
||||
const normalizedField = generatePropertyKey({ name: field });
|
||||
|
||||
return ({ document }) => {
|
||||
const prop = document.customProperties?.find(p => p.key === normalizedField);
|
||||
|
||||
if (!prop || prop.value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (prop.type) {
|
||||
case 'boolean': {
|
||||
if (operator !== '=') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const boolValue = ['true', 'yes', '1', 'on', 'enabled'].includes(value.toLowerCase());
|
||||
|
||||
return prop.value === boolValue;
|
||||
}
|
||||
case 'number': {
|
||||
const numValue = Number(value);
|
||||
|
||||
if (Number.isNaN(numValue)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propNum = Number(prop.value);
|
||||
|
||||
switch (operator) {
|
||||
case '=': return propNum === numValue;
|
||||
case '<': return propNum < numValue;
|
||||
case '<=': return propNum <= numValue;
|
||||
case '>': return propNum > numValue;
|
||||
case '>=': return propNum >= numValue;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
case 'date': {
|
||||
const dateValue = getDateValue({ value });
|
||||
|
||||
if (Number.isNaN(dateValue.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propDate = new Date(prop.value as string);
|
||||
|
||||
switch (operator) {
|
||||
case '=': return propDate.toDateString() === dateValue.toDateString();
|
||||
case '<': return propDate < dateValue;
|
||||
case '<=': return propDate <= dateValue;
|
||||
case '>': return propDate > dateValue;
|
||||
case '>=': return propDate >= dateValue;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
case 'text': {
|
||||
if (operator !== '=') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return someCorpusTokenStartsWith({ corpus: String(prop.value), prefix: value });
|
||||
}
|
||||
case 'select': {
|
||||
if (operator !== '=') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propValue = prop.value as { key: string; name: string } | string;
|
||||
const propKey = typeof propValue === 'object' ? propValue.key : propValue;
|
||||
|
||||
return propKey.toLowerCase() === value.toLowerCase();
|
||||
}
|
||||
case 'multi_select': {
|
||||
if (operator !== '=') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const propValues = prop.value as Array<{ key: string; name: string } | string>;
|
||||
|
||||
return propValues.some((v) => {
|
||||
const optKey = typeof v === 'object' ? v.key : v;
|
||||
|
||||
return optKey.toLowerCase() === value.toLowerCase();
|
||||
});
|
||||
}
|
||||
default: {
|
||||
if (operator !== '=') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return String(prop.value).toLowerCase() === value.toLowerCase();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const KNOWN_FILTER_FIELDS = new Set(['tag', 'name', 'content', 'created', 'date', 'has']);
|
||||
|
||||
function buildExpressionCondition({ expression }: { expression: Expression }): DocumentCondition {
|
||||
switch (expression.type) {
|
||||
case 'text': return buildTextCondition({ expression });
|
||||
|
|
@ -117,14 +278,24 @@ function buildExpressionCondition({ expression }: { expression: Expression }): D
|
|||
case 'name': return buildNameFilterCondition({ expression });
|
||||
case 'content': return buildContentFilterCondition({ expression });
|
||||
case 'created': return buildCreatedFilterCondition({ expression });
|
||||
case 'date': return buildDateFilterCondition({ expression });
|
||||
case 'has':
|
||||
switch (expression.value) {
|
||||
case 'tags': return buildHasTagsFilter({ expression });
|
||||
default: return falseCondition;
|
||||
case 'date': return buildHasDateFilter({ expression });
|
||||
default:
|
||||
// has:<customPropertyKey> — check if document has a non-null value for this property
|
||||
return buildHasCustomPropertyFilter({ propertyKey: expression.value });
|
||||
}
|
||||
default: return falseCondition;
|
||||
default:
|
||||
// Unknown field — treat as a custom property key filter
|
||||
if (!KNOWN_FILTER_FIELDS.has(expression.field)) {
|
||||
return buildCustomPropertyFilterCondition({ field: expression.field, expression });
|
||||
}
|
||||
|
||||
return falseCondition;
|
||||
}
|
||||
case 'empty': return falseCondition;
|
||||
case 'empty': return trueCondition;
|
||||
default: return falseCondition;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import type { CustomPropertySelectOption, CustomPropertyType } from '../../custom-properties/custom-properties.types';
|
||||
|
||||
export type DemoCustomPropertyDefinitionFixture = {
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
type: CustomPropertyType;
|
||||
options?: CustomPropertySelectOption[];
|
||||
};
|
||||
|
||||
export const customPropertyDefinitionsFixtures: DemoCustomPropertyDefinitionFixture[] = [
|
||||
{
|
||||
name: 'Status',
|
||||
key: 'status',
|
||||
description: 'Current processing status of the document',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ id: 'opt_status_pending', key: 'pending', name: 'Pending', displayOrder: 0 },
|
||||
{ id: 'opt_status_reviewed', key: 'reviewed', name: 'Reviewed', displayOrder: 1 },
|
||||
{ id: 'opt_status_archived', key: 'archived', name: 'Archived', displayOrder: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Priority',
|
||||
key: 'priority',
|
||||
description: 'Investigation priority level',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ id: 'opt_priority_low', key: 'low', name: 'Low', displayOrder: 0 },
|
||||
{ id: 'opt_priority_medium', key: 'medium', name: 'Medium', displayOrder: 1 },
|
||||
{ id: 'opt_priority_high', key: 'high', name: 'High', displayOrder: 2 },
|
||||
{ id: 'opt_priority_critical', key: 'critical', name: 'Critical', displayOrder: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Amount',
|
||||
key: 'amount',
|
||||
description: 'Monetary amount in GBP',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'Confidential',
|
||||
key: 'confidential',
|
||||
description: 'Whether this document is confidential',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'Reference',
|
||||
key: 'reference',
|
||||
description: 'External reference number or identifier',
|
||||
type: 'text',
|
||||
},
|
||||
] as const satisfies DemoCustomPropertyDefinitionFixture[];
|
||||
|
|
@ -8,6 +8,12 @@ const demoDocumentFixture: DemoDocumentFixture = {
|
|||
mimeType: 'application/pdf',
|
||||
size: 31564,
|
||||
tags: ['Cases'],
|
||||
customProperties: [
|
||||
{ key: 'status', value: 'reviewed' },
|
||||
{ key: 'priority', value: 'critical' },
|
||||
{ key: 'confidential', value: true },
|
||||
{ key: 'reference', value: 'CASE-2026-001' },
|
||||
],
|
||||
content: `
|
||||
blackmail letter from reginald thornton fiennes date january 20 2026
|
||||
reggie com to sherlock holmes sleuth subject the secrets you
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@ const demoDocumentFixture: DemoDocumentFixture = {
|
|||
mimeType: 'application/pdf',
|
||||
size: 54921,
|
||||
tags: ['Legal', 'Cases'],
|
||||
customProperties: [
|
||||
{ key: 'status', value: 'reviewed' },
|
||||
{ key: 'priority', value: 'high' },
|
||||
{ key: 'amount', value: 5000 },
|
||||
{ key: 'confidential', value: true },
|
||||
{ key: 'reference', value: 'SH-2024-087' },
|
||||
],
|
||||
content: `
|
||||
contract private investigation pemberton number sh 2024 087 date 22nd
|
||||
september parties service provider sherlock holmes consulting detective 221b baker
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ const demoDocumentFixture: DemoDocumentFixture = {
|
|||
mimeType: 'application/pdf',
|
||||
size: 50374,
|
||||
tags: ['Receipts'],
|
||||
customProperties: [
|
||||
{ key: 'status', value: 'archived' },
|
||||
{ key: 'amount', value: 19.56 },
|
||||
],
|
||||
content: `
|
||||
receipt for groceries date january 20 2026 store tesco superstore
|
||||
123 regent st london sw1e 7na order number tcgro000023 item
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ const demoDocumentFixture: DemoDocumentFixture = {
|
|||
mimeType: 'application/pdf',
|
||||
size: 38421,
|
||||
tags: ['Receipts', 'Property'],
|
||||
customProperties: [
|
||||
{ key: 'status', value: 'archived' },
|
||||
{ key: 'amount', value: 2000 },
|
||||
],
|
||||
content: `
|
||||
rent receipt april 2025 221b baker street marylebone london nw1
|
||||
6xe date 1st received from mr sherlock holmes for first
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ const demoDocumentFixture: DemoDocumentFixture = {
|
|||
mimeType: 'application/pdf',
|
||||
size: 37805,
|
||||
tags: ['Receipts', 'Property'],
|
||||
customProperties: [
|
||||
{ key: 'status', value: 'archived' },
|
||||
{ key: 'amount', value: 2000 },
|
||||
],
|
||||
content: `
|
||||
rent receipt july 2025 221b baker street marylebone london nw1
|
||||
6xe date 1st received from mr sherlock holmes for first
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ const demoDocumentFixture: DemoDocumentFixture = {
|
|||
mimeType: 'application/pdf',
|
||||
size: 51229,
|
||||
tags: ['Receipts'],
|
||||
customProperties: [
|
||||
{ key: 'status', value: 'archived' },
|
||||
{ key: 'amount', value: 3260 },
|
||||
{ key: 'reference', value: 'SL-2025-1108' },
|
||||
],
|
||||
content: `
|
||||
violin receipt vendor information transaction details item description price quantity
|
||||
customized the baker 2 500 00 1 hard case with
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import type { DemoCustomPropertyDefinitionFixture } from './custom-property-definitions.fixtures';
|
||||
import type { DemoTagFixtureNames } from './tags.fixtures';
|
||||
|
||||
export type DemoDocumentCustomPropertyValue = {
|
||||
key: DemoCustomPropertyDefinitionFixture['key'];
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export type DemoDocumentFixture = {
|
||||
name: string;
|
||||
date: Date;
|
||||
|
|
@ -8,4 +14,5 @@ export type DemoDocumentFixture = {
|
|||
tags: DemoTagFixtureNames[];
|
||||
mimeType: string;
|
||||
size: number;
|
||||
customProperties?: DemoDocumentCustomPropertyValue[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
import type { Document } from '../documents.types';
|
||||
import Calendar from '@corvu/calendar';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import { Show } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { CalendarGrid } from '@/modules/ui/components/calendar';
|
||||
import { CalendarMonthYearHeader } from '@/modules/ui/components/calendar-month-year-header';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/modules/ui/components/popover';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { updateDocument } from '../documents.services';
|
||||
|
||||
export function DocumentDatePicker(props: { document: Document; organizationId: string }) {
|
||||
const { t, formatDate } = useI18n();
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
|
||||
const updateDateMutation = useMutation(() => ({
|
||||
mutationFn: (date: Date | null) => updateDocument({
|
||||
documentId: props.document.id,
|
||||
organizationId: props.organizationId,
|
||||
documentDate: date,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await invalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
|
||||
},
|
||||
onError: (error) => {
|
||||
createToast({
|
||||
message: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const handleDateChange = (date: Date | null) => {
|
||||
if (updateDateMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDateMutation.mutate(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
disabled={updateDateMutation.isPending}
|
||||
>
|
||||
<Show
|
||||
when={props.document.documentDate}
|
||||
fallback={<span class="text-muted-foreground">{t('documents.info.no-date')}</span>}
|
||||
>
|
||||
{date => formatDate(date(), { dateStyle: 'medium' })}
|
||||
</Show>
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-3">
|
||||
<Calendar
|
||||
mode="single"
|
||||
value={props.document.documentDate ?? null}
|
||||
onValueChange={handleDateChange}
|
||||
fixedWeeks
|
||||
>
|
||||
{() => (
|
||||
<div class="flex">
|
||||
<div class="flex flex-col gap-2">
|
||||
<CalendarMonthYearHeader />
|
||||
<CalendarGrid />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1 min-w-32 ml-2 border-l pl-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start text-sm"
|
||||
onClick={() => handleDateChange(new Date())}
|
||||
disabled={updateDateMutation.isPending}
|
||||
>
|
||||
<div class="i-tabler-calendar-event size-4 mr-2 text-muted-foreground" />
|
||||
{t('documents.info.today')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="justify-start text-sm"
|
||||
onClick={() => handleDateChange(null)}
|
||||
disabled={updateDateMutation.isPending}
|
||||
>
|
||||
<div class="i-tabler-calendar-off size-4 mr-2 text-muted-foreground" />
|
||||
{t('documents.info.no-date')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Calendar>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ const PdfViewer = lazy(() => import('./pdf-viewer/simple-pdf-viewer.component').
|
|||
|
||||
const imageMimeType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const pdfMimeType = ['application/pdf'];
|
||||
const txtLikeMimeType = ['application/x-yaml', 'application/json', 'application/xml'];
|
||||
const txtLikeMimeType = ['application/yaml', 'application/json', 'application/xml'];
|
||||
|
||||
function blobToString(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export const DocumentsPaginatedList: Component<{
|
|||
header: () => t('documents.list.table.headers.file-name'),
|
||||
id: 'fileName',
|
||||
cell: data => (
|
||||
<div class="overflow-hidden flex gap-4 items-center">
|
||||
<div class="overflow-hidden flex gap-4 items-center max-w-500px">
|
||||
<div class="bg-muted flex items-center justify-center p-2 rounded-lg">
|
||||
<div
|
||||
class={cn(
|
||||
|
|
@ -103,6 +103,7 @@ export const DocumentsPaginatedList: Component<{
|
|||
<A
|
||||
href={`/organizations/${data.row.original.organizationId}/documents/${data.row.original.id}`}
|
||||
class="font-bold truncate block hover:underline"
|
||||
title={data.row.original.name}
|
||||
>
|
||||
{getDocumentNameWithoutExtension({
|
||||
name: data.row.original.name,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Component } from 'solid-js';
|
|||
import { usePDFSlick } from '@pdfslick/solid';
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { ThumbnailsBar } from './thumbnails-bar/thumbnails-bar.component';
|
||||
import { SideBar } from './sidebar/sidebar.component';
|
||||
import { PdfViewerToolbar } from './toolbar/pdf-viewer-toolbar.component';
|
||||
import '@pdfslick/solid/dist/pdf_viewer.css';
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ export const PdfViewer: Component<{ url: string }> = (props) => {
|
|||
class={cn('shrink-0 h-full overflow-hidden', { 'transition-width duration-200': !isDragging() })}
|
||||
style={{ width: isSidebarOpen() ? `${sidebarWidth()}px` : '0px' }}
|
||||
>
|
||||
<ThumbnailsBar
|
||||
<SideBar
|
||||
store={store}
|
||||
thumbsRef={thumbsRef}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { PDFSlickState } from '../pdf-viewer.types';
|
||||
import { For } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
|
||||
type AttachmentsPanelProps = {
|
||||
store: PDFSlickState;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
export const AttachmentsPanel: Component<AttachmentsPanelProps> = (props) => {
|
||||
return (
|
||||
<div class={cn('p-2 text-sm', { invisible: !props.show })}>
|
||||
<div class="p-2 text-sm">
|
||||
<For each={Array.from(props.store.attachments.values())}>
|
||||
{({ filename, content }) => (
|
||||
<button
|
||||
|
|
@ -6,7 +6,6 @@ import { Button } from '@/modules/ui/components/button';
|
|||
|
||||
type OutlinePanelProps = {
|
||||
store: PDFSlickState;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
const OutlineItem: Component<{ title: string; dest: any; items?: TPDFDocumentOutline; store: PDFSlickState; level: number }> = (props) => {
|
||||
|
|
@ -73,7 +72,7 @@ const OutlineItems: Component<{
|
|||
|
||||
export const OutlinePanel: Component<OutlinePanelProps> = (props) => {
|
||||
return (
|
||||
<div class={cn('overflow-auto pt-2 text-foreground text-sm', { invisible: !props.show })}>
|
||||
<div class="pt-2 text-foreground text-sm">
|
||||
<OutlineItems
|
||||
outline={props.store.documentOutline}
|
||||
store={props.store}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { Component, ParentComponent } from 'solid-js';
|
||||
import type { ThumbnailsBarProps, ThumbnailsBarTab } from '../pdf-viewer.types';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { AttachmentsPanel } from './attachments-panel.component';
|
||||
import { OutlinePanel } from './outline-panel.component';
|
||||
import { SidebarButtonsBar } from './sidebar-buttons-bar.component';
|
||||
import { ThumbnailsPanel } from './thumbnails-panel.component';
|
||||
|
||||
const SideBarPanel: ParentComponent<{ active: boolean }> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={cn('absolute inset-0 flex flex-col overflow-y-auto transition-opacity z-10', { 'opacity-0 pointer-events-none z-0': !props.active })}
|
||||
aria-hidden={!props.active}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SideBar: Component<ThumbnailsBarProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal<ThumbnailsBarTab>('thumbnails');
|
||||
|
||||
return (
|
||||
<div class="h-full w-full flex relative bg-card border-r shrink-0">
|
||||
<SidebarButtonsBar
|
||||
store={props.store}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
|
||||
<div class="flex-1 relative overflow-hidden min-h-0">
|
||||
|
||||
<SideBarPanel active={activeTab() === 'thumbnails'}>
|
||||
<ThumbnailsPanel store={props.store} thumbsRef={props.thumbsRef} />
|
||||
</SideBarPanel>
|
||||
|
||||
<SideBarPanel active={activeTab() === 'outline'}>
|
||||
<OutlinePanel store={props.store} />
|
||||
</SideBarPanel>
|
||||
|
||||
<SideBarPanel active={activeTab() === 'attachments'}>
|
||||
<AttachmentsPanel store={props.store} />
|
||||
</SideBarPanel>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,7 +8,6 @@ import { cn } from '@/modules/shared/style/cn';
|
|||
type ThumbnailsPanelProps = {
|
||||
store: PDFSlickState;
|
||||
thumbsRef: (instance: HTMLElement) => void;
|
||||
show: boolean;
|
||||
};
|
||||
|
||||
const PageThumbnail: Component<{
|
||||
|
|
@ -17,7 +16,6 @@ const PageThumbnail: Component<{
|
|||
src: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
loaded: boolean;
|
||||
onClick?: () => void;
|
||||
currentPage: number;
|
||||
altText: string;
|
||||
|
|
@ -30,15 +28,18 @@ const PageThumbnail: Component<{
|
|||
onClick={props.onClick}
|
||||
class={cn('p-2 rounded-xl transition-colors hover:bg-accent mx-auto block', { 'bg-muted': isCurrentPageSelected() })}
|
||||
>
|
||||
{props.src && (
|
||||
<img
|
||||
src={props.src}
|
||||
width={props.width}
|
||||
height={props.height}
|
||||
alt={props.altText}
|
||||
class="block rounded"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
class="rounded bg-muted overflow-hidden"
|
||||
style={{ width: `${props.width}px`, height: `${props.height}px` }}
|
||||
>
|
||||
{props.src && (
|
||||
<img
|
||||
src={props.src}
|
||||
alt={props.altText}
|
||||
class="block w-full h-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={cn('text-center text-xs pt-2 transition-colors text-muted-foreground', { 'text-foreground font-medium': isCurrentPageSelected() })}
|
||||
|
|
@ -71,7 +72,7 @@ export const ThumbnailsPanel: Component<ThumbnailsPanelProps> = (props) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
class={cn('px-2 relative h-full', { invisible: !props.show })}
|
||||
class="px-2 relative h-full"
|
||||
ref={containerRef}
|
||||
>
|
||||
<PDFSlickThumbnails
|
||||
|
|
@ -87,14 +88,13 @@ export const ThumbnailsPanel: Component<ThumbnailsPanelProps> = (props) => {
|
|||
},
|
||||
)}
|
||||
>
|
||||
{({ pageNumber, width, height, src, pageLabel, loaded }) => (
|
||||
{({ pageNumber, width, height, src, pageLabel }) => (
|
||||
<PageThumbnail
|
||||
pageNumber={pageNumber}
|
||||
width={width}
|
||||
height={height}
|
||||
src={src}
|
||||
pageLabel={pageLabel}
|
||||
loaded={loaded}
|
||||
currentPage={props.store.pageNumber}
|
||||
onClick={() => props.store.pdfSlick?.gotoPage(pageNumber)}
|
||||
altText={t('documents.pdf-viewer.thumbnails.page-alt', { page: pageLabel ?? pageNumber })}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { ThumbnailsBarProps, ThumbnailsBarTab } from '../pdf-viewer.types';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { AttachmentsPanel } from './attachments-panel.component';
|
||||
import { OutlinePanel } from './outline-panel.component';
|
||||
import { SidebarButtonsBar } from './sidebar-buttons-bar.component';
|
||||
import { ThumbnailsPanel } from './thumbnails-panel.component';
|
||||
|
||||
export const ThumbnailsBar: Component<ThumbnailsBarProps> = (props) => {
|
||||
const [activeTab, setActiveTab] = createSignal<ThumbnailsBarTab>('thumbnails');
|
||||
|
||||
return (
|
||||
<div class="h-full w-full flex relative bg-card border-r shrink-0">
|
||||
<SidebarButtonsBar
|
||||
store={props.store}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
|
||||
<div class="flex-1 relative transition-all duration-200">
|
||||
<ThumbnailsPanel
|
||||
show={activeTab() === 'thumbnails'}
|
||||
store={props.store}
|
||||
thumbsRef={props.thumbsRef}
|
||||
/>
|
||||
<OutlinePanel
|
||||
show={activeTab() === 'outline'}
|
||||
store={props.store}
|
||||
/>
|
||||
<AttachmentsPanel
|
||||
show={activeTab() === 'attachments'}
|
||||
store={props.store}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -177,16 +177,18 @@ export async function updateDocument({
|
|||
organizationId,
|
||||
content,
|
||||
name,
|
||||
documentDate,
|
||||
}: {
|
||||
documentId: string;
|
||||
organizationId: string;
|
||||
content?: string;
|
||||
name?: string;
|
||||
documentDate?: Date | null;
|
||||
}) {
|
||||
const { document } = await apiClient<{ document: AsDto<Document> }>({
|
||||
method: 'PATCH',
|
||||
path: `/api/organizations/${organizationId}/documents/${documentId}`,
|
||||
body: { content, name },
|
||||
body: { content, name, documentDate },
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { DocumentCustomProperty } from '../custom-properties/custom-properties.types';
|
||||
import type { Tag } from '../tags/tags.types';
|
||||
import type { User } from '../users/users.types';
|
||||
import type { DOCUMENT_ACTIVITY_EVENTS } from './documents.constants';
|
||||
|
|
@ -10,11 +11,13 @@ export type Document = {
|
|||
originalSize: number;
|
||||
createdAt: Date;
|
||||
updatedAt?: Date;
|
||||
documentDate?: Date;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: Date;
|
||||
deletedBy?: string;
|
||||
content: string;
|
||||
tags: Tag[];
|
||||
customProperties?: DocumentCustomProperty[];
|
||||
};
|
||||
|
||||
export type DocumentActivityEvent = (typeof DOCUMENT_ACTIVITY_EVENTS)[keyof typeof DOCUMENT_ACTIVITY_EVENTS];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
|||
import { useInfiniteQuery, useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { DocumentCustomPropertiesPanel } from '@/modules/custom-properties/components/document-custom-properties-panel.component';
|
||||
import { fetchCustomPropertyDefinitions } from '@/modules/custom-properties/custom-properties.services';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { downloadFile } from '@/modules/shared/files/download';
|
||||
|
|
@ -17,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/module
|
|||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from '@/modules/ui/components/tabs';
|
||||
import { DocumentContentEditionPanel } from '../components/document-content-edition-panel.component';
|
||||
import { DocumentDatePicker } from '../components/document-date-picker.component';
|
||||
import { DocumentPreview } from '../components/document-preview.component';
|
||||
import { DocumentOpenWithDropdownItems } from '../components/open-with.component';
|
||||
import { useRenameDocumentDialog } from '../components/rename-document-button.component';
|
||||
|
|
@ -32,21 +35,17 @@ type KeyValueItem = {
|
|||
|
||||
const KeyValues: Component<{ data?: KeyValueItem[] }> = (props) => {
|
||||
return (
|
||||
<div class="flex flex-col gap-2">
|
||||
<table>
|
||||
<For each={props.data}>
|
||||
{item => (
|
||||
<tr>
|
||||
<td class="py-1 pr-2 text-sm text-muted-foreground flex items-center gap-2 whitespace-nowrap">
|
||||
{item.icon && <div class={item.icon} />}
|
||||
{item.label}
|
||||
</td>
|
||||
<td class="py-1 pl-2 text-sm">{item.value}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</table>
|
||||
</div>
|
||||
<For each={props.data}>
|
||||
{item => (
|
||||
<>
|
||||
<div class="py-1 pr-2 text-sm text-muted-foreground flex items-center gap-2 whitespace-nowrap">
|
||||
{item.icon && <div class={item.icon} />}
|
||||
{item.label}
|
||||
</div>
|
||||
<div class="py-1 pl-2 text-sm">{item.value}</div>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -157,6 +156,11 @@ export const DocumentPage: Component = () => {
|
|||
queryFn: () => fetchDocumentFile({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const customPropertyDefinitionsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'custom-properties'],
|
||||
queryFn: () => fetchCustomPropertyDefinitions({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const activityPageSize = 20;
|
||||
const activityQuery = useInfiniteQuery(() => ({
|
||||
enabled: getTab() === 'activity',
|
||||
|
|
@ -290,53 +294,70 @@ export const DocumentPage: Component = () => {
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="info">
|
||||
<KeyValues data={[
|
||||
{
|
||||
label: t('documents.info.id'),
|
||||
value: getDocument().id,
|
||||
icon: 'i-tabler-id',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.name'),
|
||||
value: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
onClick={() => openRenameDialog({
|
||||
documentId: getDocument().id,
|
||||
organizationId: params.organizationId,
|
||||
documentName: getDocument().name,
|
||||
})}
|
||||
>
|
||||
{getDocument().name}
|
||||
<div class="grid grid-cols-[max-content_1fr]">
|
||||
<KeyValues data={[
|
||||
{
|
||||
label: t('documents.info.id'),
|
||||
value: getDocument().id,
|
||||
icon: 'i-tabler-id',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.name'),
|
||||
value: (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex items-center gap-2 group bg-transparent! p-0 h-auto text-left"
|
||||
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 flex-shrink-0" />
|
||||
</Button>
|
||||
),
|
||||
icon: 'i-tabler-file-text',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.type'),
|
||||
value: getDocument().mimeType,
|
||||
icon: 'i-tabler-file-unknown',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.size'),
|
||||
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
|
||||
icon: 'i-tabler-weight',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.created-at'),
|
||||
value: formatRelativeTime(getDocument().createdAt),
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.updated-at'),
|
||||
value: getDocument().updatedAt ? formatRelativeTime(getDocument().updatedAt!) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div class="i-tabler-pencil size-4 text-muted-foreground group-hover:text-foreground transition-colors flex-shrink-0" />
|
||||
</Button>
|
||||
),
|
||||
icon: 'i-tabler-file-text',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.type'),
|
||||
value: getDocument().mimeType,
|
||||
icon: 'i-tabler-file-unknown',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.size'),
|
||||
value: formatBytes({ bytes: getDocument().originalSize, base: 1000 }),
|
||||
icon: 'i-tabler-weight',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.document-date'),
|
||||
value: <DocumentDatePicker document={getDocument()} organizationId={params.organizationId} />,
|
||||
icon: 'i-tabler-calendar-event',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.created-at'),
|
||||
value: formatRelativeTime(getDocument().createdAt),
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.updated-at'),
|
||||
value: getDocument().updatedAt ? formatRelativeTime(getDocument().updatedAt!) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Show when={customPropertyDefinitionsQuery.data?.propertyDefinitions}>
|
||||
{getDefinitions => (
|
||||
<DocumentCustomPropertiesPanel
|
||||
document={getDocument()}
|
||||
organizationId={params.organizationId}
|
||||
propertyDefinitions={getDefinitions()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="content">
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export const DocumentsPage: Component = () => {
|
|||
)
|
||||
: (
|
||||
<>
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
<h2 class="text-xl font-bold mb-4">
|
||||
{t('documents.list.title')}
|
||||
</h2>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,5 +11,6 @@ export const locales = [
|
|||
{ key: 'es', name: 'Español' },
|
||||
{ key: 'it', name: 'Italiano' },
|
||||
{ key: 'nl', name: 'Nederlands' },
|
||||
{ key: 'sv', name: 'Svenska' },
|
||||
{ key: 'zh', name: '简体中文' },
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -2,18 +2,22 @@ import type { Component } from 'solid-js';
|
|||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { Show, Suspense } from 'solid-js';
|
||||
import { useDocumentUpload } from '@/modules/documents/components/document-import-status.component';
|
||||
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
|
||||
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '@/modules/documents/components/documents-list.component';
|
||||
import { fetchOrganizationDocuments, getOrganizationDocumentsStats } from '@/modules/documents/documents.services';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createParamSynchronizedPagination } from '@/modules/shared/pagination/query-synchronized-pagination';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
|
||||
export const OrganizationPage: Component = () => {
|
||||
const params = useParams();
|
||||
const { t } = useI18n();
|
||||
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
|
||||
const [getPagination, setPagination] = createParamSynchronizedPagination({
|
||||
defaultPageSize: 100,
|
||||
defaultPageIndex: 0,
|
||||
});
|
||||
|
||||
const documentsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
|
||||
|
|
|
|||
|
|
@ -1,26 +1,41 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import { createSignal } from 'solid-js';
|
||||
import type { Accessor, ParentComponent } from 'solid-js';
|
||||
import { createContext, createSignal, useContext } from 'solid-js';
|
||||
import { Dialog, DialogContent } from '@/modules/ui/components/dialog';
|
||||
import { AboutContent } from './about-content';
|
||||
|
||||
export const AboutDialog: Component<{ open?: boolean; onOpenChange?: (open: boolean) => void }> = (props) => {
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent class="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<AboutContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
const aboutDialogContext = createContext<{
|
||||
getIsOpen: Accessor<boolean>;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}>();
|
||||
|
||||
export function useAboutDialog() {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const context = useContext(aboutDialogContext);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
open: () => setIsOpen(true),
|
||||
close: () => setIsOpen(false),
|
||||
toggle: () => setIsOpen(prev => !prev),
|
||||
};
|
||||
if (!context) {
|
||||
throw new Error('useAboutDialog must be used within an AboutDialogProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export const AboutDialogProvider: ParentComponent = (props) => {
|
||||
const [getIsOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
const open = () => setIsOpen(true);
|
||||
const close = () => setIsOpen(false);
|
||||
const toggle = () => setIsOpen(prev => !prev);
|
||||
|
||||
return (
|
||||
<aboutDialogContext.Provider value={{ getIsOpen, open, close, toggle }}>
|
||||
{props.children}
|
||||
|
||||
<Dialog open={getIsOpen()} onOpenChange={setIsOpen}>
|
||||
<DialogContent class="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<AboutContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aboutDialogContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,6 +31,16 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
|
|||
}
|
||||
}
|
||||
|
||||
const responseMessage = get(
|
||||
error,
|
||||
['data', 'error', 'message'], // From fetch errors
|
||||
['details', 'message'], // From custom errors throw in better auth hooks, must be before ['message'] as they have both
|
||||
);
|
||||
|
||||
if (responseMessage && typeof responseMessage === 'string') {
|
||||
return responseMessage;
|
||||
}
|
||||
|
||||
// Fetch error messages without codes are not helpful
|
||||
if (error instanceof FetchError) {
|
||||
return defaultMessage;
|
||||
|
|
@ -38,8 +48,6 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
|
|||
|
||||
const message = get(
|
||||
error,
|
||||
['data', 'error', 'message'], // From fetch errors
|
||||
['details', 'message'], // From custom errors throw in better auth hooks, must be before ['message'] as they have both
|
||||
['message'], // From generic errors
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export function getFormData(pojo: Record<string, string | Blob>): FormData {
|
|||
return formData;
|
||||
}
|
||||
|
||||
type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt';
|
||||
type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt' | 'documentDate';
|
||||
|
||||
type CoerceDate<T> = T extends string | Date
|
||||
? Date
|
||||
|
|
@ -28,5 +28,6 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
|
|||
...('lastTriggeredAt' in obj ? { lastTriggeredAt: toDate(obj.lastTriggeredAt) } : {}),
|
||||
...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}),
|
||||
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: toDate(obj.scheduledPurgeAt) } : {}),
|
||||
...('documentDate' in obj ? { documentDate: toDate(obj.documentDate) } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { safely } from '@corentinth/chisels';
|
|||
import { getValues, setValue } from '@modular-forms/solid';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getSortedRowModel } from '@tanstack/solid-table';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { makeDocumentSearchPermalink } from '@/modules/documents/document.models';
|
||||
|
|
@ -321,6 +322,73 @@ export const TagsPage: Component = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const table = createSolidTable({
|
||||
get data() {
|
||||
return query.data?.tags ?? [];
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
header: () => t('tags.table.headers.tag'),
|
||||
accessorKey: 'name',
|
||||
sortingFn: 'alphanumeric',
|
||||
cell: data => <TagLink {...data.row.original} />,
|
||||
},
|
||||
{
|
||||
header: () => t('tags.table.headers.description'),
|
||||
accessorKey: 'description',
|
||||
sortingFn: 'alphanumeric',
|
||||
cell: data => (
|
||||
<span class="text-wrap">
|
||||
{data.getValue<string | null>() || <span class="text-muted-foreground">{t('tags.form.no-description')}</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => t('tags.table.headers.documents'),
|
||||
accessorKey: 'documentsCount',
|
||||
sortingFn: 'basic',
|
||||
cell: data => (
|
||||
<A href={makeDocumentSearchPermalink({ organizationId: params.organizationId, search: { tags: [data.row.original] } })} class="inline-flex items-center gap-1 hover:underline">
|
||||
<div class="i-tabler-file-text size-5 text-muted-foreground" />
|
||||
{data.getValue<number>()}
|
||||
</A>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: () => t('tags.table.headers.created'),
|
||||
accessorKey: 'createdAt',
|
||||
sortingFn: 'datetime',
|
||||
cell: data => <RelativeTime date={data.getValue<Date>()} class="text-muted-foreground" />,
|
||||
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <div class="text-right">{t('tags.table.headers.actions')}</div>,
|
||||
enableSorting: false,
|
||||
cell: data => (
|
||||
<div class="flex gap-2 justify-end">
|
||||
<UpdateTagModal organizationId={params.organizationId} tag={data.row.original}>
|
||||
{props => (
|
||||
<Button size="icon" variant="outline" class="size-7" {...props}>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</UpdateTagModal>
|
||||
|
||||
<Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag: data.row.original })}>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
initialState: {
|
||||
sorting: [{ id: 'name', desc: false }],
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 mx-auto max-w-5xl">
|
||||
<Suspense>
|
||||
|
|
@ -371,51 +439,50 @@ export const TagsPage: Component = () => {
|
|||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('tags.table.headers.tag')}</TableHead>
|
||||
<TableHead>{t('tags.table.headers.description')}</TableHead>
|
||||
<TableHead>{t('tags.table.headers.documents')}</TableHead>
|
||||
<TableHead>{t('tags.table.headers.created')}</TableHead>
|
||||
<TableHead class="text-right">
|
||||
{t('tags.table.headers.actions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
<For each={table.getHeaderGroups()}>
|
||||
{headerGroup => (
|
||||
<TableRow>
|
||||
<For each={headerGroup.headers}>
|
||||
{header => (
|
||||
<TableHead>
|
||||
<Show
|
||||
when={header.column.getCanSort()}
|
||||
fallback={flexRender(header.column.columnDef.header, header.getContext())}
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 cursor-pointer select-none"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<Show when={header.column.getIsSorted() === 'asc'}>
|
||||
<div class="i-tabler-arrow-down size-3.5" />
|
||||
</Show>
|
||||
<Show when={header.column.getIsSorted() === 'desc'}>
|
||||
<div class="i-tabler-arrow-up size-3.5" />
|
||||
</Show>
|
||||
<Show when={!header.column.getIsSorted()}>
|
||||
<div class="i-tabler-arrows-sort size-3.5 opacity-40" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
</TableHead>
|
||||
)}
|
||||
</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<For each={getTags()}>
|
||||
{tag => (
|
||||
<For each={table.getRowModel().rows}>
|
||||
{row => (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div>
|
||||
<TagLink {...tag} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="text-wrap">{tag.description || <span class="text-muted-foreground">{t('tags.form.no-description')}</span>}</TableCell>
|
||||
<TableCell>
|
||||
<A href={makeDocumentSearchPermalink({ organizationId: params.organizationId, search: { tags: [tag] } })} class="inline-flex items-center gap-1 hover:underline">
|
||||
<div class="i-tabler-file-text size-5 text-muted-foreground" />
|
||||
{tag.documentsCount}
|
||||
</A>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground" title={tag.createdAt.toLocaleString()}>
|
||||
<RelativeTime date={tag.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-2 justify-end">
|
||||
|
||||
<UpdateTagModal organizationId={params.organizationId} tag={tag}>
|
||||
{props => (
|
||||
<Button size="icon" variant="outline" class="size-7" {...props}>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</UpdateTagModal>
|
||||
|
||||
<Button size="icon" variant="outline" class="size-7 text-red" onClick={() => del({ tag })}>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<For each={row.getVisibleCells()}>
|
||||
{cell => (
|
||||
<TableCell>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
)}
|
||||
</For>
|
||||
</TableRow>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { Component } from 'solid-js';
|
||||
import type { ThemePreference } from './theme.types';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from '@/modules/ui/components/dropdown-menu';
|
||||
import { useTheme } from './theme.provider';
|
||||
|
||||
export const ThemeSwitcher: Component = () => {
|
||||
const { getThemePreference, setThemePreference } = useTheme();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<DropdownMenuRadioGroup value={getThemePreference()} onChange={value => setThemePreference(value as ThemePreference)}>
|
||||
<DropdownMenuRadioItem value="light" disabled={getThemePreference() === 'light'}>
|
||||
{t('layout.theme.light')}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark" disabled={getThemePreference() === 'dark'}>
|
||||
{t('layout.theme.dark')}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system" disabled={getThemePreference() === 'system'}>
|
||||
{t('layout.theme.system')}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
);
|
||||
};
|
||||
9
apps/papra-client/src/modules/theme/theme.constants.ts
Normal file
9
apps/papra-client/src/modules/theme/theme.constants.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const THEME_PREFERENCES = {
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark',
|
||||
SYSTEM: 'system',
|
||||
} as const;
|
||||
|
||||
export const THEME_STORAGE_KEY = 'papra_color_mode';
|
||||
export const DEFAULT_THEME_PREFERENCE = THEME_PREFERENCES.DARK;
|
||||
export const THEME_ATTRIBUTE = 'data-kb-theme';
|
||||
31
apps/papra-client/src/modules/theme/theme.models.test.ts
Normal file
31
apps/papra-client/src/modules/theme/theme.models.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { getThemeFromPreference, isValidThemePreference } from './theme.models';
|
||||
|
||||
describe('theme models', () => {
|
||||
describe('isValidThemePreference', () => {
|
||||
test('valid theme preferences are either "light", "dark" or "system"', () => {
|
||||
expect(isValidThemePreference('light')).toBe(true);
|
||||
expect(isValidThemePreference('dark')).toBe(true);
|
||||
expect(isValidThemePreference('system')).toBe(true);
|
||||
|
||||
expect(isValidThemePreference('invalid')).toBe(false);
|
||||
expect(isValidThemePreference(123)).toBe(false);
|
||||
expect(isValidThemePreference(null)).toBe(false);
|
||||
expect(isValidThemePreference(undefined)).toBe(false);
|
||||
expect(isValidThemePreference({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThemeFromPreference', () => {
|
||||
test('returns the system theme when preference is "system", otherwise returns the theme preference', () => {
|
||||
expect(getThemeFromPreference({ themePreference: 'light', systemTheme: 'dark' })).toBe('light');
|
||||
expect(getThemeFromPreference({ themePreference: 'light', systemTheme: 'light' })).toBe('light');
|
||||
|
||||
expect(getThemeFromPreference({ themePreference: 'dark', systemTheme: 'dark' })).toBe('dark');
|
||||
expect(getThemeFromPreference({ themePreference: 'dark', systemTheme: 'light' })).toBe('dark');
|
||||
|
||||
expect(getThemeFromPreference({ themePreference: 'system', systemTheme: 'dark' })).toBe('dark');
|
||||
expect(getThemeFromPreference({ themePreference: 'system', systemTheme: 'light' })).toBe('light');
|
||||
});
|
||||
});
|
||||
});
|
||||
18
apps/papra-client/src/modules/theme/theme.models.ts
Normal file
18
apps/papra-client/src/modules/theme/theme.models.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Theme, ThemePreference } from './theme.types';
|
||||
import { THEME_PREFERENCES } from './theme.constants';
|
||||
|
||||
export function isValidThemePreference(value: unknown): value is ThemePreference {
|
||||
return (
|
||||
value === THEME_PREFERENCES.LIGHT
|
||||
|| value === THEME_PREFERENCES.DARK
|
||||
|| value === THEME_PREFERENCES.SYSTEM
|
||||
);
|
||||
}
|
||||
|
||||
export function getThemeFromPreference({ themePreference, systemTheme }: { themePreference: ThemePreference; systemTheme: Theme }): Theme {
|
||||
if (themePreference === THEME_PREFERENCES.SYSTEM) {
|
||||
return systemTheme;
|
||||
}
|
||||
|
||||
return themePreference;
|
||||
}
|
||||
79
apps/papra-client/src/modules/theme/theme.provider.tsx
Normal file
79
apps/papra-client/src/modules/theme/theme.provider.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Theme, ThemePreference } from './theme.types';
|
||||
import { createContext, createSignal, onCleanup, useContext } from 'solid-js';
|
||||
import { DEFAULT_THEME_PREFERENCE, THEME_ATTRIBUTE, THEME_STORAGE_KEY } from './theme.constants';
|
||||
import { getThemeFromPreference, isValidThemePreference } from './theme.models';
|
||||
|
||||
const themeContext = createContext<{
|
||||
getTheme: () => Theme;
|
||||
getThemePreference: () => ThemePreference;
|
||||
setThemePreference: (theme: ThemePreference) => void;
|
||||
}>();
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(themeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export const ThemeProvider: ParentComponent = (props) => {
|
||||
const themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const [systemTheme, setSystemTheme] = createSignal<Theme>(themeMediaQuery.matches ? 'dark' : 'light');
|
||||
|
||||
const getInitialThemePreference = (): ThemePreference => {
|
||||
const storedPreference = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
|
||||
if (isValidThemePreference(storedPreference)) {
|
||||
return storedPreference;
|
||||
}
|
||||
|
||||
return DEFAULT_THEME_PREFERENCE;
|
||||
};
|
||||
|
||||
const [getLocalThemePreference, setLocalThemePreference] = createSignal<ThemePreference>(getInitialThemePreference());
|
||||
|
||||
const getTheme = () => getThemeFromPreference({ themePreference: getLocalThemePreference(), systemTheme: systemTheme() });
|
||||
|
||||
const applyTheme = (theme: Theme) => {
|
||||
document.documentElement.setAttribute(THEME_ATTRIBUTE, theme);
|
||||
};
|
||||
|
||||
const setThemePreference = (newPreference: ThemePreference) => {
|
||||
setLocalThemePreference(newPreference);
|
||||
localStorage.setItem(THEME_STORAGE_KEY, newPreference);
|
||||
|
||||
const newTheme = getThemeFromPreference({ themePreference: newPreference, systemTheme: systemTheme() });
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = (e: MediaQueryListEvent) => {
|
||||
const newSystemTheme: Theme = e.matches ? 'dark' : 'light';
|
||||
setSystemTheme(newSystemTheme);
|
||||
|
||||
if (getLocalThemePreference() === 'system') {
|
||||
applyTheme(newSystemTheme);
|
||||
}
|
||||
};
|
||||
|
||||
themeMediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
onCleanup(() => {
|
||||
themeMediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||
});
|
||||
|
||||
return (
|
||||
<themeContext.Provider
|
||||
value={{
|
||||
getTheme,
|
||||
getThemePreference: getLocalThemePreference,
|
||||
setThemePreference,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</themeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import type { ConfigColorMode } from '@kobalte/core/color-mode';
|
||||
import { useColorMode } from '@kobalte/core/color-mode';
|
||||
|
||||
export function useThemeStore() {
|
||||
const { setColorMode, colorMode: getColorMode } = useColorMode();
|
||||
|
||||
return {
|
||||
setColorMode: ({ mode }: { mode: ConfigColorMode }) => setColorMode(mode),
|
||||
getColorMode,
|
||||
};
|
||||
}
|
||||
2
apps/papra-client/src/modules/theme/theme.types.ts
Normal file
2
apps/papra-client/src/modules/theme/theme.types.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type Theme = 'light' | 'dark';
|
||||
export type ThemePreference = Theme | 'system';
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue