Compare commits
101 Commits
@papra/cli
...
@papra/lec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3903eed170 | ||
|
|
c70d7e419a | ||
|
|
2240f58f04 | ||
|
|
79e9bb1b61 | ||
|
|
6e18162435 | ||
|
|
16ae4617df | ||
|
|
1c46071e00 | ||
|
|
377c11c185 | ||
|
|
28c3c15cef | ||
|
|
0391a3bcd5 | ||
|
|
2c75eec862 | ||
|
|
ccf7602f19 | ||
|
|
b8a515a313 | ||
|
|
0aad88471b | ||
|
|
efd2ae1c73 | ||
|
|
e9a719d06a | ||
|
|
68714267ad | ||
|
|
75a13da526 | ||
|
|
59d5819018 | ||
|
|
a857370343 | ||
|
|
f4740ba59a | ||
|
|
b0abf7f78a | ||
|
|
182ccbb30b | ||
|
|
75340f0ce7 | ||
|
|
1228486f28 | ||
|
|
655a1c5475 | ||
|
|
d1797eb9be | ||
|
|
bd3e321eb7 | ||
|
|
be25de7721 | ||
|
|
e85403f9a1 | ||
|
|
7de5d0956b | ||
|
|
b1a88230cd | ||
|
|
55bb29582e | ||
|
|
d9263dc703 | ||
|
|
c3ffa8387e | ||
|
|
d40514c043 | ||
|
|
d7df2f095b | ||
|
|
afdcc1c5ba | ||
|
|
92daaa35bb | ||
|
|
e4295e14ab | ||
|
|
ae37d1db36 | ||
|
|
a7464f8b89 | ||
|
|
2dd9ca9835 | ||
|
|
54cc14052c | ||
|
|
f930e46dde | ||
|
|
df75e5accb | ||
|
|
f66a9f5d1b | ||
|
|
c5b337f3bb | ||
|
|
bb1ba3e15e | ||
|
|
ce839c4127 | ||
|
|
8aabd28168 | ||
|
|
1a7a14b3ed | ||
|
|
17cebde051 | ||
|
|
12ead3d017 | ||
|
|
f6c0221858 | ||
|
|
1aaf2c96cd | ||
|
|
9c6f14fc13 | ||
|
|
3d49962ca5 | ||
|
|
c434d873bc | ||
|
|
60982da847 | ||
|
|
73ab9e8ab5 | ||
|
|
c4a9b9b088 | ||
|
|
9a6e822e71 | ||
|
|
e52bc261db | ||
|
|
624ad62c53 | ||
|
|
630f9cc328 | ||
|
|
9f5be458fe | ||
|
|
1bfdb8aa66 | ||
|
|
2e2bb6fbbd | ||
|
|
d09b9ed70d | ||
|
|
e1571d2b87 | ||
|
|
c9a66e4aa8 | ||
|
|
9fa2df4235 | ||
|
|
c84a921988 | ||
|
|
9b5f3993c3 | ||
|
|
b28772317c | ||
|
|
a3f9f05c66 | ||
|
|
0616635cd6 | ||
|
|
9e7a3ba70b | ||
|
|
04990b986e | ||
|
|
097b6bf2b7 | ||
|
|
cb3ce6b1d8 | ||
|
|
405ba645f6 | ||
|
|
ab6fd6ad10 | ||
|
|
782f70ff66 | ||
|
|
1abbf18e94 | ||
|
|
6bcb2a71e9 | ||
|
|
936bc2bd0a | ||
|
|
2efe7321cd | ||
|
|
947bdf8385 | ||
|
|
b5bf0cca4b | ||
|
|
208a561668 | ||
|
|
40cb1d71d5 | ||
|
|
3da13f7591 | ||
|
|
2a444aad31 | ||
|
|
47d8bbd356 | ||
|
|
ed4d7e4a00 | ||
|
|
f382397c0e | ||
|
|
54514e15db | ||
|
|
bb9d5556d3 | ||
|
|
83e943c5b4 |
@@ -5,13 +5,11 @@
|
||||
{ "repo": "papra-hq/papra"}
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [
|
||||
["@papra/app-client", "@papra/app-server"]
|
||||
],
|
||||
"fixed": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"ignore": ["@papra/app-client", "@papra/app-server", "@papra/docs"],
|
||||
"privatePackages": {
|
||||
"tag": true,
|
||||
"version": true
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.*
|
||||
*.log
|
||||
dist
|
||||
*.local
|
||||
.git
|
||||
db.sqlite
|
||||
local-documents
|
||||
.env
|
||||
**/.env
|
||||
1
.dockerignore
Symbolic link
@@ -0,0 +1 @@
|
||||
packages/docker/.dockerignore
|
||||
8
.github/workflows/release-docker.yaml
vendored
@@ -43,8 +43,8 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./packages/docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
corentinth/papra:latest-root
|
||||
@@ -56,8 +56,8 @@ jobs:
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.rootless
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./packages/docker/Dockerfile.rootless
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
corentinth/papra:latest
|
||||
|
||||
14
.github/workflows/release.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'papra-hq/papra'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -18,14 +19,18 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -41,12 +46,11 @@ jobs:
|
||||
title: "chore(release): update versions"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/docker')
|
||||
run: |
|
||||
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
|
||||
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/docker") | .version')
|
||||
echo "VERSION: $VERSION"
|
||||
gh workflow run release-docker.yaml -f version="$VERSION"
|
||||
env:
|
||||
|
||||
5
.gitignore
vendored
@@ -35,10 +35,13 @@ cache
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
local-documents
|
||||
ingestion
|
||||
.cursorrules
|
||||
*.traineddata
|
||||
|
||||
.eslintcache
|
||||
.eslintcache
|
||||
.claude
|
||||
222
CLAUDE.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Papra is a minimalistic document management and archiving platform built as a monorepo using PNPM workspaces. The project includes a SolidJS frontend, HonoJS backend, CLI tools, and supporting packages.
|
||||
It's open-source and designed for easy self-hosting using Docker, and also offers a cloud-hosted SaaS version.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
- **apps/papra-server**: Backend API server (HonoJS + Drizzle ORM + Better Auth)
|
||||
- **apps/papra-client**: Frontend application (SolidJS + UnoCSS + Shadcn Solid)
|
||||
- **apps/docs**: Documentation site (Astro + Starlight)
|
||||
- **packages/lecture**: Text extraction library for documents
|
||||
- **packages/api-sdk**: API client SDK
|
||||
- **packages/cli**: Command-line interface
|
||||
- **packages/webhooks**: Webhook types and utilities
|
||||
|
||||
### Backend Architecture (apps/papra-server)
|
||||
|
||||
The backend follows a modular architecture with feature-based modules:
|
||||
|
||||
- **Module pattern**: Each feature lives in `src/modules/<feature>/` with:
|
||||
- `*.repository.ts`: Database access layer (Drizzle ORM queries)
|
||||
- `*.usecases.ts`: Business logic and orchestration
|
||||
- `*.routes.ts`: HTTP route handlers (Hono)
|
||||
- `*.services.ts`: Service layer for external integrations
|
||||
- `*.table.ts`: Drizzle schema definitions
|
||||
- `*.types.ts`: TypeScript type definitions
|
||||
- `*.errors.ts`: Error definitions
|
||||
|
||||
- **Core modules**: `app`, `shared`, `config`, `tasks`
|
||||
- **Feature modules**: `documents`, `organizations`, `users`, `tags`, `tagging-rules`, `intake-emails`, `ingestion-folders`, `webhooks`, `api-keys`, `subscriptions`, etc.
|
||||
|
||||
- **Database**: Uses Drizzle ORM with SQLite/Turso (libsql). Schema is in `*.table.ts` files, migrations in `src/migrations/`
|
||||
|
||||
- **Authentication**: Better Auth library for user auth
|
||||
|
||||
- **Task system**: Background job processing using Cadence MQ, a custom made queue system (papra-hq/cadence-mq)
|
||||
|
||||
- **Document storage**: Abstracted storage supporting local filesystem, S3, and Azure Blob
|
||||
|
||||
### Frontend Architecture (apps/papra-client)
|
||||
|
||||
- **SolidJS** for reactivity with router (`@solidjs/router`)
|
||||
- **Module pattern**: Features in `src/modules/<feature>/` with:
|
||||
- `components/`: UI components
|
||||
- `pages/`: Route components
|
||||
- `*.services.ts`: API client calls
|
||||
- `*.provider.tsx`: Context providers
|
||||
- `*.types.ts`: Type definitions
|
||||
|
||||
- **Routing**: Defined in `src/routes.tsx`
|
||||
- **Styling**: UnoCSS for atomic CSS with Shadcn Solid components
|
||||
- **State**: TanStack Query for server state, local storage for client state
|
||||
- **i18n**: TypeScript-based translations in `src/locales/*.dictionary.ts`
|
||||
|
||||
### Dependency Injection Pattern
|
||||
|
||||
The server uses a dependency injection pattern with `@corentinth/chisels/injectArguments` to create testable services that accept dependencies as parameters.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Initial Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build all packages (required before running apps)
|
||||
pnpm build:packages
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
|
||||
```bash
|
||||
cd apps/papra-server
|
||||
|
||||
# Run database migrations
|
||||
pnpm migrate:up
|
||||
|
||||
# Start development server (localhost:1221)
|
||||
pnpm dev
|
||||
|
||||
# Run tests
|
||||
pnpm test # All tests
|
||||
pnpm test:watch # Watch mode
|
||||
pnpm test:unit # Unit tests only
|
||||
pnpm test:int # Integration tests only
|
||||
|
||||
# Lint and typecheck
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
|
||||
# Database management
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
pnpm migrate:create "migration_name" # Create new migration
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
```bash
|
||||
cd apps/papra-client
|
||||
|
||||
# Start development server (localhost:3000)
|
||||
pnpm dev
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
pnpm test:e2e # Playwright E2E tests
|
||||
|
||||
# Lint and typecheck
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
|
||||
# i18n key synchronization
|
||||
pnpm script:sync-i18n-key-order
|
||||
```
|
||||
|
||||
### Package Development
|
||||
|
||||
```bash
|
||||
cd packages/<package-name>
|
||||
|
||||
# Build package
|
||||
pnpm build
|
||||
pnpm build:watch # Watch mode (or pnpm dev)
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
```
|
||||
|
||||
### Root-level Commands
|
||||
|
||||
```bash
|
||||
# Run tests across all packages
|
||||
pnpm test
|
||||
pnpm test:watch
|
||||
|
||||
# Build all packages
|
||||
pnpm build:packages
|
||||
|
||||
# Version management (changesets)
|
||||
pnpm changeset # Create changeset
|
||||
pnpm version # Apply changesets and bump versions
|
||||
|
||||
# Docker builds
|
||||
pnpm docker:build:root
|
||||
pnpm docker:build:root:amd64
|
||||
pnpm docker:build:root:arm64
|
||||
```
|
||||
|
||||
### Documentation Development
|
||||
|
||||
```bash
|
||||
cd apps/docs
|
||||
pnpm dev # localhost:4321
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Use **Vitest** for all testing
|
||||
- Test files: `*.test.ts` for unit tests, `*.int.test.ts` for integration tests
|
||||
- Integration tests may use Testcontainers (Azurite, LocalStack)
|
||||
- All new features require test coverage
|
||||
|
||||
### Writing Good Test Names
|
||||
|
||||
Test names should explain the **why** (business logic, user scenario, or expected behavior), not the **how** (implementation details or return values).
|
||||
|
||||
**Key principles:**
|
||||
- **Describe blocks** should explain the business goal or rule being tested
|
||||
- **Test names** should explain the scenario, context, and reason for the behavior
|
||||
- Avoid implementation details like "returns X", "should be Y", "calls Z method"
|
||||
- Focus on user scenarios and business rules
|
||||
- Make tests readable as documentation - someone unfamiliar with the code should understand what's being tested and why
|
||||
|
||||
## Code Style
|
||||
|
||||
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)
|
||||
- **Conventions**:
|
||||
- Use functional programming where possible
|
||||
- Prefer clarity and maintainability over performance
|
||||
- Use meaningful names for variables, functions, and components
|
||||
- Follow Conventional Commits for commit messages
|
||||
- **Type safety**: Strict TypeScript throughout
|
||||
|
||||
## i18n
|
||||
|
||||
- Language files in `apps/papra-client/src/locales/*.dictionary.ts`
|
||||
- Reference `en.dictionary.ts` for all keys (English is fallback)
|
||||
- Fully type-safe with TypeScript
|
||||
- Update `i18n.constants.ts` when adding new languages
|
||||
- Use `pnpm script:sync-i18n-key-order` to sync key order
|
||||
- **Branchlet/core**: Uses `@branchlet/core` for pluralization and conditional i18n string templates (variant of ICU message format)
|
||||
- Basic interpolation: `'Hello {{ name }}!'` with `{ name: 'World' }`
|
||||
- Conditionals: `'{{ count, =0:no items, =1:one item, many items }}'`
|
||||
- Pluralization with variables: `'{{ count, =0:no items, =1:{count} item, {count} items }}'`
|
||||
- Range conditions: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'`
|
||||
- See [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details
|
||||
|
||||
## Contributing Flow
|
||||
|
||||
1. Open an issue before submitting PRs for features/bugs
|
||||
2. Target the `main` branch (continuously deployed to production)
|
||||
3. Keep PRs small and atomic
|
||||
4. Ensure CI is green (linting, type checking, testing, building)
|
||||
5. PRs are squashed on merge
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **Frontend**: SolidJS, UnoCSS, Shadcn Solid, TanStack Query, Vite
|
||||
- **Backend**: HonoJS, Drizzle ORM, Better Auth, Zod, Cadence MQ
|
||||
- **Database**: SQLite/Turso (libsql)
|
||||
- **Testing**: Vitest, Playwright, Testcontainers
|
||||
- **Monorepo**: PNPM workspaces with catalog for shared dependencies
|
||||
- **Build**: esbuild (backend), Vite (frontend), tsdown (packages)
|
||||
@@ -58,6 +58,17 @@ If you want to update an existing language file, you can do so directly in the c
|
||||
> [!TIP]
|
||||
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the TypeScript i18n files, it'll also add the missing keys as comments.
|
||||
|
||||
### Using Branchlet for Pluralization and Conditionals
|
||||
|
||||
Papra uses [`@branchlet/core`](https://github.com/CorentinTh/branchlet) for pluralization and conditional i18n string templates (a variant of ICU message format). Here are some common patterns:
|
||||
|
||||
- **Basic interpolation**: `'Hello {{ name }}!'` with `{ name: 'World' }`
|
||||
- **Conditionals**: `'{{ count, =0:no items, =1:one item, many items }}'`
|
||||
- **Pluralization with variables**: `'{{ count, =0:no items, =1:{count} item, {count} items }}'`
|
||||
- **Range conditions**: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'`
|
||||
|
||||
See the [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details on syntax and advanced usage.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Local Environment Setup
|
||||
@@ -105,6 +116,73 @@ We recommend running the app locally for development. Follow these steps:
|
||||
|
||||
6. Open your browser and navigate to `http://localhost:3000`.
|
||||
|
||||
### IDE Setup
|
||||
|
||||
#### ESLint Extension
|
||||
|
||||
We recommend installing the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for VS Code to get real-time linting feedback and automatic code fixing.
|
||||
The linting configuration is based on [@antfu/eslint-config](https://github.com/antfu/eslint-config), you can find specific IDE configurations in their repository.
|
||||
|
||||
<details>
|
||||
<summary>Recommended VS Code Settings</summary>
|
||||
|
||||
Create or update your `.vscode/settings.json` file with the following configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
// Disable the default formatter, use eslint instead
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
|
||||
// Auto fix
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
|
||||
// Silent the stylistic rules in your IDE, but still auto fix them
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "style/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "format/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-indent", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spacing", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-spaces", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-order", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-dangle", "severity": "off", "fixable": true },
|
||||
{ "rule": "*-newline", "severity": "off", "fixable": true },
|
||||
{ "rule": "*quotes", "severity": "off", "fixable": true },
|
||||
{ "rule": "*semi", "severity": "off", "fixable": true }
|
||||
],
|
||||
|
||||
// Enable eslint for all supported languages
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"gql",
|
||||
"graphql",
|
||||
"astro",
|
||||
"svelte",
|
||||
"css",
|
||||
"less",
|
||||
"scss",
|
||||
"pcss",
|
||||
"postcss"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Testing
|
||||
|
||||
We use **Vitest** for testing. Each package comes with its own testing commands.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @papra/docs
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "@papra/docs",
|
||||
"type": "module",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -28,19 +27,21 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.13.0",
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.1.120",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^2.2.2",
|
||||
"figue": "^3.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
"unocss": "0.65.0-beta.2"
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
48
apps/docs/src/changelog-parser.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const linesToRemove = [
|
||||
/^# (.*)$/gm, // Remove main title
|
||||
/^### (.*)$/gm, // Remove section titles
|
||||
];
|
||||
|
||||
export function parseChangelog(changelog: string) {
|
||||
const logs: { entries: {
|
||||
pr: { number: number; url: string };
|
||||
commit: { hash: string; url: string };
|
||||
contributor: { username: string; url: string };
|
||||
content: string;
|
||||
}[]; version: string; }[] = [];
|
||||
|
||||
for (const lineToRemove of linesToRemove) {
|
||||
changelog = changelog.replace(lineToRemove, '');
|
||||
}
|
||||
|
||||
const sections = changelog.match(/## (.*)\n([\s\S]*?)(?=\n## |$)/g) ?? [];
|
||||
|
||||
for (const section of sections) {
|
||||
const version = section.match(/## (.*)\n/)?.[1].trim() ?? 'unknown version';
|
||||
|
||||
const entries = section.split('\n- ').slice(1).map((entry) => {
|
||||
// Example entry:
|
||||
// [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Maybe multiline content
|
||||
|
||||
// Thanks copilot! :sweat-smile:
|
||||
const prMatch = entry.match(/\[#(\d+)\]\((https:\/\/github\.com\/papra-hq\/papra\/pull\/\d+)\)/);
|
||||
const commitMatch = entry.match(/\[`([a-f0-9]{7,40})`\]\((https:\/\/github\.com\/papra-hq\/papra\/commit\/[a-f0-9]{7,40})\)/);
|
||||
const contributorMatch = entry.match(/Thanks \[@([\w-]+)\]\((https:\/\/github\.com\/[\w-]+)\)/);
|
||||
const contentMatch = entry.match(/\)! - (.*)$/s);
|
||||
|
||||
return {
|
||||
pr: prMatch ? { number: Number.parseInt(prMatch[1], 10), url: prMatch[2] } : { number: 0, url: '' },
|
||||
commit: commitMatch ? { hash: commitMatch[1], url: commitMatch[2] } : { hash: '', url: '' },
|
||||
contributor: contributorMatch ? { username: contributorMatch[1], url: contributorMatch[2] } : { username: 'unknown', url: '' },
|
||||
content: contentMatch ? contentMatch[1].trim() : entry.trim(),
|
||||
};
|
||||
});
|
||||
|
||||
logs.push({
|
||||
version,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
|
||||
import { isArray, isEmpty, isNil } from 'lodash-es';
|
||||
import { marked } from 'marked';
|
||||
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
|
||||
|
||||
import { configDefinition } from '../../papra-server/src/modules/config/config';
|
||||
import { renderMarkdown } from './markdown';
|
||||
|
||||
function walk(configDefinition: ConfigDefinition, path: string[] = []): (ConfigDefinitionElement & { path: string[] })[] {
|
||||
return Object
|
||||
@@ -46,16 +46,21 @@ const rows = configDetails
|
||||
};
|
||||
});
|
||||
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
|
||||
### ${env}
|
||||
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
|
||||
const envs = castArray(env);
|
||||
const [firstEnv, ...restEnvs] = envs;
|
||||
|
||||
return `
|
||||
### ${firstEnv}
|
||||
${documentation}
|
||||
|
||||
- Path: \`${path.join('.')}\`
|
||||
- Environment variable: \`${env}\`
|
||||
- Environment variable: \`${firstEnv}\` ${restEnvs.length ? `, with fallback to: ${restEnvs.map(e => `\`${e}\``).join(', ')}` : ''}
|
||||
- Default value: \`${defaultValue}\`
|
||||
|
||||
|
||||
`.trim()).join('\n\n---\n\n');
|
||||
`.trim();
|
||||
}).join('\n\n---\n\n');
|
||||
|
||||
function wrapText(text: string, maxLength = 75) {
|
||||
const words = text.split(' ');
|
||||
@@ -80,25 +85,15 @@ 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 [firstEnv] = envs;
|
||||
|
||||
return [
|
||||
...wrapText(documentation),
|
||||
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
|
||||
`# ${firstEnv}=${isEmptyDefaultValue ? '' : defaultValue}`,
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
|
||||
// Dirty hack to add the same anchors to the headings as the ones generated by Starlight
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = function ({ text, depth }) {
|
||||
const slug = text.toLowerCase().replace(/\W+/g, '-');
|
||||
return `
|
||||
<div class="sl-heading-wrapper level-h${depth}">
|
||||
<h${depth} id="${slug}">${text}</h${depth}>
|
||||
<a class="sl-anchor-link" href="#${slug}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only">Section titled “Configuration files”</span></a>
|
||||
</div>
|
||||
`.trim().replace(/\n/g, '');
|
||||
};
|
||||
|
||||
const sectionsHtml = marked.parse(mdSections, { renderer });
|
||||
const sectionsHtml = renderMarkdown(mdSections);
|
||||
|
||||
export { fullDotEnv, mdSections, sectionsHtml };
|
||||
|
||||
@@ -39,7 +39,7 @@ By integrating Papra with OwlRelay, your instance will generate email addresses
|
||||
|
||||
3. **Configure your Papra instance**
|
||||
|
||||
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `OWLRELAY_WEBHOOK_SECRET` environment variables.
|
||||
Once you have created your API key, you can configure your Papra instance to receive emails by setting the `OWLRELAY_API_KEY` and `INTAKE_EMAILS_WEBHOOK_SECRET` environment variables.
|
||||
|
||||
```bash
|
||||
# Enable intake emails
|
||||
|
||||
102
apps/docs/src/content/docs/03-guides/06-tagging-rules.mdx
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Using Tagging Rules
|
||||
description: Learn how to automate document organization with tagging rules.
|
||||
slug: guides/tagging-rules
|
||||
---
|
||||
|
||||
## What are Tagging Rules?
|
||||
|
||||
Tagging rules allow you to automatically apply tags to documents based on specific conditions. This helps maintain consistent organization without manual effort, especially when dealing with large numbers of documents.
|
||||
|
||||
## How Tagging Rules Work
|
||||
|
||||
When a tagging rule is enabled, it automatically checks new documents as they're uploaded. If a document matches the rule's conditions, the specified tags are automatically applied.
|
||||
|
||||
### Rule Components
|
||||
|
||||
Each tagging rule consists of:
|
||||
|
||||
1. **Conditions**: Rules that determine which documents should be tagged
|
||||
- Field: The document property to check (e.g., name, content)
|
||||
- Operator: How to compare the field (e.g., contains, equals)
|
||||
- Value: The text to match against
|
||||
|
||||
2. **Actions**: The tags to apply when conditions are met
|
||||
|
||||
## Applying Rules to Existing Documents
|
||||
|
||||
### The "Run Now" Feature
|
||||
|
||||
When you create a new tagging rule, it only applies to documents uploaded *after* the rule is created. To apply the rule to documents that already exist in your organization, use the **"Apply to existing documents"** button.
|
||||
|
||||
This feature is particularly useful when:
|
||||
- You create a new rule and want to organize your existing documents
|
||||
- You modify a rule and want to reprocess documents
|
||||
- You're setting up your organization and want to retroactively organize imported documents
|
||||
|
||||
### How to Apply a Rule to Existing Documents
|
||||
|
||||
1. Navigate to your organization's Tagging Rules page
|
||||
2. Find the rule you want to apply
|
||||
3. Click the **"Apply to existing documents"** button
|
||||
4. Confirm the action in the dialog
|
||||
5. The task is queued and will be processed in the background
|
||||
|
||||
The system will:
|
||||
- Queue a background task to process all documents
|
||||
- Process documents in batches to avoid overloading the system
|
||||
- Check all existing documents in your organization
|
||||
- Apply tags where the rule's conditions match
|
||||
- Show you a success message once the task is queued
|
||||
|
||||
:::tip
|
||||
Applying a rule to existing documents runs as a background task, so you don't need to wait for it to complete. The processing happens asynchronously and efficiently handles large document collections by processing them in batches.
|
||||
:::
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Creating Effective Rules
|
||||
|
||||
1. **Be specific**: Use precise conditions to avoid over-tagging
|
||||
2. **Test first**: Create a rule and test it on a few documents before applying to all existing documents
|
||||
3. **Use multiple conditions**: Combine conditions for more accurate matching
|
||||
4. **Review regularly**: Periodically review your rules to ensure they're still relevant
|
||||
|
||||
### Example Rules
|
||||
|
||||
**Invoice Classification**
|
||||
- Condition: Document name contains "invoice"
|
||||
- Action: Apply "Invoice" tag
|
||||
|
||||
**Quarterly Reports**
|
||||
- Condition: Document name contains "Q1" or "Q2" or "Q3" or "Q4"
|
||||
- Action: Apply "Report" tag
|
||||
|
||||
## Using the API
|
||||
|
||||
You can also apply tagging rules programmatically using the API. The endpoint enqueues a background task and returns immediately:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://api.papra.app/api/organizations/YOUR_ORG_ID/tagging-rules/RULE_ID/apply
|
||||
```
|
||||
|
||||
Response (HTTP 202 Accepted):
|
||||
```json
|
||||
{
|
||||
"taskId": "task_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Where:
|
||||
- `taskId`: The ID of the background task processing your request
|
||||
|
||||
:::note
|
||||
The API returns a task ID immediately. The actual processing happens in the background and may take some time depending on the number of documents. Task status retrieval will be available in a future release.
|
||||
:::
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [API Endpoints Documentation](/resources/api-endpoints)
|
||||
- [CLI Documentation](/resources/cli)
|
||||
@@ -18,8 +18,107 @@ The public API uses a bearer token for authentication. You can get a token by lo
|
||||
</details>
|
||||
|
||||
|
||||
To authenticate your requests, include the token in the `Authorization` header with the `Bearer` prefix:
|
||||
|
||||
```
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**Using cURL:**
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://api.papra.app/api/organizations
|
||||
```
|
||||
|
||||
**Using JavaScript (fetch):**
|
||||
```javascript
|
||||
const response = await fetch('https://api.papra.app/api/organizations', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer YOUR_API_TOKEN',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### API Key Permissions
|
||||
|
||||
When creating an API key, you can select from the following permissions:
|
||||
|
||||
**Organizations:**
|
||||
- `organizations:create` - Create new organizations
|
||||
- `organizations:read` - Read organization information and list organizations of the user
|
||||
- `organizations:update` - Update organization details
|
||||
- `organizations:delete` - Delete organizations
|
||||
|
||||
**Documents:**
|
||||
- `documents:create` - Upload and create new documents
|
||||
- `documents:read` - Read and download documents
|
||||
- `documents:update` - Update document metadata and content
|
||||
- `documents:delete` - Delete documents
|
||||
|
||||
**Tags:**
|
||||
- `tags:create` - Create new tags
|
||||
- `tags:read` - Read tag information and list tags
|
||||
- `tags:update` - Update tag details
|
||||
- `tags:delete` - Delete tags
|
||||
|
||||
## Endpoints
|
||||
|
||||
### List organizations
|
||||
|
||||
**GET** `/api/organizations`
|
||||
|
||||
List all organizations accessible to the authenticated user.
|
||||
|
||||
- Required API key permissions: `organizations:read`
|
||||
- Response (JSON)
|
||||
- `organizations`: The list of organizations.
|
||||
|
||||
### Create an organization
|
||||
|
||||
**POST** `/api/organizations`
|
||||
|
||||
Create a new organization.
|
||||
|
||||
- Required API key permissions: `organizations:create`
|
||||
- Body (JSON)
|
||||
- `name`: The organization name (3-50 characters).
|
||||
- Response (JSON)
|
||||
- `organization`: The created organization.
|
||||
|
||||
### Get an organization
|
||||
|
||||
**GET** `/api/organizations/:organizationId`
|
||||
|
||||
Get an organization by its ID.
|
||||
|
||||
- Required API key permissions: `organizations:read`
|
||||
- Response (JSON)
|
||||
- `organization`: The organization.
|
||||
|
||||
### Update an organization
|
||||
|
||||
**PUT** `/api/organizations/:organizationId`
|
||||
|
||||
Update an organization's name.
|
||||
|
||||
- Required API key permissions: `organizations:update`
|
||||
- Body (JSON)
|
||||
- `name`: The new organization name (3-50 characters).
|
||||
- Response (JSON)
|
||||
- `organization`: The updated organization.
|
||||
|
||||
### Delete an organization
|
||||
|
||||
**DELETE** `/api/organizations/:organizationId`
|
||||
|
||||
Delete an organization by its ID.
|
||||
|
||||
- Required API key permissions: `organizations:delete`
|
||||
- Response: empty (204 status code)
|
||||
|
||||
### Create a document
|
||||
|
||||
**POST** `/api/organizations/:organizationId/documents`
|
||||
@@ -208,3 +307,13 @@ Remove a tag from a document.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response: empty (204 status code)
|
||||
|
||||
### Apply tagging rule to existing documents
|
||||
|
||||
**POST** `/api/organizations/:organizationId/tagging-rules/:taggingRuleId/apply`
|
||||
|
||||
Enqueue a background task to apply a tagging rule to all existing documents in the organization. This endpoint returns immediately with a task ID, and the processing happens asynchronously in the background. The task will check all documents and apply tags where the rule's conditions match.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response (JSON, HTTP 202)
|
||||
- `taskId`: The ID of the background task. You can use this to track the task's progress (task status retrieval coming in a future release).
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Document Deduplication
|
||||
description: How Papra prevents duplicate documents and saves storage space.
|
||||
slug: architecture/document-deduplication
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Papra automatically detects and prevents duplicate documents per organization using content hashing. This ensures that if the same file is uploaded multiple times, only one copy is stored, saving storage space and reducing clutter.
|
||||
|
||||
## How It Works
|
||||
|
||||
When a document is added to an organization (upload, email ingestion, folder sync, ...), the server computes a **SHA-256 hash** of the file content and checks if a document with the same hash already exists in that organization.
|
||||
|
||||
- If there is **no document with the same hash** in the organization, the new document is added as usual
|
||||
- If a document **with same content exists**, the upload is rejected
|
||||
- If a document **with same content was previously deleted** (in trash), it is restored instead of creating a new copy, the metadata is updated to match the newly added document
|
||||
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Hash Algorithm
|
||||
|
||||
- Papra uses **SHA-256** for content hashing.
|
||||
- Computed during streaming upload (no extra I/O)
|
||||
- 64-character hexadecimal string stored in the database
|
||||
|
||||
### Database Constraint
|
||||
|
||||
The database enforces uniqueness with a composite index:
|
||||
|
||||
```sql
|
||||
UNIQUE (organization_id, original_sha256_hash)
|
||||
```
|
||||
|
||||
This guarantees no two active documents in the same organization can have identical content.
|
||||
|
||||
### File Content Only
|
||||
|
||||
Only the **file content** is hashed and used for deduplication, filenames, upload dates, and metadata don't affect deduplication. Two files are considered duplicates if and only if their content is strictly identical.
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: No-Mutation Principle
|
||||
description: Why Papra never modifies your original documents and the architectural decisions behind this choice.
|
||||
slug: architecture/no-mutation-principle
|
||||
---
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
Papra follows a fundamental principle: **documents are never mutated after upload**. When you input a document, you can always retrieve it exactly as it was uploaded.
|
||||
|
||||
## The Design Choice
|
||||
|
||||
An archiving platform should guarantee users they can retrieve their documents in their original form. This means:
|
||||
|
||||
- No conversion to different formats
|
||||
- No metadata injection into the file itself
|
||||
- No overlay of OCR-ed content on scanned PDFs
|
||||
- No processing that modifies the original file
|
||||
|
||||
The simple mental model is: **"If I input X, I'll retrieve X"**
|
||||
|
||||
## Why This Matters
|
||||
|
||||
### Trust and Reliability
|
||||
|
||||
When archiving important documents, users need absolute confidence that their files remain untouched. Whether it's a legal document, a medical record, or a personal photo, the original should be sacrosanct.
|
||||
|
||||
### Simplicity
|
||||
|
||||
This approach eliminates the mental overhead of wondering "what happened to my file?" Users don't need to understand concepts like:
|
||||
- Original vs. processed versions
|
||||
- Format conversions
|
||||
- OCR overlays
|
||||
- Metadata injection
|
||||
|
||||
### Flexibility for the Future
|
||||
|
||||
While Papra currently doesn't mutate documents, the architecture leaves room for future enhancements. If needed, a "processed" version concept could be added alongside originals, giving users the choice without forcing a particular model.
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Organization Deletion & Purge
|
||||
description: How Papra handles organization deletion with a grace period and eventual purge.
|
||||
slug: architecture/organization-deletion-purge
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Papra implements a two-phase deletion process for organizations: soft deletion followed by hard deletion (purge). This provides a grace period for recovery while ensuring eventual cleanup of resources.
|
||||
|
||||
## Deletion Process
|
||||
|
||||
### Who Can Delete
|
||||
|
||||
Only the **organization owner** can delete an organization. Admins and members do not have this permission.
|
||||
|
||||
### What Happens During Deletion
|
||||
|
||||
When an organization is deleted:
|
||||
|
||||
1. **Members are removed** - All organization members are stripped from the organization, leaving them dangling
|
||||
2. **Invitations are removed** - All pending invitations are deleted
|
||||
3. **Metadata is recorded**:
|
||||
- `deletedAt`: Timestamp when the deletion occurred
|
||||
- `deletedBy`: ID of the user (owner) who deleted the organization
|
||||
- `scheduledPurgeAt`: Future date when hard deletion will occur (default: 30 days)
|
||||
|
||||
The organization itself remains in the database in a soft-deleted state, allowing for potential restoration.
|
||||
|
||||
## Purge Process
|
||||
|
||||
### When Purge Occurs
|
||||
|
||||
Hard deletion (purge) happens when `scheduledPurgeAt` is reached. By default, this is **30 days** after the deletion date.
|
||||
|
||||
### What Gets Purged
|
||||
|
||||
When an organization is purged:
|
||||
|
||||
- **All documents** are deleted from storage
|
||||
- **All database records** related to the organization are removed (cascade handles related records, like Tags, Intake Emails, etc.)
|
||||
- The organization itself is permanently deleted
|
||||
|
||||
The process handles documents in batches using an iterator to avoid memory issues with large organizations.
|
||||
|
||||
### Background Task
|
||||
|
||||
Purging is handled by a periodic background task that:
|
||||
|
||||
1. Queries for organizations with `scheduledPurgeAt` in the past
|
||||
2. For each expired organization:
|
||||
- Deletes all document files from storage
|
||||
- Hard deletes the organization (cascade handles related records)
|
||||
3. Logs the process for monitoring and debugging
|
||||
|
||||
The task continues even if individual file deletions fail, logging errors without blocking the entire purge operation.
|
||||
|
||||
## Recovery
|
||||
|
||||
Organizations can be restored before the `scheduledPurgeAt` date is reached, but only by the user who deleted them (the previous owner). After this date, recovery is no longer possible, even if the purge has not yet occurred.
|
||||
|
||||
> Note: After recovery, the organization owner must re-invite members as they were removed during deletion.
|
||||
@@ -5,6 +5,7 @@ export const sidebar = [
|
||||
label: 'Getting Started',
|
||||
items: [
|
||||
{ label: 'Introduction', slug: '' },
|
||||
{ label: 'Changelog', link: '/changelog' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -39,6 +40,27 @@ export const sidebar = [
|
||||
label: 'Document Encryption',
|
||||
slug: 'guides/document-encryption',
|
||||
},
|
||||
{
|
||||
label: 'Tagging Rules',
|
||||
slug: 'guides/tagging-rules',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Architecture',
|
||||
items: [
|
||||
{
|
||||
label: 'No-Mutation Principle',
|
||||
slug: 'architecture/no-mutation-principle',
|
||||
},
|
||||
{
|
||||
label: 'Document Deduplication',
|
||||
slug: 'architecture/document-deduplication',
|
||||
},
|
||||
{
|
||||
label: 'Organization Deletion',
|
||||
slug: 'architecture/organization-deletion-purge',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
16
apps/docs/src/markdown.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { marked } from 'marked';
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.heading = function ({ text, depth }) {
|
||||
const slug = text.toLowerCase().replace(/\W+/g, '-');
|
||||
return `
|
||||
<div class="sl-heading-wrapper level-h${depth}">
|
||||
<h${depth} id="${slug}">${text}</h${depth}>
|
||||
<a class="sl-anchor-link" href="#${slug}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only">Section titled “Configuration files”</span></a>
|
||||
</div>
|
||||
`.trim().replace(/\n/g, '');
|
||||
};
|
||||
|
||||
export function renderMarkdown(markdown: string) {
|
||||
return marked.parse(markdown, { renderer });
|
||||
}
|
||||
55
apps/docs/src/pages/changelog.astro
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import rawChangelog from '../../../../packages/docker/CHANGELOG.md?raw';
|
||||
import { parseChangelog } from '../changelog-parser';
|
||||
import { renderMarkdown } from '../markdown';
|
||||
|
||||
const changelog = parseChangelog(rawChangelog);
|
||||
|
||||
---
|
||||
|
||||
<StarlightPage
|
||||
frontmatter={{
|
||||
title: 'Papra changelog',
|
||||
description: 'View the changelogs of the docker images released by Papra.',
|
||||
tableOfContents: false,
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Here are the changelogs of the docker images released by Papra.<br />
|
||||
For version after v0.9.6, Papra uses Calver as a versioning system with the format YY.MM.N where N is the number of releases in the month starting at 0 (e.g. 25.06.0 is the first release of June 2025).
|
||||
</p>
|
||||
|
||||
|
||||
{
|
||||
changelog.map(({ entries, version }) => (
|
||||
<section>
|
||||
|
||||
<h2 id={version} class="pb-1 mt-14">v{version}</h2>
|
||||
<ul>
|
||||
{entries.map(entry => (
|
||||
<li>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-foreground lh-normal changelog-entry" set:html={renderMarkdown(entry.content)} />
|
||||
<div class="text-xs mt-1 flex gap-1 flex-wrap">
|
||||
<a href={entry.pr.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">PR #{entry.pr.number}</a>
|
||||
<a href={entry.commit.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">{entry.commit.hash.slice(0, 7)}</a>
|
||||
<a href={entry.contributor.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">
|
||||
By @{entry.contributor.username}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
}
|
||||
</StarlightPage>
|
||||
|
||||
<style is:global>
|
||||
.changelog-entry pre {
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--muted-foreground) / var(--un-text-opacity));
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'astro:content';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
defineConfig,
|
||||
presetTypography,
|
||||
presetUno,
|
||||
transformerDirectives,
|
||||
transformerVariantGroup,
|
||||
@@ -16,6 +17,7 @@ export default defineConfig({
|
||||
prefix: '',
|
||||
}),
|
||||
presetAnimations(),
|
||||
presetTypography(),
|
||||
],
|
||||
transformers: [transformerVariantGroup(), transformerDirectives()],
|
||||
theme: {
|
||||
|
||||
1
apps/papra-client/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -1,145 +0,0 @@
|
||||
# @papra/app-client
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#471](https://github.com/papra-hq/papra/pull/471) [`e77a42f`](https://github.com/papra-hq/papra/commit/e77a42fbf14da011cd396426aa0bbea56c889740) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Lazy load the PDF viewer to reduce the main chunk size
|
||||
|
||||
- [#481](https://github.com/papra-hq/papra/pull/481) [`1606310`](https://github.com/papra-hq/papra/commit/1606310745e8edf405b527127078143481419e8c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Allow for more complex intake-email origin adresses
|
||||
|
||||
- [#470](https://github.com/papra-hq/papra/pull/470) [`d488efe`](https://github.com/papra-hq/papra/commit/d488efe2cc4aa4f433cec4e9b8cc909b091eccc4) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Simplified i18n tooling + improved performances
|
||||
|
||||
- [#468](https://github.com/papra-hq/papra/pull/468) [`14c3587`](https://github.com/papra-hq/papra/commit/14c3587de07a605ec586bdc428d9e76956bf1c67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Prevent infinit loading in search modal when an error occure
|
||||
|
||||
- [#468](https://github.com/papra-hq/papra/pull/468) [`14c3587`](https://github.com/papra-hq/papra/commit/14c3587de07a605ec586bdc428d9e76956bf1c67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved the UX of the document content edition panel
|
||||
|
||||
- [#468](https://github.com/papra-hq/papra/pull/468) [`14c3587`](https://github.com/papra-hq/papra/commit/14c3587de07a605ec586bdc428d9e76956bf1c67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added content edition support in demo mode
|
||||
|
||||
## 0.8.2
|
||||
|
||||
## 0.8.1
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#432](https://github.com/papra-hq/papra/pull/432) [`6723baf`](https://github.com/papra-hq/papra/commit/6723baf98ad46f989fe1e1e19ad0dd25622cca77) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added new webhook events: document:updated, document:tag:added, document:tag:removed
|
||||
|
||||
- [#432](https://github.com/papra-hq/papra/pull/432) [`6723baf`](https://github.com/papra-hq/papra/commit/6723baf98ad46f989fe1e1e19ad0dd25622cca77) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Webhooks invocation is now defered
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#419](https://github.com/papra-hq/papra/pull/419) [`7768840`](https://github.com/papra-hq/papra/commit/7768840aa4425a03cb96dc1c17605bfa8e6a0de4) Thanks [@Edward205](https://github.com/Edward205)! - Added diacritics and improved wording for Romanian translation
|
||||
|
||||
- [#448](https://github.com/papra-hq/papra/pull/448) [`5868800`](https://github.com/papra-hq/papra/commit/5868800bcec6ed69b5441b50e4445fae5cdb5bfb) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added feedback when an error occurs while deleting a tag
|
||||
|
||||
- [#412](https://github.com/papra-hq/papra/pull/412) [`ffdae8d`](https://github.com/papra-hq/papra/commit/ffdae8db56c6ecfe63eb263ee606e9469eef8874) Thanks [@OsafAliSayed](https://github.com/OsafAliSayed)! - Simplified the organization intake email list
|
||||
|
||||
- [#441](https://github.com/papra-hq/papra/pull/441) [`5e46bb9`](https://github.com/papra-hq/papra/commit/5e46bb9e6a39cd16a83636018370607a27db042a) Thanks [@Zavy86](https://github.com/Zavy86)! - Added Italian (it) language support
|
||||
|
||||
- [#455](https://github.com/papra-hq/papra/pull/455) [`b33fde3`](https://github.com/papra-hq/papra/commit/b33fde35d3e8622e31b51aadfe56875d8e48a2ef) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved feedback message in case of invalid origin configuration
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#417](https://github.com/papra-hq/papra/pull/417) [`a82ff3a`](https://github.com/papra-hq/papra/commit/a82ff3a755fa1164b4d8ff09b591ed6482af0ccc) Thanks [@CorentinTh](https://github.com/CorentinTh)! - v0.7 release
|
||||
|
||||
## 0.6.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#377](https://github.com/papra-hq/papra/pull/377) [`205c6cf`](https://github.com/papra-hq/papra/commit/205c6cfd461fa0020a93753571f886726ddfdb57) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improve file preview for text-like files (.env, yaml, extension-less text files,...)
|
||||
|
||||
- [#393](https://github.com/papra-hq/papra/pull/393) [`aad36f3`](https://github.com/papra-hq/papra/commit/aad36f325296548019148bc4e32782fe562fd95b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix weird centering in document page for long filenames
|
||||
|
||||
- [#394](https://github.com/papra-hq/papra/pull/394) [`f28d824`](https://github.com/papra-hq/papra/commit/f28d8245bf385d7be3b3b8ee449c3fdc88fa375c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to disable login via email, to support sso-only auth
|
||||
|
||||
- [#405](https://github.com/papra-hq/papra/pull/405) [`3401cfb`](https://github.com/papra-hq/papra/commit/3401cfbfdc7e280d2f0f3166ceddcbf55486f574) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Introduce APP_BASE_URL to mutualize server and client base url
|
||||
|
||||
- [#346](https://github.com/papra-hq/papra/pull/346) [`c54a71d`](https://github.com/papra-hq/papra/commit/c54a71d2c5998abde8ec78741b8c2e561203a045) Thanks [@blstmo](https://github.com/blstmo)! - Fixes 400 error when submitting tags with uppercase hex colour codes.
|
||||
|
||||
- [#408](https://github.com/papra-hq/papra/pull/408) [`09e3bc5`](https://github.com/papra-hq/papra/commit/09e3bc5e151594bdbcb1f9df1b869a78e583af3f) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added Romanian (ro) translation
|
||||
|
||||
- [#383](https://github.com/papra-hq/papra/pull/383) [`0b276ee`](https://github.com/papra-hq/papra/commit/0b276ee0d5e936fffc1f8284c654a8ada0efbafb) Thanks [@LMArantes](https://github.com/LMArantes)! - Added Brazilian Portuguese (pt-BR) language support
|
||||
|
||||
- [#399](https://github.com/papra-hq/papra/pull/399) [`47b69b1`](https://github.com/papra-hq/papra/commit/47b69b15f4f711e47421fc21a3ac447824d67642) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix back to organization link in organization settings
|
||||
|
||||
- [#403](https://github.com/papra-hq/papra/pull/403) [`1711ef8`](https://github.com/papra-hq/papra/commit/1711ef866d0071a804484b3e163a5e2ccbcec8fd) Thanks [@Icikowski](https://github.com/Icikowski)! - Added Polish (pl) language support
|
||||
|
||||
- [#379](https://github.com/papra-hq/papra/pull/379) [`6cedc30`](https://github.com/papra-hq/papra/commit/6cedc30716e320946f79a0a9fd8d3b26e834f4db) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Updated dependencies
|
||||
|
||||
- [#411](https://github.com/papra-hq/papra/pull/411) [`2601566`](https://github.com/papra-hq/papra/commit/26015666de197827a65a5bebf376921bbfcc3ab8) Thanks [@4DRIAN0RTIZ](https://github.com/4DRIAN0RTIZ)! - Added Spanish (es) translation
|
||||
|
||||
- [#391](https://github.com/papra-hq/papra/pull/391) [`40a1f91`](https://github.com/papra-hq/papra/commit/40a1f91b67d92e135d13dfcd41e5fd3532c30ca5) Thanks [@itsjuoum](https://github.com/itsjuoum)! - Added European Portuguese (pt) translation
|
||||
|
||||
- [#378](https://github.com/papra-hq/papra/pull/378) [`f1e1b40`](https://github.com/papra-hq/papra/commit/f1e1b4037b31ff5de1fd228b8390dd4d97a8bda8) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag color swatches and picker
|
||||
|
||||
## 0.6.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#357](https://github.com/papra-hq/papra/pull/357) [`585c53c`](https://github.com/papra-hq/papra/commit/585c53cd9d0d7dbd517dbb1adddfd9e7b70f9fe5) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a /llms.txt on main website
|
||||
|
||||
- [#359](https://github.com/papra-hq/papra/pull/359) [`0c2cf69`](https://github.com/papra-hq/papra/commit/0c2cf698d1a9e9a3cea023920b10cfcd5d83be14) Thanks [@Mavv3006](https://github.com/Mavv3006)! - Add German translation
|
||||
|
||||
## 0.6.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#333](https://github.com/papra-hq/papra/pull/333) [`ff830c2`](https://github.com/papra-hq/papra/commit/ff830c234a02ddb4cbc480cf77ef49b8de35fbae) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed version release link
|
||||
|
||||
## 0.6.1
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#317](https://github.com/papra-hq/papra/pull/317) [`79c1d32`](https://github.com/papra-hq/papra/commit/79c1d3206b140cf8b3d33ef8bda6098dcf4c9c9c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document activity log
|
||||
|
||||
- [#319](https://github.com/papra-hq/papra/pull/319) [`60059c8`](https://github.com/papra-hq/papra/commit/60059c895c4860cbfda69d3c989ad00542def65b) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added pending invitation management page
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#309](https://github.com/papra-hq/papra/pull/309) [`d4f72e8`](https://github.com/papra-hq/papra/commit/d4f72e889a4d39214de998942bc0eb88cd5cee3d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Disable "Manage subscription" from organization setting by default
|
||||
|
||||
- [#308](https://github.com/papra-hq/papra/pull/308) [`759a3ff`](https://github.com/papra-hq/papra/commit/759a3ff713db8337061418b9c9b122b957479343) Thanks [@CorentinTh](https://github.com/CorentinTh)! - I18n: full support for French language
|
||||
|
||||
- [#312](https://github.com/papra-hq/papra/pull/312) [`e5ef40f`](https://github.com/papra-hq/papra/commit/e5ef40f36c27ea25dc8a79ef2805d673761eec2a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue with the reset-password page navigation guard that prevented reset
|
||||
|
||||
## 0.5.1
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#295](https://github.com/papra-hq/papra/pull/295) [`438a311`](https://github.com/papra-hq/papra/commit/438a31171c606138c4b7fa299fdd58dcbeaaf298) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for custom oauth2 providers
|
||||
|
||||
- [#291](https://github.com/papra-hq/papra/pull/291) [`0627ec2`](https://github.com/papra-hq/papra/commit/0627ec25a422b7b820b08740cfc2905f9c55c00e) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added invitation system to add users to an organization
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#296](https://github.com/papra-hq/papra/pull/296) [`0ddc234`](https://github.com/papra-hq/papra/commit/0ddc2340f092cf6fe5bf2175b55fb46db7681c36) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix register page description
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added webhook management
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added API keys support
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added document searchable content edit
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added tag creation button in document page
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Improved tag selector input wrapping
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle file names without extensions
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Wrap text in document preview
|
||||
|
||||
- [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Excluded deleted documents from doc count
|
||||
@@ -6,8 +6,7 @@ export default antfu({
|
||||
},
|
||||
|
||||
ignores: [
|
||||
// Generated file
|
||||
'src/modules/i18n/locales.types.ts',
|
||||
'public/manifest.json',
|
||||
],
|
||||
|
||||
rules: {
|
||||
@@ -23,4 +22,10 @@ export default antfu({
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
files: ['src/locales/*.dictionary.ts'],
|
||||
rules: {
|
||||
// Sometimes for formatting amounts of dollar, we need "${{value}}" as value is interpolated later, it's not a template string here
|
||||
'no-template-curly-in-string': 'off',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,10 +27,23 @@
|
||||
<meta property="twitter:image" content="https://papra.app/og-image.png">
|
||||
|
||||
<!-- Favicon and Icons -->
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<!-- Structured Data (JSON-LD for rich snippets) -->
|
||||
<script type="application/ld+json">
|
||||
@@ -52,8 +65,19 @@
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<script>
|
||||
(function () {
|
||||
const stored = localStorage?.getItem('papra_color_mode') ?? 'dark';
|
||||
|
||||
if (stored === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-background text-foreground">
|
||||
<h1 class="sr-only">Papra - Document archiving and sharing platform</h1>
|
||||
<p class="sr-only">Papra, the document archiving and sharing platform.</p>
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "@papra/app-client",
|
||||
"type": "module",
|
||||
"version": "0.9.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra frontend client",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -21,39 +20,35 @@
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"test": "pnpm check-i18n-types-outdated && vitest run",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest watch",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check-i18n-types-outdated": "pnpm script:generate-i18n-types && git diff --exit-code -- src/modules/i18n/locales.types.ts > /dev/null || (echo \"Locales types are outdated, please run 'pnpm script:generate-i18n-types' and commit the changes.\" && exit 1)",
|
||||
"script:get-missing-i18n-keys": "tsx src/scripts/get-missing-i18n-keys.script.ts",
|
||||
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@branchlet/core": "^1.0.0",
|
||||
"@corentinth/chisels": "catalog:",
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
"@tanstack/solid-query": "^5.81.2",
|
||||
"@tanstack/solid-query": "^5.90.3",
|
||||
"@tanstack/solid-table": "^8.21.3",
|
||||
"@unocss/reset": "^0.64.1",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-solid": "^1.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"posthog-js-lite": "^4.1.5",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-js": "^1.9.9",
|
||||
"solid-sonner": "^0.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"ts-pattern": "^5.7.1",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"unocss-preset-animations": "^1.3.0",
|
||||
"unstorage": "^1.16.0",
|
||||
"valibot": "1.0.0-beta.10"
|
||||
},
|
||||
@@ -61,16 +56,14 @@
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.2.19",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "^25.0.1",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"unocss": "^66.5.4",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-solid": "^2.11.9",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
apps/papra-client/public/android-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/papra-client/public/android-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
apps/papra-client/public/android-icon-36x36.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/papra-client/public/android-icon-48x48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/papra-client/public/android-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/papra-client/public/android-icon-96x96.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/papra-client/public/apple-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
apps/papra-client/public/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
apps/papra-client/public/apple-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/papra-client/public/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
apps/papra-client/public/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
apps/papra-client/public/apple-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/papra-client/public/apple-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/papra-client/public/apple-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
apps/papra-client/public/apple-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
apps/papra-client/public/apple-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
apps/papra-client/public/apple-icon.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
2
apps/papra-client/public/browserconfig.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
|
||||
BIN
apps/papra-client/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 831 B |
BIN
apps/papra-client/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.1 KiB |
41
apps/papra-client/public/manifest.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "Papra",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
apps/papra-client/public/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
apps/papra-client/public/ms-icon-150x150.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
apps/papra-client/public/ms-icon-310x310.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
apps/papra-client/public/ms-icon-70x70.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
@@ -21,10 +21,10 @@
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--destructive-foreground: 0 0% 0%;
|
||||
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 98%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
@@ -59,7 +59,7 @@
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--warning: 31 98% 50%;
|
||||
--warning-foreground: 0 0% 98%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @refresh reload */
|
||||
|
||||
import type { ConfigColorMode } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { Router } from '@solidjs/router';
|
||||
import { QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
@@ -17,7 +17,6 @@ import { IdentifyUser } from './modules/tracking/components/identify-user.compon
|
||||
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
|
||||
import { Toaster } from './modules/ui/components/sonner';
|
||||
import { routes } from './routes';
|
||||
import '@unocss/reset/tailwind.css';
|
||||
import 'virtual:uno.css';
|
||||
import './app.css';
|
||||
|
||||
@@ -28,17 +27,15 @@ render(
|
||||
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
|
||||
|
||||
return (
|
||||
<Router
|
||||
children={routes}
|
||||
root={props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router
|
||||
children={routes}
|
||||
root={props => (
|
||||
<Suspense>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
<I18nProvider>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
@@ -60,9 +57,9 @@ render(
|
||||
</ConfirmModalProvider>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
document.getElementById('root')!,
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'E-Mail verifizieren',
|
||||
'auth.email-validation-required.description': 'Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-Mail verifiziert',
|
||||
'auth.email-verification.success.description': 'Ihre E-Mail wurde erfolgreich verifiziert. Sie können sich jetzt in Ihr Konto einloggen.',
|
||||
'auth.email-verification.success.login': 'Zur Anmeldung',
|
||||
'auth.email-verification.error.title': 'Verifizierung fehlgeschlagen',
|
||||
'auth.email-verification.error.description': 'Der Verifizierungslink ist ungültig oder abgelaufen. Bitte fordern Sie eine neue Verifizierungs-E-Mail an, indem Sie sich anmelden.',
|
||||
'auth.email-verification.error.back': 'Zurück zur Anmeldung',
|
||||
|
||||
'auth.legal-links.description': 'Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.',
|
||||
'auth.legal-links.terms': 'Nutzungsbedingungen',
|
||||
'auth.legal-links.privacy': 'Datenschutzrichtlinie',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'Ihre Organisationen',
|
||||
'organizations.list.description': 'Organisationen sind eine Möglichkeit, Ihre Dokumente zu gruppieren und den Zugriff darauf zu verwalten. Sie können mehrere Organisationen erstellen und Ihre Teammitglieder zur Zusammenarbeit einladen.',
|
||||
'organizations.list.create-new': 'Neue Organisation erstellen',
|
||||
'organizations.list.back': 'Zurück zu Organisationen',
|
||||
'organizations.list.deleted.title': 'Gelöschte Organisationen',
|
||||
'organizations.list.deleted.description': 'Gelöschte Organisationen werden für {{ days }} Tage aufbewahrt, bevor sie dauerhaft entfernt werden. Sie können sie während dieser Zeit wiederherstellen.',
|
||||
'organizations.list.deleted.empty': 'Keine gelöschten Organisationen',
|
||||
'organizations.list.deleted.empty-description': 'Wenn Sie eine Organisation löschen, wird sie hier für {{ days }} Tage angezeigt, bevor sie dauerhaft gelöscht wird.',
|
||||
'organizations.list.deleted.restore': 'Wiederherstellen',
|
||||
'organizations.list.deleted.restore-success': 'Organisation erfolgreich wiederhergestellt',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Organisation wiederherstellen',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Sind Sie sicher, dass Sie diese Organisation wiederherstellen möchten? Sie wird wieder in Ihre Liste der aktiven Organisationen verschoben.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Organisation wiederherstellen',
|
||||
'organizations.list.deleted.deleted-at': 'Gelöscht {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Wird dauerhaft gelöscht am {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} Tag, {daysUntilPurge} Tage }} verbleibend)',
|
||||
|
||||
'organizations.details.no-documents.title': 'Keine Dokumente',
|
||||
'organizations.details.no-documents.description': 'Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Organisation löschen',
|
||||
'organization.settings.delete.description': 'Das Löschen dieser Organisation entfernt dauerhaft alle damit verbundenen Daten.',
|
||||
'organization.settings.delete.confirm.title': 'Organisation löschen',
|
||||
'organization.settings.delete.confirm.message': 'Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden und alle mit dieser Organisation verbundenen Daten werden dauerhaft entfernt.',
|
||||
'organization.settings.delete.confirm.message': 'Sind Sie sicher, dass Sie diese Organisation löschen möchten? Die Organisation wird zum Löschen markiert und nach {{ days }} Tagen endgültig entfernt. Während dieser Zeit können Sie sie aus Ihrer Organisationsliste wiederherstellen. Alle Dokumente und Daten werden nach dieser Frist dauerhaft gelöscht.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Organisation löschen',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Abbrechen',
|
||||
'organization.settings.delete.success': 'Organisation gelöscht',
|
||||
'organization.settings.delete.only-owner': 'Nur der Organisationsinhaber kann diese Organisation löschen.',
|
||||
'organization.settings.delete.has-active-subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden, bitte kündigen Sie zuerst Ihr Abonnement oben.',
|
||||
|
||||
'organization.usage.page.title': 'Nutzung',
|
||||
'organization.usage.page.description': 'Sehen Sie die aktuelle Nutzung und Limits Ihrer Organisation.',
|
||||
'organization.usage.storage.title': 'Dokumentenspeicher',
|
||||
'organization.usage.storage.description': 'Gesamtspeicher, der von Ihren Dokumenten verwendet wird',
|
||||
'organization.usage.intake-emails.title': 'Eingangs-E-Mails',
|
||||
'organization.usage.intake-emails.description': 'Anzahl der Eingangs-E-Mail-Adressen',
|
||||
'organization.usage.members.title': 'Mitglieder',
|
||||
'organization.usage.members.description': 'Anzahl der Mitglieder in der Organisation',
|
||||
'organization.usage.unlimited': 'Unbegrenzt',
|
||||
|
||||
'organizations.members.title': 'Mitglieder',
|
||||
'organizations.members.description': 'Verwalten Sie Ihre Organisationsmitglieder',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Beispiel: Dokumente mit \'Rechnung\' im Namen taggen',
|
||||
'tagging-rules.form.description.max-length': 'Die Beschreibung muss weniger als 256 Zeichen lang sein',
|
||||
'tagging-rules.form.conditions.label': 'Bedingungen',
|
||||
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.',
|
||||
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Keine Bedingungen bedeutet, dass die Regel auf alle Dokumente angewendet wird',
|
||||
'tagging-rules.form.conditions.add-condition': 'Bedingung hinzufügen',
|
||||
'tagging-rules.form.conditions.connector.when': 'Wenn',
|
||||
'tagging-rules.form.conditions.connector.and': 'und',
|
||||
'tagging-rules.form.conditions.connector.or': 'oder',
|
||||
'tagging-rules.condition-match-mode.all': 'Alle Bedingungen müssen erfüllt sein',
|
||||
'tagging-rules.condition-match-mode.any': 'Mindestens eine Bedingung muss erfüllt sein',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Keine Bedingungen',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Regel ohne Bedingungen anwenden',
|
||||
@@ -376,9 +413,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.tags.add-tag': 'Tag erstellen',
|
||||
'tagging-rules.form.submit': 'Regel erstellen',
|
||||
'tagging-rules.update.title': 'Tagging-Regel aktualisieren',
|
||||
'tagging-rules.update.error': 'Tagging-Regel konnte nicht aktualisiert werden',
|
||||
'tagging-rules.update.error': 'Fehler beim Aktualisieren der Tagging-Regel',
|
||||
'tagging-rules.update.submit': 'Regel aktualisieren',
|
||||
'tagging-rules.update.cancel': 'Abbrechen',
|
||||
'tagging-rules.apply.button': 'Auf vorhandene Dokumente anwenden',
|
||||
'tagging-rules.apply.confirm.title': 'Regel auf vorhandene Dokumente anwenden?',
|
||||
'tagging-rules.apply.confirm.description': 'Dies überprüft alle vorhandenen Dokumente in Ihrer Organisation und wendet Tags an, wo Bedingungen übereinstimmen. Die Verarbeitung erfolgt im Hintergrund.',
|
||||
'tagging-rules.apply.confirm.button': 'Regel anwenden',
|
||||
'tagging-rules.apply.success': 'Regelanwendung im Hintergrund gestartet',
|
||||
'tagging-rules.apply.error': 'Fehler beim Starten der Regelanwendung',
|
||||
'tagging-rules.apply.processing': 'Wird gestartet...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Alle auswählen',
|
||||
'api-keys.permissions.deselect-all': 'Alle abwählen',
|
||||
'api-keys.permissions.organizations.title': 'Organisationen',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Organisationen erstellen',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Organisationen lesen',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Organisationen aktualisieren',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Organisationen löschen',
|
||||
'api-keys.permissions.documents.title': 'Dokumente',
|
||||
'api-keys.permissions.documents.documents:create': 'Dokumente erstellen',
|
||||
'api-keys.permissions.documents.documents:read': 'Dokumente lesen',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Einstellungen',
|
||||
'layout.menu.account': 'Konto',
|
||||
'layout.menu.general-settings': 'Allgemeine Einstellungen',
|
||||
'layout.menu.usage': 'Nutzung',
|
||||
'layout.menu.intake-emails': 'E-Mail-Eingang',
|
||||
'layout.menu.webhooks': 'Webhooks',
|
||||
'layout.menu.members': 'Mitglieder',
|
||||
'layout.menu.invitations': 'Einladungen',
|
||||
|
||||
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
|
||||
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
|
||||
'layout.upgrade-cta.button': 'Jetzt upgraden',
|
||||
|
||||
'layout.theme.light': 'Heller Modus',
|
||||
'layout.theme.dark': 'Dunkler Modus',
|
||||
'layout.theme.system': 'Systemmodus',
|
||||
@@ -540,8 +596,9 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
|
||||
'api-errors.document.file_too_big': 'Die Dokumentdatei ist zu groß',
|
||||
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
|
||||
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
|
||||
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
|
||||
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingang-EMails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
|
||||
'api-errors.user.max_organization_count_reached': 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.',
|
||||
'api-errors.default': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.',
|
||||
'api-errors.organization.invitation_already_exists': 'Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'Ein Tag mit diesem Namen existiert bereits für diese Organisation',
|
||||
'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
'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.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Fehler beim Erstellen des Benutzers',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Fehler beim Erstellen der Sitzung',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Fehler beim Aktualisieren des Benutzers',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Fehler beim Abrufen der Sitzung',
|
||||
'api-errors.INVALID_PASSWORD': 'Ungültiges Passwort',
|
||||
'api-errors.INVALID_EMAIL': 'Ungültige E-Mail',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Die E-Mail oder das Passwort ist falsch, oder das Konto existiert nicht.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social-Media-Konto bereits verknüpft',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Anbieter nicht gefunden',
|
||||
'api-errors.INVALID_TOKEN': 'Ungültiger Token',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID-Token wird nicht unterstützt',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Fehler beim Abrufen der Benutzerinformationen',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Benutzer-E-Mail nicht gefunden',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'E-Mail nicht verifiziert',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Passwort zu kurz',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Passwort zu lang',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Ein Benutzer mit dieser E-Mail existiert bereits',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'E-Mail kann nicht aktualisiert werden',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Anmeldekonto nicht gefunden',
|
||||
'api-errors.SESSION_EXPIRED': 'Sitzung abgelaufen',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Fehler beim Trennen des letzten Kontos',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Konto nicht gefunden',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Benutzer hat bereits ein Passwort',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Farbe auswählen',
|
||||
'color-picker.select-a-color': 'Eine Farbe auswählen',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Zahlung erfolgreich!',
|
||||
'subscriptions.checkout-success.description': 'Ihr Abonnement wurde erfolgreich aktiviert.',
|
||||
'subscriptions.checkout-success.thank-you': 'Vielen Dank für Ihr Upgrade auf Papra Plus. Sie haben jetzt Zugriff auf alle Premium-Funktionen.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Zu Organisationen',
|
||||
'subscriptions.checkout-success.redirecting': 'Weiterleitung in {{ count }} Sekunde{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Zahlung abgebrochen',
|
||||
'subscriptions.checkout-cancel.description': 'Ihr Abonnement-Upgrade wurde abgebrochen.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'Es wurden keine Gebühren von Ihrem Konto abgebucht. Sie können es jederzeit erneut versuchen.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Zurück zu Organisationen',
|
||||
'subscriptions.checkout-cancel.need-help': 'Benötigen Sie Hilfe?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Support kontaktieren',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Diese Organisation upgraden',
|
||||
'subscriptions.upgrade-dialog.description': 'Schalten Sie leistungsstarke Funktionen für Ihre Organisation frei',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Kontaktieren Sie uns',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'wenn Sie benutzerdefinierte Enterprise-Pläne benötigen.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Aktueller Plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Empfohlen',
|
||||
'subscriptions.upgrade-dialog.per-month': '/Monat',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} jährlich abgerechnet',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Jetzt upgraden',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Zeitlich begrenztes Angebot',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Erhalten Sie {{ percent }}% Rabatt pro Organisation auf alle Tarife für immer als Early Adopter! Angebot läuft ab in {{ days, >1:{days} Tagen, =1:1 Tag, weniger als 1 Tag }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Kostenloser Plan',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dokumentenspeichergröße',
|
||||
'subscriptions.features.members': 'Organisationsmitglieder',
|
||||
'subscriptions.features.members-count': '{{ count }} Mitglieder',
|
||||
'subscriptions.features.email-intakes': 'E-Mail-Eingänge',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} Adresse',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} Adressen',
|
||||
'subscriptions.features.max-upload-size': 'Maximale Upload-Dateigröße',
|
||||
'subscriptions.features.support': 'Support',
|
||||
'subscriptions.features.support-community': 'Community-Support',
|
||||
'subscriptions.features.support-email': 'E-Mail-Support',
|
||||
'subscriptions.features.support-priority': 'Prioritäts-Support',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Monatlich',
|
||||
'subscriptions.billing-interval.annual': 'Jährlich',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Sie haben {{ percent }}% Ihres Dokumentenspeichers verwendet. Erwägen Sie ein Upgrade Ihres Plans, um mehr Speicherplatz zu erhalten.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Geben Sie "{{ text }}" ein zur Bestätigung',
|
||||
};
|
||||
|
||||
@@ -68,6 +68,13 @@ export const translations = {
|
||||
'auth.email-validation-required.title': 'Verify your email',
|
||||
'auth.email-validation-required.description': 'A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email verified',
|
||||
'auth.email-verification.success.description': 'Your email has been successfully verified. You can now log in to your account.',
|
||||
'auth.email-verification.success.login': 'Go to login',
|
||||
'auth.email-verification.error.title': 'Verification failed',
|
||||
'auth.email-verification.error.description': 'The verification link has expired or is invalid. Please request a new verification email by logging in.',
|
||||
'auth.email-verification.error.back': 'Back to login',
|
||||
|
||||
'auth.legal-links.description': 'By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Terms of Service',
|
||||
'auth.legal-links.privacy': 'Privacy Policy',
|
||||
@@ -100,6 +107,19 @@ export const translations = {
|
||||
'organizations.list.title': 'Your organizations',
|
||||
'organizations.list.description': 'Organizations are a way to group your documents and manage access to them. You can create multiple organizations and invite your team members to collaborate.',
|
||||
'organizations.list.create-new': 'Create new organization',
|
||||
'organizations.list.back': 'Back to organizations',
|
||||
'organizations.list.deleted.title': 'Deleted organizations',
|
||||
'organizations.list.deleted.description': 'Deleted organizations are kept for {{ days }} days before being permanently removed. You can restore them during this period.',
|
||||
'organizations.list.deleted.empty': 'No deleted organizations',
|
||||
'organizations.list.deleted.empty-description': 'When you delete an organization, it will appear here for {{ days }} days before being permanently deleted.',
|
||||
'organizations.list.deleted.restore': 'Restore',
|
||||
'organizations.list.deleted.restore-success': 'Organization restored successfully',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Restore organization',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Are you sure you want to restore this organization? It will be moved back to your active organizations list.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Restore organization',
|
||||
'organizations.list.deleted.deleted-at': 'Deleted {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Will be permanently deleted on {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} day, {daysUntilPurge} days }} remaining)',
|
||||
|
||||
'organizations.details.no-documents.title': 'No documents',
|
||||
'organizations.details.no-documents.description': 'There are no documents in this organization yet. Start by uploading some documents.',
|
||||
@@ -137,10 +157,22 @@ export const translations = {
|
||||
'organization.settings.delete.title': 'Delete organization',
|
||||
'organization.settings.delete.description': 'Deleting this organization will permanently remove all data associated with it.',
|
||||
'organization.settings.delete.confirm.title': 'Delete organization',
|
||||
'organization.settings.delete.confirm.message': 'Are you sure you want to delete this organization? This action cannot be undone, and all data associated with this organization will be permanently removed.',
|
||||
'organization.settings.delete.confirm.message': 'Are you sure you want to delete this organization? The organization will be marked for deletion and permanently removed after {{ days }} days. During this period, you can restore it from your organizations list. All documents and data will be permanently deleted after this delay.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Delete organization',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancel',
|
||||
'organization.settings.delete.success': 'Organization deleted',
|
||||
'organization.settings.delete.only-owner': 'Only the organization owner can delete this organization.',
|
||||
'organization.settings.delete.has-active-subscription': 'Cannot delete organization with an active subscription, please cancel your subscription above first.',
|
||||
|
||||
'organization.usage.page.title': 'Usage',
|
||||
'organization.usage.page.description': 'View your organization\'s current usage and limits.',
|
||||
'organization.usage.storage.title': 'Document storage',
|
||||
'organization.usage.storage.description': 'Total storage used by your documents',
|
||||
'organization.usage.intake-emails.title': 'Intake emails',
|
||||
'organization.usage.intake-emails.description': 'Number of intake email addresses',
|
||||
'organization.usage.members.title': 'Members',
|
||||
'organization.usage.members.description': 'Number of members in the organization',
|
||||
'organization.usage.unlimited': 'Unlimited',
|
||||
|
||||
'organizations.members.title': 'Members',
|
||||
'organizations.members.description': 'Manage your organization members',
|
||||
@@ -360,8 +392,13 @@ export const translations = {
|
||||
'tagging-rules.form.description.placeholder': 'Example: Tag documents with \'invoice\' in the name',
|
||||
'tagging-rules.form.description.max-length': 'The description must be less than 256 characters',
|
||||
'tagging-rules.form.conditions.label': 'Conditions',
|
||||
'tagging-rules.form.conditions.description': 'Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.',
|
||||
'tagging-rules.form.conditions.description': 'Define the conditions that must be met for the rule to apply. No conditions means the rule will apply to all documents',
|
||||
'tagging-rules.form.conditions.add-condition': 'Add condition',
|
||||
'tagging-rules.form.conditions.connector.when': 'When',
|
||||
'tagging-rules.form.conditions.connector.and': 'and',
|
||||
'tagging-rules.form.conditions.connector.or': 'or',
|
||||
'tagging-rules.condition-match-mode.all': 'All conditions must match',
|
||||
'tagging-rules.condition-match-mode.any': 'Any condition must match',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'No conditions',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'You didn\'t add any conditions to this rule. This rule will apply its tags to all documents.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Apply rule without conditions',
|
||||
@@ -377,6 +414,13 @@ export const translations = {
|
||||
'tagging-rules.update.error': 'Failed to update tagging rule',
|
||||
'tagging-rules.update.submit': 'Update rule',
|
||||
'tagging-rules.update.cancel': 'Cancel',
|
||||
'tagging-rules.apply.button': 'Apply to existing documents',
|
||||
'tagging-rules.apply.confirm.title': 'Apply rule to existing documents?',
|
||||
'tagging-rules.apply.confirm.description': 'This will check all existing documents in your organization and apply tags where conditions match. The processing will happen in the background.',
|
||||
'tagging-rules.apply.confirm.button': 'Apply rule',
|
||||
'tagging-rules.apply.success': 'Rule application started in the background',
|
||||
'tagging-rules.apply.error': 'Failed to start rule application',
|
||||
'tagging-rules.apply.processing': 'Starting...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -415,6 +459,13 @@ export const translations = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Select all',
|
||||
'api-keys.permissions.deselect-all': 'Deselect all',
|
||||
'api-keys.permissions.organizations.title': 'Organizations',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Create organizations',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Read organizations',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Update organizations',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Delete organizations',
|
||||
'api-keys.permissions.documents.title': 'Documents',
|
||||
'api-keys.permissions.documents.documents:create': 'Create documents',
|
||||
'api-keys.permissions.documents.documents:read': 'Read documents',
|
||||
@@ -510,11 +561,16 @@ export const translations = {
|
||||
'layout.menu.settings': 'Settings',
|
||||
'layout.menu.account': 'Account',
|
||||
'layout.menu.general-settings': 'General settings',
|
||||
'layout.menu.usage': 'Usage',
|
||||
'layout.menu.intake-emails': 'Intake emails',
|
||||
'layout.menu.webhooks': 'Webhooks',
|
||||
'layout.menu.members': 'Members',
|
||||
'layout.menu.invitations': 'Invitations',
|
||||
|
||||
'layout.upgrade-cta.title': 'Need more space?',
|
||||
'layout.upgrade-cta.description': 'Get 10x more storage + team collaboration',
|
||||
'layout.upgrade-cta.button': 'Upgrade now',
|
||||
|
||||
'layout.theme.light': 'Light mode',
|
||||
'layout.theme.dark': 'Dark mode',
|
||||
'layout.theme.system': 'System mode',
|
||||
@@ -538,7 +594,8 @@ export const translations = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'The document already exists',
|
||||
'api-errors.document.file_too_big': 'The document file is too big',
|
||||
'api-errors.document.size_too_large': 'The file size is too large',
|
||||
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
|
||||
'api-errors.intake_email.limit_reached': 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
|
||||
'api-errors.user.max_organization_count_reached': 'You have reached the maximum number of organizations you can create, if you need to create more, please contact support.',
|
||||
'api-errors.default': 'An error occurred while processing your request.',
|
||||
@@ -549,6 +606,33 @@ export const translations = {
|
||||
'api-errors.tags.already_exists': 'A tag with this name already exists for this organization',
|
||||
'api-errors.internal.error': 'An error occurred while processing your request. Please try again later.',
|
||||
'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.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'User not found',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Failed to create user',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Failed to create session',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Failed to update user',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Failed to get session',
|
||||
'api-errors.INVALID_PASSWORD': 'Invalid password',
|
||||
'api-errors.INVALID_EMAIL': 'Invalid email',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'The email or password is incorrect, or the account does not exist.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social account already linked',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Provider not found',
|
||||
'api-errors.INVALID_TOKEN': 'Invalid token',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID token not supported',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Failed to get user info',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'User email not found',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email not verified',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Password too short',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Password too long',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'A user with this email already exists',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email can not be updated',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Credential account not found',
|
||||
'api-errors.SESSION_EXPIRED': 'Session expired',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Failed to unlink last account',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Account not found',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'User already has password',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -572,4 +656,56 @@ export const translations = {
|
||||
'color-picker.select-color': 'Select color',
|
||||
'color-picker.select-a-color': 'Select a color',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Payment Successful!',
|
||||
'subscriptions.checkout-success.description': 'Your subscription has been activated successfully.',
|
||||
'subscriptions.checkout-success.thank-you': 'Thank you for upgrading to Papra Plus. You now have access to all premium features.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Go to Organizations',
|
||||
'subscriptions.checkout-success.redirecting': 'Redirecting in {{ count }} second{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Payment Canceled',
|
||||
'subscriptions.checkout-cancel.description': 'Your subscription upgrade was canceled.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'No charges have been made to your account. You can try again anytime you\'re ready.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Back to Organizations',
|
||||
'subscriptions.checkout-cancel.need-help': 'Need help?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contact support',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Upgrade this organization',
|
||||
'subscriptions.upgrade-dialog.description': 'Unlock powerful features for your organization',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contact us',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'if you need custom enterprise plans.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Current Plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recommended',
|
||||
'subscriptions.upgrade-dialog.per-month': '/month',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} billed annually',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade now',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Limited Time Offer',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Get {{ percent }}% off all plans forever per organization as an early adopter! Offer expires in {{ days, >1:{days} days, =1:1 day, less than 1 day }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Free plan',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Document storage size',
|
||||
'subscriptions.features.members': 'Organization Members',
|
||||
'subscriptions.features.members-count': '{{ count }} members',
|
||||
'subscriptions.features.email-intakes': 'Email Intakes',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} address',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} addresses',
|
||||
'subscriptions.features.max-upload-size': 'Max upload file size',
|
||||
'subscriptions.features.support': 'Support',
|
||||
'subscriptions.features.support-community': 'Community support',
|
||||
'subscriptions.features.support-email': 'Email support',
|
||||
'subscriptions.features.support-priority': 'Priority support',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Monthly',
|
||||
'subscriptions.billing-interval.annual': 'Annual',
|
||||
|
||||
'subscriptions.usage-warning.message': 'You have used {{ percent }}% of your document storage. Consider upgrading your plan to get more space.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Upgrade Plan',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Type "{{ text }}" to confirm',
|
||||
} as const;
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifica tu correo electrónico',
|
||||
'auth.email-validation-required.description': 'Se ha enviado un correo de verificación a tu dirección de correo electrónico. Por favor, verifica tu correo haciendo clic en el enlace del correo.',
|
||||
|
||||
'auth.email-verification.success.title': 'Correo verificado',
|
||||
'auth.email-verification.success.description': 'Tu correo ha sido verificado exitosamente. Ahora puedes iniciar sesión en tu cuenta.',
|
||||
'auth.email-verification.success.login': 'Ir a iniciar sesión',
|
||||
'auth.email-verification.error.title': 'Verificación fallida',
|
||||
'auth.email-verification.error.description': 'El enlace de verificación es inválido o ha expirado. Por favor, solicita un nuevo correo de verificación iniciando sesión.',
|
||||
'auth.email-verification.error.back': 'Volver a iniciar sesión',
|
||||
|
||||
'auth.legal-links.description': 'Al continuar, reconoces que entiendes y aceptas los {{ terms }} y la {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Términos de servicio',
|
||||
'auth.legal-links.privacy': 'Política de privacidad',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'Tus organizaciones',
|
||||
'organizations.list.description': 'Las organizaciones son una manera de agrupar tus documentos y gestionar el acceso a ellos. Puedes crear varias organizaciones e invitar a tus compañeros para colaborar.',
|
||||
'organizations.list.create-new': 'Crear nueva organización',
|
||||
'organizations.list.back': 'Volver a organizaciones',
|
||||
'organizations.list.deleted.title': 'Organizaciones eliminadas',
|
||||
'organizations.list.deleted.description': 'Las organizaciones eliminadas se conservan durante {{ days }} días antes de ser eliminadas permanentemente. Puedes restaurarlas durante este período.',
|
||||
'organizations.list.deleted.empty': 'No hay organizaciones eliminadas',
|
||||
'organizations.list.deleted.empty-description': 'Cuando elimines una organización, aparecerá aquí durante {{ days }} días antes de ser eliminada permanentemente.',
|
||||
'organizations.list.deleted.restore': 'Restaurar',
|
||||
'organizations.list.deleted.restore-success': 'Organización restaurada exitosamente',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Restaurar organización',
|
||||
'organizations.list.deleted.restore-confirm.message': '¿Estás seguro de que quieres restaurar esta organización? Se moverá de vuelta a tu lista de organizaciones activas.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurar organización',
|
||||
'organizations.list.deleted.deleted-at': 'Eliminada el {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Se eliminará permanentemente el {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} día, {daysUntilPurge} días }} restante{{ daysUntilPurge, >1:s}})',
|
||||
|
||||
'organizations.details.no-documents.title': 'Sin documentos',
|
||||
'organizations.details.no-documents.description': 'Aún no hay documentos en esta organización. Comienza subiendo algunos documentos.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Eliminar organización',
|
||||
'organization.settings.delete.description': 'Eliminar esta organización eliminará permanentemente todos los datos asociados a ella.',
|
||||
'organization.settings.delete.confirm.title': 'Eliminar organización',
|
||||
'organization.settings.delete.confirm.message': '¿Estás seguro de que deseas eliminar esta organización? Esta acción no se puede deshacer, y todos los datos asociados se eliminarán permanentemente.',
|
||||
'organization.settings.delete.confirm.message': '¿Estás seguro de que deseas eliminar esta organización? La organización se marcará para eliminación y se eliminará permanentemente después de {{ days }} días. Durante este período, puedes restaurarla desde tu lista de organizaciones. Todos los documentos y datos se eliminarán permanentemente después de este plazo.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Eliminar organización',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
|
||||
'organization.settings.delete.success': 'Organización eliminada',
|
||||
'organization.settings.delete.only-owner': 'Solo el propietario de la organización puede eliminar esta organización.',
|
||||
'organization.settings.delete.has-active-subscription': 'No se puede eliminar la organización con una suscripción activa, por favor cancela tu suscripción arriba primero.',
|
||||
|
||||
'organization.usage.page.title': 'Uso',
|
||||
'organization.usage.page.description': 'Ver el uso y los límites actuales de su organización.',
|
||||
'organization.usage.storage.title': 'Almacenamiento de documentos',
|
||||
'organization.usage.storage.description': 'Almacenamiento total usado por sus documentos',
|
||||
'organization.usage.intake-emails.title': 'Correos de ingesta',
|
||||
'organization.usage.intake-emails.description': 'Número de direcciones de correo de ingesta',
|
||||
'organization.usage.members.title': 'Miembros',
|
||||
'organization.usage.members.description': 'Número de miembros en la organización',
|
||||
'organization.usage.unlimited': 'Ilimitado',
|
||||
|
||||
'organizations.members.title': 'Miembros',
|
||||
'organizations.members.description': 'Administra los miembros de tu organización',
|
||||
@@ -331,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nombre del documento',
|
||||
'tagging-rules.field.content': 'contenido del documento',
|
||||
'tagging-rules.field.name': 'el nombre del documento',
|
||||
'tagging-rules.field.content': 'el contenido del documento',
|
||||
'tagging-rules.operator.equals': 'es igual a',
|
||||
'tagging-rules.operator.not-equals': 'no es igual a',
|
||||
'tagging-rules.operator.contains': 'contiene',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Ejemplo: Etiquetar documentos con \'factura\' en el nombre',
|
||||
'tagging-rules.form.description.max-length': 'La descripción debe tener menos de 256 caracteres',
|
||||
'tagging-rules.form.conditions.label': 'Condiciones',
|
||||
'tagging-rules.form.conditions.description': 'Define las condiciones que deben cumplirse para que la regla se aplique. Todas las condiciones deben cumplirse.',
|
||||
'tagging-rules.form.conditions.description': 'Define las condiciones que deben cumplirse para que la regla se aplique. Sin condiciones significa que la regla se aplicará a todos los documentos',
|
||||
'tagging-rules.form.conditions.add-condition': 'Añadir condición',
|
||||
'tagging-rules.form.conditions.connector.when': 'Cuando',
|
||||
'tagging-rules.form.conditions.connector.and': 'y que',
|
||||
'tagging-rules.form.conditions.connector.or': 'o que',
|
||||
'tagging-rules.condition-match-mode.all': 'Todas las condiciones deben coincidir',
|
||||
'tagging-rules.condition-match-mode.any': 'Cualquier condición debe coincidir',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Sin condiciones',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'No añadiste ninguna condición a esta regla. Esta regla aplicará sus etiquetas a todos los documentos.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regla sin condiciones',
|
||||
@@ -379,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Error al actualizar la regla de etiquetado',
|
||||
'tagging-rules.update.submit': 'Actualizar regla',
|
||||
'tagging-rules.update.cancel': 'Cancelar',
|
||||
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
|
||||
'tagging-rules.apply.confirm.title': '¿Aplicar regla a documentos existentes?',
|
||||
'tagging-rules.apply.confirm.description': 'Esto verificará todos los documentos existentes en tu organización y aplicará etiquetas donde las condiciones coincidan. El procesamiento se realizará en segundo plano.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicar regla',
|
||||
'tagging-rules.apply.success': 'Aplicación de regla iniciada en segundo plano',
|
||||
'tagging-rules.apply.error': 'Error al iniciar la aplicación de la regla',
|
||||
'tagging-rules.apply.processing': 'Iniciando...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Seleccionar todo',
|
||||
'api-keys.permissions.deselect-all': 'Deseleccionar todo',
|
||||
'api-keys.permissions.organizations.title': 'Organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Crear organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Leer organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Actualizar organizaciones',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizaciones',
|
||||
'api-keys.permissions.documents.title': 'Documentos',
|
||||
'api-keys.permissions.documents.documents:create': 'Crear documentos',
|
||||
'api-keys.permissions.documents.documents:read': 'Leer documentos',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Ajustes',
|
||||
'layout.menu.account': 'Cuenta',
|
||||
'layout.menu.general-settings': 'Ajustes generales',
|
||||
'layout.menu.usage': 'Uso',
|
||||
'layout.menu.intake-emails': 'Correos de ingreso',
|
||||
'layout.menu.webhooks': 'Webhooks',
|
||||
'layout.menu.members': 'Miembros',
|
||||
'layout.menu.invitations': 'Invitaciones',
|
||||
|
||||
'layout.upgrade-cta.title': '¿Necesitas más espacio?',
|
||||
'layout.upgrade-cta.description': 'Obtén 10x más almacenamiento + colaboración en equipo',
|
||||
'layout.upgrade-cta.button': 'Actualizar ahora',
|
||||
|
||||
'layout.theme.light': 'Modo claro',
|
||||
'layout.theme.dark': 'Modo oscuro',
|
||||
'layout.theme.system': 'Modo del sistema',
|
||||
@@ -540,7 +596,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'El documento ya existe',
|
||||
'api-errors.document.file_too_big': 'El archivo del documento es demasiado grande',
|
||||
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
|
||||
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
|
||||
'api-errors.intake_email.limit_reached': 'Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.',
|
||||
'api-errors.user.max_organization_count_reached': 'Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.',
|
||||
'api-errors.default': 'Ocurrió un error al procesar tu solicitud.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'Ya existe una etiqueta con este nombre en esta organización',
|
||||
'api-errors.internal.error': 'Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.',
|
||||
'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.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Usuario no encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Error al crear usuario',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Error al crear sesión',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Error al actualizar usuario',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Error al obtener sesión',
|
||||
'api-errors.INVALID_PASSWORD': 'Contraseña inválida',
|
||||
'api-errors.INVALID_EMAIL': 'Email inválido',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'El email o la contraseña es incorrecta, o la cuenta no existe.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Cuenta social ya vinculada',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Proveedor no encontrado',
|
||||
'api-errors.INVALID_TOKEN': 'Token inválido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID no soportado',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Error al obtener información del usuario',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email del usuario no encontrado',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email no verificado',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Contraseña demasiado corta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Contraseña demasiado larga',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Ya existe un usuario con este email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'El email no puede ser actualizado',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Cuenta de credenciales no encontrada',
|
||||
'api-errors.SESSION_EXPIRED': 'Sesión expirada',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Error al desvincular la última cuenta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Cuenta no encontrada',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'El usuario ya tiene contraseña',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Seleccionar color',
|
||||
'color-picker.select-a-color': 'Selecciona un color',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': '¡Pago exitoso!',
|
||||
'subscriptions.checkout-success.description': 'Tu suscripción ha sido activada exitosamente.',
|
||||
'subscriptions.checkout-success.thank-you': 'Gracias por actualizar a Papra Plus. Ahora tienes acceso a todas las funciones premium.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Ir a Organizaciones',
|
||||
'subscriptions.checkout-success.redirecting': 'Redirigiendo en {{ count }} segundo{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Pago cancelado',
|
||||
'subscriptions.checkout-cancel.description': 'Tu actualización de suscripción fue cancelada.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'No se han realizado cargos a tu cuenta. Puedes intentarlo de nuevo cuando estés listo.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Volver a Organizaciones',
|
||||
'subscriptions.checkout-cancel.need-help': '¿Necesitas ayuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contactar soporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Actualizar esta organización',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloquea funciones poderosas para tu organización',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contáctanos',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'si necesitas planes empresariales personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan actual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mes',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Actualizar ahora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tiempo limitado',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': '¡Obtén {{ percent }}% de descuento por organización en todos los planes para siempre como early adopter! La oferta expira en {{ days, >1:{days} días, =1:1 día, menos de un día }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamaño de almacenamiento de documentos',
|
||||
'subscriptions.features.members': 'Miembros de la organización',
|
||||
'subscriptions.features.members-count': '{{ count }} miembros',
|
||||
'subscriptions.features.email-intakes': 'Entradas de correo',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} dirección',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} direcciones',
|
||||
'subscriptions.features.max-upload-size': 'Tamaño máximo de archivo de carga',
|
||||
'subscriptions.features.support': 'Soporte',
|
||||
'subscriptions.features.support-community': 'Soporte de la comunidad',
|
||||
'subscriptions.features.support-email': 'Soporte por correo',
|
||||
'subscriptions.features.support-priority': 'Soporte prioritario',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensual',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Ha utilizado el {{ percent }}% de su almacenamiento de documentos. Considere actualizar su plan para obtener más espacio.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Actualizar plan',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Escriba "{{ text }}" para confirmar',
|
||||
};
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Vérifier votre email',
|
||||
'auth.email-validation-required.description': 'Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre adresse email en cliquant sur le lien dans l\'email.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email vérifié',
|
||||
'auth.email-verification.success.description': 'Votre email a été vérifié avec succès. Vous pouvez maintenant vous connecter à votre compte.',
|
||||
'auth.email-verification.success.login': 'Aller à la connexion',
|
||||
'auth.email-verification.error.title': 'Échec de la vérification',
|
||||
'auth.email-verification.error.description': 'Le lien de vérification est invalide ou a expiré. Veuillez demander un nouvel email de vérification en vous connectant.',
|
||||
'auth.email-verification.error.back': 'Retour à la connexion',
|
||||
|
||||
'auth.legal-links.description': 'En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Conditions d\'utilisation',
|
||||
'auth.legal-links.privacy': 'Politique de confidentialité',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'Vos organisations',
|
||||
'organizations.list.description': 'Les organisations sont un moyen de grouper vos documents et de gérer l\'accès à eux. Vous pouvez créer plusieurs organisations et inviter vos membres de l\'équipe à collaborer.',
|
||||
'organizations.list.create-new': 'Créer une nouvelle organisation',
|
||||
'organizations.list.back': 'Retour aux organisations',
|
||||
'organizations.list.deleted.title': 'Organisations supprimées',
|
||||
'organizations.list.deleted.description': 'Les organisations supprimées sont conservées pendant {{ days }} jours avant d\'être définitivement supprimées. Vous pouvez les restaurer pendant cette période.',
|
||||
'organizations.list.deleted.empty': 'Aucune organisation supprimée',
|
||||
'organizations.list.deleted.empty-description': 'Lorsque vous supprimez une organisation, elle apparaîtra ici pendant {{ days }} jours avant d\'être définitivement supprimée.',
|
||||
'organizations.list.deleted.restore': 'Restaurer',
|
||||
'organizations.list.deleted.restore-success': 'Organisation restaurée avec succès',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Restaurer l\'organisation',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Êtes-vous sûr de vouloir restaurer cette organisation ? Elle sera remise dans votre liste d\'organisations actives.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurer l\'organisation',
|
||||
'organizations.list.deleted.deleted-at': 'Supprimée le {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Sera définitivement supprimée le {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} jour, {daysUntilPurge} jours }} restant{{ daysUntilPurge, >1:s}})',
|
||||
|
||||
'organizations.details.no-documents.title': 'Aucun document',
|
||||
'organizations.details.no-documents.description': 'Il n\'y a pas de documents dans cette organisation. Commencez par télécharger des documents.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Supprimer l\'organisation',
|
||||
'organization.settings.delete.description': 'Supprimer cette organisation supprimera définitivement toutes les données associées à elle.',
|
||||
'organization.settings.delete.confirm.title': 'Supprimer l\'organisation',
|
||||
'organization.settings.delete.confirm.message': 'Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action est irréversible, et toutes les données associées à cette organisation seront supprimées définitivement.',
|
||||
'organization.settings.delete.confirm.message': 'Êtes-vous sûr de vouloir supprimer cette organisation ? L\'organisation sera marquée pour suppression et définitivement supprimée après {{ days }} jours. Pendant cette période, vous pouvez la restaurer depuis votre liste d\'organisations. Tous les documents et données seront définitivement supprimés après ce délai.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Supprimer l\'organisation',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Annuler',
|
||||
'organization.settings.delete.success': 'Organisation supprimée',
|
||||
'organization.settings.delete.only-owner': 'Seul le propriétaire de l\'organisation peut supprimer cette organisation.',
|
||||
'organization.settings.delete.has-active-subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif, veuillez d\'abord annuler votre abonnement ci-dessus.',
|
||||
|
||||
'organization.usage.page.title': 'Utilisation',
|
||||
'organization.usage.page.description': 'Consultez l\'utilisation actuelle et les limites de votre organisation.',
|
||||
'organization.usage.storage.title': 'Stockage de documents',
|
||||
'organization.usage.storage.description': 'Stockage total utilisé par vos documents',
|
||||
'organization.usage.intake-emails.title': 'E-mails d\'ingestion',
|
||||
'organization.usage.intake-emails.description': 'Nombre d\'adresses e-mail d\'ingestion',
|
||||
'organization.usage.members.title': 'Membres',
|
||||
'organization.usage.members.description': 'Nombre de membres dans l\'organisation',
|
||||
'organization.usage.unlimited': 'Illimité',
|
||||
|
||||
'organizations.members.title': 'Membres',
|
||||
'organizations.members.description': 'Gérez les membres de votre organisation.',
|
||||
@@ -331,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nom du document',
|
||||
'tagging-rules.field.content': 'contenu du document',
|
||||
'tagging-rules.field.name': 'le nom du document',
|
||||
'tagging-rules.field.content': 'le contenu du document',
|
||||
'tagging-rules.operator.equals': 'égal à',
|
||||
'tagging-rules.operator.not-equals': 'différent de',
|
||||
'tagging-rules.operator.contains': 'contient',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemple: Catégoriser les documents avec \'facture\' dans le nom',
|
||||
'tagging-rules.form.description.max-length': 'La description doit contenir moins de 256 caractères',
|
||||
'tagging-rules.form.conditions.label': 'Conditions',
|
||||
'tagging-rules.form.conditions.description': 'Définissez les conditions que doivent remplir la règle pour qu\'elle s\'applique. Toutes les conditions doivent être remplies pour que la règle s\'applique.',
|
||||
'tagging-rules.form.conditions.description': 'Définissez les conditions que doivent remplir la règle pour qu\'elle s\'applique. Si aucune condition n\'est définie, la règle s\'appliquera à tous les documents.',
|
||||
'tagging-rules.form.conditions.add-condition': 'Ajouter une condition',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quand',
|
||||
'tagging-rules.form.conditions.connector.and': 'et que',
|
||||
'tagging-rules.form.conditions.connector.or': 'ou que',
|
||||
'tagging-rules.condition-match-mode.all': 'Toutes les conditions doivent correspondre',
|
||||
'tagging-rules.condition-match-mode.any': 'Au moins une condition doit correspondre',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Aucune condition',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Vous n\'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Appliquer la règle sans conditions',
|
||||
@@ -379,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Échec de la mise à jour de la règle de catégorisation',
|
||||
'tagging-rules.update.submit': 'Mettre à jour la règle',
|
||||
'tagging-rules.update.cancel': 'Annuler',
|
||||
'tagging-rules.apply.button': 'Appliquer aux documents existants',
|
||||
'tagging-rules.apply.confirm.title': 'Appliquer la règle aux documents existants ?',
|
||||
'tagging-rules.apply.confirm.description': 'Cela vérifiera tous les documents existants dans votre organisation et appliquera les tags où les conditions correspondent. Le traitement se fera en arrière-plan.',
|
||||
'tagging-rules.apply.confirm.button': 'Appliquer la règle',
|
||||
'tagging-rules.apply.success': 'Application de la règle démarrée en arrière-plan',
|
||||
'tagging-rules.apply.error': 'Échec du démarrage de l\'application de la règle',
|
||||
'tagging-rules.apply.processing': 'Démarrage...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Tout sélectionner',
|
||||
'api-keys.permissions.deselect-all': 'Tout désélectionner',
|
||||
'api-keys.permissions.organizations.title': 'Organisations',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Créer des organisations',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Lire des organisations',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Mettre à jour des organisations',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Supprimer des organisations',
|
||||
'api-keys.permissions.documents.title': 'Documents',
|
||||
'api-keys.permissions.documents.documents:create': 'Créer des documents',
|
||||
'api-keys.permissions.documents.documents:read': 'Lire des documents',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Paramètres',
|
||||
'layout.menu.account': 'Compte',
|
||||
'layout.menu.general-settings': 'Paramètres généraux',
|
||||
'layout.menu.usage': 'Utilisation',
|
||||
'layout.menu.intake-emails': 'Adresses de réception',
|
||||
'layout.menu.webhooks': 'Webhooks',
|
||||
'layout.menu.members': 'Membres',
|
||||
'layout.menu.invitations': 'Invitations',
|
||||
|
||||
'layout.upgrade-cta.title': 'Besoin de plus d\'espace ?',
|
||||
'layout.upgrade-cta.description': 'Obtenez 10x plus de stockage + collaboration d\'équipe',
|
||||
'layout.upgrade-cta.button': 'Mettre à niveau maintenant',
|
||||
|
||||
'layout.theme.light': 'Mode clair',
|
||||
'layout.theme.dark': 'Mode sombre',
|
||||
'layout.theme.system': 'Mode système',
|
||||
@@ -540,7 +596,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'Le document existe déjà',
|
||||
'api-errors.document.file_too_big': 'Le fichier du document est trop grand',
|
||||
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
|
||||
'api-errors.intake_email.limit_reached': 'Le nombre maximum d\'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d\'emails de réception.',
|
||||
'api-errors.user.max_organization_count_reached': 'Vous avez atteint le nombre maximum d\'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.',
|
||||
'api-errors.default': 'Une erreur est survenue lors du traitement de votre requête.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'Un tag avec ce nom existe déjà pour cette organisation',
|
||||
'api-errors.internal.error': 'Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.',
|
||||
'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.',
|
||||
// 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',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Échec de la création de la session',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Échec de la mise à jour de l\'utilisateur',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Échec de la récupération de la session',
|
||||
'api-errors.INVALID_PASSWORD': 'Mot de passe invalide',
|
||||
'api-errors.INVALID_EMAIL': 'Email invalide',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'L\'email ou le mot de passe est incorrect, ou le compte n\'existe pas.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Compte social déjà associé',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Fournisseur introuvable',
|
||||
'api-errors.INVALID_TOKEN': 'Jeton invalide',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Jeton d\'identité non pris en charge',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Échec de la récupération des informations utilisateur',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email de l\'utilisateur introuvable',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email non vérifié',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Mot de passe trop court',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Mot de passe trop long',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Un utilisateur avec cet email existe déjà',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'L\'email ne peut pas être modifié',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Compte d\'identification introuvable',
|
||||
'api-errors.SESSION_EXPIRED': 'Session expirée',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Échec de la dissociation du dernier compte',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Compte introuvable',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utilisateur a déjà un mot de passe',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Sélectionner la couleur',
|
||||
'color-picker.select-a-color': 'Sélectionner une couleur',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Paiement réussi !',
|
||||
'subscriptions.checkout-success.description': 'Votre abonnement a été activé avec succès.',
|
||||
'subscriptions.checkout-success.thank-you': 'Merci d\'avoir mis à niveau vers Papra Plus. Vous avez maintenant accès à toutes les fonctionnalités premium.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Aller aux Organisations',
|
||||
'subscriptions.checkout-success.redirecting': 'Redirection dans {{ count }} seconde{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Paiement annulé',
|
||||
'subscriptions.checkout-cancel.description': 'Votre mise à niveau d\'abonnement a été annulée.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'Aucun frais n\'a été prélevé sur votre compte. Vous pouvez réessayer à tout moment.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Retour aux Organisations',
|
||||
'subscriptions.checkout-cancel.need-help': 'Besoin d\'aide ?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contacter le support',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Mettre à niveau cette organisation',
|
||||
'subscriptions.upgrade-dialog.description': 'Débloquez des fonctionnalités puissantes pour votre organisation',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contactez-nous',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'si vous avez besoin de plans d\'entreprise personnalisés.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan actuel',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recommandé',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mois',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturé annuellement',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Mettre à niveau',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Offre à durée limitée',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Bénéficiez de {{ percent }}% de réduction à vie par organisation sur tous les forfaits en tant qu\'early adopter ! L\'offre expire dans {{ days, >1:{days} jours, =1:1 jour, moins d\'un jour }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuit',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Taille de stockage de documents',
|
||||
'subscriptions.features.members': 'Membres de l\'organisation',
|
||||
'subscriptions.features.members-count': '{{ count }} membres',
|
||||
'subscriptions.features.email-intakes': 'Emails de réception',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} adresse',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} adresses',
|
||||
'subscriptions.features.max-upload-size': 'Taille maximale de téléchargement',
|
||||
'subscriptions.features.support': 'Support',
|
||||
'subscriptions.features.support-community': 'Support communautaire',
|
||||
'subscriptions.features.support-email': 'Support par email',
|
||||
'subscriptions.features.support-priority': 'Support prioritaire',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensuel',
|
||||
'subscriptions.billing-interval.annual': 'Annuel',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Vous avez utilisé {{ percent }}% de votre stockage de documents. Envisagez de mettre à niveau votre plan pour obtenir plus d\'espace.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Mettre à niveau',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Saisissez "{{ text }}" pour confirmer',
|
||||
};
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifica la tua email',
|
||||
'auth.email-validation-required.description': 'Una email di verifica è stata inviata al tuo indirizzo email. Verifica il tuo indirizzo cliccando il link nell\'email.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email verificata',
|
||||
'auth.email-verification.success.description': 'La tua email è stata verificata con successo. Ora puoi accedere al tuo account.',
|
||||
'auth.email-verification.success.login': 'Vai al login',
|
||||
'auth.email-verification.error.title': 'Verifica fallita',
|
||||
'auth.email-verification.error.description': 'Il link di verifica non è valido o è scaduto. Richiedi una nuova email di verifica effettuando l\'accesso.',
|
||||
'auth.email-verification.error.back': 'Torna al login',
|
||||
|
||||
'auth.legal-links.description': 'Continuando, confermi di aver letto e accettato i {{ terms }} e l\'{{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termini di servizio',
|
||||
'auth.legal-links.privacy': 'Informativa sulla privacy',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'Le tue organizzazioni',
|
||||
'organizations.list.description': 'Le organizzazioni sono un modo per raggruppare i tuoi documenti e gestire l\'accesso. Puoi creare più organizzazioni e invitare i tuoi collaboratori.',
|
||||
'organizations.list.create-new': 'Crea una nuova organizzazione',
|
||||
'organizations.list.back': 'Torna alle organizzazioni',
|
||||
'organizations.list.deleted.title': 'Organizzazioni eliminate',
|
||||
'organizations.list.deleted.description': 'Le organizzazioni eliminate vengono conservate per {{ days }} giorni prima di essere rimosse definitivamente. Puoi ripristinarle durante questo periodo.',
|
||||
'organizations.list.deleted.empty': 'Nessuna organizzazione eliminata',
|
||||
'organizations.list.deleted.empty-description': 'Quando elimini un\'organizzazione, apparirà qui per {{ days }} giorni prima di essere eliminata definitivamente.',
|
||||
'organizations.list.deleted.restore': 'Ripristina',
|
||||
'organizations.list.deleted.restore-success': 'Organizzazione ripristinata con successo',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Ripristina organizzazione',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Sei sicuro di voler ripristinare questa organizzazione? Verrà rimossa nella tua lista di organizzazioni attive.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Ripristina organizzazione',
|
||||
'organizations.list.deleted.deleted-at': 'Eliminata il {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Sarà eliminata definitivamente il {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} giorno, {daysUntilPurge} giorni }} rimanent{{ daysUntilPurge, =1:e, i}})',
|
||||
|
||||
'organizations.details.no-documents.title': 'Nessun documento',
|
||||
'organizations.details.no-documents.description': 'Non ci sono ancora documenti in questa organizzazione. Inizia caricando dei documenti.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Elimina organizzazione',
|
||||
'organization.settings.delete.description': 'Eliminando questa organizzazione rimuoverai definitivamente tutti i dati associati.',
|
||||
'organization.settings.delete.confirm.title': 'Elimina organizzazione',
|
||||
'organization.settings.delete.confirm.message': 'Sei sicuro di voler eliminare questa organizzazione? Questa azione non può essere annullata e tutti i dati associati saranno rimossi in modo permanente.',
|
||||
'organization.settings.delete.confirm.message': 'Sei sicuro di voler eliminare questa organizzazione? L\'organizzazione verrà contrassegnata per l\'eliminazione e rimossa definitivamente dopo {{ days }} giorni. Durante questo periodo, puoi ripristinarla dalla tua lista di organizzazioni. Tutti i documenti e i dati verranno eliminati definitivamente dopo questo periodo.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Elimina organizzazione',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Annulla',
|
||||
'organization.settings.delete.success': 'Organizzazione eliminata',
|
||||
'organization.settings.delete.only-owner': 'Solo il proprietario dell\'organizzazione può eliminare questa organizzazione.',
|
||||
'organization.settings.delete.has-active-subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo, si prega di annullare prima l\'abbonamento sopra.',
|
||||
|
||||
'organization.usage.page.title': 'Utilizzo',
|
||||
'organization.usage.page.description': 'Visualizza l\'utilizzo attuale e i limiti della tua organizzazione.',
|
||||
'organization.usage.storage.title': 'Archiviazione documenti',
|
||||
'organization.usage.storage.description': 'Archiviazione totale utilizzata dai tuoi documenti',
|
||||
'organization.usage.intake-emails.title': 'Email di acquisizione',
|
||||
'organization.usage.intake-emails.description': 'Numero di indirizzi email di acquisizione',
|
||||
'organization.usage.members.title': 'Membri',
|
||||
'organization.usage.members.description': 'Numero di membri nell\'organizzazione',
|
||||
'organization.usage.unlimited': 'Illimitato',
|
||||
|
||||
'organizations.members.title': 'Membri',
|
||||
'organizations.members.description': 'Gestisci i membri della tua organizzazione',
|
||||
@@ -331,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nome documento',
|
||||
'tagging-rules.field.content': 'contenuto documento',
|
||||
'tagging-rules.field.name': 'il nome del documento',
|
||||
'tagging-rules.field.content': 'il contenuto del documento',
|
||||
'tagging-rules.operator.equals': 'uguale a',
|
||||
'tagging-rules.operator.not-equals': 'diverso da',
|
||||
'tagging-rules.operator.contains': 'contiene',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Esempio: Tagga i documenti con \'fattura\' nel nome',
|
||||
'tagging-rules.form.description.max-length': 'La descrizione deve essere inferiore a 256 caratteri',
|
||||
'tagging-rules.form.conditions.label': 'Condizioni',
|
||||
'tagging-rules.form.conditions.description': 'Definisci le condizioni che devono essere soddisfatte affinché la regola si applichi. Tutte le condizioni devono essere soddisfatte.',
|
||||
'tagging-rules.form.conditions.description': 'Definisci le condizioni che devono essere soddisfatte affinché la regola si applichi. Nessuna condizione significa che la regola si applicherà a tutti i documenti',
|
||||
'tagging-rules.form.conditions.add-condition': 'Aggiungi condizione',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quando',
|
||||
'tagging-rules.form.conditions.connector.and': 'e che',
|
||||
'tagging-rules.form.conditions.connector.or': 'o che',
|
||||
'tagging-rules.condition-match-mode.all': 'Tutte le condizioni devono corrispondere',
|
||||
'tagging-rules.condition-match-mode.any': 'Qualsiasi condizione deve corrispondere',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Nessuna condizione',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Non hai aggiunto nessuna condizione a questa regola. Questa regola applicherà i suoi tag a tutti i documenti.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Applica regola senza condizioni',
|
||||
@@ -379,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Errore nell\'aggiornamento della regola di tagging',
|
||||
'tagging-rules.update.submit': 'Aggiorna regola',
|
||||
'tagging-rules.update.cancel': 'Annulla',
|
||||
'tagging-rules.apply.button': 'Applica ai documenti esistenti',
|
||||
'tagging-rules.apply.confirm.title': 'Applicare la regola ai documenti esistenti?',
|
||||
'tagging-rules.apply.confirm.description': 'Questo controllerà tutti i documenti esistenti nella tua organizzazione e applicherà i tag dove le condizioni corrispondono. L\'elaborazione avverrà in background.',
|
||||
'tagging-rules.apply.confirm.button': 'Applica regola',
|
||||
'tagging-rules.apply.success': 'Applicazione della regola avviata in background',
|
||||
'tagging-rules.apply.error': 'Impossibile avviare l\'applicazione della regola',
|
||||
'tagging-rules.apply.processing': 'Avvio in corso...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Seleziona tutto',
|
||||
'api-keys.permissions.deselect-all': 'Deseleziona tutto',
|
||||
'api-keys.permissions.organizations.title': 'Organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Crea organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Leggi organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Aggiorna organizzazioni',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Elimina organizzazioni',
|
||||
'api-keys.permissions.documents.title': 'Documenti',
|
||||
'api-keys.permissions.documents.documents:create': 'Crea documenti',
|
||||
'api-keys.permissions.documents.documents:read': 'Leggi documenti',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Impostazioni',
|
||||
'layout.menu.account': 'Account',
|
||||
'layout.menu.general-settings': 'Impostazioni generali',
|
||||
'layout.menu.usage': 'Utilizzo',
|
||||
'layout.menu.intake-emails': 'Email di acquisizione',
|
||||
'layout.menu.webhooks': 'Webhook',
|
||||
'layout.menu.members': 'Membri',
|
||||
'layout.menu.invitations': 'Inviti',
|
||||
|
||||
'layout.upgrade-cta.title': 'Serve più spazio?',
|
||||
'layout.upgrade-cta.description': 'Ottieni 10x più storage + collaborazione del team',
|
||||
'layout.upgrade-cta.button': 'Aggiorna ora',
|
||||
|
||||
'layout.theme.light': 'Modalità chiara',
|
||||
'layout.theme.dark': 'Modalità scura',
|
||||
'layout.theme.system': 'Modalità sistema',
|
||||
@@ -540,7 +596,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'Il documento esiste già',
|
||||
'api-errors.document.file_too_big': 'Il file del documento è troppo grande',
|
||||
'api-errors.document.size_too_large': 'Il file è troppo grande',
|
||||
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
|
||||
'api-errors.intake_email.limit_reached': 'È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.',
|
||||
'api-errors.user.max_organization_count_reached': 'Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.',
|
||||
'api-errors.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'Esiste già un tag con questo nome per questa organizzazione',
|
||||
'api-errors.internal.error': 'Si è verificato un errore durante l\'elaborazione della richiesta. Riprova.',
|
||||
'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.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utente non trovato',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Impossibile creare l\'utente',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Impossibile creare la sessione',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Impossibile aggiornare l\'utente',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Impossibile recuperare la sessione',
|
||||
'api-errors.INVALID_PASSWORD': 'Password non valida',
|
||||
'api-errors.INVALID_EMAIL': 'Email non valida',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'L\'email o la password non è corretta, oppure l\'account non esiste.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Account social già collegato',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Provider non trovato',
|
||||
'api-errors.INVALID_TOKEN': 'Token non valido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID non supportato',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Impossibile recuperare le informazioni utente',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email utente non trovata',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email non verificata',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Password troppo corta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Password troppo lunga',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Esiste già un utente con questa email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'L\'email non può essere aggiornata',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Account credenziali non trovato',
|
||||
'api-errors.SESSION_EXPIRED': 'Sessione scaduta',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Impossibile scollegare l\'ultimo account',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Account non trovato',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utente ha già una password',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Seleziona colore',
|
||||
'color-picker.select-a-color': 'Seleziona un colore',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Pagamento riuscito!',
|
||||
'subscriptions.checkout-success.description': 'Il tuo abbonamento è stato attivato con successo.',
|
||||
'subscriptions.checkout-success.thank-you': 'Grazie per l\'upgrade a Papra Plus. Ora hai accesso a tutte le funzionalità premium.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Vai alle Organizzazioni',
|
||||
'subscriptions.checkout-success.redirecting': 'Reindirizzamento tra {{ count }} secondo{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Pagamento annullato',
|
||||
'subscriptions.checkout-cancel.description': 'L\'upgrade del tuo abbonamento è stato annullato.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'Non sono stati effettuati addebiti sul tuo account. Puoi riprovare quando sei pronto.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Torna alle Organizzazioni',
|
||||
'subscriptions.checkout-cancel.need-help': 'Hai bisogno di aiuto?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contatta il supporto',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Aggiorna questa organizzazione',
|
||||
'subscriptions.upgrade-dialog.description': 'Sblocca funzionalità potenti per la tua organizzazione',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contattaci',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se hai bisogno di piani aziendali personalizzati.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Piano attuale',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Consigliato',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mese',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} fatturato annualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Aggiorna ora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Offerta a tempo limitato',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Ottieni {{ percent }}% di sconto per organizzazione su tutti i piani per sempre come early adopter! L\'offerta scade tra {{ days, >1:{days} giorni, =1:1 giorno, meno di un giorno }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Piano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dimensione archiviazione documenti',
|
||||
'subscriptions.features.members': 'Membri dell\'organizzazione',
|
||||
'subscriptions.features.members-count': '{{ count }} membri',
|
||||
'subscriptions.features.email-intakes': 'Email di acquisizione',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} indirizzo',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} indirizzi',
|
||||
'subscriptions.features.max-upload-size': 'Dimensione massima file caricamento',
|
||||
'subscriptions.features.support': 'Supporto',
|
||||
'subscriptions.features.support-community': 'Supporto della comunità',
|
||||
'subscriptions.features.support-email': 'Supporto via email',
|
||||
'subscriptions.features.support-priority': 'Supporto prioritario',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensile',
|
||||
'subscriptions.billing-interval.annual': 'Annuale',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Hai utilizzato il {{ percent }}% dello spazio di archiviazione dei documenti. Considera l\'aggiornamento del piano per ottenere più spazio.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Aggiorna piano',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Digita "{{ text }}" per confermare',
|
||||
};
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Zweryfikuj swój adres e-mail',
|
||||
'auth.email-validation-required.description': 'Wiadomość weryfikacyjna została wysłana na Twój adres e-mail. Zweryfikuj swój adres e-mail, klikając link w wiadomości.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-mail zweryfikowany',
|
||||
'auth.email-verification.success.description': 'Twój adres e-mail został pomyślnie zweryfikowany. Możesz teraz zalogować się do swojego konta.',
|
||||
'auth.email-verification.success.login': 'Przejdź do logowania',
|
||||
'auth.email-verification.error.title': 'Weryfikacja nie powiodła się',
|
||||
'auth.email-verification.error.description': 'Link weryfikacyjny jest nieprawidłowy lub wygasł. Poproś o nową wiadomość weryfikacyjną, logując się.',
|
||||
'auth.email-verification.error.back': 'Powrót do logowania',
|
||||
|
||||
'auth.legal-links.description': 'Kontynuując, potwierdzasz, że rozumiesz i zgadzasz się na {{ terms }} oraz {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Warunki korzystania z usługi',
|
||||
'auth.legal-links.privacy': 'Polityka prywatności',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'Twoje organizacje',
|
||||
'organizations.list.description': 'Organizacje to sposób grupowania dokumentów i zarządzania dostępem do nich. Możesz tworzyć wiele organizacji i zapraszać członków zespołu do współpracy.',
|
||||
'organizations.list.create-new': 'Utwórz nową organizację',
|
||||
'organizations.list.back': 'Powrót do organizacji',
|
||||
'organizations.list.deleted.title': 'Usunięte organizacje',
|
||||
'organizations.list.deleted.description': 'Usunięte organizacje są przechowywane przez {{ days }} dni przed trwałym usunięciem. Możesz je przywrócić w tym okresie.',
|
||||
'organizations.list.deleted.empty': 'Brak usuniętych organizacji',
|
||||
'organizations.list.deleted.empty-description': 'Kiedy usuniesz organizację, pojawi się tutaj na {{ days }} dni przed trwałym usunięciem.',
|
||||
'organizations.list.deleted.restore': 'Przywróć',
|
||||
'organizations.list.deleted.restore-success': 'Organizacja została pomyślnie przywrócona',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Przywróć organizację',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Czy na pewno chcesz przywrócić tę organizację? Zostanie przeniesiona z powrotem do listy aktywnych organizacji.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Przywróć organizację',
|
||||
'organizations.list.deleted.deleted-at': 'Usunięto {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Zostanie trwale usunięta {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dzień, {daysUntilPurge} dni }} pozostał{{ daysUntilPurge, =1:o, o}})',
|
||||
|
||||
'organizations.details.no-documents.title': 'Brak dokumentów',
|
||||
'organizations.details.no-documents.description': 'W tej organizacji nie ma jeszcze żadnych dokumentów. Zacznij od przesłania kilku dokumentów.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Usuń organizację',
|
||||
'organization.settings.delete.description': 'Usunięcie tej organizacji spowoduje trwałe usunięcie wszystkich danych z nią związanych.',
|
||||
'organization.settings.delete.confirm.title': 'Usuń organizację',
|
||||
'organization.settings.delete.confirm.message': 'Czy na pewno chcesz usunąć tę organizację? Ta operacja jest nieodwracalna, a wszystkie dane związane z tą organizacją zostaną trwale usunięte.',
|
||||
'organization.settings.delete.confirm.message': 'Czy na pewno chcesz usunąć tę organizację? Organizacja zostanie oznaczona do usunięcia i trwale usunięta po {{ days}} dniach. W tym okresie możesz ją przywrócić z listy organizacji. Wszystkie dokumenty i dane zostaną trwale usunięte po upływie tego terminu.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Usuń organizację',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Anuluj',
|
||||
'organization.settings.delete.success': 'Organizacja została usunięta',
|
||||
'organization.settings.delete.only-owner': 'Tylko właściciel organizacji może usunąć tę organizację.',
|
||||
'organization.settings.delete.has-active-subscription': 'Nie można usunąć organizacji z aktywną subskrypcją, proszę najpierw anulować subskrypcję powyżej.',
|
||||
|
||||
'organization.usage.page.title': 'Użycie',
|
||||
'organization.usage.page.description': 'Zobacz aktualne użycie i limity Twojej organizacji.',
|
||||
'organization.usage.storage.title': 'Przechowywanie dokumentów',
|
||||
'organization.usage.storage.description': 'Całkowite miejsce używane przez Twoje dokumenty',
|
||||
'organization.usage.intake-emails.title': 'E-maile przychodzące',
|
||||
'organization.usage.intake-emails.description': 'Liczba adresów e-mail przychodzących',
|
||||
'organization.usage.members.title': 'Członkowie',
|
||||
'organization.usage.members.description': 'Liczba członków w organizacji',
|
||||
'organization.usage.unlimited': 'Nieograniczone',
|
||||
|
||||
'organizations.members.title': 'Członkowie',
|
||||
'organizations.members.description': 'Zarządzaj członkami swojej organizacji',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Przykład: Oznacz dokumenty ze słowem \'faktura\' w nazwie',
|
||||
'tagging-rules.form.description.max-length': 'Opis musi mieć mniej niż 256 znaków',
|
||||
'tagging-rules.form.conditions.label': 'Warunki',
|
||||
'tagging-rules.form.conditions.description': 'Zdefiniuj warunki, które muszą być spełnione, aby reguła mogła zostać zastosowana. Wszystkie warunki muszą być spełnione, aby reguła mogła zostać zastosowana.',
|
||||
'tagging-rules.form.conditions.description': 'Zdefiniuj warunki, które muszą być spełnione, aby reguła mogła zostać zastosowana. Brak warunków oznacza, że reguła zostanie zastosowana do wszystkich dokumentów',
|
||||
'tagging-rules.form.conditions.add-condition': 'Dodaj warunek',
|
||||
'tagging-rules.form.conditions.connector.when': 'Kiedy',
|
||||
'tagging-rules.form.conditions.connector.and': 'i',
|
||||
'tagging-rules.form.conditions.connector.or': 'lub',
|
||||
'tagging-rules.condition-match-mode.all': 'Wszystkie warunki muszą być spełnione',
|
||||
'tagging-rules.condition-match-mode.any': 'Dowolny warunek musi być spełniony',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Brak warunków',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Nie dodałeś żadnych warunków do tej reguły. Ta reguła zastosuje swoje tagi do wszystkich dokumentów.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Zastosuj regułę bez warunków',
|
||||
@@ -379,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Nie udało się zaktualizować reguły tagowania',
|
||||
'tagging-rules.update.submit': 'Zaktualizuj regułę',
|
||||
'tagging-rules.update.cancel': 'Anuluj',
|
||||
'tagging-rules.apply.button': 'Zastosuj do istniejących dokumentów',
|
||||
'tagging-rules.apply.confirm.title': 'Zastosować regułę do istniejących dokumentów?',
|
||||
'tagging-rules.apply.confirm.description': 'Sprawdzi to wszystkie istniejące dokumenty w organizacji i zastosuje tagi tam, gdzie warunki są spełnione. Przetwarzanie odbędzie się w tle.',
|
||||
'tagging-rules.apply.confirm.button': 'Zastosuj regułę',
|
||||
'tagging-rules.apply.success': 'Rozpoczęto stosowanie reguły w tle',
|
||||
'tagging-rules.apply.error': 'Nie udało się rozpocząć stosowania reguły',
|
||||
'tagging-rules.apply.processing': 'Uruchamianie...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Zaznacz wszystko',
|
||||
'api-keys.permissions.deselect-all': 'Odznacz wszystko',
|
||||
'api-keys.permissions.organizations.title': 'Organizacje',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Tworzenie organizacji',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Odczyt organizacji',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Aktualizacja organizacji',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Usuwanie organizacji',
|
||||
'api-keys.permissions.documents.title': 'Dokumenty',
|
||||
'api-keys.permissions.documents.documents:create': 'Tworzenie dokumentów',
|
||||
'api-keys.permissions.documents.documents:read': 'Odczyt dokumentów',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Ustawienia',
|
||||
'layout.menu.account': 'Konto',
|
||||
'layout.menu.general-settings': 'Ustawienia ogólne',
|
||||
'layout.menu.usage': 'Użycie',
|
||||
'layout.menu.intake-emails': 'Adresy przyjęć',
|
||||
'layout.menu.webhooks': 'Webhooki',
|
||||
'layout.menu.members': 'Członkowie',
|
||||
'layout.menu.invitations': 'Zaproszenia',
|
||||
|
||||
'layout.upgrade-cta.title': 'Potrzebujesz więcej miejsca?',
|
||||
'layout.upgrade-cta.description': 'Uzyskaj 10x więcej przestrzeni + współpracę zespołową',
|
||||
'layout.upgrade-cta.button': 'Ulepsz teraz',
|
||||
|
||||
'layout.theme.light': 'Tryb jasny',
|
||||
'layout.theme.dark': 'Tryb ciemny',
|
||||
'layout.theme.system': 'Tryb systemowy',
|
||||
@@ -540,7 +596,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'Dokument już istnieje',
|
||||
'api-errors.document.file_too_big': 'Plik dokumentu jest zbyt duży',
|
||||
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
|
||||
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
|
||||
'api-errors.intake_email.limit_reached': 'Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.',
|
||||
'api-errors.user.max_organization_count_reached': 'Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.',
|
||||
'api-errors.default': 'Wystąpił błąd podczas przetwarzania żądania.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'Tag o tej nazwie już istnieje w tej organizacji',
|
||||
'api-errors.internal.error': 'Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.',
|
||||
'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.',
|
||||
// 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',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Nie udało się utworzyć sesji',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Nie udało się zaktualizować użytkownika',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Nie udało się pobrać sesji',
|
||||
'api-errors.INVALID_PASSWORD': 'Nieprawidłowe hasło',
|
||||
'api-errors.INVALID_EMAIL': 'Nieprawidłowy email',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Email lub hasło jest nieprawidłowe, lub konto nie istnieje.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Konto społecznościowe już połączone',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Nie znaleziono dostawcy',
|
||||
'api-errors.INVALID_TOKEN': 'Nieprawidłowy token',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID nie jest obsługiwany',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Nie udało się pobrać informacji o użytkowniku',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Nie znaleziono emaila użytkownika',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email nie został zweryfikowany',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Hasło zbyt krótkie',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Hasło zbyt długie',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Użytkownik z tym emailem już istnieje',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email nie może być zaktualizowany',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Nie znaleziono konta uwierzytelniającego',
|
||||
'api-errors.SESSION_EXPIRED': 'Sesja wygasła',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Nie udało się odłączyć ostatniego konta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Nie znaleziono konta',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Użytkownik ma już hasło',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Wybierz kolor',
|
||||
'color-picker.select-a-color': 'Wybierz kolor',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Płatność zakończona sukcesem!',
|
||||
'subscriptions.checkout-success.description': 'Twoja subskrypcja została pomyślnie aktywowana.',
|
||||
'subscriptions.checkout-success.thank-you': 'Dziękujemy za przejście na Papra Plus. Teraz masz dostęp do wszystkich funkcji premium.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Przejdź do Organizacji',
|
||||
'subscriptions.checkout-success.redirecting': 'Przekierowanie za {{ count }} sekund{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Płatność anulowana',
|
||||
'subscriptions.checkout-cancel.description': 'Twoja aktualizacja subskrypcji została anulowana.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'Nie pobrano żadnych opłat z Twojego konta. Możesz spróbować ponownie w dowolnym momencie.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Powrót do Organizacji',
|
||||
'subscriptions.checkout-cancel.need-help': 'Potrzebujesz pomocy?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Skontaktuj się z pomocą techniczną',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Ulepsz tę organizację',
|
||||
'subscriptions.upgrade-dialog.description': 'Odblokuj zaawansowane funkcje dla swojej organizacji',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Skontaktuj się z nami',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'jeśli potrzebujesz niestandardowych planów biznesowych.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Obecny plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Polecane',
|
||||
'subscriptions.upgrade-dialog.per-month': '/miesiąc',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} rozliczane rocznie',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Ulepsz teraz',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta ograniczona czasowo',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Uzyskaj {{ percent }}% zniżki na organizację na wszystkie plany na zawsze jako early adopter! Oferta wygasa za {{ days, >1:{days} dni, =1:1 dzień, mniej niż 1 dzień }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan darmowy',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Rozmiar przechowywania dokumentów',
|
||||
'subscriptions.features.members': 'Członkowie organizacji',
|
||||
'subscriptions.features.members-count': '{{ count }} członków',
|
||||
'subscriptions.features.email-intakes': 'Adresy e-mail do przyjęć',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} adres',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} adresy',
|
||||
'subscriptions.features.max-upload-size': 'Maksymalny rozmiar pliku',
|
||||
'subscriptions.features.support': 'Wsparcie',
|
||||
'subscriptions.features.support-community': 'Wsparcie społeczności',
|
||||
'subscriptions.features.support-email': 'Wsparcie e-mail',
|
||||
'subscriptions.features.support-priority': 'Wsparcie priorytetowe',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Miesięcznie',
|
||||
'subscriptions.billing-interval.annual': 'Rocznie',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Wykorzystano {{ percent }}% miejsca na dokumenty. Rozważ aktualizację planu, aby uzyskać więcej miejsca.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Ulepsz plan',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Wpisz "{{ text }}", aby potwierdzić',
|
||||
};
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifique seu e-mail',
|
||||
'auth.email-validation-required.description': 'Um e-mail de verificação foi enviado para seu endereço. Por favor, verifique seu e-mail clicando no link enviado.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-mail verificado',
|
||||
'auth.email-verification.success.description': 'Seu e-mail foi verificado com sucesso. Você já pode fazer login na sua conta.',
|
||||
'auth.email-verification.success.login': 'Ir para login',
|
||||
'auth.email-verification.error.title': 'Falha na verificação',
|
||||
'auth.email-verification.error.description': 'O link de verificação é inválido ou expirou. Por favor, solicite um novo e-mail de verificação ao fazer login.',
|
||||
'auth.email-verification.error.back': 'Voltar ao login',
|
||||
|
||||
'auth.legal-links.description': 'Ao continuar, você reconhece que leu e concorda com os {{ terms }} e a {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termos de Serviço',
|
||||
'auth.legal-links.privacy': 'Política de Privacidade',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'Suas organizações',
|
||||
'organizations.list.description': 'Organizações são uma forma de agrupar seus documentos e gerenciar o acesso a eles. Você pode criar várias organizações e convidar membros da sua equipe para colaborar.',
|
||||
'organizations.list.create-new': 'Criar nova organização',
|
||||
'organizations.list.back': 'Voltar às organizações',
|
||||
'organizations.list.deleted.title': 'Organizações excluídas',
|
||||
'organizations.list.deleted.description': 'As organizações excluídas são mantidas por {{ days }} dias antes de serem removidas permanentemente. Você pode restaurá-las durante este período.',
|
||||
'organizations.list.deleted.empty': 'Nenhuma organização excluída',
|
||||
'organizations.list.deleted.empty-description': 'Quando você excluir uma organização, ela aparecerá aqui por {{ days }} dias antes de ser excluída permanentemente.',
|
||||
'organizations.list.deleted.restore': 'Restaurar',
|
||||
'organizations.list.deleted.restore-success': 'Organização restaurada com sucesso',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Restaurar organização',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Tem certeza de que deseja restaurar esta organização? Ela será movida de volta para sua lista de organizações ativas.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurar organização',
|
||||
'organizations.list.deleted.deleted-at': 'Excluída em {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Será excluída permanentemente em {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dia, {daysUntilPurge} dias }} restante{{ daysUntilPurge, >1:s}})',
|
||||
|
||||
'organizations.details.no-documents.title': 'Nenhum documento',
|
||||
'organizations.details.no-documents.description': 'Ainda não há documentos nesta organização. Comece enviando documentos.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Excluir organização',
|
||||
'organization.settings.delete.description': 'A exclusão desta organização removerá permanentemente todos seus dados associados.',
|
||||
'organization.settings.delete.confirm.title': 'Excluir organização',
|
||||
'organization.settings.delete.confirm.message': 'Tem certeza de que deseja excluir esta organização? Esta ação não pode ser desfeita e todos os dados associados serão permanentemente removidos.',
|
||||
'organization.settings.delete.confirm.message': 'Tem certeza de que deseja excluir esta organização? A organização será marcada para exclusão e removida permanentemente após {{ days }} dias. Durante este período, você pode restaurá-la da sua lista de organizações. Todos os documentos e dados serão excluídos permanentemente após este prazo.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Excluir organização',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
|
||||
'organization.settings.delete.success': 'Organização excluída',
|
||||
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode excluir esta organização.',
|
||||
'organization.settings.delete.has-active-subscription': 'Não é possível excluir a organização com uma assinatura ativa, por favor cancele sua assinatura acima primeiro.',
|
||||
|
||||
'organization.usage.page.title': 'Uso',
|
||||
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
|
||||
'organization.usage.storage.title': 'Armazenamento de documentos',
|
||||
'organization.usage.storage.description': 'Armazenamento total usado pelos seus documentos',
|
||||
'organization.usage.intake-emails.title': 'E-mails de entrada',
|
||||
'organization.usage.intake-emails.description': 'Número de endereços de e-mail de entrada',
|
||||
'organization.usage.members.title': 'Membros',
|
||||
'organization.usage.members.description': 'Número de membros na organização',
|
||||
'organization.usage.unlimited': 'Ilimitado',
|
||||
|
||||
'organizations.members.title': 'Membros',
|
||||
'organizations.members.description': 'Gerencie os membros da sua organização',
|
||||
@@ -331,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nome do documento',
|
||||
'tagging-rules.field.content': 'conteúdo do documento',
|
||||
'tagging-rules.field.name': 'o nome do documento',
|
||||
'tagging-rules.field.content': 'o conteúdo do documento',
|
||||
'tagging-rules.operator.equals': 'é igual a',
|
||||
'tagging-rules.operator.not-equals': 'é diferente de',
|
||||
'tagging-rules.operator.contains': 'contém',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemplo: Marcar documentos com \'fatura\' no nome',
|
||||
'tagging-rules.form.description.max-length': 'A descrição deve ter menos de 256 caracteres',
|
||||
'tagging-rules.form.conditions.label': 'Condições',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser atendidas para que a regra seja aplicada. Todas as condições devem ser atendidas.',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser atendidas para que a regra seja aplicada. Sem condições significa que a regra será aplicada a todos os documentos',
|
||||
'tagging-rules.form.conditions.add-condition': 'Adicionar condição',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quando',
|
||||
'tagging-rules.form.conditions.connector.and': 'e que',
|
||||
'tagging-rules.form.conditions.connector.or': 'ou que',
|
||||
'tagging-rules.condition-match-mode.all': 'Todas as condições devem corresponder',
|
||||
'tagging-rules.condition-match-mode.any': 'Qualquer condição deve corresponder',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Nenhuma condição',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Você não adicionou nenhuma condição a esta regra. Ela será aplicada a todos os documentos.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regra sem condições',
|
||||
@@ -379,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Falha ao atualizar a regra de marcação',
|
||||
'tagging-rules.update.submit': 'Atualizar regra',
|
||||
'tagging-rules.update.cancel': 'Cancelar',
|
||||
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
|
||||
'tagging-rules.apply.confirm.title': 'Aplicar regra a documentos existentes?',
|
||||
'tagging-rules.apply.confirm.description': 'Isso verificará todos os documentos existentes em sua organização e aplicará tags onde as condições correspondam. O processamento será feito em segundo plano.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicar regra',
|
||||
'tagging-rules.apply.success': 'Aplicação da regra iniciada em segundo plano',
|
||||
'tagging-rules.apply.error': 'Falha ao iniciar a aplicação da regra',
|
||||
'tagging-rules.apply.processing': 'Iniciando...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Selecionar tudo',
|
||||
'api-keys.permissions.deselect-all': 'Desmarcar tudo',
|
||||
'api-keys.permissions.organizations.title': 'Organizações',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Excluir organizações',
|
||||
'api-keys.permissions.documents.title': 'Documentos',
|
||||
'api-keys.permissions.documents.documents:create': 'Criar documentos',
|
||||
'api-keys.permissions.documents.documents:read': 'Ler documentos',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Configurações',
|
||||
'layout.menu.account': 'Conta',
|
||||
'layout.menu.general-settings': 'Configurações gerais',
|
||||
'layout.menu.usage': 'Uso',
|
||||
'layout.menu.intake-emails': 'E-mails de entrada',
|
||||
'layout.menu.webhooks': 'Webhooks',
|
||||
'layout.menu.members': 'Membros',
|
||||
'layout.menu.invitations': 'Convites',
|
||||
|
||||
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
||||
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipe',
|
||||
'layout.upgrade-cta.button': 'Atualizar agora',
|
||||
|
||||
'layout.theme.light': 'Tema claro',
|
||||
'layout.theme.dark': 'Tema escuro',
|
||||
'layout.theme.system': 'Tema do sistema',
|
||||
@@ -540,7 +596,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.file_too_big': 'O arquivo do documento é muito grande',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
|
||||
'api-errors.user.max_organization_count_reached': 'Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.',
|
||||
'api-errors.default': 'Ocorreu um erro ao processar sua solicitação.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'Já existe uma tag com este nome nesta organização',
|
||||
'api-errors.internal.error': 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.',
|
||||
'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.',
|
||||
// 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',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Falha ao criar sessão',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Falha ao atualizar usuário',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Falha ao obter sessão',
|
||||
'api-errors.INVALID_PASSWORD': 'Senha inválida',
|
||||
'api-errors.INVALID_EMAIL': 'Email inválido',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'O email ou a senha está incorreta, ou a conta não existe.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Conta social já vinculada',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Provedor não encontrado',
|
||||
'api-errors.INVALID_TOKEN': 'Token inválido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID não suportado',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Falha ao obter informações do usuário',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email do usuário não encontrado',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email não verificado',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Senha muito curta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Senha muito longa',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Já existe um usuário com este email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'O email não pode ser atualizado',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Conta de credenciais não encontrada',
|
||||
'api-errors.SESSION_EXPIRED': 'Sessão expirada',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desvincular a última conta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O usuário já possui uma senha',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Selecionar cor',
|
||||
'color-picker.select-a-color': 'Selecione uma cor',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Pagamento bem-sucedido!',
|
||||
'subscriptions.checkout-success.description': 'Sua assinatura foi ativada com sucesso.',
|
||||
'subscriptions.checkout-success.thank-you': 'Obrigado por fazer upgrade para o Papra Plus. Agora você tem acesso a todos os recursos premium.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Ir para Organizações',
|
||||
'subscriptions.checkout-success.redirecting': 'Redirecionando em {{ count }} segundo{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Pagamento cancelado',
|
||||
'subscriptions.checkout-cancel.description': 'Seu upgrade de assinatura foi cancelado.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'Nenhuma cobrança foi feita em sua conta. Você pode tentar novamente quando estiver pronto.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Voltar para Organizações',
|
||||
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contatar suporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar esta organização',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para sua organização',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Entre em contato',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se você precisar de planos empresariais personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mês',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Fazer upgrade agora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tempo limitado',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Ganhe {{ percent }}% de desconto por organização em todos os planos para sempre como early adopter! A oferta expira em {{ days, >1:{days} dias, =1:1 dia, menos de um dia }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
|
||||
'subscriptions.features.members': 'Membros da organização',
|
||||
'subscriptions.features.members-count': '{{ count }} membros',
|
||||
'subscriptions.features.email-intakes': 'E-mails de entrada',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} endereço',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} endereços',
|
||||
'subscriptions.features.max-upload-size': 'Tamanho máximo de upload',
|
||||
'subscriptions.features.support': 'Suporte',
|
||||
'subscriptions.features.support-community': 'Suporte da comunidade',
|
||||
'subscriptions.features.support-email': 'Suporte por e-mail',
|
||||
'subscriptions.features.support-priority': 'Suporte prioritário',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensal',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Você usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar seu plano para obter mais espaço.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar',
|
||||
};
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifique o seu e-mail',
|
||||
'auth.email-validation-required.description': 'Foi enviado um e-mail de verificação para o seu endereço de e-mail. Por favor, verifique o seu endereço de e-mail clicando na ligação no e-mail.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-mail verificado',
|
||||
'auth.email-verification.success.description': 'O seu e-mail foi verificado com sucesso. Pode agora iniciar sessão na sua conta.',
|
||||
'auth.email-verification.success.login': 'Ir para o login',
|
||||
'auth.email-verification.error.title': 'Falha na verificação',
|
||||
'auth.email-verification.error.description': 'A ligação de verificação é inválida ou expirou. Por favor, solicite um novo e-mail de verificação ao iniciar sessão.',
|
||||
'auth.email-verification.error.back': 'Voltar ao login',
|
||||
|
||||
'auth.legal-links.description': 'Ao continuar, reconhece que compreende e concorda com os {{ terms }} e a {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termos de Serviço',
|
||||
'auth.legal-links.privacy': 'Política de Privacidade',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'As suas organizações',
|
||||
'organizations.list.description': 'As organizações são uma forma de agrupar os seus documentos e gerir o acesso aos mesmos. Pode criar várias organizações e convidar os membros da sua equipa para colaborar.',
|
||||
'organizations.list.create-new': 'Criar nova organização',
|
||||
'organizations.list.back': 'Voltar às organizações',
|
||||
'organizations.list.deleted.title': 'Organizações eliminadas',
|
||||
'organizations.list.deleted.description': 'As organizações eliminadas são mantidas durante {{ days }} dias antes de serem removidas permanentemente. Pode restaurá-las durante este período.',
|
||||
'organizations.list.deleted.empty': 'Nenhuma organização eliminada',
|
||||
'organizations.list.deleted.empty-description': 'Quando eliminar uma organização, ela aparecerá aqui durante {{ days }} dias antes de ser eliminada permanentemente.',
|
||||
'organizations.list.deleted.restore': 'Restaurar',
|
||||
'organizations.list.deleted.restore-success': 'Organização restaurada com sucesso',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Restaurar organização',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Tem a certeza de que quer restaurar esta organização? Ela será movida de volta para a sua lista de organizações ativas.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurar organização',
|
||||
'organizations.list.deleted.deleted-at': 'Eliminada em {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Será eliminada permanentemente em {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dia, {daysUntilPurge} dias }} restante{{ daysUntilPurge, >1:s}})',
|
||||
|
||||
'organizations.details.no-documents.title': 'Sem documentos',
|
||||
'organizations.details.no-documents.description': 'Não há documentos nesta organização ainda. Comece por carregar alguns documentos.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Eliminar organização',
|
||||
'organization.settings.delete.description': 'Eliminar esta organização removerá permanentemente todos os dados associados à mesma.',
|
||||
'organization.settings.delete.confirm.title': 'Eliminar organização',
|
||||
'organization.settings.delete.confirm.message': 'Tem a certeza de que quer eliminar esta organização? Esta ação não pode ser desfeita e todos os dados associados a esta organização serão permanentemente removidos.',
|
||||
'organization.settings.delete.confirm.message': 'Tem a certeza de que pretende eliminar esta organização? A organização será marcada para eliminação e permanentemente removida após {{ days }} dias. Durante este período, pode restaurá-la a partir da sua lista de organizações. Todos os documentos e dados serão permanentemente eliminados após este prazo.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Eliminar organização',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
|
||||
'organization.settings.delete.success': 'Organização eliminada',
|
||||
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode eliminar esta organização.',
|
||||
'organization.settings.delete.has-active-subscription': 'Não é possível eliminar a organização com uma subscrição ativa, por favor cancele a sua subscrição acima primeiro.',
|
||||
|
||||
'organization.usage.page.title': 'Uso',
|
||||
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
|
||||
'organization.usage.storage.title': 'Armazenamento de documentos',
|
||||
'organization.usage.storage.description': 'Armazenamento total usado pelos seus documentos',
|
||||
'organization.usage.intake-emails.title': 'E-mails de entrada',
|
||||
'organization.usage.intake-emails.description': 'Número de endereços de e-mail de entrada',
|
||||
'organization.usage.members.title': 'Membros',
|
||||
'organization.usage.members.description': 'Número de membros na organização',
|
||||
'organization.usage.unlimited': 'Ilimitado',
|
||||
|
||||
'organizations.members.title': 'Membros',
|
||||
'organizations.members.description': 'Gira os membros da sua organização',
|
||||
@@ -331,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nome do documento',
|
||||
'tagging-rules.field.content': 'conteúdo do documento',
|
||||
'tagging-rules.field.name': 'o nome do documento',
|
||||
'tagging-rules.field.content': 'o conteúdo do documento',
|
||||
'tagging-rules.operator.equals': 'igual a',
|
||||
'tagging-rules.operator.not-equals': 'não igual a',
|
||||
'tagging-rules.operator.contains': 'contém',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemplo: Etiquetar documentos com \'fatura\' no nome',
|
||||
'tagging-rules.form.description.max-length': 'A descrição deve ter menos de 256 caracteres',
|
||||
'tagging-rules.form.conditions.label': 'Condições',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser cumpridas para a regra se aplicar. Todas as condições devem ser cumpridas para a regra se aplicar.',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser cumpridas para a regra se aplicar. Sem condições significa que a regra será aplicada a todos os documentos',
|
||||
'tagging-rules.form.conditions.add-condition': 'Adicionar condição',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quando',
|
||||
'tagging-rules.form.conditions.connector.and': 'e que',
|
||||
'tagging-rules.form.conditions.connector.or': 'ou que',
|
||||
'tagging-rules.condition-match-mode.all': 'Todas as condições devem corresponder',
|
||||
'tagging-rules.condition-match-mode.any': 'Qualquer condição deve corresponder',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Sem condições',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Não adicionou nenhuma condição a esta regra. Esta regra aplicará as suas etiquetas a todos os documentos.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regra sem condições',
|
||||
@@ -379,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Falha ao atualizar regra de etiquetagem',
|
||||
'tagging-rules.update.submit': 'Atualizar regra',
|
||||
'tagging-rules.update.cancel': 'Cancelar',
|
||||
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
|
||||
'tagging-rules.apply.confirm.title': 'Aplicar regra a documentos existentes?',
|
||||
'tagging-rules.apply.confirm.description': 'Isto irá verificar todos os documentos existentes na sua organização e aplicar etiquetas onde as condições correspondam. O processamento será feito em segundo plano.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicar regra',
|
||||
'tagging-rules.apply.success': 'Aplicação da regra iniciada em segundo plano',
|
||||
'tagging-rules.apply.error': 'Falha ao iniciar a aplicação da regra',
|
||||
'tagging-rules.apply.processing': 'A iniciar...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Selecionar tudo',
|
||||
'api-keys.permissions.deselect-all': 'Desselecionar tudo',
|
||||
'api-keys.permissions.organizations.title': 'Organizações',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizações',
|
||||
'api-keys.permissions.documents.title': 'Documentos',
|
||||
'api-keys.permissions.documents.documents:create': 'Criar documentos',
|
||||
'api-keys.permissions.documents.documents:read': 'Ler documentos',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Definições',
|
||||
'layout.menu.account': 'Conta',
|
||||
'layout.menu.general-settings': 'Definições gerais',
|
||||
'layout.menu.usage': 'Uso',
|
||||
'layout.menu.intake-emails': 'E-mails de entrada',
|
||||
'layout.menu.webhooks': 'Webhooks',
|
||||
'layout.menu.members': 'Membros',
|
||||
'layout.menu.invitations': 'Convites',
|
||||
|
||||
'layout.upgrade-cta.title': 'Precisa de mais espaço?',
|
||||
'layout.upgrade-cta.description': 'Obtenha 10x mais armazenamento + colaboração em equipa',
|
||||
'layout.upgrade-cta.button': 'Atualizar agora',
|
||||
|
||||
'layout.theme.light': 'Tema claro',
|
||||
'layout.theme.dark': 'Tema escuro',
|
||||
'layout.theme.system': 'Tema do sistema',
|
||||
@@ -540,7 +596,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'O documento já existe',
|
||||
'api-errors.document.file_too_big': 'O arquivo do documento é muito grande',
|
||||
'api-errors.document.size_too_large': 'O arquivo é muito grande',
|
||||
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
|
||||
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
|
||||
'api-errors.user.max_organization_count_reached': 'Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.',
|
||||
'api-errors.default': 'Ocorreu um erro ao processar a solicitação.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'Já existe uma etiqueta com este nome nesta organização',
|
||||
'api-errors.internal.error': 'Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.',
|
||||
'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.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utilizador não encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar utilizador',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Falha ao criar sessão',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Falha ao atualizar utilizador',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Falha ao obter sessão',
|
||||
'api-errors.INVALID_PASSWORD': 'Palavra-passe inválida',
|
||||
'api-errors.INVALID_EMAIL': 'Email inválido',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'O email ou a palavra-passe está incorreta, ou a conta não existe.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Conta social já associada',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Fornecedor não encontrado',
|
||||
'api-errors.INVALID_TOKEN': 'Token inválido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID não suportado',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Falha ao obter informações do utilizador',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email do utilizador não encontrado',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email não verificado',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Palavra-passe demasiado curta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Palavra-passe demasiado longa',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Já existe um utilizador com este email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'O email não pode ser atualizado',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Conta de credenciais não encontrada',
|
||||
'api-errors.SESSION_EXPIRED': 'Sessão expirada',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desassociar a última conta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O utilizador já tem uma palavra-passe',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Selecionar cor',
|
||||
'color-picker.select-a-color': 'Selecione uma cor',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Pagamento bem-sucedido!',
|
||||
'subscriptions.checkout-success.description': 'A sua subscrição foi ativada com sucesso.',
|
||||
'subscriptions.checkout-success.thank-you': 'Obrigado por fazer upgrade para o Papra Plus. Agora tem acesso a todos os recursos premium.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Ir para Organizações',
|
||||
'subscriptions.checkout-success.redirecting': 'A redirecionar em {{ count }} segundo{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Pagamento cancelado',
|
||||
'subscriptions.checkout-cancel.description': 'O seu upgrade de subscrição foi cancelado.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'Nenhuma cobrança foi feita na sua conta. Pode tentar novamente quando estiver pronto.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Voltar para Organizações',
|
||||
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contactar suporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar esta organização',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para a sua organização',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contacte-nos',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se precisar de planos empresariais personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mês',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Atualizar agora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tempo limitado',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Obtenha {{ percent }}% de desconto por organização em todos os planos para sempre como early adopter! A oferta expira em {{ days, >1:{days} dias, =1:1 dia, menos de um dia }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
|
||||
'subscriptions.features.members': 'Membros da organização',
|
||||
'subscriptions.features.members-count': '{{ count }} membros',
|
||||
'subscriptions.features.email-intakes': 'E-mails de entrada',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} endereço',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} endereços',
|
||||
'subscriptions.features.max-upload-size': 'Tamanho máximo de upload',
|
||||
'subscriptions.features.support': 'Suporte',
|
||||
'subscriptions.features.support-community': 'Suporte da comunidade',
|
||||
'subscriptions.features.support-email': 'Suporte por e-mail',
|
||||
'subscriptions.features.support-priority': 'Suporte prioritário',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Mensal',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Usou {{ percent }}% do seu armazenamento de documentos. Considere atualizar o seu plano para obter mais espaço.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Atualizar plano',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Digite "{{ text }}" para confirmar',
|
||||
};
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifică-ți email-ul',
|
||||
'auth.email-validation-required.description': 'A fost trimis un e-mail de verificare la adresa ta de e-mail. Te rugăm să îți verifici adresa de e-mail dând click pe linkul din e-mail.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email verificat',
|
||||
'auth.email-verification.success.description': 'Email-ul tău a fost verificat cu succes. Acum te poți autentifica în contul tău.',
|
||||
'auth.email-verification.success.login': 'Mergi la autentificare',
|
||||
'auth.email-verification.error.title': 'Verificare eșuată',
|
||||
'auth.email-verification.error.description': 'Linkul de verificare este invalid sau a expirat. Te rugăm să soliciți un nou e-mail de verificare autentificându-te.',
|
||||
'auth.email-verification.error.back': 'Înapoi la autentificare',
|
||||
|
||||
'auth.legal-links.description': 'Continuând, confirmați că întelegeți și sunteti de acord cu {{ terms }} și {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termenii și condițiile',
|
||||
'auth.legal-links.privacy': 'Politica de confidențialitate',
|
||||
@@ -102,6 +109,19 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organizations.list.title': 'Organizațiile tale',
|
||||
'organizations.list.description': 'Organizațiile sunt o modalitate de a grupa documentele și de a gestiona accesul la acestea. Poți crea multiple organizații și invita membrii echipei tale să colaboreze.',
|
||||
'organizations.list.create-new': 'Creează o organizație nouă',
|
||||
'organizations.list.back': 'Înapoi la organizații',
|
||||
'organizations.list.deleted.title': 'Organizații șterse',
|
||||
'organizations.list.deleted.description': 'Organizațiile șterse sunt păstrate {{ days }} zile înainte de a fi eliminate definitiv. Le poți restaura în această perioadă.',
|
||||
'organizations.list.deleted.empty': 'Nu există organizații șterse',
|
||||
'organizations.list.deleted.empty-description': 'Când ștergi o organizație, va apărea aici pentru {{ days }} zile înainte de a fi ștearsă definitiv.',
|
||||
'organizations.list.deleted.restore': 'Restaurează',
|
||||
'organizations.list.deleted.restore-success': 'Organizația a fost restaurată cu succes',
|
||||
'organizations.list.deleted.restore-confirm.title': 'Restaurează organizația',
|
||||
'organizations.list.deleted.restore-confirm.message': 'Ești sigur că vrei să restaurezi această organizație? Va fi mutată înapoi în lista organizațiilor active.',
|
||||
'organizations.list.deleted.restore-confirm.confirm-button': 'Restaurează organizația',
|
||||
'organizations.list.deleted.deleted-at': 'Ștearsă {{ date }}',
|
||||
'organizations.list.deleted.purge-at': 'Va fi ștearsă definitiv {{ date }}',
|
||||
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} zi, {daysUntilPurge} zile }} rămas{{ daysUntilPurge, =1:ă, e}})',
|
||||
|
||||
'organizations.details.no-documents.title': 'Niciun document',
|
||||
'organizations.details.no-documents.description': 'Încă nu există documente în această organizație. Încarcă niște documente pentru a începe.',
|
||||
@@ -139,10 +159,22 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.title': 'Șterge organizația',
|
||||
'organization.settings.delete.description': 'Ștergerea acestei organizații va elimina definitiv toate datele asociate cu aceasta.',
|
||||
'organization.settings.delete.confirm.title': 'Șterge organizatia',
|
||||
'organization.settings.delete.confirm.message': 'Ești sigur că vrei să ștergi această organizație? Aceasta operatie nu poate fi anulată si toate datele asociate cu aceasta vor fi eliminate definitiv.',
|
||||
'organization.settings.delete.confirm.message': 'Sigur doriți să ștergeți această organizație? Organizația va fi marcată pentru ștergere și eliminată definitiv după {{ days }} zile. În această perioadă, o puteți restaura din lista dvs. de organizații. Toate documentele și datele vor fi șterse definitiv după această perioadă.',
|
||||
'organization.settings.delete.confirm.confirm-button': 'Șterge organizație',
|
||||
'organization.settings.delete.confirm.cancel-button': 'Anulează',
|
||||
'organization.settings.delete.success': 'Organizație ștearsă cu succes',
|
||||
'organization.settings.delete.only-owner': 'Doar proprietarul organizației poate șterge această organizație.',
|
||||
'organization.settings.delete.has-active-subscription': 'Nu se poate șterge organizația cu un abonament activ, vă rugăm să anulați mai întâi abonamentul de mai sus.',
|
||||
|
||||
'organization.usage.page.title': 'Utilizare',
|
||||
'organization.usage.page.description': 'Vizualizează utilizarea curentă și limitele organizației tale.',
|
||||
'organization.usage.storage.title': 'Stocare documente',
|
||||
'organization.usage.storage.description': 'Spațiul total folosit de documentele tale',
|
||||
'organization.usage.intake-emails.title': 'E-mailuri de intrare',
|
||||
'organization.usage.intake-emails.description': 'Număr de adrese de e-mail de intrare',
|
||||
'organization.usage.members.title': 'Membri',
|
||||
'organization.usage.members.description': 'Număr de membri în organizație',
|
||||
'organization.usage.unlimited': 'Nelimitat',
|
||||
|
||||
'organizations.members.title': 'Membri',
|
||||
'organizations.members.description': 'Gestionează membrii organizației tale',
|
||||
@@ -331,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nume document',
|
||||
'tagging-rules.field.content': 'conținut document',
|
||||
'tagging-rules.field.name': 'numele documentului',
|
||||
'tagging-rules.field.content': 'conținutul documentului',
|
||||
'tagging-rules.operator.equals': 'egal cu',
|
||||
'tagging-rules.operator.not-equals': 'nu este egal cu',
|
||||
'tagging-rules.operator.contains': 'conține',
|
||||
@@ -362,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemplu: Etichetează documentele cu \'factură\' în nume',
|
||||
'tagging-rules.form.description.max-length': 'Descrierea trebuie să aibă mai puțin de 256 de caractere',
|
||||
'tagging-rules.form.conditions.label': 'Condiții',
|
||||
'tagging-rules.form.conditions.description': 'Definește condițiile care trebuie îndeplinite pentru ca regula să se aplice. Toate condițiile trebuie îndeplinite pentru ca regula să se aplice.',
|
||||
'tagging-rules.form.conditions.description': 'Definește condițiile care trebuie îndeplinite pentru ca regula să se aplice. Fără condiții înseamnă că regula se va aplica tuturor documentelor',
|
||||
'tagging-rules.form.conditions.add-condition': 'Adaugă condiție',
|
||||
'tagging-rules.form.conditions.connector.when': 'Când',
|
||||
'tagging-rules.form.conditions.connector.and': 'și că',
|
||||
'tagging-rules.form.conditions.connector.or': 'sau că',
|
||||
'tagging-rules.condition-match-mode.all': 'Toate condițiile trebuie îndeplinite',
|
||||
'tagging-rules.condition-match-mode.any': 'Orice condiție trebuie îndeplinită',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Nicio condiție',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Nu ai adăugat nicio condiție acestei reguli. Această regula va aplica etichetele sale tuturor documentelor.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplică regula fara condiții',
|
||||
@@ -379,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Nu s-a putut actualiza regula de etichetare',
|
||||
'tagging-rules.update.submit': 'Actualizează regula',
|
||||
'tagging-rules.update.cancel': 'Anulează',
|
||||
'tagging-rules.apply.button': 'Aplicați la documente existente',
|
||||
'tagging-rules.apply.confirm.title': 'Aplicați regula la documente existente?',
|
||||
'tagging-rules.apply.confirm.description': 'Aceasta va verifica toate documentele existente din organizația dvs. și va aplica etichetele unde condițiile corespund. Procesarea va avea loc în fundal.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicați regula',
|
||||
'tagging-rules.apply.success': 'Aplicarea regulii a fost pornită în fundal',
|
||||
'tagging-rules.apply.error': 'Eroare la pornirea aplicării regulii',
|
||||
'tagging-rules.apply.processing': 'Se pornește...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -417,6 +461,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// API keys
|
||||
|
||||
'api-keys.permissions.select-all': 'Selectează tot',
|
||||
'api-keys.permissions.deselect-all': 'Deselectează tot',
|
||||
'api-keys.permissions.organizations.title': 'Organizații',
|
||||
'api-keys.permissions.organizations.organizations:create': 'Creează organizații',
|
||||
'api-keys.permissions.organizations.organizations:read': 'Citește organizații',
|
||||
'api-keys.permissions.organizations.organizations:update': 'Actualizează organizații',
|
||||
'api-keys.permissions.organizations.organizations:delete': 'Șterge organizații',
|
||||
'api-keys.permissions.documents.title': 'Documente',
|
||||
'api-keys.permissions.documents.documents:create': 'Creează documente',
|
||||
'api-keys.permissions.documents.documents:read': 'Citește documente',
|
||||
@@ -512,11 +563,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'layout.menu.settings': 'Setări',
|
||||
'layout.menu.account': 'Cont',
|
||||
'layout.menu.general-settings': 'Setări generale',
|
||||
'layout.menu.usage': 'Utilizare',
|
||||
'layout.menu.intake-emails': 'Email-uri de primire',
|
||||
'layout.menu.webhooks': 'Webhook-uri',
|
||||
'layout.menu.members': 'Membri',
|
||||
'layout.menu.invitations': 'Invitații',
|
||||
|
||||
'layout.upgrade-cta.title': 'Ai nevoie de mai mult spațiu?',
|
||||
'layout.upgrade-cta.description': 'Obține de 10x mai mult spațiu de stocare + colaborare în echipă',
|
||||
'layout.upgrade-cta.button': 'Actualizează la Plus',
|
||||
|
||||
'layout.theme.light': 'Mod luminos',
|
||||
'layout.theme.dark': 'Mod intunecat',
|
||||
'layout.theme.system': 'Modul sistemului',
|
||||
@@ -540,7 +596,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// API errors
|
||||
|
||||
'api-errors.document.already_exists': 'Documentul există deja',
|
||||
'api-errors.document.file_too_big': 'Fișierul documentului este prea mare',
|
||||
'api-errors.document.size_too_large': 'Fișierul este prea mare',
|
||||
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
|
||||
'api-errors.intake_email.limit_reached': 'Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.',
|
||||
'api-errors.user.max_organization_count_reached': 'Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.',
|
||||
'api-errors.default': 'A apărut o eroare la procesarea cererii.',
|
||||
@@ -551,6 +608,33 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.tags.already_exists': 'O etichetă cu acest nume există deja pentru aceasta organizație',
|
||||
'api-errors.internal.error': 'A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.',
|
||||
'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.',
|
||||
// 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',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Eroare la crearea sesiunii',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Eroare la actualizarea utilizatorului',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Eroare la obținerea sesiunii',
|
||||
'api-errors.INVALID_PASSWORD': 'Parolă invalidă',
|
||||
'api-errors.INVALID_EMAIL': 'Email invalid',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Email-ul sau parola este incorectă, sau contul nu există.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Contul social este deja asociat',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Furnizorul nu a fost găsit',
|
||||
'api-errors.INVALID_TOKEN': 'Token invalid',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID nu este suportat',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Eroare la obținerea informațiilor utilizatorului',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email-ul utilizatorului nu a fost găsit',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email-ul nu este verificat',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Parolă prea scurtă',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Parolă prea lungă',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Există deja un utilizator cu acest email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email-ul nu poate fi actualizat',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Contul de autentificare nu a fost găsit',
|
||||
'api-errors.SESSION_EXPIRED': 'Sesiunea a expirat',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Eroare la disocierea ultimului cont',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Contul nu a fost găsit',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Utilizatorul are deja o parolă',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -574,4 +658,56 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'color-picker.select-color': 'Selectează culoarea',
|
||||
'color-picker.select-a-color': 'Selectează o culoare',
|
||||
|
||||
// Subscriptions
|
||||
|
||||
'subscriptions.checkout-success.title': 'Plată reușită!',
|
||||
'subscriptions.checkout-success.description': 'Abonamentul tău a fost activat cu succes.',
|
||||
'subscriptions.checkout-success.thank-you': 'Mulțumim pentru că ai făcut upgrade la Papra Plus. Acum ai acces la toate funcționalitățile premium.',
|
||||
'subscriptions.checkout-success.go-to-organizations': 'Mergi la Organizații',
|
||||
'subscriptions.checkout-success.redirecting': 'Redirecționare în {{ count }} secundă{{ plural }}...',
|
||||
|
||||
'subscriptions.checkout-cancel.title': 'Plată anulată',
|
||||
'subscriptions.checkout-cancel.description': 'Upgrade-ul abonamentului tău a fost anulat.',
|
||||
'subscriptions.checkout-cancel.no-charges': 'Nu au fost efectuate taxe pe contul tău. Poți încerca din nou oricând ești gata.',
|
||||
'subscriptions.checkout-cancel.back-to-organizations': 'Înapoi la Organizații',
|
||||
'subscriptions.checkout-cancel.need-help': 'Ai nevoie de ajutor?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contactează asistența',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Upgrade la Plus',
|
||||
'subscriptions.upgrade-dialog.description': 'Deblochează funcționalități puternice pentru organizația ta',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contactează-ne',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'dacă ai nevoie de planuri enterprise personalizate.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan curent',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomandat',
|
||||
'subscriptions.upgrade-dialog.per-month': '/lună',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturat anual',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade acum',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Ofertă pe durată limitată',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Obțineți {{ percent }}% reducere pe organizație la toate planurile pentru totdeauna ca early adopter! Oferta expiră în {{ days, >1:{days} zile, =1:1 zi, mai puțin de o zi }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuit',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dimensiune stocare documente',
|
||||
'subscriptions.features.members': 'Membri ai organizației',
|
||||
'subscriptions.features.members-count': '{{ count }} membri',
|
||||
'subscriptions.features.email-intakes': 'Email-uri de primire',
|
||||
'subscriptions.features.email-intakes-count-singular': '{{ count }} adresă',
|
||||
'subscriptions.features.email-intakes-count-plural': '{{ count }} adrese',
|
||||
'subscriptions.features.max-upload-size': 'Dimensiune maximă fișier upload',
|
||||
'subscriptions.features.support': 'Asistență',
|
||||
'subscriptions.features.support-community': 'Asistență comunitate',
|
||||
'subscriptions.features.support-email': 'Asistență email',
|
||||
'subscriptions.features.support-priority': 'Asistență prioritară',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Lunar',
|
||||
'subscriptions.billing-interval.annual': 'Anual',
|
||||
|
||||
'subscriptions.usage-warning.message': 'Ai folosit {{ percent }}% din spațiul de stocare pentru documente. Ia în considerare actualizarea planului pentru a obține mai mult spațiu.',
|
||||
'subscriptions.usage-warning.upgrade-button': 'Actualizează planul',
|
||||
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Tastează "{{ text }}" pentru confirmare',
|
||||
};
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
// } as const;
|
||||
|
||||
export const API_KEY_PERMISSIONS = [
|
||||
{
|
||||
section: 'organizations',
|
||||
permissions: [
|
||||
'organizations:create',
|
||||
'organizations:read',
|
||||
'organizations:update',
|
||||
'organizations:delete',
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'documents',
|
||||
permissions: [
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Component } from 'solid-js';
|
||||
import type { TranslationKeys } from '@/modules/i18n/locales.types';
|
||||
import { createSignal, For } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
|
||||
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
|
||||
|
||||
@@ -42,34 +43,78 @@ export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChang
|
||||
props.onChange(permissions());
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<For each={getPermissionsSections()}>
|
||||
{section => (
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">{section.title}</p>
|
||||
const toggleSection = (sectionName: typeof API_KEY_PERMISSIONS[number]['section']) => {
|
||||
const section = API_KEY_PERMISSIONS.find(s => s.section === sectionName);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
<div class="pl-4 flex flex-col gap-4 mt-4">
|
||||
<For each={section.permissions}>
|
||||
{permission => (
|
||||
<Checkbox
|
||||
class="flex items-center gap-2"
|
||||
checked={isPermissionSelected(permission.name)}
|
||||
onChange={() => togglePermission(permission.name)}
|
||||
>
|
||||
<CheckboxControl />
|
||||
<div class="flex flex-col gap-1">
|
||||
<CheckboxLabel class="text-sm leading-none">
|
||||
{permission.description}
|
||||
</CheckboxLabel>
|
||||
</div>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
const sectionPermissions: ReadonlyArray<string> = section.permissions;
|
||||
const currentPermissions = permissions();
|
||||
|
||||
const allSelected = sectionPermissions.every(p => currentPermissions.includes(p));
|
||||
|
||||
setPermissions((prev) => {
|
||||
if (allSelected) {
|
||||
return [...prev.filter(p => !sectionPermissions.includes(p))];
|
||||
}
|
||||
|
||||
return [...new Set([...prev, ...sectionPermissions])];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 pb-8 border rounded-md mt-2">
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<For each={getPermissionsSections()}>
|
||||
{section => (
|
||||
<div>
|
||||
<Button variant="link" class="text-muted-foreground text-xs p-0 h-auto hover:no-underline" onClick={() => toggleSection(section.section)}>{section.title}</Button>
|
||||
|
||||
<div class="pl-4 flex flex-col mt-2">
|
||||
<For each={section.permissions}>
|
||||
{permission => (
|
||||
<Checkbox
|
||||
class="flex items-center gap-2"
|
||||
checked={isPermissionSelected(permission.name)}
|
||||
onChange={() => togglePermission(permission.name)}
|
||||
>
|
||||
<CheckboxControl />
|
||||
<div class="flex flex-col gap-1">
|
||||
<CheckboxLabel class="text-sm leading-none py-1">
|
||||
{permission.description}
|
||||
</CheckboxLabel>
|
||||
</div>
|
||||
</Checkbox>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-6 border-t pt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="disabled:(op-100! border-op-50 text-muted-foreground)"
|
||||
onClick={() => setPermissions(API_KEY_PERMISSIONS.flatMap(section => section.permissions))}
|
||||
disabled={permissions().length === API_KEY_PERMISSIONS.flatMap(section => section.permissions).length}
|
||||
>
|
||||
{t('api-keys.permissions.select-all')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="disabled:(op-100! border-op-50 text-muted-foreground)"
|
||||
onClick={() => setPermissions([])}
|
||||
disabled={permissions().length === 0}
|
||||
>
|
||||
{t('api-keys.permissions.deselect-all')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Component } from 'solid-js';
|
||||
import type { ApiKey } from '../api-keys.types';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { format } from 'date-fns';
|
||||
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
@@ -13,7 +12,7 @@ import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
|
||||
|
||||
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
||||
const { t } = useI18n();
|
||||
const { t, formatRelativeTime, formatDate } = useI18n();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const deleteApiKeyMutation = useMutation(() => ({
|
||||
@@ -57,15 +56,15 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{/* <p class="text-muted-foreground text-xs">
|
||||
{t('api-keys.list.card.last-used')}
|
||||
{' '}
|
||||
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : t('api-keys.list.card.never')}
|
||||
</p> */}
|
||||
<p class="text-muted-foreground text-xs" title={formatDate(apiKey.createdAt, { dateStyle: 'short', timeStyle: 'long' })}>
|
||||
{t('api-keys.list.card.created')}
|
||||
{' '}
|
||||
{format(apiKey.createdAt, 'MMM d, yyyy')}
|
||||
{formatRelativeTime(apiKey.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -96,9 +96,7 @@ export const CreateApiKeyPage: Component = () => {
|
||||
<div>
|
||||
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
|
||||
|
||||
<div class="p-6 pb-8 border rounded-md mt-2">
|
||||
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
|
||||
</div>
|
||||
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
|
||||
|
||||
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
|
||||
</div>
|
||||
|
||||
@@ -10,3 +10,7 @@ export const ssoProviders = [
|
||||
icon: 'i-tabler-brand-github',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const authPagesPaths = {
|
||||
emailVerification: '/email-verification',
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Config } from '../config/config';
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { get } from '../shared/utils/get';
|
||||
import { ssoProviders } from './auth.constants';
|
||||
|
||||
export function isAuthErrorWithCode({ error, code }: { error: unknown; code: string }) {
|
||||
return get(error, 'code') === code;
|
||||
return get(error, ['code']) === code;
|
||||
}
|
||||
|
||||
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
|
||||
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
|
||||
const enabledSsoProviders: SsoProviderConfig[] = [
|
||||
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
|
||||
...ssoProviders.filter(({ key }) => config.auth.providers[key]?.isEnabled),
|
||||
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
|
||||
key: providerId,
|
||||
name: providerName,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SsoProviderConfig } from './auth.types';
|
||||
import { genericOAuthClient } from 'better-auth/client/plugins';
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { queryClient } from '../shared/query/query-client';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
import { createDemoAuthClient } from './auth.demo.services';
|
||||
|
||||
@@ -28,6 +29,8 @@ export function createAuthClient() {
|
||||
const result = await client.signOut();
|
||||
trackingServices.reset();
|
||||
|
||||
queryClient.clear();
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, useSearchParams } from '@solidjs/router';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
|
||||
export const EmailVerificationPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const getHasError = () => Boolean(searchParams.error);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
||||
<div class="max-w-xs w-full flex flex-col items-center text-center">
|
||||
{getHasError()
|
||||
? (
|
||||
<>
|
||||
<div class="i-tabler-alert-circle size-12 text-destructive mb-2" />
|
||||
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.email-verification.error.title')}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.email-verification.error.description')}
|
||||
</p>
|
||||
|
||||
<Button as={A} href="/login" class="gap-2" variant="secondary">
|
||||
<div class="i-tabler-arrow-left size-4" />
|
||||
{t('auth.email-verification.error.back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-circle-check size-12 text-primary mb-2" />
|
||||
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.email-verification.success.title')}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.email-verification.success.description')}
|
||||
</p>
|
||||
|
||||
<Button as={A} href="/login" class="gap-2">
|
||||
{t('auth.email-verification.success.login')}
|
||||
<div class="i-tabler-arrow-right size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -12,6 +13,7 @@ import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/component
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { authPagesPaths } from '../auth.constants';
|
||||
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
|
||||
import { authWithProvider, signIn } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
@@ -26,7 +28,13 @@ export const EmailLoginForm: Component = () => {
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ email, password, rememberMe }) => {
|
||||
const { error } = await signIn.email({ email, password, rememberMe, callbackURL: config.baseUrl });
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
// This URL is where the user will be redirected after email verification
|
||||
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
|
||||
});
|
||||
|
||||
if (isEmailVerificationRequiredError({ error })) {
|
||||
navigate('/email-validation-required');
|
||||
@@ -35,6 +43,8 @@ export const EmailLoginForm: Component = () => {
|
||||
if (error) {
|
||||
throw createI18nApiError({ error });
|
||||
}
|
||||
|
||||
// If all good guard will redirect to dashboard
|
||||
},
|
||||
schema: v.object({
|
||||
email: v.pipe(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -11,6 +12,7 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { authPagesPaths } from '../auth.constants';
|
||||
import { getEnabledSsoProviderConfigs } from '../auth.models';
|
||||
import { authWithProvider, signUp } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
@@ -29,7 +31,8 @@ export const EmailRegisterForm: Component = () => {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
callbackURL: config.baseUrl,
|
||||
// This URL is where the user will be redirected after email verification
|
||||
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { Accessor, ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
||||
import { getDocumentIcon } from '../documents/document.models';
|
||||
import { searchDocuments } from '../documents/documents.services';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
import { cn } from '../shared/style/cn';
|
||||
import { debounce } from '../shared/utils/timing';
|
||||
import { useThemeStore } from '../theme/theme.store';
|
||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Config, RuntimePublicConfig } from './config';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { merge } from 'lodash-es';
|
||||
import { createContext, Match, Switch, useContext } from 'solid-js';
|
||||
import { deepMerge } from '../shared/utils/object';
|
||||
import { Button } from '../ui/components/button';
|
||||
import { EmptyState } from '../ui/components/empty';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
@@ -27,10 +27,11 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchPublicConfig,
|
||||
refetchOnWindowFocus: false,
|
||||
}));
|
||||
|
||||
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {
|
||||
return merge({}, buildTimeConfig, runtimeConfig);
|
||||
return deepMerge(buildTimeConfig, runtimeConfig);
|
||||
};
|
||||
|
||||
const retry = async () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ const asString = <T extends string | undefined>(value: string | undefined, defau
|
||||
const asNumber = <T extends number | undefined>(value: string | undefined, defaultValue?: T): T extends undefined ? number | undefined : number => (value === undefined ? defaultValue : Number(value)) as T extends undefined ? number | undefined : number;
|
||||
|
||||
export const buildTimeConfig = {
|
||||
papraVersion: asString(import.meta.env.VITE_PAPRA_VERSION, '0.0.0'),
|
||||
baseUrl: asString(import.meta.env.VITE_BASE_URL, window.location.origin),
|
||||
baseApiUrl: asString(import.meta.env.VITE_BASE_API_URL, window.location.origin),
|
||||
vitrineBaseUrl: asString(import.meta.env.VITE_VITRINE_BASE_URL, 'http://localhost:3000/'),
|
||||
@@ -29,6 +28,9 @@ export const buildTimeConfig = {
|
||||
documents: {
|
||||
deletedDocumentsRetentionDays: asNumber(import.meta.env.VITE_DOCUMENTS_DELETED_DOCUMENTS_RETENTION_DAYS, 30),
|
||||
},
|
||||
organizations: {
|
||||
deletedOrganizationsPurgeDaysDelay: asNumber(import.meta.env.VITE_ORGANIZATIONS_DELETED_PURGE_DAYS_DELAY, 30),
|
||||
},
|
||||
posthog: {
|
||||
apiKey: asString(import.meta.env.VITE_POSTHOG_API_KEY),
|
||||
host: asString(import.meta.env.VITE_POSTHOG_HOST),
|
||||
@@ -41,4 +43,4 @@ export const buildTimeConfig = {
|
||||
} as const;
|
||||
|
||||
export type Config = typeof buildTimeConfig;
|
||||
export type RuntimePublicConfig = Pick<Config, 'auth'>;
|
||||
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'intakeEmails' | 'organizations'>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import type { Webhook } from '../webhooks/webhooks.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { createRouter } from 'radix3';
|
||||
import { get } from '../shared/utils/get';
|
||||
import { defineHandler } from './demo-api-mock.models';
|
||||
import {
|
||||
apiKeyStorage,
|
||||
@@ -94,7 +94,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
path: '/api/organizations/:organizationId/documents',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId }, query }) => {
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && !document.deletedAt);
|
||||
@@ -197,7 +197,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
searchQuery: rawSearchQuery = '',
|
||||
} = query ?? {};
|
||||
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
|
||||
@@ -221,7 +221,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
path: '/api/organizations/:organizationId/documents/deleted',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const deletedDocuments = await findMany(
|
||||
@@ -341,9 +341,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const tag = {
|
||||
id: createId({ prefix: 'tag' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
color: get(body, 'color'),
|
||||
description: get(body, 'description'),
|
||||
name: get(body, ['name']) as string,
|
||||
color: get(body, ['color']) as string,
|
||||
description: (get(body, ['description']) ?? null) as string | null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -396,7 +396,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tagId = get(body, 'tagId');
|
||||
const tagId = get(body, ['tagId']) as string;
|
||||
|
||||
assert(tagId, { status: 400 });
|
||||
|
||||
@@ -441,7 +441,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
handler: async ({ body }) => {
|
||||
const organization = {
|
||||
id: createId({ prefix: 'org' }),
|
||||
name: get(body, 'name'),
|
||||
name: get(body, ['name']) as string,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -480,7 +480,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
organization.name = get(body, 'name');
|
||||
organization.name = get(body, ['name']) as string;
|
||||
organization.updatedAt = new Date();
|
||||
|
||||
await organizationStorage.setItem(organizationId, organization);
|
||||
@@ -506,10 +506,10 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const taggingRule = {
|
||||
id: createId({ prefix: 'tr' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
description: get(body, 'description'),
|
||||
conditions: get(body, 'conditions'),
|
||||
actions: get(body, 'tagIds').map((tagId: string) => ({ tagId })),
|
||||
name: get(body, ['name']) as string,
|
||||
description: (get(body, ['description']) ?? '') as string,
|
||||
conditions: get(body, ['conditions']) as any,
|
||||
actions: (get(body, ['tagIds']) as string[]).map((tagId: string) => ({ tagId })),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -641,11 +641,11 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
const apiKey = {
|
||||
id: createId({ prefix: 'apiKey' }),
|
||||
name: get(body, 'name'),
|
||||
permissions: get(body, 'permissions'),
|
||||
organizationIds: get(body, 'organizationIds'),
|
||||
allOrganizations: get(body, 'allOrganizations'),
|
||||
expiresAt: get(body, 'expiresAt'),
|
||||
name: get(body, ['name']),
|
||||
permissions: get(body, ['permissions']),
|
||||
organizationIds: get(body, ['organizationIds']),
|
||||
allOrganizations: get(body, ['allOrganizations']),
|
||||
expiresAt: get(body, ['expiresAt']),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
prefix: token.slice(0, 11),
|
||||
@@ -694,10 +694,10 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const webhook: Webhook = {
|
||||
id: createId({ prefix: 'webhook' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
url: get(body, 'url'),
|
||||
name: get(body, ['name']) as string,
|
||||
url: get(body, ['url']) as string,
|
||||
enabled: true,
|
||||
events: get(body, 'events'),
|
||||
events: get(body, ['events']) as Webhook['events'],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -761,6 +761,72 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
return { document: newDocument };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/subscription',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
// Demo mode uses free plan with no subscription
|
||||
return {
|
||||
subscription: null,
|
||||
plan: {
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 500, // 500 MiB
|
||||
maxIntakeEmailsCount: 1,
|
||||
maxOrganizationsMembersCount: 3,
|
||||
maxFileSize: 1024 * 1024 * 50, // 50 MiB
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/usage',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId);
|
||||
|
||||
const totalDocumentsSize = documents.reduce((acc, doc) => acc + (doc.originalSize ?? 0), 0);
|
||||
const deletedDocumentsSize = documents
|
||||
.filter(doc => doc.deletedAt)
|
||||
.reduce((acc, doc) => acc + (doc.originalSize ?? 0), 0);
|
||||
|
||||
return {
|
||||
usage: {
|
||||
documentsStorage: {
|
||||
used: totalDocumentsSize,
|
||||
deleted: deletedDocumentsSize,
|
||||
limit: 1024 * 1024 * 500, // 500 MiB
|
||||
},
|
||||
intakeEmailsCount: {
|
||||
used: 0,
|
||||
limit: 1,
|
||||
},
|
||||
membersCount: {
|
||||
used: 1,
|
||||
limit: 3,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 500, // 500 MiB
|
||||
maxIntakeEmailsCount: 1,
|
||||
maxOrganizationsMembersCount: 3,
|
||||
maxFileSize: 1024 * 1024 * 50, // 50 MiB
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });
|
||||
|
||||
@@ -2,22 +2,24 @@ import type { ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createContext, createSignal, For, Match, Show, Switch, useContext } from 'solid-js';
|
||||
import { Portal } from 'solid-js/web';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { throttle } from '@/modules/shared/utils/timing';
|
||||
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { invalidateOrganizationDocumentsQuery } from '../documents.composables';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
|
||||
const DocumentUploadContext = createContext<{
|
||||
uploadDocuments: (args: { files: File[]; organizationId: string }) => Promise<void>;
|
||||
uploadDocuments: (args: { files: File[] }) => Promise<void>;
|
||||
}>();
|
||||
|
||||
export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: () => string }) {
|
||||
export function useDocumentUpload() {
|
||||
const context = useContext(DocumentUploadContext);
|
||||
|
||||
if (!context) {
|
||||
@@ -27,11 +29,11 @@ export function useDocumentUpload({ getOrganizationId }: { getOrganizationId: ()
|
||||
const { uploadDocuments } = context;
|
||||
|
||||
return {
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files, organizationId: getOrganizationId() }),
|
||||
uploadDocuments: async ({ files }: { files: File[] }) => uploadDocuments({ files }),
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files, organizationId: getOrganizationId() });
|
||||
await uploadDocuments({ files });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -53,7 +55,7 @@ type Task = TaskSuccess | TaskError | {
|
||||
status: 'pending' | 'uploading';
|
||||
};
|
||||
|
||||
export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
export const DocumentUploadProvider: ParentComponent<{ organizationId: string }> = (props) => {
|
||||
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
const { t } = useI18n();
|
||||
@@ -65,14 +67,33 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
setTasks(tasks => tasks.map(task => task.file === args.file ? { ...task, ...args } : task));
|
||||
};
|
||||
|
||||
const uploadDocuments = async ({ files, organizationId }: { files: File[]; organizationId: string }) => {
|
||||
const organizationLimitsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', props.organizationId, 'subscription'],
|
||||
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
|
||||
refetchOnWindowFocus: false,
|
||||
}));
|
||||
|
||||
const uploadDocuments = async ({ files }: { files: File[] }) => {
|
||||
setTasks(tasks => [...tasks, ...files.map(file => ({ file, status: 'pending' } as const))]);
|
||||
setState('open');
|
||||
|
||||
if (!organizationLimitsQuery.data) {
|
||||
await organizationLimitsQuery.promise;
|
||||
}
|
||||
|
||||
// Optimistic prevent upload if file is too large, the server will still validate it
|
||||
const maxUploadSize = organizationLimitsQuery.data?.plan.limits.maxFileSize;
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
updateTaskStatus({ file, status: 'uploading' });
|
||||
|
||||
const [result, error] = await safely(uploadDocument({ file, organizationId }));
|
||||
// maxUploadSize can also be null when self hosting which means no limit
|
||||
if (maxUploadSize && file.size > maxUploadSize) {
|
||||
updateTaskStatus({ file, status: 'error', error: Object.assign(new Error('File too large'), { code: 'document.size_too_large' }) });
|
||||
return;
|
||||
}
|
||||
|
||||
const [result, error] = await safely(uploadDocument({ file, organizationId: props.organizationId }));
|
||||
|
||||
if (error) {
|
||||
updateTaskStatus({ file, status: 'error', error });
|
||||
@@ -82,7 +103,7 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
|
||||
updateTaskStatus({ file, status: 'success', document });
|
||||
}
|
||||
|
||||
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
|
||||
throttledInvalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { promptUploadFiles } from '@/modules/shared/files/upload';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { uploadDocument } from '../documents.services';
|
||||
import { useDocumentUpload } from './document-import-status.component';
|
||||
|
||||
export const DocumentUploadArea: Component<{ organizationId?: string }> = (props) => {
|
||||
export const DocumentUploadArea: Component = () => {
|
||||
const [isDragging, setIsDragging] = createSignal(false);
|
||||
const params = useParams();
|
||||
|
||||
const getOrganizationId = () => props.organizationId ?? params.organizationId;
|
||||
|
||||
const uploadFiles = async ({ files }: { files: File[] }) => {
|
||||
for (const file of files) {
|
||||
await uploadDocument({ file, organizationId: getOrganizationId() });
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['organizations', getOrganizationId(), 'documents'],
|
||||
refetchType: 'all',
|
||||
});
|
||||
};
|
||||
|
||||
const promptImport = async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
await uploadFiles({ files });
|
||||
};
|
||||
const { promptImport, uploadDocuments } = useDocumentUpload();
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -46,7 +26,7 @@ export const DocumentUploadArea: Component<{ organizationId?: string }> = (props
|
||||
}
|
||||
|
||||
const files = [...event.dataTransfer.files].filter(file => file.type === 'application/pdf');
|
||||
await uploadFiles({ files });
|
||||
await uploadDocuments({ files });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { ColumnDef } from '@tanstack/solid-table';
|
||||
import type { Accessor, Component, Setter } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
@@ -7,13 +6,12 @@ import { formatBytes } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
|
||||
import { DocumentManagementDropdown } from './document-management-dropdown.component';
|
||||
|
||||
@@ -25,13 +23,13 @@ type Pagination = {
|
||||
export const createdAtColumn: ColumnDef<Document> = {
|
||||
header: () => (<span class="hidden sm:block">Created at</span>),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
cell: data => <RelativeTime class="text-muted-foreground hidden sm:block" date={data.getValue<Date>()} />,
|
||||
};
|
||||
|
||||
export const deletedAtColumn: ColumnDef<Document> = {
|
||||
header: () => (<span class="hidden sm:block">Deleted at</span>),
|
||||
accessorKey: 'deletedAt',
|
||||
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
cell: data => <RelativeTime class="text-muted-foreground hidden sm:block" date={data.getValue<Date>()} />,
|
||||
};
|
||||
|
||||
export const standardActionsColumn: ColumnDef<Document> = {
|
||||
@@ -92,17 +90,7 @@ export const DocumentsPaginatedList: Component<{
|
||||
{' '}
|
||||
-
|
||||
{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger as={(tooltipProps: TooltipTriggerProps) => (
|
||||
<span {...tooltipProps}>
|
||||
{timeAgo({ date: data.row.original.createdAt })}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{data.row.original.createdAt.toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<RelativeTime date={data.row.original.createdAt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { icons as tablerIconSet } from '@iconify-json/tabler';
|
||||
import { values } from 'lodash-es';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getDaysBeforePermanentDeletion, getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension, iconByFileType } from './document.models';
|
||||
|
||||
describe('files models', () => {
|
||||
describe('iconByFileType', () => {
|
||||
const icons = values(iconByFileType);
|
||||
const icons = Object.values(iconByFileType);
|
||||
|
||||
test('they must at least have the default icon', () => {
|
||||
expect(iconByFileType['*']).toBeDefined();
|
||||
@@ -100,6 +99,98 @@ describe('files models', () => {
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(undefined);
|
||||
});
|
||||
|
||||
test('returns 0 when the permanent deletion date is today', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-31');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(0);
|
||||
});
|
||||
|
||||
test('returns negative days when the permanent deletion date has passed', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-02-15');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(-15);
|
||||
});
|
||||
|
||||
test('handles deletion that happened on the same day (considers time of day)', () => {
|
||||
const document = { deletedAt: new Date('2021-01-10T08:00:00') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-10T14:00:00');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
// Since differenceInDays counts full days, and there's only 6 hours difference,
|
||||
// the permanent deletion date (30 days from 08:00) is 29 full days from 14:00
|
||||
expect(daysBeforeDeletion).to.eql(29);
|
||||
});
|
||||
|
||||
test('handles very short retention periods', () => {
|
||||
const document = { deletedAt: new Date('2021-01-10') };
|
||||
const deletedDocumentsRetentionDays = 1;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(1);
|
||||
});
|
||||
|
||||
test('handles very long retention periods', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01') };
|
||||
const deletedDocumentsRetentionDays = 365;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(356);
|
||||
});
|
||||
|
||||
test('handles zero retention days (immediate deletion)', () => {
|
||||
const document = { deletedAt: new Date('2021-01-10') };
|
||||
const deletedDocumentsRetentionDays = 0;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(0);
|
||||
});
|
||||
|
||||
test('handles dates across year boundaries', () => {
|
||||
const document = { deletedAt: new Date('2020-12-20') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-05');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(14);
|
||||
});
|
||||
|
||||
test('handles dates across leap year February', () => {
|
||||
const document = { deletedAt: new Date('2020-02-15') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2020-02-20');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(25);
|
||||
});
|
||||
|
||||
test('handles timestamp precision with hours and minutes', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01T23:59:59') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-02T00:00:01');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(29);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentNameWithoutExtension', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DocumentActivityEvent } from './documents.types';
|
||||
import { addDays, differenceInDays } from 'date-fns';
|
||||
import { IN_MS } from '../shared/utils/units';
|
||||
|
||||
export const iconByFileType = {
|
||||
'*': 'i-tabler-file',
|
||||
@@ -44,9 +44,12 @@ export function getDaysBeforePermanentDeletion({ document, deletedDocumentsReten
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deletionDate = addDays(document.deletedAt, deletedDocumentsRetentionDays);
|
||||
// Calculate the permanent deletion date by adding retention days to the deleted date
|
||||
const deletionDate = new Date(document.deletedAt);
|
||||
deletionDate.setDate(deletionDate.getDate() + deletedDocumentsRetentionDays);
|
||||
|
||||
const daysBeforeDeletion = differenceInDays(deletionDate, now);
|
||||
// Calculate the difference in milliseconds and convert to days
|
||||
const daysBeforeDeletion = Math.floor((deletionDate.getTime() - now.getTime()) / IN_MS.DAY);
|
||||
|
||||
return daysBeforeDeletion;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { Document } from './documents.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { useConfirmModal } from '../shared/confirm';
|
||||
import { promptUploadFiles } from '../shared/files/upload';
|
||||
import { isHttpErrorWithCode } from '../shared/http/http-errors';
|
||||
import { queryClient } from '../shared/query/query-client';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
import { deleteDocument, restoreDocument, uploadDocument } from './documents.services';
|
||||
import { deleteDocument, restoreDocument } from './documents.services';
|
||||
|
||||
export function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
|
||||
return queryClient.invalidateQueries({
|
||||
@@ -76,57 +72,3 @@ export function useRestoreDocument() {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toastUploadError({ error, file }: { error: Error; file: File }) {
|
||||
if (isHttpErrorWithCode({ error, code: 'document.already_exists' })) {
|
||||
createToast({
|
||||
type: 'error',
|
||||
message: 'Document already exists',
|
||||
description: `The document ${file.name} already exists, it has not been uploaded.`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'document.file_too_big' })) {
|
||||
createToast({
|
||||
type: 'error',
|
||||
message: 'Document too big',
|
||||
description: `The document ${file.name} is too big, it has not been uploaded.`,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
createToast({
|
||||
type: 'error',
|
||||
message: 'Failed to upload document',
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadDocuments({ organizationId }: { organizationId: string }) {
|
||||
const uploadDocuments = async ({ files }: { files: File[] }) => {
|
||||
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);
|
||||
|
||||
await Promise.all(files.map(async (file) => {
|
||||
const [, error] = await safely(uploadDocument({ file, organizationId }));
|
||||
|
||||
if (error) {
|
||||
toastUploadError({ error, file });
|
||||
}
|
||||
|
||||
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
uploadDocuments,
|
||||
promptImport: async () => {
|
||||
const { files } = await promptUploadFiles();
|
||||
|
||||
await uploadDocuments({ files });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,7 +168,16 @@ export async function searchDocuments({
|
||||
}
|
||||
|
||||
export async function getOrganizationDocumentsStats({ organizationId }: { organizationId: string }) {
|
||||
const { organizationStats } = await apiClient<{ organizationStats: { documentsCount: number; documentsSize: number } }>({
|
||||
const { organizationStats } = await apiClient<{
|
||||
organizationStats: {
|
||||
documentsCount: number;
|
||||
documentsSize: number;
|
||||
deletedDocumentsSize: number;
|
||||
deletedDocumentsCount: number;
|
||||
totalDocumentsCount: number;
|
||||
totalDocumentsSize: number;
|
||||
};
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents/statistics`,
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useParams } from '@solidjs/router';
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
@@ -199,7 +199,7 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
<div class="text-muted-foreground hidden sm:block">
|
||||
{t('documents.deleted.deleted-at')}
|
||||
{' '}
|
||||
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
|
||||
<RelativeTime class="text-foreground font-bold" date={data.row.original.deletedAt!} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { Component, JSX } from 'solid-js';
|
||||
import type { DocumentActivity } from '../documents.types';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
|
||||
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 { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { downloadFile } from '@/modules/shared/files/download';
|
||||
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
@@ -83,7 +83,7 @@ const ActivityItem: Component<{ activity: DocumentActivity }> = (props) => {
|
||||
</Switch>
|
||||
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span title={props.activity.createdAt.toLocaleString()}>{timeAgo({ date: props.activity.createdAt })}</span>
|
||||
<RelativeTime date={props.activity.createdAt} />
|
||||
<Show when={props.activity.user}>
|
||||
{getUser => (
|
||||
<span>{te('activity.document.user.name', { name: <A href={`/organizations/${params.organizationId}/members`} class="underline hover:text-primary transition">{getUser().name}</A> })}</span>
|
||||
@@ -99,7 +99,7 @@ const tabs = ['info', 'content', 'activity'] as const;
|
||||
type Tab = typeof tabs[number];
|
||||
|
||||
export const DocumentPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const { t, formatRelativeTime } = useI18n();
|
||||
const params = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
@@ -122,17 +122,14 @@ export const DocumentPage: Component = () => {
|
||||
setSearchParams({ tab: getTab() }, { replace: true });
|
||||
});
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
|
||||
queryFn: () => fetchDocument({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
const documentQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
|
||||
queryFn: () => fetchDocument({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const documentFileQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const activityPageSize = 20;
|
||||
@@ -160,14 +157,14 @@ export const DocumentPage: Component = () => {
|
||||
}));
|
||||
|
||||
const deleteDoc = async () => {
|
||||
if (!queries[0].data) {
|
||||
if (!documentQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasDeleted } = await deleteDocument({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
documentName: queries[0].data.document.name,
|
||||
documentName: documentQuery.data.document.name,
|
||||
});
|
||||
|
||||
if (!hasDeleted) {
|
||||
@@ -177,13 +174,13 @@ export const DocumentPage: Component = () => {
|
||||
navigate(`/organizations/${params.organizationId}/documents`);
|
||||
};
|
||||
|
||||
const getDataUrl = () => queries[1].data ? URL.createObjectURL(queries[1].data) : undefined;
|
||||
const getDataUrl = () => documentFileQuery.data ? URL.createObjectURL(documentFileQuery.data) : undefined;
|
||||
|
||||
return (
|
||||
<div class="p-6 flex gap-6 h-full flex-col md:flex-row max-w-7xl mx-auto">
|
||||
<Suspense>
|
||||
<div class="md:flex-1 md:border-r">
|
||||
<Show when={queries[0].data?.document}>
|
||||
<Show when={documentQuery.data?.document}>
|
||||
{getDocument => (
|
||||
<div class="flex gap-4 md:pr-6">
|
||||
<div class="flex-1">
|
||||
@@ -214,15 +211,6 @@ export const DocumentPage: Component = () => {
|
||||
{t('documents.actions.download')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open(getDataUrl()!, '_blank')}
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-eye size-4 mr-2"></div>
|
||||
{t('documents.actions.open-in-new-tab')}
|
||||
</Button>
|
||||
|
||||
{getDocument().isDeleted
|
||||
? (
|
||||
<Button
|
||||
@@ -337,12 +325,12 @@ export const DocumentPage: Component = () => {
|
||||
},
|
||||
{
|
||||
label: t('documents.info.created-at'),
|
||||
value: timeAgo({ date: getDocument().createdAt }),
|
||||
value: formatRelativeTime(getDocument().createdAt),
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.updated-at'),
|
||||
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
value: getDocument().updatedAt ? formatRelativeTime(getDocument().updatedAt!) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
]}
|
||||
@@ -399,7 +387,7 @@ export const DocumentPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-50vh">
|
||||
<Show when={queries[0].data?.document}>
|
||||
<Show when={documentQuery.data?.document}>
|
||||
{getDocument => (
|
||||
<DocumentPreview document={getDocument()} />
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { castArray } from 'lodash-es';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { castArray } from '@/modules/shared/utils/array';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import { DocumentUploadArea } from '../components/document-upload-area.component';
|
||||
@@ -19,37 +18,30 @@ export const DocumentsPage: Component = () => {
|
||||
|
||||
const getFiltererTagIds = () => searchParams.tags ? castArray(searchParams.tags) : [];
|
||||
|
||||
const query = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
filters: {
|
||||
tags: getFiltererTagIds(),
|
||||
},
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
const documentsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
filters: {
|
||||
tags: getFiltererTagIds(),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
const getFilteredTags = () => query[2].data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
|
||||
const tagsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const getFilteredTags = () => tagsQuery.data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
|
||||
const hasFilters = () => getFiltererTagIds().length > 0;
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Suspense>
|
||||
{query[0].data?.documents?.length === 0 && !hasFilters()
|
||||
{documentsQuery.data?.documents?.length === 0 && !hasFilters()
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
@@ -83,15 +75,15 @@ export const DocumentsPage: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
|
||||
<Show when={hasFilters() && documentsQuery.data?.documentsCount === 0}>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
{t('documents.list.no-results')}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
documents={query[0].data?.documents ?? []}
|
||||
documentsCount={query[0].data?.documentsCount ?? 0}
|
||||
documents={documentsQuery.data?.documents ?? []}
|
||||
documentsCount={documentsQuery.data?.documentsCount ?? 0}
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
extraColumns={[
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { CoercibleDate } from '@/modules/shared/date/date.types';
|
||||
import { splitProps } from 'solid-js';
|
||||
import { coerceDate } from '@/modules/shared/date/coerce-date';
|
||||
import { useI18n } from '../i18n.provider';
|
||||
|
||||
export const RelativeTime: Component<{ date: CoercibleDate } & JSX.IntrinsicElements['time']> = (props) => {
|
||||
const [local, rest] = splitProps(props, ['date', 'title', 'dateTime']);
|
||||
const { formatRelativeTime, formatDate } = useI18n();
|
||||
|
||||
return (
|
||||
<time
|
||||
title={local.title ?? formatDate(local.date, { dateStyle: 'short', timeStyle: 'short' })}
|
||||
dateTime={local.dateTime ?? coerceDate(local.date).toISOString()}
|
||||
{...rest}
|
||||
>
|
||||
{formatRelativeTime(local.date)}
|
||||
</time>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createTranslator, findMatchingLocale } from './i18n.models';
|
||||
import { createDateFormatter, createRelativeTimeFormatter, createTranslator, findMatchingLocale } from './i18n.models';
|
||||
|
||||
describe('i18n models', () => {
|
||||
describe('findMatchingLocale', () => {
|
||||
@@ -125,4 +125,99 @@ describe('i18n models', () => {
|
||||
expect(t('hello', { name: 'John' })).to.eql('John, John, John and John!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDateFormatter', () => {
|
||||
test('formats date according to locale, by default in short format', () => {
|
||||
expect(
|
||||
createDateFormatter({ getLocale: () => 'en' })(new Date('2025-01-15')),
|
||||
).to.eql('Jan 15, 2025');
|
||||
|
||||
expect(
|
||||
createDateFormatter({ getLocale: () => 'fr' })(new Date('2025-01-15')),
|
||||
).to.eql('15 janv. 2025');
|
||||
|
||||
expect(
|
||||
createDateFormatter({ getLocale: () => 'pt-BR' })(new Date('2025-01-15')),
|
||||
).to.eql('15 de jan. de 2025');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRelativeTimeFormatter', () => {
|
||||
test('formats relative time according to locale', () => {
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('now');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:00:06Z') }),
|
||||
).to.eql('6 seconds ago');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'fr' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:02:00Z') }),
|
||||
).to.eql('il y a 2 minutes');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'pt-BR' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-03T00:00:00Z') }),
|
||||
).to.eql('anteontem');
|
||||
});
|
||||
|
||||
test('use the best unit for relative time', () => {
|
||||
const formatter = createRelativeTimeFormatter({ getLocale: () => 'en' });
|
||||
const timeAgo = (now: Date) => formatter(new Date('2021-01-01T00:00:00Z'), { now });
|
||||
|
||||
expect(timeAgo(new Date('2021-01-01T00:00:00Z'))).toBe('now');
|
||||
expect(timeAgo(new Date('2021-01-01T00:00:06Z'))).toBe('6 seconds ago');
|
||||
expect(timeAgo(new Date('2021-01-01T00:01:00Z'))).toBe('1 minute ago');
|
||||
expect(timeAgo(new Date('2021-01-01T00:02:00Z'))).toBe('2 minutes ago');
|
||||
expect(timeAgo(new Date('2021-01-01T01:00:00Z'))).toBe('1 hour ago');
|
||||
expect(timeAgo(new Date('2021-01-01T02:00:00Z'))).toBe('2 hours ago');
|
||||
expect(timeAgo(new Date('2021-01-02T00:00:00Z'))).toBe('yesterday');
|
||||
expect(timeAgo(new Date('2021-01-03T00:00:00Z'))).toBe('2 days ago');
|
||||
expect(timeAgo(new Date('2021-02-01T00:00:00Z'))).toBe('last month');
|
||||
expect(timeAgo(new Date('2021-03-02T00:00:00Z'))).toBe('2 months ago');
|
||||
expect(timeAgo(new Date('2022-01-12T00:00:00Z'))).toBe('last year');
|
||||
expect(timeAgo(new Date('2023-01-01T00:00:00Z'))).toBe('2 years ago');
|
||||
});
|
||||
|
||||
test('handles future dates correctly', () => {
|
||||
const formatter = createRelativeTimeFormatter({ getLocale: () => 'en' });
|
||||
const timeUntil = (now: Date) => formatter(new Date('2021-01-01T00:00:00Z'), { now });
|
||||
|
||||
expect(timeUntil(new Date('2020-12-31T23:59:54Z'))).toBe('in 6 seconds');
|
||||
expect(timeUntil(new Date('2020-12-31T23:59:00Z'))).toBe('in 1 minute');
|
||||
expect(timeUntil(new Date('2020-12-31T23:58:00Z'))).toBe('in 2 minutes');
|
||||
expect(timeUntil(new Date('2020-12-31T23:00:00Z'))).toBe('in 1 hour');
|
||||
expect(timeUntil(new Date('2020-12-31T22:00:00Z'))).toBe('in 2 hours');
|
||||
expect(timeUntil(new Date('2020-12-31T00:00:00Z'))).toBe('tomorrow');
|
||||
expect(timeUntil(new Date('2020-12-30T00:00:00Z'))).toBe('in 2 days');
|
||||
expect(timeUntil(new Date('2020-12-01T00:00:00Z'))).toBe('next month');
|
||||
expect(timeUntil(new Date('2020-11-01T00:00:00Z'))).toBe('in 2 months');
|
||||
expect(timeUntil(new Date('2020-01-01T00:00:00Z'))).toBe('next year');
|
||||
expect(timeUntil(new Date('2019-01-01T00:00:00Z'))).toBe('in 2 years');
|
||||
});
|
||||
|
||||
test('formats future dates according to locale', () => {
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:02:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('in 2 minutes');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'fr' })(new Date('2021-01-01T00:02:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('dans 2 minutes');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'pt-BR' })(new Date('2021-01-03T00:00:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('depois de amanhã');
|
||||
});
|
||||
|
||||
test('the date can be a parsable string', () => {
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })('2021-01-01T00:00:00Z', { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('now');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })('2021-01-01', { now: new Date('2021-01-01T00:02:00Z') }),
|
||||
).to.eql('2 minutes ago');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import type { CoercibleDate } from '../shared/date/date.types';
|
||||
import type { Locale } from './i18n.provider';
|
||||
import { createBranchlet } from '@branchlet/core';
|
||||
import { coerceDate } from '../shared/date/coerce-date';
|
||||
import { IN_MS } from '../shared/utils/units';
|
||||
|
||||
// This tries to get the most preferred language compatible with the supported languages
|
||||
// It tries to find a supported language by comparing both region and language, if not, then just language
|
||||
@@ -29,6 +33,8 @@ export function findMatchingLocale({
|
||||
}
|
||||
|
||||
export function createTranslator<Dict extends Record<string, string>>({ getDictionary }: { getDictionary: () => Dict }) {
|
||||
const { parse } = createBranchlet();
|
||||
|
||||
return (key: keyof Dict, args?: Record<string, string | number>) => {
|
||||
const translationFromDictionary = getDictionary()[key];
|
||||
|
||||
@@ -37,11 +43,7 @@ export function createTranslator<Dict extends Record<string, string>>({ getDicti
|
||||
}
|
||||
|
||||
if (args && translationFromDictionary) {
|
||||
return Object.entries(args)
|
||||
.reduce(
|
||||
(acc, [key, value]) => acc.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), String(value)),
|
||||
String(translationFromDictionary),
|
||||
);
|
||||
return parse(translationFromDictionary, args);
|
||||
}
|
||||
|
||||
return translationFromDictionary;
|
||||
@@ -72,3 +74,46 @@ export function createFragmentTranslator<Dict extends Record<string, string>>({
|
||||
return translation;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDateFormatter({ getLocale }: { getLocale: () => string }) {
|
||||
return (date: CoercibleDate, options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }) => {
|
||||
return new Intl.DateTimeFormat(getLocale(), options).format(coerceDate(date));
|
||||
};
|
||||
}
|
||||
|
||||
export function createRelativeTimeFormatter({ getLocale }: { getLocale: () => string }) {
|
||||
return (rawDate: CoercibleDate, { now = new Date(), numeric = 'auto', style = 'long' }: { now?: Date; numeric?: 'auto' | 'always'; style?: 'long' | 'short' } = {}) => {
|
||||
const formatter = new Intl.RelativeTimeFormat(getLocale(), { numeric, style });
|
||||
|
||||
const date = coerceDate(rawDate);
|
||||
const msDiff = now.getTime() - date.getTime();
|
||||
const absDiff = Math.abs(msDiff);
|
||||
const sign = msDiff >= 0 ? -1 : 1;
|
||||
|
||||
if (absDiff < IN_MS.MINUTE) {
|
||||
return formatter.format(sign * Math.round(absDiff / 1_000), 'second');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.HOUR) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.MINUTE), 'minute');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.DAY) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.HOUR), 'hour');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.WEEK) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.DAY), 'day');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.MONTH) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.WEEK), 'week');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.YEAR) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.MONTH), 'month');
|
||||
}
|
||||
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.YEAR), 'year');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { makePersisted } from '@solid-primitives/storage';
|
||||
import { createContext, createEffect, createResource, createSignal, Show, useContext } from 'solid-js';
|
||||
import { translations as defaultTranslations } from '../../locales/en.dictionary';
|
||||
import { locales } from './i18n.constants';
|
||||
import { createFragmentTranslator, createTranslator, findMatchingLocale } from './i18n.models';
|
||||
import { createDateFormatter, createFragmentTranslator, createRelativeTimeFormatter, createTranslator, findMatchingLocale } from './i18n.models';
|
||||
|
||||
export type Locale = typeof locales[number]['key'];
|
||||
|
||||
@@ -14,6 +14,8 @@ const I18nContext = createContext<{
|
||||
getLocale: Accessor<Locale>;
|
||||
setLocale: Setter<Locale>;
|
||||
locales: typeof locales;
|
||||
formatDate: ReturnType<typeof createDateFormatter>;
|
||||
formatRelativeTime: ReturnType<typeof createRelativeTimeFormatter>;
|
||||
}>();
|
||||
|
||||
export function useI18n() {
|
||||
@@ -58,6 +60,8 @@ export const I18nProvider: ParentComponent = (props) => {
|
||||
getLocale,
|
||||
setLocale,
|
||||
locales,
|
||||
formatDate: createDateFormatter({ getLocale }),
|
||||
formatRelativeTime: createRelativeTimeFormatter({ getLocale }),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { translations as defaultTranslations } from '@/locales/en.dictionary';
|
||||
import type { translations as defaultTranslations } from '@/locales/en.dictionary';
|
||||
|
||||
export type TranslationKeys = keyof typeof defaultTranslations;
|
||||
export type TranslationsDictionary = Record<TranslationKeys, string>;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
@@ -187,6 +187,7 @@ export const IntakeEmailsPage: Component = () => {
|
||||
|
||||
const params = useParams();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { getErrorMessage } = useI18nApiErrors({ t });
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'intake-emails'],
|
||||
@@ -196,16 +197,12 @@ export const IntakeEmailsPage: Component = () => {
|
||||
const createEmail = async () => {
|
||||
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
|
||||
|
||||
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
|
||||
if (error) {
|
||||
createToast({
|
||||
message: t('api-errors.intake_email.limit_reached'),
|
||||
message: getErrorMessage({ error }),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Component } from 'solid-js';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
@@ -56,7 +56,7 @@ export const InvitationsPage: Component = () => {
|
||||
{
|
||||
header: t('invitations.list.headers.created'),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <time dateTime={data.getValue()}>{timeAgo({ date: data.getValue() })}</time>,
|
||||
cell: data => <RelativeTime date={data.getValue()} />,
|
||||
},
|
||||
{
|
||||
header: () => <div class="text-right">{t('invitations.list.headers.actions')}</div>,
|
||||
|
||||
@@ -115,3 +115,21 @@ export async function updateOrganizationMemberRole({ organizationId, memberId, r
|
||||
member: coerceDates(member),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchDeletedOrganizations() {
|
||||
const { organizations } = await apiClient<{ organizations: AsDto<Organization>[] }>({
|
||||
path: '/api/organizations/deleted',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return {
|
||||
organizations: organizations.map(coerceDates),
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreOrganization({ organizationId }: { organizationId: string }) {
|
||||
await apiClient({
|
||||
path: `/api/organizations/${organizationId}/restore`,
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ export type Organization = {
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt?: Date | null;
|
||||
deletedBy?: string | null;
|
||||
scheduledPurgeAt?: Date | null;
|
||||
};
|
||||
|
||||
export type OrganizationMember = {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/solid-query';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { fetchDeletedOrganizations, restoreOrganization } from '../organizations.services';
|
||||
|
||||
export const DeletedOrganizationsPage: Component = () => {
|
||||
const { t, formatDate } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { config } = useConfig();
|
||||
|
||||
const purgeDaysDelay = config.organizations.deletedOrganizationsPurgeDaysDelay;
|
||||
|
||||
const deletedOrgsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', 'deleted'],
|
||||
queryFn: fetchDeletedOrganizations,
|
||||
}));
|
||||
|
||||
const restoreMutation = useMutation(() => ({
|
||||
mutationFn: restoreOrganization,
|
||||
onSuccess: async () => {
|
||||
createToast({
|
||||
message: t('organizations.list.deleted.restore-success'),
|
||||
type: 'success',
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
},
|
||||
}));
|
||||
|
||||
const handleRestore = async (organizationId: string) => {
|
||||
const confirmed = await confirm({
|
||||
title: t('organizations.list.deleted.restore-confirm.title'),
|
||||
message: t('organizations.list.deleted.restore-confirm.message'),
|
||||
confirmButton: {
|
||||
text: t('organizations.list.deleted.restore-confirm.confirm-button'),
|
||||
},
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
restoreMutation.mutate({ organizationId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Button variant="ghost" as={A} href="/organizations" class="text-muted-foreground gap-2 ml--4">
|
||||
<div class="i-tabler-arrow-left size-5" />
|
||||
{t('organizations.list.back')}
|
||||
</Button>
|
||||
|
||||
<h2 class="text-xl font-bold">
|
||||
{t('organizations.list.deleted.title')}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
{t('organizations.list.deleted.description', { days: purgeDaysDelay })}
|
||||
</p>
|
||||
|
||||
<Show
|
||||
when={deletedOrgsQuery.data?.organizations && deletedOrgsQuery.data.organizations.length > 0}
|
||||
fallback={(
|
||||
<Alert variant="muted" class="my-4 flex items-center gap-4">
|
||||
<div class="i-tabler-info-circle size-10 text-primary flex-shrink-0 hidden sm:block" />
|
||||
<div>
|
||||
<AlertTitle>{t('organizations.list.deleted.empty')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('organizations.list.deleted.empty-description', { days: purgeDaysDelay })}
|
||||
</AlertDescription>
|
||||
<Button as={A} href="/organizations" variant="outline" class="mt-2 hover:(bg-primary text-primary-foreground) transition-colors" size="sm">
|
||||
<div class="i-tabler-arrow-left size-4 mr-2" />
|
||||
{t('organizations.list.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<For each={deletedOrgsQuery.data?.organizations}>
|
||||
{(organization) => {
|
||||
const daysUntilPurge = organization.scheduledPurgeAt
|
||||
? Math.ceil((organization.scheduledPurgeAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||
: purgeDaysDelay;
|
||||
|
||||
return (
|
||||
<div class="border rounded-lg p-4 bg-card">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-base truncate">
|
||||
{organization.name}
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-muted-foreground flex flex-col sm:flex-row sm:gap-2 flex-wrap">
|
||||
<Show when={organization.deletedAt}>
|
||||
<div class="flex-shrink-0">
|
||||
{t('organizations.list.deleted.deleted-at', {
|
||||
date: formatDate(organization.deletedAt!),
|
||||
})}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={organization.scheduledPurgeAt}>
|
||||
<div class="text-red-500 flex-shrink-0">
|
||||
{t('organizations.list.deleted.purge-at', {
|
||||
date: formatDate(organization.scheduledPurgeAt!),
|
||||
})}
|
||||
{' '}
|
||||
{t('organizations.list.deleted.days-remaining', { daysUntilPurge })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => handleRestore(organization.id)}
|
||||
disabled={restoreMutation.isPending}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<div class="i-tabler-restore size-4 mr-2" />
|
||||
{t('organizations.list.deleted.restore')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||