Compare commits

...

77 Commits

Author SHA1 Message Date
Corentin Thomasset
3903eed170 chore(release): update versions (#605)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-02 21:20:30 +01:00
Corentin Thomasset
c70d7e419a chore(release): use provenance for release (#604) 2025-11-02 20:16:24 +00:00
Corentin Thomasset
2240f58f04 chore(release): update versions (#576)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-31 16:39:30 +01:00
Corentin Thomasset
79e9bb1b61 feat(auth): added an email verification confirmation/expiration page (#602) 2025-10-30 10:38:23 +01:00
Corentin Thomasset
6e18162435 fix(queries): clear query client on sign out and invalidate organization queries on error (#600) 2025-10-28 23:56:22 +01:00
Corentin Thomasset
16ae4617df feat(tagging-rules): add condition match mode to tagging rules (#601)
- Introduced a new column `condition_match_mode` in the `tagging_rules` table to specify how conditions should be evaluated (either 'all' or 'any').
- Updated the tagging rules repository, routes, and schemas to handle the new `conditionMatchMode` property.
- Enhanced the tagging rules use cases to apply tags based on the specified condition match mode.
- Added tests to verify the behavior of tagging rules with different condition match modes.
- Created a migration to add the new column and update existing records accordingly.
2025-10-28 14:07:16 +01:00
Corentin Thomasset
1c46071e00 refactor(unocss): migrated preset to wind4 (#599) 2025-10-27 22:09:09 +00:00
Corentin Thomasset
377c11c185 fix(organization): corrected organization redirect (#598)
* fix(organization): corrected organization redirect

* Update .changeset/chatty-monkeys-joke.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-27 12:03:34 +01:00
Corentin Thomasset
28c3c15cef chore(deps): update better-auth (#596) 2025-10-27 01:35:47 +01:00
Corentin Thomasset
0391a3bcd5 chore(deps): updated eslint and config dependencies (#595) 2025-10-27 00:00:28 +00:00
Corentin Thomasset
2c75eec862 test(logs): reduced log spam in test (#594) 2025-10-26 23:50:59 +00:00
Corentin Thomasset
ccf7602f19 chore(deps): use catalog eslint packages in doc app (#593) 2025-10-26 23:29:50 +00:00
Corentin Thomasset
b8a515a313 chore(deps): updated vitest monorepo (#592) 2025-10-26 23:09:49 +00:00
Corentin Thomasset
0aad88471b chore(pnpm): updated pnpm to 10.19.0 (#591) 2025-10-26 21:43:23 +00:00
Corentin Thomasset
efd2ae1c73 chore(deps): removed unused jsdom in client (#590) 2025-10-26 21:48:25 +01:00
Corentin Thomasset
e9a719d06a fix(client): proper feedback messages in auth pages (#589) 2025-10-26 17:04:52 +00:00
Corentin Thomasset
68714267ad fix(subscriptions): stabilized subscriptions webhook states (#588) 2025-10-26 16:47:34 +01:00
Corentin Thomasset
75a13da526 fix(subscriptions): stop preventing org deletion when subscription is active (#587) 2025-10-26 09:27:56 +00:00
Corentin Thomasset
59d5819018 fix(cli): correct import path to prevent CLI crash (#586) 2025-10-25 15:43:21 +00:00
Corentin Thomasset
a857370343 fix(webhooks): update webhook creation to allow without secrets (#585) 2025-10-25 13:08:07 +00:00
Corentin Thomasset
f4740ba59a feat(date): replace timeAgo with RelativeTime component (#584) 2025-10-25 14:26:47 +02:00
Corentin Thomasset
b0abf7f78a feat(i18n): add date and time ago formatting functions (#583) 2025-10-25 11:51:28 +02:00
Corentin Thomasset
182ccbb30b fix(webhooks): corrected webhooks last triggered date (#582) 2025-10-25 02:12:57 +02:00
Jan-Olaf Becker
75340f0ce7 feat(tagging-rules): added a "run now" button for tagging rules (#540)
* feat: add run now button for tagging rules

Allow users to apply existing tagging rules to all documents
in their organization. This helps when rules are created after
documents have already been imported.

Fixes #251

* docs: add tagging rules guide and API endpoint

- Add comprehensive guide for using tagging rules
- Document the new 'Apply to existing documents' feature
- Add API endpoint documentation for applying rules to existing documents

* feat(docs): add Tagging Rules to sidebar navigation

* refactor(ui): normalized button sizes

* refactor(repository): remove unused getOrganizationDocumentsQuery function

* refactor(tagging-rules): mutualized tagging rule application

* chore(version): added changeset

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-10-24 23:44:12 +02:00
Corentin Thomasset
1228486f28 feat(lecture): add support for extracting text from .docx, .odt, .rtf, .pptx and .odp (#580) 2025-10-24 21:44:11 +02:00
Corentin Thomasset
655a1c5475 feat(auth): enhance user signup logging with email capture (#579) 2025-10-24 16:07:45 +00:00
Corentin Thomasset
d1797eb9be fix(fly.toml): update deployment strategy to canary and adjust process environment variable syntax (#578) 2025-10-24 17:28:54 +02:00
Corentin Thomasset
bd3e321eb7 feat(processes): added worker vs web processes (#577) 2025-10-24 17:06:28 +02:00
Corentin Thomasset
be25de7721 fix(server): add global error handlers for uncaught exceptions and unhandled promise rejections (#575) 2025-10-24 16:05:21 +02:00
Corentin Thomasset
e85403f9a1 fix(fly.toml): set minimum machines running to 1 (#574) 2025-10-24 11:47:51 +00:00
Corentin Thomasset
7de5d0956b feat(upgrade-dialog): add promotional banner for early adopters (#573) 2025-10-24 10:03:23 +00:00
Corentin Thomasset
b1a88230cd fix(subscriptions): added organization deletion restrictions based on active subscriptions (#572) 2025-10-24 01:37:30 +02:00
Corentin Thomasset
55bb29582e fix(docker): update build context paths in package.json scripts (#571) 2025-10-23 21:36:25 +00:00
Corentin Thomasset
d9263dc703 chore(release): update versions (#549)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-23 21:51:13 +02:00
Corentin Thomasset
c3ffa8387e feat(config): add hostname configuration (#570) 2025-10-23 21:48:39 +02:00
Corentin Thomasset
d40514c043 feat(subscriptions): enhance subscription webhook handling (#569) 2025-10-23 21:23:11 +02:00
Corentin Thomasset
d7df2f095b refactor(layouts): removed icons bar (#567)
* refactor(layouts): removed icons bar

* Update .changeset/bumpy-pens-study.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 14:58:31 +02:00
Corentin Thomasset
afdcc1c5ba feat(demo): add subscription and usage endpoints (#559) 2025-10-19 23:18:33 +02:00
Corentin Thomasset
92daaa35bb fix(webhooks): omit secret from webhook response in update route (#566) 2025-10-19 14:04:59 +00:00
Corentin Thomasset
e4295e14ab fix(theme): prevent flash of wrong theme on load (#565) 2025-10-19 15:55:00 +02:00
Corentin Thomasset
ae37d1db36 fix(tasks): add FLY_MACHINE_ID fallback to worker ids (#564) 2025-10-19 12:16:42 +00:00
Corentin Thomasset
a7464f8b89 chore(fly): update configuration for deployment and health checks (#563) 2025-10-18 21:42:09 +00:00
Corentin Thomasset
2dd9ca9835 chore(fly): test fly.io hosting (#561) 2025-10-18 14:17:17 +00:00
Corentin Thomasset
54cc14052c refactor(tracking): replace posthog-js with posthog-js-lite to reduced bundle (#560) 2025-10-17 21:22:56 +00:00
Corentin Thomasset
f930e46dde fix(docker): correct package changelog title to @papra/docker (#551) 2025-10-16 16:15:17 +02:00
Corentin Thomasset
df75e5accb feat(subscriptions): add global coupon support for checkout sessions (#558) 2025-10-16 15:36:42 +02:00
Corentin Thomasset
f66a9f5d1b feat(documents): added deleted and total metrics in the organization stats route (#556) 2025-10-14 17:59:37 +02:00
Corentin Thomasset
c5b337f3bb fix(upload): use organization-specific file size limits (#555) 2025-10-14 03:09:54 +02:00
Corentin Thomasset
bb1ba3e15e chore(release): ensure job runs only for the correct repository (#554) 2025-10-13 21:40:34 +00:00
Corentin Thomasset
ce839c4127 feat(plans): pro plan (#553) 2025-10-13 23:33:55 +02:00
Corentin Thomasset
8aabd28168 refactor(utils): removed lodash-es (#552) 2025-10-13 17:03:25 +02:00
Corentin Thomasset
1a7a14b3ed refactor(query): dropped unnecessary tanstack useQueries (#550) 2025-10-13 02:22:58 +02:00
Corentin Thomasset
17cebde051 fix(intake-emails): make email validation more permissive for webhook addresses (#548) 2025-10-12 18:56:18 +00:00
Corentin Thomasset
12ead3d017 chore(release): update versions (#535)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-12 16:50:36 +02:00
Corentin Thomasset
f6c0221858 fix(release): update Docker build trigger to use '@papra/docker' package (#546) 2025-10-12 14:35:26 +00:00
Corentin Thomasset
1aaf2c96cd fix(docker): update version from 25.10.0 to 25.9.0 and change release type to minor (#545) 2025-10-12 14:30:42 +00:00
Corentin Thomasset
9c6f14fc13 refactor(docker): dedicated package for docker management (#544)
* feat(docker): initialize Docker package with build configurations and README

* Update packages/docker/CHANGELOG.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/docker/package.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-12 16:22:38 +02:00
Corentin Thomasset
3d49962ca5 feat(docs): add architecture documentation (#543)
* feat(docs): add architecture documentation

* Update apps/docs/src/content/navigation.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-11 19:39:12 +00:00
Corentin Thomasset
c434d873bc feat(organizations): soft delete organizations with recovery (#542) 2025-10-11 16:21:55 +00:00
Corentin Thomasset
60982da847 refactor(tagging-rules): enhance tagging rules repository with tag associations (#539) 2025-10-08 22:08:35 +02:00
Corentin Thomasset
73ab9e8ab5 fix(webhooks): trigger webhooks and save activity log on auto-tagging (#538) 2025-10-08 18:30:59 +00:00
Corentin Thomasset
c4a9b9b088 fix(test): forward injected date in invitation tests (#537) 2025-10-08 11:09:11 +00:00
Corentin Thomasset
9a6e822e71 feat(docker): drop support for armv7 (#532) 2025-10-06 23:56:24 +02:00
Corentin Thomasset
e52bc261db feat(organizations): added max members count check for organization invitations (#536) 2025-10-05 15:11:08 +02:00
Corentin Thomasset
624ad62c53 feat(orgs): added usage page and related components (#534)
- Implemented a new page to view organization usage, including document storage, intake emails, and member counts.
- Added translations for the new usage features in multiple languages (DE, EN, ES, FR, IT, PL, PT-BR, RO).
- Created a `UsageWarningCard` to alert users when they are nearing their storage limits.
- Updated the sidebar and organization settings layout to include a link to the usage page.
- Added API endpoints to fetch organization usage data and handle limits.
- Introduced a `ProgressCircle` component for visual representation of usage statistics.
- Refactored utility functions to handle positive infinity values in usage calculations.
2025-10-05 02:45:21 +02:00
Corentin Thomasset
630f9cc328 feat(subscriptions): add billing interval options (#533) 2025-10-04 21:57:09 +02:00
Corentin Thomasset
9f5be458fe feat(subscriptions): added cta and subscription management features (#523) 2025-10-04 14:58:42 +02:00
Corentin Thomasset
1bfdb8aa66 chore(release): update versions (#525)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-10-04 11:47:47 +02:00
Corentin Thomasset
2e2bb6fbbd chore(changeset): added changeset for ip env variable (#531) 2025-10-02 22:48:38 +00:00
Corentin Thomasset
d09b9ed70d feat(auth): add IP address header configuration and logging support (#530) 2025-10-03 00:41:42 +02:00
Corentin Thomasset
e1571d2b87 fix(auth): enhance logging to include additional arguments in log messages (#529) 2025-10-02 22:36:06 +00:00
Corentin Thomasset
c9a66e4aa8 fix(docs): update env variable name for OwlRelay configuration (#528) 2025-10-02 20:29:55 +00:00
Corentin Thomasset
9fa2df4235 feat(package): add module type to root package.json (#526) 2025-10-01 14:15:23 +00:00
Corentin Thomasset
c84a921988 feat(tags): update tag color validation to allow uppercase letters (#524)
* feat(tags): update tag color validation to allow uppercase letters

* Update .changeset/quiet-peas-mate.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-01 14:09:49 +00:00
Corentin Thomasset
9b5f3993c3 chore(release): update versions (#518)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-30 11:51:10 +02:00
Corentin Thomasset
b28772317c fix(file-upload): set default parameter charset to utf8 (#521) 2025-09-29 21:20:43 +02:00
Corentin Thomasset
a3f9f05c66 feat(organizations): restrict organization deletion to owners only (#517) 2025-09-26 01:49:59 +02:00
242 changed files with 17299 additions and 4936 deletions

View File

@@ -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

View File

@@ -1,11 +0,0 @@
node_modules
.pnp
.pnp.*
*.log
dist
*.local
.git
db.sqlite
local-documents
.env
**/.env

1
.dockerignore Symbolic link
View File

@@ -0,0 +1 @@
packages/docker/.dockerignore

View File

@@ -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

View File

@@ -11,6 +11,7 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
if: github.repository == 'papra-hq/papra'
permissions:
contents: write
pull-requests: write
@@ -25,7 +26,11 @@ jobs:
- 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:

1
.gitignore vendored
View File

@@ -44,3 +44,4 @@ ingestion
*.traineddata
.eslintcache
.claude

222
CLAUDE.md Normal file
View 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)

View File

@@ -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

View File

@@ -3,7 +3,6 @@
"type": "module",
"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": "^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:"
}
}

View 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;
}

View File

@@ -1,8 +1,8 @@
import type { ConfigDefinition, ConfigDefinitionElement } from 'figue';
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
import { marked } from 'marked';
import { configDefinition } from '../../papra-server/src/modules/config/config';
import { renderMarkdown } from './markdown';
function walk(configDefinition: ConfigDefinition, path: string[] = []): (ConfigDefinitionElement & { path: string[] })[] {
return Object
@@ -94,18 +94,6 @@ const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
].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 };

View File

@@ -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

View 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)

View File

@@ -307,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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
View 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 });
}

View 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>

View File

@@ -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';

View File

@@ -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
View File

@@ -0,0 +1 @@
22

View File

@@ -1,177 +0,0 @@
# @papra/app-client
## 0.9.4
### 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.9.3
### Patch Changes
- [#506](https://github.com/papra-hq/papra/pull/506) [`6bcb2a7`](https://github.com/papra-hq/papra/commit/6bcb2a71e990d534dd12d84e64a38f2b2baea25a) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define patterns for email intake username generation
## 0.9.2
### Patch Changes
- [#501](https://github.com/papra-hq/papra/pull/501) [`b5bf0cc`](https://github.com/papra-hq/papra/commit/b5bf0cca4b571495329cb553da06e0d334ee8968) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix an issue preventing to disable the max upload size
- [#498](https://github.com/papra-hq/papra/pull/498) [`3da13f7`](https://github.com/papra-hq/papra/commit/3da13f759155df5d7c532160a7ea582385db63b6) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Removed the "open in new tab" button for security improvement (xss prevention)
## 0.9.1
### Patch Changes
- [#492](https://github.com/papra-hq/papra/pull/492) [`54514e1`](https://github.com/papra-hq/papra/commit/54514e15db5deaffc59dcba34929b5e2e74282e1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added a client side guard for rejecting too-big files
- [#488](https://github.com/papra-hq/papra/pull/488) [`83e943c`](https://github.com/papra-hq/papra/commit/83e943c5b46432e55b6dfbaa587019a95ffab466) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix favicons display issues on firefox
- [#492](https://github.com/papra-hq/papra/pull/492) [`54514e1`](https://github.com/papra-hq/papra/commit/54514e15db5deaffc59dcba34929b5e2e74282e1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix i18n messages when a file-too-big error happens
- [#492](https://github.com/papra-hq/papra/pull/492) [`54514e1`](https://github.com/papra-hq/papra/commit/54514e15db5deaffc59dcba34929b5e2e74282e1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Clean all upload method to happen through the import status modal
## 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

View File

@@ -22,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',
},
});

View File

@@ -65,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>

View File

@@ -1,9 +1,8 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.9.4",
"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",
@@ -28,30 +27,28 @@
"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"
},
@@ -59,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:"
}
}

View File

@@ -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%;

View File

@@ -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')!,

View File

@@ -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
@@ -519,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',
@@ -559,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
@@ -582,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',
};

View File

@@ -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
@@ -517,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',
@@ -557,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
@@ -580,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;

View File

@@ -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
@@ -519,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',
@@ -559,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
@@ -582,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',
};

View File

@@ -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
@@ -519,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',
@@ -559,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
@@ -582,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',
};

View File

@@ -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
@@ -519,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',
@@ -559,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
@@ -582,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',
};

View File

@@ -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
@@ -519,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',
@@ -559,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
@@ -582,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ć',
};

View File

@@ -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
@@ -519,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',
@@ -559,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
@@ -582,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',
};

View File

@@ -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 se 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
@@ -519,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',
@@ -559,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
@@ -582,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',
};

View File

@@ -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ă ștergi 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
@@ -519,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',
@@ -559,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
@@ -582,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',
};

View File

@@ -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>

View File

@@ -10,3 +10,7 @@ export const ssoProviders = [
icon: 'i-tabler-brand-github',
},
] as const;
export const authPagesPaths = {
emailVerification: '/email-verification',
};

View File

@@ -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,

View File

@@ -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;
},
};

View File

@@ -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>
);
};

View File

@@ -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(

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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 () => {

View File

@@ -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),
@@ -38,10 +40,7 @@ export const buildTimeConfig = {
isEnabled: asBoolean(import.meta.env.VITE_INTAKE_EMAILS_IS_ENABLED, false),
},
isSubscriptionsEnabled: asBoolean(import.meta.env.VITE_IS_SUBSCRIPTIONS_ENABLED, false),
documentsStorage: {
maxUploadSize: asNumber(import.meta.env.VITE_DOCUMENTS_STORAGE_MAX_UPLOAD_SIZE, 10 * 1024 * 1024),
},
} as const;
export type Config = typeof buildTimeConfig;
export type RuntimePublicConfig = Pick<Config, 'auth'>;
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'intakeEmails' | 'organizations'>;

View File

@@ -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 });

View File

@@ -2,23 +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 { useConfig } from '@/modules/config/config.provider';
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) {
@@ -28,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 });
},
};
}
@@ -54,11 +55,10 @@ 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();
const { config } = useConfig();
const [getState, setState] = createSignal<'open' | 'closed' | 'collapsed'>('closed');
const [getTasks, setTasks] = createSignal<Task[]>([]);
@@ -67,20 +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) => {
const { maxUploadSize } = config.documentsStorage;
updateTaskStatus({ file, status: 'uploading' });
if (maxUploadSize > 0 && file.size > maxUploadSize) {
// 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 }));
const [result, error] = await safely(uploadDocument({ file, organizationId: props.organizationId }));
if (error) {
updateTaskStatus({ file, status: 'error', error });
@@ -90,7 +103,7 @@ export const DocumentUploadProvider: ParentComponent = (props) => {
updateTaskStatus({ file, status: 'success', document });
}
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
throttledInvalidateOrganizationDocumentsQuery({ organizationId: props.organizationId });
}));
};

View File

@@ -1,17 +1,13 @@
import type { Component } from 'solid-js';
import { useParams } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
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 { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId });
const { promptImport, uploadDocuments } = useDocumentUpload();
const handleDragOver = (event: DragEvent) => {
event.preventDefault();

View File

@@ -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>

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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`,
});

View File

@@ -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>
),
},

View File

@@ -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">
@@ -328,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',
},
]}
@@ -390,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()} />
)}

View File

@@ -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={[

View File

@@ -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>
);
};

View File

@@ -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');
});
});
});

View File

@@ -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');
};
}

View File

@@ -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}

View File

@@ -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>,

View File

@@ -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',
});
}

View File

@@ -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 = {

View File

@@ -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>
);
};

View File

@@ -4,10 +4,10 @@ import { A, useNavigate, useParams } from '@solidjs/router';
import { useMutation, useQuery } from '@tanstack/solid-query';
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
import { For, Match, onMount, Show, Switch } from 'solid-js';
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { cancelInvitation, resendInvitation } from '@/modules/invitations/invitations.services';
import { useConfirmModal } from '@/modules/shared/confirm';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { queryClient } from '@/modules/shared/query/query-client';
import { Badge } from '@/modules/ui/components/badge';
import { Button } from '@/modules/ui/components/button';
@@ -148,7 +148,7 @@ const InvitationsList: Component = () => {
{
header: t('organizations.members.table.headers.created'),
accessorKey: 'createdAt',
cell: data => <span title={data.getValue<Date>().toLocaleString()} class="text-muted-foreground">{timeAgo({ date: data.getValue<Date>() })}</span>,
cell: data => <RelativeTime date={data.getValue<Date>()} class="text-muted-foreground" />,
},
{
header: '',

View File

@@ -0,0 +1,102 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { Show, Suspense } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganizationUsage } from '@/modules/subscriptions/subscriptions.services';
import { Card, CardContent } from '@/modules/ui/components/card';
import { ProgressCircle } from '@/modules/ui/components/progress-circle';
import { Separator } from '@/modules/ui/components/separator';
const UsageCardLine: Component<{
title: string;
description: string;
used: number;
limit: number | null;
formatValue?: (value: number) => string;
}> = (props) => {
const { t } = useI18n();
const percentage = () => {
if (props.limit === null) {
return 0;
}
return Math.min((props.used / props.limit) * 100, 100);
};
const formatValue = (value: number) => {
return props.formatValue ? props.formatValue(value) : value.toString();
};
return (
<div class="flex gap-4 items-center ">
<ProgressCircle value={percentage()} size="xs" class="flex-shrink-0" />
<div class="flex-1">
<div class="font-medium leading-none">{props.title}</div>
<div class="text-sm text-muted-foreground">{props.description}</div>
</div>
<div class="text-muted-foreground leading-none">{ `${formatValue(props.used)} / ${props.limit === null ? t('organization.usage.unlimited') : formatValue(props.limit)}${props.limit ? ` - ${percentage().toFixed(2)}%` : ''}`}</div>
</div>
);
};
export const OrganizationUsagePage: Component = () => {
const params = useParams();
const { t } = useI18n();
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'usage'],
queryFn: () => fetchOrganizationUsage({ organizationId: params.organizationId }),
}));
return (
<div class="p-6 mt-10 pb-32 mx-auto max-w-screen-md w-full">
<Suspense>
<Show when={query.data}>
{getData => (
<>
<h1 class="text-xl font-semibold mb-2">
{t('organization.usage.page.title')}
</h1>
<p class="text-muted-foreground mb-6">
{t('organization.usage.page.description')}
</p>
<Card>
<CardContent class="pt-6 flex flex-col gap-4">
<UsageCardLine
title={t('organization.usage.storage.title')}
description={t('organization.usage.storage.description')}
used={getData().usage.documentsStorage.used}
limit={getData().usage.documentsStorage.limit}
formatValue={bytes => formatBytes({ bytes, base: 1000 })}
/>
<Separator />
<UsageCardLine
title={t('organization.usage.intake-emails.title')}
description={t('organization.usage.intake-emails.description')}
used={getData().usage.intakeEmailsCount.used}
limit={getData().usage.intakeEmailsCount.limit}
/>
<Separator />
<UsageCardLine
title={t('organization.usage.members.title')}
description={t('organization.usage.members.description')}
used={getData().usage.membersCount.used}
limit={getData().usage.membersCount.limit}
/>
</CardContent>
</Card>
</>
)}
</Show>
</Suspense>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import type { Component } from 'solid-js';
import { formatBytes } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { keepPreviousData, useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { useDocumentUpload } from '@/modules/documents/components/document-import-status.component';
import { DocumentUploadArea } from '@/modules/documents/components/document-upload-area.component';
@@ -15,29 +15,26 @@ export const OrganizationPage: Component = () => {
const { t } = useI18n();
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });
const query = createQueries(() => ({
queries: [
{
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
queryFn: () => fetchOrganizationDocuments({
organizationId: params.organizationId,
...getPagination(),
}),
placeholderData: keepPreviousData,
},
{
queryKey: ['organizations', params.organizationId, 'documents', 'stats'],
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
},
],
const documentsQuery = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
queryFn: () => fetchOrganizationDocuments({
organizationId: params.organizationId,
...getPagination(),
}),
placeholderData: keepPreviousData,
}));
const { promptImport } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
const statsQuery = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'documents', 'stats'],
queryFn: () => getOrganizationDocumentsStats({ organizationId: params.organizationId }),
}));
const { promptImport } = useDocumentUpload();
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<Suspense>
{query[0].data?.documents?.length === 0
{documentsQuery.data?.documents?.length === 0
? (
<>
<h2 class="text-xl font-bold ">
@@ -62,7 +59,7 @@ export const OrganizationPage: Component = () => {
{t('organizations.details.upload-documents')}
</Button>
<Show when={query[1].data?.organizationStats}>
<Show when={statsQuery.data?.organizationStats}>
{organizationStats => (
<>
<div class="border rounded-lg p-2 flex items-center gap-4 py-4 px-6">
@@ -96,8 +93,8 @@ export const OrganizationPage: Component = () => {
</h2>
<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={[

View File

@@ -1,21 +1,22 @@
import type { Component } from 'solid-js';
import type { Organization } from '../organizations.types';
import { safely } from '@corentinth/chisels';
import { useParams } from '@solidjs/router';
import { useNavigate, useParams } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show, Suspense } from 'solid-js';
import { createSignal, Match, Show, Suspense, Switch } from 'solid-js';
import * as v from 'valibot';
import { buildTimeConfig } from '@/modules/config/config';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form';
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { fetchOrganizationSubscription, getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
import { Button } from '@/modules/ui/components/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
import { createToast } from '@/modules/ui/components/sonner';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { useCurrentUserRole, useDeleteOrganization, useUpdateOrganization } from '../organizations.composables';
import { organizationNameSchema } from '../organizations.schemas';
import { fetchOrganization } from '../organizations.services';
@@ -23,11 +24,43 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
const { deleteOrganization } = useDeleteOrganization();
const { confirm } = useConfirmModal();
const { t } = useI18n();
const { getErrorMessage } = useI18nApiErrors();
const navigate = useNavigate();
const { config } = useConfig();
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
// Fetch subscription to check if organization has an active subscription
const subscriptionQuery = useQuery(() => ({
queryKey: ['organizations', props.organization.id, 'subscription'],
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organization.id }),
enabled: config.isSubscriptionsEnabled,
}));
// Check if subscription blocks deletion (active and not scheduled to cancel)
const getHasBlockingSubscription = () => {
if (!config.isSubscriptionsEnabled) {
return false;
}
const subscription = subscriptionQuery.data?.subscription;
if (!subscription) {
return false;
}
// Allow deletion if subscription is canceled or scheduled to cancel
if (subscription.status === 'canceled' || subscription.cancelAtPeriodEnd) {
return false;
}
// Block deletion for all other active subscription statuses
return true;
};
const handleDelete = async () => {
const confirmed = await confirm({
title: t('organization.settings.delete.confirm.title'),
message: t('organization.settings.delete.confirm.message'),
message: t('organization.settings.delete.confirm.message', { days: 30 }),
confirmButton: {
text: t('organization.settings.delete.confirm.confirm-button'),
variant: 'destructive',
@@ -35,13 +68,22 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
cancelButton: {
text: t('organization.settings.delete.confirm.cancel-button'),
},
shouldType: props.organization.name,
});
if (confirmed) {
await deleteOrganization({ organizationId: props.organization.id });
createToast({ type: 'success', message: t('organization.settings.delete.success') });
if (!confirmed) {
return;
}
const [, error] = await safely(deleteOrganization({ organizationId: props.organization.id }));
if (error) {
createToast({ type: 'error', message: getErrorMessage({ error }) });
return;
}
createToast({ type: 'success', message: t('organization.settings.delete.success') });
navigate('/organizations');
};
return (
@@ -54,10 +96,25 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
</CardDescription>
</CardHeader>
<CardFooter class="pt-6">
<Button onClick={handleDelete} variant="destructive">
<CardFooter class="pt-6 gap-4 flex-col items-start sm:flex-row sm:items-center">
<Button class="flex-shrink-0" onClick={handleDelete} variant="destructive" disabled={!getIsOwner() || getHasBlockingSubscription()}>
{t('organization.settings.delete.confirm.confirm-button')}
</Button>
<Switch>
<Match when={query.isSuccess && !getIsOwner()}>
<span class="text-xs text-muted-foreground">
{t('organization.settings.delete.only-owner')}
</span>
</Match>
<Match when={getHasBlockingSubscription()}>
<span class="text-xs text-muted-foreground">
{t('organization.settings.delete.has-active-subscription')}
</span>
</Match>
</Switch>
</CardFooter>
</Card>
</div>

View File

@@ -3,6 +3,8 @@ import { A, useNavigate } from '@solidjs/router';
import { useQuery } from '@tanstack/solid-query';
import { createEffect, For, on } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/modules/ui/components/dropdown-menu';
import { fetchOrganizations } from '../organizations.services';
export const OrganizationsPage: Component = () => {
@@ -25,9 +27,25 @@ export const OrganizationsPage: Component = () => {
return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<h2 class="text-xl font-bold mb-2">
{t('organizations.list.title')}
</h2>
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<h2 class="text-xl font-bold">
{t('organizations.list.title')}
</h2>
</div>
<DropdownMenu>
<DropdownMenuTrigger as={Button} variant="outline" size="sm">
<div class="i-tabler-dots size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem as={A} href="/organizations/deleted" class="cursor-pointer flex items-center gap-2">
<div class="i-tabler-trash size-4 text-muted-foreground" />
{t('organizations.list.deleted.title')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<p class="text-muted-foreground mb-6">
{t('organizations.list.description')}

View File

@@ -0,0 +1,3 @@
export const FREE_PLAN_ID = 'free';
export const PLUS_PLAN_ID = 'plus';
export const PRO_PLAN_ID = 'pro';

View File

@@ -0,0 +1,6 @@
export type PlanLimits = {
maxDocumentStorageBytes: number | null;
maxIntakeEmailsCount: number | null;
maxOrganizationsMembersCount: number | null;
maxFileSize: number | null;
};

View File

@@ -1,7 +1,9 @@
import type { JSX, ParentComponent } from 'solid-js';
import { createContext, createSignal, useContext } from 'solid-js';
import { createContext, createSignal, Show, useContext } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '../ui/components/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/components/dialog';
import { TextField, TextFieldLabel, TextFieldRoot } from '../ui/components/textfield';
type ConfirmModalConfig = {
title: JSX.Element | string;
@@ -14,6 +16,7 @@ type ConfirmModalConfig = {
text?: string;
variant?: 'default' | 'secondary';
};
shouldType?: string;
};
const ConfirmModalContext = createContext<{ confirm: (config: ConfirmModalConfig) => Promise<boolean> }>(undefined);
@@ -29,14 +32,17 @@ export function useConfirmModal() {
}
export const ConfirmModalProvider: ParentComponent = (props) => {
const { t } = useI18n();
const [getIsOpen, setIsOpen] = createSignal(false);
const [getConfig, setConfig] = createSignal<ConfirmModalConfig | undefined>();
const [getResolve, setResolve] = createSignal<((isConfirmed: boolean) => void) | undefined>();
const [getTypedText, setTypedText] = createSignal<string>('');
const confirm = ({ title, message, confirmButton, cancelButton }: ConfirmModalConfig) => {
const confirm = ({ title, message, confirmButton, cancelButton, shouldType }: ConfirmModalConfig) => {
setConfig({
title,
message,
shouldType,
confirmButton: {
text: confirmButton?.text,
variant: confirmButton?.variant ?? 'default',
@@ -66,6 +72,16 @@ export const ConfirmModalProvider: ParentComponent = (props) => {
setIsOpen(false);
}
const getIsConfirmEnabled = () => {
const { shouldType } = getConfig() ?? {};
if (shouldType === undefined) {
return true;
}
return getTypedText().trim().toLowerCase() === shouldType.trim().toLowerCase();
};
return (
<ConfirmModalContext.Provider value={{ confirm }}>
<Dialog open={getIsOpen()} onOpenChange={onOpenChange}>
@@ -75,13 +91,27 @@ export const ConfirmModalProvider: ParentComponent = (props) => {
{getConfig()?.message && <DialogDescription>{getConfig()?.message}</DialogDescription>}
</DialogHeader>
<Show when={getConfig()?.shouldType}>
{getText => (
<div class="mt-0">
<TextFieldRoot>
<TextFieldLabel class="font-semibold">{t('common.confirm-modal.type-to-confirm', { text: getText() })}</TextFieldLabel>
<TextField
value={getTypedText()}
onInput={e => setTypedText(e.currentTarget.value)}
/>
</TextFieldRoot>
</div>
)}
</Show>
<DialogFooter>
<div class="flex gap-2 justify-end flex-col-reverse sm:flex-row">
<Button onClick={() => handleConfirm({ isConfirmed: false })} variant={getConfig()?.cancelButton?.variant ?? 'secondary'}>
{getConfig()?.cancelButton?.text ?? 'Cancel'}
</Button>
<Button onClick={() => handleConfirm({ isConfirmed: true })} variant={getConfig()?.confirmButton?.variant ?? 'default'}>
<Button onClick={() => handleConfirm({ isConfirmed: true })} variant={getConfig()?.confirmButton?.variant ?? 'default'} disabled={!getIsConfirmEnabled()}>
{getConfig()?.confirmButton?.text ?? 'Confirm'}
</Button>
</div>

View File

@@ -0,0 +1,13 @@
import type { CoercibleDate } from './date.types';
export function coerceDate(date: CoercibleDate): Date {
if (date instanceof Date) {
return date;
}
if (typeof date === 'string' || typeof date === 'number') {
return new Date(date);
}
throw new Error(`Invalid date: expected Date, string, or number, but received value "${date}" of type "${typeof date}"`);
}

View File

@@ -0,0 +1 @@
export type CoercibleDate = Date | string | number;

View File

@@ -1,26 +0,0 @@
import { describe, expect, test } from 'vitest';
import { timeAgo } from './time-ago';
describe('time-ago', () => {
describe('timeAgo', () => {
test('formats the relative time', () => {
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:00:00Z') })).toBe('just now');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:00:06Z') })).toBe('a few seconds ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:01:00Z') })).toBe('a minute ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:02:00Z') })).toBe('2 minutes ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T01:00:00Z') })).toBe('an hour ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T02:00:00Z') })).toBe('2 hours ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-02T00:00:00Z') })).toBe('a day ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-03T00:00:00Z') })).toBe('2 days ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-02-01T00:00:00Z') })).toBe('a month ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-03-02T00:00:00Z') })).toBe('2 months ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2022-01-01T00:00:00Z') })).toBe('a year ago');
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2023-01-01T00:00:00Z') })).toBe('2 years ago');
});
test('the date can be a parsable string', () => {
expect(timeAgo({ date: '2021-01-01T00:00:00Z', now: new Date('2021-01-01T00:00:00Z') })).toBe('just now');
expect(timeAgo({ date: '2021-01-01T00:00:00Z', now: new Date('2021-03-02T00:00:00Z') })).toBe('2 months ago');
});
});
});

View File

@@ -1,63 +0,0 @@
export { timeAgo };
function timeAgo({ date: maybeRawDate, now = new Date() }: { date: Date | string; now?: Date }): string {
const date = typeof maybeRawDate === 'string' ? new Date(maybeRawDate) : maybeRawDate;
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffMonths / 12);
if (diffSeconds < 5) {
return 'just now';
}
if (diffSeconds < 10) {
return 'a few seconds ago';
}
if (diffSeconds < 60) {
return `${diffSeconds} seconds ago`;
}
if (diffMinutes === 1) {
return 'a minute ago';
}
if (diffMinutes < 60) {
return `${diffMinutes} minutes ago`;
}
if (diffHours === 1) {
return 'an hour ago';
}
if (diffHours < 24) {
return `${diffHours} hours ago`;
}
if (diffDays === 1) {
return 'a day ago';
}
if (diffDays < 30) {
return `${diffDays} days ago`;
}
if (diffMonths === 1) {
return 'a month ago';
}
if (diffMonths < 12) {
return `${diffMonths} months ago`;
}
if (diffYears === 1) {
return 'a year ago';
}
return `${diffYears} years ago`;
}

View File

@@ -1,12 +1,12 @@
import type { FetchError } from 'ofetch';
import { get } from 'lodash-es';
import { getErrorStatus } from '../utils/errors';
export function shouldRefreshAuthTokens({ error }: { error: FetchError | unknown | undefined }) {
if (!error) {
return false;
}
return get(error, 'status') === 401;
return getErrorStatus(error) === 401;
}
export function buildAuthHeader({ accessToken }: { accessToken?: string | null | undefined } = {}): Record<string, string> {

View File

@@ -1,7 +1,8 @@
import type { TranslationKeys } from '@/modules/i18n/locales.types';
import { get } from 'lodash-es';
import { castError } from '@corentinth/chisels';
import { FetchError } from 'ofetch';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { get } from '@/modules/shared/utils/get';
function codeToKey(code: string): TranslationKeys {
// Better auth may returns different error codes like INVALID_ORIGIN, INVALID_CALLBACKURL when the origin is invalid
@@ -23,9 +24,9 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
}
if ('error' in args) {
const { error } = args;
const code = get(error, 'data.error.code') ?? get(error, 'code');
const translation = code ? t(codeToKey(code)) : undefined;
const error = castError(args.error);
const code = get(error, ['data', 'error', 'code'], ['code']);
const translation = code && typeof code === 'string' ? t(codeToKey(code)) : undefined;
if (translation) {
return translation;

View File

@@ -25,6 +25,8 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
...('updatedAt' in obj ? { updatedAt: toDate(obj.updatedAt) } : {}),
...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}),
...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}),
...('lastTriggeredAt' in obj ? { lastTriggeredAt: toDate(obj.lastTriggeredAt) } : {}),
...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}),
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: toDate(obj.scheduledPurgeAt) } : {}),
};
}

View File

@@ -1,13 +1,13 @@
import { get } from 'lodash-es';
import { get } from '../utils/get';
export { isHttpErrorWithCode, isHttpErrorWithStatusCode, isRateLimitError };
function isHttpErrorWithCode({ error, code }: { error: unknown; code: string }) {
return get(error, 'data.error.code') === code;
return get(error, ['data', 'error', 'code']) === code;
}
function isHttpErrorWithStatusCode({ error, statusCode }: { error: unknown; statusCode: number }) {
return get(error, 'status') === statusCode;
return get(error, ['status']) === statusCode;
}
function isRateLimitError({ error }: { error: unknown }) {

View File

@@ -1,63 +0,0 @@
import { castError } from '@corentinth/chisels';
import { identity } from 'lodash-es';
import { createSignal } from 'solid-js';
import { createHook } from '../hooks/hooks';
export { useAsyncState };
function useAsyncState<Args, Return, ReturnFormatted = Return>(
getter: (args: Args) => Promise<Return>,
{ initialData, formatValue = identity, immediate = false }: { initialData?: ReturnFormatted; formatValue?: (value: Return) => ReturnFormatted; immediate?: boolean } = {},
) {
const [getIsLoading, setIsLoading] = createSignal(false);
const [getError, setError] = createSignal<Error | undefined>(undefined);
const [getData, setData] = createSignal<ReturnFormatted | undefined>(initialData);
const [getStatus, setStatus] = createSignal<'idle' | 'loading' | 'success' | 'error'>('idle');
const successHook = createHook<{ data: Return; args: Args }>();
const errorHook = createHook<{ error: Error; args: Args }>();
const finishHook = createHook<{ data: ReturnFormatted | undefined; error: Error | undefined; args: Args }>();
const execute = async (args: Args) => {
setIsLoading(true);
setStatus('loading');
try {
const data = await getter(args);
// eslint-disable-next-line ts/no-unsafe-function-type
setData(formatValue(data) as Exclude<ReturnFormatted, Function>);
setError(undefined);
setStatus('success');
successHook.trigger({ data, args });
return data;
} catch (err) {
const error = castError(err);
console.error(error);
setData(undefined);
setError(error);
setStatus('error');
errorHook.trigger({ error, args });
} finally {
setIsLoading(false);
finishHook.trigger({ data: getData(), error: getError(), args });
}
};
if (immediate) {
execute({} as Args);
}
return {
getIsLoading,
getError,
getData,
getStatus,
execute,
onSuccess: successHook.on,
onError: errorHook.on,
onFinish: finishHook.on,
};
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, test } from 'vitest';
import { castArray } from './array';
describe('array', () => {
describe('castArray', () => {
test('wraps non-array values in an array', () => {
expect(castArray(5)).toEqual([5]);
expect(castArray('hello')).toEqual(['hello']);
expect(castArray({ key: 'value' })).toEqual([{ key: 'value' }]);
});
test('returns the same array if an array is provided', () => {
expect(castArray([1, 2, 3])).toEqual([1, 2, 3]);
expect(castArray(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']);
expect(castArray([])).toEqual([]);
const objArray = [{ key: 'value1' }, { key: 'value2' }];
expect(castArray(objArray)).toEqual(objArray);
});
});
});

View File

@@ -0,0 +1,3 @@
export function castArray<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value];
}

View File

@@ -0,0 +1,9 @@
import { isObject } from './object';
export function getErrorStatus(error: unknown): number | undefined {
if (isObject(error) && 'status' in error && typeof error.status === 'number') {
return error.status;
}
return undefined;
}

View File

@@ -0,0 +1,160 @@
import { describe, expect, test } from 'vitest';
import { get } from './get';
describe('get', () => {
test('gets value from simple path', () => {
const obj = { user: { name: 'John', age: 30 } };
expect(get(obj, ['user', 'name'])).toBe('John');
expect(get(obj, ['user', 'age'])).toBe(30);
});
test('returns undefined for missing path', () => {
const obj = { user: { name: 'John' } };
expect(get(obj, ['user', 'missing'])).toBeUndefined();
expect(get(obj, ['missing', 'path'])).toBeUndefined();
});
test('returns undefined for null/undefined objects', () => {
expect(get(null, ['any'])).toBeUndefined();
expect(get(undefined, ['any'])).toBeUndefined();
});
test('returns undefined for non-object values', () => {
expect(get('string', ['any'])).toBeUndefined();
expect(get(123, ['any'])).toBeUndefined();
expect(get(true, ['any'])).toBeUndefined();
});
test('handles deeply nested paths', () => {
const obj = {
level1: {
level2: {
level3: {
value: 'deep',
},
},
},
};
expect(get(obj, ['level1', 'level2', 'level3', 'value'])).toBe('deep');
});
test('returns first non-undefined value from multiple paths', () => {
const obj = {
primary: undefined,
secondary: 'fallback',
tertiary: 'another',
};
expect(get(obj, ['missing'], ['secondary'])).toBe('fallback');
expect(get(obj, ['primary'], ['secondary'])).toBe('fallback');
expect(get(obj, ['missing'], ['another-missing'], ['tertiary'])).toBe('another');
});
test('returns undefined if all paths are undefined', () => {
const obj = { a: 1, b: 2 };
expect(get(obj, ['x'], ['y'], ['z'])).toBeUndefined();
});
test('handles paths with undefined intermediate values', () => {
const obj = {
user: {
profile: undefined,
},
};
expect(get(obj, ['user', 'profile', 'name'])).toBeUndefined();
});
test('handles arrays in paths', () => {
const obj = {
users: [
{ name: 'John' },
{ name: 'Jane' },
],
};
expect(get(obj, ['users', '0', 'name'])).toBe('John');
expect(get(obj, ['users', '1', 'name'])).toBe('Jane');
});
test('handles empty path array', () => {
const obj = { value: 'test' };
expect(get(obj, [])).toBe(obj);
});
test('works with numeric keys', () => {
const obj = {
123: { value: 'numeric key' },
};
expect(get(obj, ['123', 'value'])).toBe('numeric key');
});
test('handles objects with null prototype', () => {
const obj = Object.create(null);
obj.key = 'value';
expect(get(obj, ['key'])).toBe('value');
});
test('returns value even if it is falsy (but not undefined)', () => {
const obj = {
zero: 0,
emptyString: '',
false: false,
null: null,
};
expect(get(obj, ['zero'])).toBe(0);
expect(get(obj, ['emptyString'])).toBe('');
expect(get(obj, ['false'])).toBe(false);
expect(get(obj, ['null'])).toBe(null);
});
test('stops at first non-undefined value in fallback chain', () => {
const obj = {
first: undefined,
second: null, // null is not undefined
third: 'value',
};
expect(get(obj, ['first'], ['second'], ['third'])).toBe(null);
});
test('handles complex real-world scenario', () => {
const apiResponse = {
data: {
user: {
profile: {
contact: {
email: 'user@example.com',
},
},
},
},
meta: {
fallbackEmail: 'admin@example.com',
},
};
// Try to get user email, fallback to admin email
expect(
get(apiResponse, ['data', 'user', 'profile', 'contact', 'email'], ['meta', 'fallbackEmail']),
).toBe('user@example.com');
// If user email is missing, get admin email
const apiResponseWithoutUserEmail = {
data: { user: {} },
meta: { fallbackEmail: 'admin@example.com' },
};
expect(
get(apiResponseWithoutUserEmail, ['data', 'user', 'profile', 'contact', 'email'], ['meta', 'fallbackEmail']),
).toBe('admin@example.com');
});
});

View File

@@ -0,0 +1,42 @@
import { isObject } from './object';
/**
* Gets a value from an object at the specified path.
* If multiple paths are provided, returns the first non-undefined value.
*
* @param obj - The object to query
* @param paths - One or more paths as string arrays (e.g., ['user', 'name'])
* @returns The value at the path, or undefined if not found
*
* @example
* const obj = { user: { name: 'John', age: 30 }, fallback: 'default' };
*
* get(obj, ['user', 'name']); // 'John'
* get(obj, ['user', 'missing']); // undefined
* get(obj, ['missing'], ['fallback']); // 'default' (first non-undefined)
* get(obj, ['a', 'b', 'c']); // undefined
*/
export function get(obj: unknown, ...paths: string[][]): unknown {
if (!isObject(obj)) {
return undefined;
}
for (const path of paths) {
let current: any = obj;
for (const key of path) {
if (isObject(current) && key in current) {
current = (current as Record<string, any>)[key];
} else {
current = undefined;
break;
}
}
if (current !== undefined) {
return current;
}
}
return undefined;
}

View File

@@ -0,0 +1,231 @@
import { describe, expect, test } from 'vitest';
import { deepMerge } from './object';
describe('object utilities', () => {
describe('deepMerge', () => {
test('merges two flat objects', () => {
const a = { x: 1, y: 2 };
const b = { y: 3, z: 4 };
const result = deepMerge(a, b);
expect(result).toEqual({ x: 1, y: 3, z: 4 });
});
test('does not mutate original objects', () => {
const a = { x: 1, y: 2 };
const b = { y: 3, z: 4 };
deepMerge(a, b);
expect(a).toEqual({ x: 1, y: 2 });
expect(b).toEqual({ y: 3, z: 4 });
});
test('creates deep copies without shared references', () => {
const a = { nested: { value: 1 }, arr: [1, 2, 3] };
const b = { other: 'test' };
const result = deepMerge(a, b);
// Mutate the result
result.nested.value = 999;
result.arr.push(4);
// Original should be unchanged
expect(a.nested.value).toBe(1);
expect(a.arr).toEqual([1, 2, 3]);
});
test('deeply merges nested objects', () => {
const a = { nested: { a: 1, b: 2 }, x: 1 };
const b = { nested: { b: 3, c: 4 }, y: 2 };
const result = deepMerge(a, b);
expect(result).toEqual({
x: 1,
y: 2,
nested: { a: 1, b: 3, c: 4 },
});
});
test('handles multiple levels of nesting', () => {
const a = {
level1: {
level2: {
a: 1,
b: 2,
},
x: 1,
},
};
const b = {
level1: {
level2: {
b: 3,
c: 4,
},
y: 2,
},
};
const result = deepMerge(a, b);
expect(result).toEqual({
level1: {
level2: {
a: 1,
b: 3,
c: 4,
},
x: 1,
y: 2,
},
});
});
test('replaces arrays instead of merging them', () => {
const a = { arr: [1, 2, 3] };
const b = { arr: [4, 5] };
const result = deepMerge(a, b);
expect(result).toEqual({ arr: [4, 5] });
});
test('replaces primitives with objects', () => {
const a = { value: 'string' };
const b = { value: { nested: 'object' } };
const result = deepMerge(a, b);
expect(result).toEqual({ value: { nested: 'object' } });
});
test('replaces objects with primitives', () => {
const a = { value: { nested: 'object' } };
const b = { value: 'string' };
const result = deepMerge(a, b);
expect(result).toEqual({ value: 'string' });
});
test('handles null values', () => {
const a = { value: { nested: 'object' } };
const b = { value: null };
const result = deepMerge(a, b);
expect(result).toEqual({ value: null });
});
test('handles undefined values', () => {
const a = { x: 1, y: 2 };
const b = { y: undefined, z: 3 };
const result = deepMerge(a, b);
expect(result).toEqual({ x: 1, y: undefined, z: 3 });
});
test('handles empty objects', () => {
const a = { x: 1 };
const b = {};
const result = deepMerge(a, b);
expect(result).toEqual({ x: 1 });
});
test('merges into empty object', () => {
const a = {};
const b = { x: 1, y: 2 };
const result = deepMerge(a, b);
expect(result).toEqual({ x: 1, y: 2 });
});
test('does not merge Date objects', () => {
const date1 = new Date('2024-01-01');
const date2 = new Date('2024-12-31');
const a = { date: date1 };
const b = { date: date2 };
const result = deepMerge(a, b);
expect(result.date).toBe(date2);
});
test('merges class instances as plain objects', () => {
class MyClass {
constructor(public value: number) {}
}
const a = { instance: new MyClass(1) };
const b = { instance: new MyClass(2) };
const result = deepMerge(a, b);
// Class instances that look like plain objects get merged
expect(result.instance.value).toBe(2);
});
test('preserves type information', () => {
const a = { x: 1 as number };
const b = { y: 'hello' as string };
const result = deepMerge(a, b);
// Type assertion to verify TypeScript types
const x: number = result.x;
const y: string = result.y;
expect(x).toBe(1);
expect(y).toBe('hello');
});
test('handles complex nested structure', () => {
const a = {
config: {
api: {
baseUrl: 'https://old.com',
timeout: 5000,
},
features: {
darkMode: false,
},
},
};
const b = {
config: {
api: {
baseUrl: 'https://new.com',
},
features: {
experimental: true,
},
},
};
const result = deepMerge(a, b);
expect(result).toEqual({
config: {
api: {
baseUrl: 'https://new.com',
timeout: 5000,
},
features: {
darkMode: false,
experimental: true,
},
},
});
});
});
});

View File

@@ -0,0 +1,66 @@
import type { Expand } from '@corentinth/chisels';
export function isObject(value: unknown): value is object {
return typeof value === 'object' && value !== null;
}
/**
* Type guard to check if a value is a plain record object (not a Date, Array, or other special object)
*/
export function isRecord(obj: unknown): obj is Record<string, unknown> {
return (
isObject(obj)
&& !Array.isArray(obj)
// Exclude Date objects and RegExp objects, and other built-in types
&& Object.prototype.toString.call(obj) === '[object Object]'
);
}
/**
* Deep merges multiple objects into a new object.
* Later objects override properties from earlier objects.
* Only plain objects are merged recursively - arrays and other types are replaced.
*
* @param objects - Objects to merge
* @returns A new merged object
*
* @example
* const a = { x: 1, nested: { a: 1 } };
* const b = { y: 2, nested: { b: 2 } };
* const c = { z: 3, nested: { c: 3 } };
* const result = deepMerge(a, b, c);
* // { x: 1, y: 2, z: 3, nested: { a: 1, b: 2, c: 3 } }
*/
export function deepMerge<T extends Record<string, any>[]>(
...objects: T
): T extends [infer First, ...infer Rest]
? Rest extends Record<string, any>[]
? Expand<First & DeepMergeAll<Rest>>
: First
: Record<string, unknown> {
return objects.reduce((prev, obj) => {
const result = { ...prev };
Object.keys(obj).forEach((key) => {
const pVal = result[key];
const oVal = obj[key];
if (isRecord(pVal) && isRecord(oVal)) {
result[key] = deepMerge(pVal, oVal);
} else if (isRecord(oVal)) {
result[key] = deepMerge({}, oVal);
} else if (Array.isArray(oVal)) {
result[key] = [...oVal];
} else {
result[key] = oVal;
}
});
return result;
}, {}) as any;
}
// Helper type for merging multiple objects
type DeepMergeAll<T extends readonly any[]> = T extends [infer First, ...infer Rest]
? First & DeepMergeAll<Rest>
: unknown;

View File

@@ -0,0 +1,312 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { debounce, throttle } from './timing';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('delays function execution until after wait time', () => {
const func = vi.fn();
const debouncedFunc = debounce(func, 100);
debouncedFunc();
expect(func).not.toHaveBeenCalled();
vi.advanceTimersByTime(150);
expect(func).toHaveBeenCalledTimes(1);
});
test('only executes the last call when invoked multiple times rapidly', () => {
const func = vi.fn();
const debouncedFunc = debounce(func, 100);
debouncedFunc('first');
debouncedFunc('second');
debouncedFunc('third');
expect(func).not.toHaveBeenCalled();
vi.advanceTimersByTime(150);
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('third');
});
test('executes multiple times if calls are spaced out beyond wait time', () => {
const func = vi.fn();
const debouncedFunc = debounce(func, 60);
debouncedFunc('first');
vi.advanceTimersByTime(100);
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('first');
debouncedFunc('second');
vi.advanceTimersByTime(100);
expect(func).toHaveBeenCalledTimes(2);
expect(func).toHaveBeenCalledWith('second');
});
test('preserves function arguments correctly', () => {
const func = vi.fn();
const debouncedFunc = debounce(func, 100);
debouncedFunc('arg1', 'arg2', 'arg3');
vi.advanceTimersByTime(150);
expect(func).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
});
test('works with async functions', () => {
const asyncFunc = vi.fn(async (value: string) => {
return `processed-${value}`;
});
const debouncedFunc = debounce(asyncFunc, 100);
debouncedFunc('test');
vi.advanceTimersByTime(150);
expect(asyncFunc).toHaveBeenCalledTimes(1);
expect(asyncFunc).toHaveBeenCalledWith('test');
});
test('works with functions that have multiple parameter types', () => {
const func = vi.fn((str: string, num: number, obj: { key: string }) => {
return { str, num, obj };
});
const debouncedFunc = debounce(func, 100);
debouncedFunc('hello', 42, { key: 'value' });
vi.advanceTimersByTime(150);
expect(func).toHaveBeenCalledWith('hello', 42, { key: 'value' });
});
test('handles zero wait time', () => {
const func = vi.fn();
const debouncedFunc = debounce(func, 0);
debouncedFunc('test');
expect(func).not.toHaveBeenCalled();
vi.advanceTimersByTime(10);
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('test');
});
test('cancels previous timeout when called again before wait time', () => {
const func = vi.fn();
const debouncedFunc = debounce(func, 100);
debouncedFunc('first');
vi.advanceTimersByTime(50);
debouncedFunc('second');
vi.advanceTimersByTime(50);
// First call should be cancelled
expect(func).not.toHaveBeenCalled();
vi.advanceTimersByTime(60);
// Only second call should execute
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('second');
});
test('works with no arguments', () => {
const func = vi.fn();
const debouncedFunc = debounce(func, 100);
debouncedFunc();
vi.advanceTimersByTime(150);
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith();
});
test('preserves type safety for function parameters', () => {
// Type-only test - this will fail TypeScript compilation if types are wrong
const typedFunc = (name: string, age: number) => ({ name, age });
const debouncedTypedFunc = debounce(typedFunc, 100);
// This should compile without errors
debouncedTypedFunc('John', 30);
// These should cause TypeScript errors (commented out):
// debouncedTypedFunc(123, 'wrong'); // Wrong argument types
// debouncedTypedFunc('John'); // Missing argument
vi.advanceTimersByTime(150);
});
});
describe('throttle', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
});
test('invokes function immediately on first call', () => {
const func = vi.fn();
const throttledFunc = throttle(func, 100);
throttledFunc('test');
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('test');
});
test('ignores rapid calls within wait period', () => {
const func = vi.fn();
const throttledFunc = throttle(func, 100);
throttledFunc('first');
throttledFunc('second');
throttledFunc('third');
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('first');
});
test('invokes function again after wait period', () => {
const func = vi.fn();
const throttledFunc = throttle(func, 100);
throttledFunc('first');
expect(func).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(150);
throttledFunc('second');
expect(func).toHaveBeenCalledTimes(2);
expect(func).toHaveBeenCalledWith('second');
});
test('schedules trailing call if invoked during wait period', () => {
const func = vi.fn();
const throttledFunc = throttle(func, 100);
throttledFunc('first');
expect(func).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(50);
throttledFunc('second');
expect(func).toHaveBeenCalledTimes(1);
// Should invoke after remaining wait time
vi.advanceTimersByTime(50);
expect(func).toHaveBeenCalledTimes(2);
expect(func).toHaveBeenCalledWith('second');
});
test('uses latest arguments for trailing call', () => {
const func = vi.fn();
const throttledFunc = throttle(func, 100);
throttledFunc('first');
expect(func).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(30);
throttledFunc('second');
vi.advanceTimersByTime(30);
throttledFunc('third');
vi.advanceTimersByTime(40);
expect(func).toHaveBeenCalledTimes(2);
expect(func).toHaveBeenCalledWith('third');
});
test('works with multiple parameter types', () => {
const func = vi.fn((str: string, num: number, obj: { key: string }) => {
return { str, num, obj };
});
const throttledFunc = throttle(func, 100);
throttledFunc('hello', 42, { key: 'value' });
expect(func).toHaveBeenCalledWith('hello', 42, { key: 'value' });
});
test('works with async functions', () => {
const asyncFunc = vi.fn(async (value: string) => {
return `processed-${value}`;
});
const throttledFunc = throttle(asyncFunc, 100);
throttledFunc('test');
expect(asyncFunc).toHaveBeenCalledTimes(1);
expect(asyncFunc).toHaveBeenCalledWith('test');
});
test('works with no arguments', () => {
const func = vi.fn();
const throttledFunc = throttle(func, 100);
throttledFunc();
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith();
});
test('preserves type safety for function parameters', () => {
// Type-only test - this will fail TypeScript compilation if types are wrong
const typedFunc = (name: string, age: number) => ({ name, age });
const throttledTypedFunc = throttle(typedFunc, 100);
// This should compile without errors
throttledTypedFunc('John', 30);
// These should cause TypeScript errors (commented out):
// throttledTypedFunc(123, 'wrong'); // Wrong argument types
// throttledTypedFunc('John'); // Missing argument
expect(true).toBe(true);
});
test('handles multiple invocations over time correctly', () => {
const func = vi.fn();
const throttledFunc = throttle(func, 100);
// First call - immediate
throttledFunc('call1');
expect(func).toHaveBeenCalledTimes(1);
// Within wait period - scheduled
vi.advanceTimersByTime(50);
throttledFunc('call2');
expect(func).toHaveBeenCalledTimes(1);
// Complete wait period - trailing call executes
vi.advanceTimersByTime(50);
expect(func).toHaveBeenCalledTimes(2);
// Wait full period before next call
vi.advanceTimersByTime(100);
throttledFunc('call3');
expect(func).toHaveBeenCalledTimes(3);
expect(func).toHaveBeenCalledWith('call3');
});
});

View File

@@ -0,0 +1,87 @@
/**
* Creates a debounced function that delays invoking func until after wait milliseconds
* have elapsed since the last time the debounced function was invoked.
*
* @param func - The function to debounce
* @param waitMs - The number of milliseconds to delay
* @returns The debounced function
*
* @example
* const search = debounce(async (query: string) => {
* const results = await searchAPI(query);
* return results;
* }, 300);
*
* search('hello'); // Only the last call within 300ms will execute
*/
export function debounce<Args extends unknown[], Return>(
func: (...args: Args) => Return,
waitMs: number = 500,
): (...args: Args) => void {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return (...args: Args): void => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
timeoutId = undefined;
}, waitMs);
};
}
/**
* Creates a throttled function that only invokes func at most once per every wait milliseconds.
* The throttled function will invoke func on the leading edge of the timeout.
*
* @param func - The function to throttle
* @param waitMs - The number of milliseconds to throttle invocations to
* @returns The throttled function
*
* @example
* const handleScroll = throttle(() => {
* console.log('Scroll event');
* }, 100);
*
* window.addEventListener('scroll', handleScroll);
*/
export function throttle<Args extends unknown[], Return>(
func: (...args: Args) => Return,
waitMs: number = 500,
): (...args: Args) => void {
let lastCallTime: number | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return (...args: Args): void => {
const now = Date.now();
if (lastCallTime === undefined) {
// First call - invoke immediately
func(...args);
lastCallTime = now;
return;
}
const timeSinceLastCall = now - lastCallTime;
if (timeSinceLastCall >= waitMs) {
// Enough time has passed - invoke immediately
func(...args);
lastCallTime = now;
return;
}
// Schedule invocation for when the wait period ends
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
lastCallTime = Date.now();
timeoutId = undefined;
}, waitMs - timeSinceLastCall);
};
}

View File

@@ -0,0 +1,9 @@
export const IN_MS = {
SECOND: 1_000,
MINUTE: 60_000, // 60 * 1_000
HOUR: 3_600_000, // 60 * 60 * 1_000
DAY: 86_400_000, // 24 * 60 * 60 * 1_000
WEEK: 604_800_000, // 7 * 24 * 60 * 60 * 1_000
MONTH: 2_630_016_000, // 30.44 * 24 * 60 * 60 * 1_000 -- approximation using average month length
YEAR: 31_556_736_000, // 365.24 * 24 * 60 * 60 * 1_000 -- approximation using average year length
};

View File

@@ -0,0 +1,299 @@
import type { DialogTriggerProps } from '@kobalte/core/dialog';
import type { Component, JSX } from 'solid-js';
import { safely } from '@corentinth/chisels';
import { createSignal } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { PLUS_PLAN_ID, PRO_PLAN_ID } from '@/modules/plans/plans.constants';
import { cn } from '@/modules/shared/style/cn';
import { Button } from '@/modules/ui/components/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/modules/ui/components/dialog';
import { getCheckoutUrl } from '../subscriptions.services';
// Hardcoded global reduction configuration, will be replaced by a dynamic configuration later
const globalReduction = {
enabled: true,
multiplier: 0.5,
// 31 december 2025 23h59 Paris time
untilDate: new Date('2025-12-31T22:59:59Z'),
};
type BillingInterval = 'monthly' | 'annual';
type PlanCardProps = {
name: string;
features: {
storageSize: number;
members: number;
emailIntakes: number;
maxUploadSize: number;
support: string;
};
isRecommended?: boolean;
isCurrent?: boolean;
onUpgrade?: () => Promise<void>;
billingInterval: BillingInterval;
monthlyPrice: number;
annualPrice: number;
};
const PlanCard: Component<PlanCardProps> = (props) => {
const { t } = useI18n();
const [getIsUpgradeLoading, setIsUpgradeLoading] = createSignal(false);
const featureItems = [
{
icon: 'i-tabler-database',
title: t('subscriptions.features.storage-size'),
value: `${props.features.storageSize}GB`,
},
{
icon: 'i-tabler-users',
title: t('subscriptions.features.members'),
value: t('subscriptions.features.members-count', { count: props.features.members }),
},
{
icon: 'i-tabler-mail',
title: t('subscriptions.features.email-intakes'),
value: props.features.emailIntakes === 1
? t('subscriptions.features.email-intakes-count-singular', { count: props.features.emailIntakes })
: t('subscriptions.features.email-intakes-count-plural', { count: props.features.emailIntakes }),
},
{
icon: 'i-tabler-file-upload',
title: t('subscriptions.features.max-upload-size'),
value: `${props.features.maxUploadSize}MB`,
},
{
icon: 'i-tabler-headset',
title: t('subscriptions.features.support'),
value: props.features.support,
},
];
const upgrade = async () => {
if (!props.onUpgrade) {
return;
}
setIsUpgradeLoading(true);
await safely(props.onUpgrade());
setIsUpgradeLoading(false);
};
const getIsReductionActive = ({ now = new Date() }: { now?: Date } = {}) => globalReduction.enabled && now < globalReduction.untilDate;
const getReductionMultiplier = ({ now = new Date() }: { now?: Date } = {}) => getIsReductionActive({ now }) ? globalReduction.multiplier : 1;
const getMonthlyPrice = ({ now = new Date() }: { now?: Date } = {}) => {
const multiplier = getReductionMultiplier({ now });
const basePrice = props.billingInterval === 'annual' ? props.annualPrice / 12 : props.monthlyPrice;
return Math.round(100 * basePrice * multiplier) / 100;
};
const getAnnualPrice = () => {
const multiplier = getReductionMultiplier();
return Math.round(100 * props.annualPrice * multiplier) / 100;
};
return (
<div class="border rounded-xl">
<div class="p-6">
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between mb-1">
<span class="min-h-24px">{props.name}</span>
{getIsReductionActive() && props.annualPrice > 0 && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{`-${100 * (1 - getReductionMultiplier())}%`}</div>}
</div>
{getIsReductionActive() && props.annualPrice > 0 && (
<span class="text-lg text-muted-foreground relative after:(content-[''] absolute left--5px right--5px top-1/2 h-2px bg-muted-foreground/40 rounded-full -rotate-12 origin-center)">{`$${(props.billingInterval === 'annual' ? props.annualPrice / 12 : props.monthlyPrice)}`}</span>
)}
<div class="flex items-baseline gap-1">
<span class="text-4xl font-semibold">{`$${getMonthlyPrice()}`}</span>
<span class="text-sm text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
</div>
{
props.annualPrice > 0 && (
<div class="overflow-hidden transition-all duration-300" style={{ 'max-height': props.billingInterval === 'annual' ? '24px' : '0px', 'opacity': props.billingInterval === 'annual' ? '1' : '0' }}>
<span class="text-xs text-muted-foreground">{t('subscriptions.upgrade-dialog.billed-annually', { price: getAnnualPrice() })}</span>
</div>
)
}
<hr class="my-6" />
<div class="flex flex-col gap-3 ">
{featureItems.map(feature => (
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class={`p-1.5 rounded-lg ${props.isCurrent ? 'bg-muted text-muted-foreground' : 'bg-primary/10 text-primary'}`}>
<div class={`size-5 ${feature.icon}`}></div>
</div>
<div>
<div class="font-medium text-sm">{feature.value}</div>
<div class="text-xs text-muted-foreground">{feature.title}</div>
</div>
</div>
</div>
))}
</div>
{ props.onUpgrade && (
<>
<hr class="my-6" />
<Button onClick={upgrade} class="w-full" autofocus isLoading={getIsUpgradeLoading()}>
{t('subscriptions.upgrade-dialog.upgrade-now')}
<div class="i-tabler-arrow-right size-5 ml-2"></div>
</Button>
</>
)}
</div>
</div>
);
};
type UpgradeDialogProps = {
children: (props: DialogTriggerProps) => JSX.Element;
organizationId: string;
};
export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
const { t } = useI18n();
const [getIsOpen, setIsOpen] = createSignal(false);
const defaultBillingInterval: BillingInterval = 'annual';
const [getBillingInterval, setBillingInterval] = createSignal<BillingInterval>(defaultBillingInterval);
const getIsReductionActive = ({ now = new Date() }: { now?: Date } = {}) => globalReduction.enabled && now < globalReduction.untilDate;
const getReductionMultiplier = ({ now = new Date() }: { now?: Date } = {}) => getIsReductionActive({ now }) ? globalReduction.multiplier : 1;
const getReductionPercent = () => 100 * (1 - getReductionMultiplier());
const getDaysUntilReductionExpiry = ({ now = new Date() }: { now?: Date } = {}) => {
if (!getIsReductionActive({ now })) {
return 0;
}
const timeDiff = globalReduction.untilDate.getTime() - now.getTime();
return Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
};
const onUpgrade = async (planId: string) => {
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId, billingInterval: getBillingInterval() });
window.location.href = checkoutUrl;
};
// Simplified plan configuration - only the values
const currentPlan = {
name: t('subscriptions.plan.free.name'),
monthlyPrice: 0,
annualPrice: 0,
features: {
storageSize: 0.5, // 500MB = 0.5GB
members: 3,
emailIntakes: 1,
maxUploadSize: 25,
support: t('subscriptions.features.support-community'),
},
isCurrent: true,
};
const plusPlan = {
name: t('subscriptions.plan.plus.name'),
monthlyPrice: 9,
annualPrice: 90,
features: {
storageSize: 5,
members: 10,
emailIntakes: 10,
maxUploadSize: 100,
support: t('subscriptions.features.support-email'),
},
isRecommended: true,
};
const proPlan = {
name: t('subscriptions.plan.pro.name'),
monthlyPrice: 30,
annualPrice: 300,
features: {
storageSize: 50,
members: 50,
emailIntakes: 100,
maxUploadSize: 500,
support: t('subscriptions.features.support-priority'),
},
};
return (
<Dialog open={getIsOpen()} onOpenChange={setIsOpen}>
<DialogTrigger as={props.children} />
<DialogContent class="sm:max-w-5xl">
<DialogHeader>
<div class="flex flex-col sm:flex-row items-center gap-3">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<div class="i-tabler-sparkles size-7 text-primary"></div>
</div>
<div>
<DialogTitle class="text-xl mb-0">{t('subscriptions.upgrade-dialog.title')}</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground">
{t('subscriptions.upgrade-dialog.description')}
</DialogDescription>
</div>
</div>
<div class="flex flex-col items-center flex-1">
<div class="inline-flex items-center justify-center border rounded-lg bg-muted p-1 gap-2">
<Button
size="sm"
variant="ghost"
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'monthly' })}
onClick={() => setBillingInterval('monthly')}
>
{t('subscriptions.billing-interval.monthly')}
</Button>
<Button
size="sm"
variant="ghost"
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
onClick={() => setBillingInterval('annual')}
>
{t('subscriptions.billing-interval.annual')}
</Button>
</div>
</div>
</div>
</DialogHeader>
{getIsReductionActive() && (
<div class="mt-4 bg-gradient-to-r from-primary/20 to-primary/2 border border-primary/30 rounded-lg p-4 flex-shrink-0">
<div class="flex items-center gap-4">
<div class="p-2.5 bg-primary/20 rounded-lg border border-primary/30 flex-shrink-0">
<div class="i-tabler-gift size-6 text-primary"></div>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h4 class="text-base font-semibold text-foreground">{t('subscriptions.upgrade-dialog.promo-banner.title')}</h4>
<div class="px-2 py-0.5 bg-primary text-primary-foreground text-xs font-bold rounded-md">
{`-${getReductionPercent()}%`}
</div>
</div>
<p class="text-sm text-muted-foreground mb-1">
{t('subscriptions.upgrade-dialog.promo-banner.description', { percent: getReductionPercent(), days: getDaysUntilReductionExpiry() })}
</p>
</div>
</div>
</div>
)}
<div class="mt-2 grid grid-cols-1 md:grid-cols-3 gap-4">
<PlanCard {...currentPlan} billingInterval={getBillingInterval()} />
<PlanCard {...plusPlan} onUpgrade={() => onUpgrade(PLUS_PLAN_ID)} billingInterval={getBillingInterval()} />
<PlanCard {...proPlan} onUpgrade={() => onUpgrade(PRO_PLAN_ID)} billingInterval={getBillingInterval()} />
</div>
<p class="text-muted-foreground text-xs text-center mt-2">
<a href="https://papra.app/contact" class="underline" target="_blank" rel="noreferrer">{t('subscriptions.upgrade-dialog.contact-us')}</a>
{' '}
{t('subscriptions.upgrade-dialog.enterprise-plans')}
</p>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,95 @@
import type { Component } from 'solid-js';
import { makePersisted } from '@solid-primitives/storage';
import { useQuery } from '@tanstack/solid-query';
import { createSignal, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { fetchOrganizationUsage } from '../subscriptions.services';
import { UpgradeDialog } from './upgrade-dialog.component';
const ONE_DAY_IN_MS = 24/* hours */ * 60/* minutes */ * 60/* seconds */ * 1000/* milliseconds */;
export const UsageWarningCard: Component<{ organizationId: string }> = (props) => {
const { t } = useI18n();
const getOrganizationId = () => props.organizationId;
// TODO: mutualize the creation of the storage key
const [getDismissedDate, setDismissedDate] = makePersisted(createSignal<number | null>(null), { name: `papra:${getOrganizationId()}:usage-warning-dismissed`, storage: localStorage });
const query = useQuery(() => ({
queryKey: ['organizations', getOrganizationId(), 'usage'],
queryFn: () => fetchOrganizationUsage({ organizationId: getOrganizationId() }),
refetchOnWindowFocus: false,
}));
const getStorageSizeUsedPercent = () => {
const { data: usageData } = query;
if (!usageData || usageData.usage.documentsStorage.limit === null) {
return 0;
}
return (usageData.usage.documentsStorage.used / usageData.usage.documentsStorage.limit) * 100;
};
const shouldShow = () => {
const { data: usageData } = query;
if (!usageData) {
return false;
}
const dismissedAt = getDismissedDate();
const storagePercent = getStorageSizeUsedPercent();
const isOver80Percent = storagePercent >= 80;
if (!isOver80Percent) {
return false;
}
if (dismissedAt) {
const now = Date.now();
// Show the warning if the banner was dismissed more than 24h ago
return dismissedAt + ONE_DAY_IN_MS < now;
}
return isOver80Percent;
};
return (
<Show when={shouldShow()}>
<div class="bg-destructive/10 border-b border-b-destructive text-red-500 px-6 py-3 flex items-center gap-4 ">
<div class="max-w-5xl mx-auto flex sm:items-center gap-2 flex-col sm:flex-row">
<span class="text-sm">
<span class="i-tabler-alert-triangle size-5 inline-block mb--1 mr-2" />
{t('subscriptions.usage-warning.message', { percent: getStorageSizeUsedPercent().toFixed(2) })}
</span>
<UpgradeDialog organizationId={getOrganizationId()}>
{triggerProps => (
<Button
variant="outline"
size="sm"
class="flex-shrink-0"
{...triggerProps}
>
{t('subscriptions.usage-warning.upgrade-button')}
</Button>
)}
</UpgradeDialog>
</div>
<Button
variant="ghost"
size="icon"
class="ml-auto op-50 hover:op-100 transition flex-shrink-0 hidden sm:flex"
onClick={() => setDismissedDate(Date.now())}
>
<span class="i-tabler-x size-5" />
</Button>
</div>
</Show>
);
};

View File

@@ -0,0 +1,47 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const CheckoutCancelPage: Component = () => {
const { t } = useI18n();
return (
<div class="flex items-center justify-center min-h-screen p-6 bg-background">
<div class="max-w-md w-full text-center">
<div class="flex justify-center mb-6">
<div class="p-4 bg-muted rounded-full">
<div class="i-tabler-x size-16 text-muted-foreground" />
</div>
</div>
<h1 class="text-3xl font-bold mb-3">
{t('subscriptions.checkout-cancel.title')}
</h1>
<p class="text-muted-foreground mb-1">
{t('subscriptions.checkout-cancel.description')}
</p>
<p class="text-muted-foreground mb-8">
{t('subscriptions.checkout-cancel.no-charges')}
</p>
<div class="flex flex-col gap-3">
<Button as={A} href="/" size="lg" class="w-full">
{t('subscriptions.checkout-cancel.back-to-organizations')}
<div class="i-tabler-arrow-left size-5 mr-2 order-first" />
</Button>
<p class="text-sm text-muted-foreground">
{t('subscriptions.checkout-cancel.need-help')}
{' '}
<a href="https://papra.app/contact" class="underline hover:no-underline" target="_blank" rel="noreferrer">
{t('subscriptions.checkout-cancel.contact-support')}
</a>
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,76 @@
import type { Component } from 'solid-js';
import { A, useNavigate, useSearchParams } from '@solidjs/router';
import { createEffect, createSignal, Show } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
export const CheckoutSuccessPage: Component = () => {
const { t } = useI18n();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [countdown, setCountdown] = createSignal(5);
createEffect(() => {
const sessionId = searchParams.sessionId;
// If no session ID, redirect immediately
if (!sessionId) {
navigate('/');
return;
}
// Start countdown
const interval = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(interval);
navigate('/');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
});
return (
<div class="flex items-center justify-center min-h-screen p-6 bg-background">
<div class="max-w-md w-full text-center">
<div class="flex justify-center mb-6">
<div class="p-4 bg-primary/10 rounded-full">
<div class="i-tabler-check size-16 text-primary" />
</div>
</div>
<h1 class="text-3xl font-bold mb-3">
{t('subscriptions.checkout-success.title')}
</h1>
<p class="text-muted-foreground mb-1">
{t('subscriptions.checkout-success.description')}
</p>
<p class="text-muted-foreground mb-8">
{t('subscriptions.checkout-success.thank-you')}
</p>
<div class="flex flex-col gap-3">
<Button as={A} href="/" size="lg" class="w-full">
{t('subscriptions.checkout-success.go-to-organizations')}
<div class="i-tabler-arrow-right size-5 ml-2" />
</Button>
<Show when={countdown() > 0}>
<p class="text-sm text-muted-foreground">
{t('subscriptions.checkout-success.redirecting', {
count: countdown(),
plural: countdown() !== 1 ? 's' : '',
})}
</p>
</Show>
</div>
</div>
</div>
);
};

View File

@@ -1,12 +1,14 @@
import type { PlanLimits } from '../plans/plans.types';
import type { OrganizationSubscription } from './subscriptions.types';
import { apiClient } from '../shared/http/api-client';
export async function getCheckoutUrl({ organizationId, planId }: { organizationId: string; planId: string }) {
export async function getCheckoutUrl({ organizationId, planId, billingInterval }: { organizationId: string; planId: string; billingInterval: 'monthly' | 'annual' }) {
const { checkoutUrl } = await apiClient<{ checkoutUrl: string }>({
method: 'POST',
path: `/api/organizations/${organizationId}/checkout-session`,
body: {
planId,
billingInterval,
},
});
@@ -22,11 +24,34 @@ export async function getCustomerPortalUrl({ organizationId }: { organizationId:
return { customerPortalUrl };
}
export async function getOrganizationSubscription({ organizationId }: { organizationId: string }) {
const { subscription } = await apiClient<{ subscription: OrganizationSubscription }>({
export async function fetchOrganizationSubscription({ organizationId }: { organizationId: string }) {
const { subscription, plan } = await apiClient<{
subscription: OrganizationSubscription;
plan: {
id: string;
name: string;
limits: PlanLimits;
};
}>({
method: 'GET',
path: `/api/organizations/${organizationId}/subscription`,
});
return { subscription };
return { subscription, plan };
}
export async function fetchOrganizationUsage({ organizationId }: { organizationId: string }) {
const { usage, limits } = await apiClient<{
usage: {
documentsStorage: { used: number; deleted: number; limit: number | null };
intakeEmailsCount: { used: number; limit: number | null };
membersCount: { used: number; limit: number | null };
};
limits: PlanLimits;
}>({
method: 'GET',
path: `/api/organizations/${organizationId}/usage`,
});
return { usage, limits };
}

View File

@@ -1,6 +1,6 @@
import type { Component } from 'solid-js';
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
import { insert, remove, setValue } from '@modular-forms/solid';
import { getValue, insert, remove, setValue } from '@modular-forms/solid';
import { A } from '@solidjs/router';
import { For, Show } from 'solid-js';
import * as v from 'valibot';
@@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Separator } from '@/modules/ui/components/separator';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
import { CONDITION_MATCH_MODES, CONDITION_MATCH_MODES_LOCALIZATION_KEYS, TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
export const TaggingRuleForm: Component<{
onSubmit: (args: { taggingRule: TaggingRuleForCreation }) => Promise<void> | void;
@@ -26,7 +26,7 @@ export const TaggingRuleForm: Component<{
const { confirm } = useConfirmModal();
const { form, Form, Field, FieldArray } = createForm({
onSubmit: async ({ name, conditions = [], tagIds, description }) => {
onSubmit: async ({ name, conditions = [], tagIds, description, conditionMatchMode }) => {
if (conditions.length === 0) {
const confirmed = await confirm({
title: t('tagging-rules.form.conditions.no-conditions.title'),
@@ -45,7 +45,7 @@ export const TaggingRuleForm: Component<{
}
}
props.onSubmit({ taggingRule: { name, conditions, tagIds, description } });
props.onSubmit({ taggingRule: { name, conditions, tagIds, description, conditionMatchMode } });
},
schema: v.object({
name: v.pipe(
@@ -57,6 +57,7 @@ export const TaggingRuleForm: Component<{
v.string(),
v.maxLength(256, t('tagging-rules.form.description.max-length')),
),
conditionMatchMode: v.optional(v.picklist(Object.values(CONDITION_MATCH_MODES))),
conditions: v.optional(
v.array(v.object({
field: v.picklist(Object.values(TAGGING_RULE_FIELDS)),
@@ -77,6 +78,7 @@ export const TaggingRuleForm: Component<{
tagIds: props.taggingRule?.actions.map(action => action.tagId) ?? [],
name: props.taggingRule?.name,
description: props.taggingRule?.description,
conditionMatchMode: props.taggingRule?.conditionMatchMode ?? CONDITION_MATCH_MODES.ALL,
},
});
@@ -88,6 +90,20 @@ export const TaggingRuleForm: Component<{
return t(TAGGING_RULE_FIELDS_LOCALIZATION_KEYS[field as keyof typeof TAGGING_RULE_FIELDS_LOCALIZATION_KEYS]);
};
const getConditionConnector = (index: number) => {
if (index === 0) {
return t('tagging-rules.form.conditions.connector.when');
}
const conditionMatchMode = getValue(form, 'conditionMatchMode');
if (conditionMatchMode === CONDITION_MATCH_MODES.ALL) {
return t('tagging-rules.form.conditions.connector.and');
}
return t('tagging-rules.form.conditions.connector.or');
};
return (
<Form>
<Field name="name">
@@ -126,13 +142,34 @@ export const TaggingRuleForm: Component<{
<p class="mb-1 font-medium">{t('tagging-rules.form.conditions.label')}</p>
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.conditions.description')}</p>
<Field name="conditionMatchMode">
{field => (
<Select
id="conditionMatchMode"
defaultValue={field.value ?? CONDITION_MATCH_MODES.ALL}
onChange={value => value && setValue(form, 'conditionMatchMode', value)}
options={Object.values(CONDITION_MATCH_MODES)}
itemComponent={props => (
<SelectItem item={props.item}>
{t(CONDITION_MATCH_MODES_LOCALIZATION_KEYS[props.item.rawValue as keyof typeof CONDITION_MATCH_MODES_LOCALIZATION_KEYS])}
</SelectItem>
)}
>
<SelectTrigger class="w-full mb-4">
<SelectValue<string>>{state => t(CONDITION_MATCH_MODES_LOCALIZATION_KEYS[state.selectedOption() as keyof typeof CONDITION_MATCH_MODES_LOCALIZATION_KEYS])}</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
)}
</Field>
<FieldArray name="conditions">
{fieldArray => (
<div>
<For each={fieldArray.items}>
{(_, index) => (
<div class="px-4 py-4 mb-1 flex gap-2 items-center bg-card border rounded-md">
<div>When</div>
<div>{getConditionConnector(index())}</div>
<Field name={`conditions.${index()}.field`}>
{field => (

View File

@@ -5,14 +5,17 @@ import { useMutation, useQuery } from '@tanstack/solid-query';
import { For, Match, Show, Switch } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { queryClient } from '@/modules/shared/query/query-client';
import { Alert } from '@/modules/ui/components/alert';
import { Button } from '@/modules/ui/components/button';
import { EmptyState } from '@/modules/ui/components/empty';
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
import { createToast } from '@/modules/ui/components/sonner';
import { applyTaggingRuleToExistingDocuments, deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
const { t } = useI18n();
const { confirm } = useConfirmModal();
const getConditionsLabel = () => {
const count = props.taggingRule.conditions.length;
@@ -37,6 +40,43 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
},
}));
const applyRuleMutation = useMutation(() => ({
mutationFn: async () => {
return applyTaggingRuleToExistingDocuments({
organizationId: props.taggingRule.organizationId,
taggingRuleId: props.taggingRule.id,
});
},
onSuccess: () => {
createToast({
message: t('tagging-rules.apply.success'),
type: 'success',
});
// Note: Documents will be processed in the background
// We'll invalidate this once task status retrieval is implemented
},
onError: () => {
createToast({
message: t('tagging-rules.apply.error'),
type: 'error',
});
},
}));
const handleApplyRule = async () => {
const isConfirmed = await confirm({
title: t('tagging-rules.apply.confirm.title'),
message: t('tagging-rules.apply.confirm.description'),
confirmButton: {
text: t('tagging-rules.apply.confirm.button'),
},
});
if (isConfirmed) {
applyRuleMutation.mutate();
}
};
return (
<div class="flex items-center gap-2 bg-card py-4 px-6 rounded-md border">
<A href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}>
@@ -52,12 +92,24 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleApplyRule}
disabled={applyRuleMutation.isPending}
aria-label={t('tagging-rules.apply.button')}
>
<div class="i-tabler-player-play size-4 mr-1" />
{applyRuleMutation.isPending ? t('tagging-rules.apply.processing') : t('tagging-rules.apply.button')}
</Button>
<Button
as={A}
href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}
variant="outline"
size="icon"
aria-label={t('tagging-rules.list.card.edit')}
class="size-8"
>
<div class="i-tabler-edit size-4" />
</Button>
@@ -68,6 +120,7 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
onClick={() => deleteTaggingRuleMutation.mutate()}
disabled={deleteTaggingRuleMutation.isPending}
aria-label={t('tagging-rules.list.card.delete')}
class="size-8"
>
<div class="i-tabler-trash size-4" />
</Button>

Some files were not shown because too many files have changed in this diff Show More