Compare commits

...

55 Commits

Author SHA1 Message Date
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
Corentin Thomasset
0616635cd6 chore(release): update versions (#509)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-24 17:00:01 +02:00
Corentin Thomasset
9e7a3ba70b chore(version): update version bump for api keys permissions changes (#516) 2025-09-24 16:49:11 +02:00
Corentin Thomasset
04990b986e docs(api-endpoints): added explications on how to use api keys (#515) 2025-09-24 14:41:14 +00:00
Corentin Thomasset
097b6bf2b7 feat(api-keys): added format check for api tokens to avoid unnecessary db call (#514) 2025-09-24 14:32:34 +00:00
Corentin Thomasset
cb3ce6b1d8 feat(api-keys): add organization permissions for api keys (#512) 2025-09-24 15:25:48 +02:00
Corentin Thomasset
405ba645f6 feat(docker): disable Better Auth telemetry in Dockerfiles (#511) 2025-09-21 20:56:43 +00:00
Corentin Thomasset
ab6fd6ad10 feat(tasks): update figue to allow for fallback task worker ids env variables (#510) 2025-09-21 22:53:04 +02:00
Corentin Thomasset
782f70ff66 feat(tasks): add option to disable PRAGMA statements in migrations (#508) 2025-09-20 22:07:34 +00:00
Corentin Thomasset
1abbf18e94 chore(release): update versions (#505)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-20 14:59:01 +02:00
Corentin Thomasset
6bcb2a71e9 feat(intake-emails): add intake email username pattern config (#506)
Co-authored-by: Alexander <goldengamerlp@users.noreply.github.com>
2025-09-19 20:37:25 +02:00
Corentin Thomasset
936bc2bd0a refactor(intake-emails): split username creation from addresses management (#504) 2025-09-18 01:59:29 +02:00
196 changed files with 11467 additions and 2410 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
@@ -44,9 +45,9 @@ jobs:
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:

3
.gitignore vendored
View File

@@ -43,4 +43,5 @@ ingestion
.cursorrules
*.traineddata
.eslintcache
.eslintcache
.claude

212
CLAUDE.md Normal file
View File

@@ -0,0 +1,212 @@
# 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
- Use business-oriented test names (avoid `it('should return true')`)
- Integration tests may use Testcontainers (Azurite, LocalStack)
- All new features require test coverage
## 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

@@ -1,5 +1,11 @@
# @papra/docs
## 0.6.1
### Patch Changes
- [#512](https://github.com/papra-hq/papra/pull/512) [`cb3ce6b`](https://github.com/papra-hq/papra/commit/cb3ce6b1d8d5dba09cbf0d2964f14b1c93220571) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added organizations permissions for api keys
## 0.6.0
### Minor Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/docs",
"type": "module",
"version": "0.6.0",
"version": "0.6.1",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra documentation website",
@@ -37,10 +37,11 @@
"@unocss/reset": "^0.64.0",
"eslint": "^9.17.0",
"eslint-plugin-astro": "^1.3.1",
"figue": "^2.2.2",
"figue": "^3.1.1",
"lodash-es": "^4.17.21",
"marked": "^15.0.6",
"typescript": "^5.7.3",
"unocss": "0.65.0-beta.2"
"unocss": "0.65.0-beta.2",
"vitest": "catalog:"
}
}

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 { isArray, isEmpty, isNil } from 'lodash-es';
import { marked } from 'marked';
import { castArray, isArray, isEmpty, isNil } from 'lodash-es';
import { configDefinition } from '../../papra-server/src/modules/config/config';
import { renderMarkdown } from './markdown';
function walk(configDefinition: ConfigDefinition, path: string[] = []): (ConfigDefinitionElement & { path: string[] })[] {
return Object
@@ -46,16 +46,21 @@ const rows = configDetails
};
});
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => `
### ${env}
const mdSections = rows.map(({ documentation, env, path, defaultValue }) => {
const envs = castArray(env);
const [firstEnv, ...restEnvs] = envs;
return `
### ${firstEnv}
${documentation}
- Path: \`${path.join('.')}\`
- Environment variable: \`${env}\`
- Environment variable: \`${firstEnv}\` ${restEnvs.length ? `, with fallback to: ${restEnvs.map(e => `\`${e}\``).join(', ')}` : ''}
- Default value: \`${defaultValue}\`
`.trim()).join('\n\n---\n\n');
`.trim();
}).join('\n\n---\n\n');
function wrapText(text: string, maxLength = 75) {
const words = text.split(' ');
@@ -80,25 +85,15 @@ function wrapText(text: string, maxLength = 75) {
const fullDotEnv = rows.map(({ env, defaultValue, documentation }) => {
const isEmptyDefaultValue = isNil(defaultValue) || (isArray(defaultValue) && isEmpty(defaultValue)) || defaultValue === '';
const envs = castArray(env);
const [firstEnv] = envs;
return [
...wrapText(documentation),
`# ${env}=${isEmptyDefaultValue ? '' : defaultValue}`,
`# ${firstEnv}=${isEmptyDefaultValue ? '' : defaultValue}`,
].join('\n');
}).join('\n\n');
// Dirty hack to add the same anchors to the headings as the ones generated by Starlight
const renderer = new marked.Renderer();
renderer.heading = function ({ text, depth }) {
const slug = text.toLowerCase().replace(/\W+/g, '-');
return `
<div class="sl-heading-wrapper level-h${depth}">
<h${depth} id="${slug}">${text}</h${depth}>
<a class="sl-anchor-link" href="#${slug}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only">Section titled “Configuration files”</span></a>
</div>
`.trim().replace(/\n/g, '');
};
const sectionsHtml = marked.parse(mdSections, { renderer });
const sectionsHtml = renderMarkdown(mdSections);
export { fullDotEnv, mdSections, sectionsHtml };

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

@@ -18,8 +18,107 @@ The public API uses a bearer token for authentication. You can get a token by lo
</details>
To authenticate your requests, include the token in the `Authorization` header with the `Bearer` prefix:
```
Authorization: Bearer YOUR_API_TOKEN
```
### Examples
**Using cURL:**
```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
https://api.papra.app/api/organizations
```
**Using JavaScript (fetch):**
```javascript
const response = await fetch('https://api.papra.app/api/organizations', {
headers: {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json'
}
})
```
### API Key Permissions
When creating an API key, you can select from the following permissions:
**Organizations:**
- `organizations:create` - Create new organizations
- `organizations:read` - Read organization information and list organizations of the user
- `organizations:update` - Update organization details
- `organizations:delete` - Delete organizations
**Documents:**
- `documents:create` - Upload and create new documents
- `documents:read` - Read and download documents
- `documents:update` - Update document metadata and content
- `documents:delete` - Delete documents
**Tags:**
- `tags:create` - Create new tags
- `tags:read` - Read tag information and list tags
- `tags:update` - Update tag details
- `tags:delete` - Delete tags
## Endpoints
### List organizations
**GET** `/api/organizations`
List all organizations accessible to the authenticated user.
- Required API key permissions: `organizations:read`
- Response (JSON)
- `organizations`: The list of organizations.
### Create an organization
**POST** `/api/organizations`
Create a new organization.
- Required API key permissions: `organizations:create`
- Body (JSON)
- `name`: The organization name (3-50 characters).
- Response (JSON)
- `organization`: The created organization.
### Get an organization
**GET** `/api/organizations/:organizationId`
Get an organization by its ID.
- Required API key permissions: `organizations:read`
- Response (JSON)
- `organization`: The organization.
### Update an organization
**PUT** `/api/organizations/:organizationId`
Update an organization's name.
- Required API key permissions: `organizations:update`
- Body (JSON)
- `name`: The new organization name (3-50 characters).
- Response (JSON)
- `organization`: The updated organization.
### Delete an organization
**DELETE** `/api/organizations/:organizationId`
Delete an organization by its ID.
- Required API key permissions: `organizations:delete`
- Response: empty (204 status code)
### Create a document
**POST** `/api/organizations/:organizationId/documents`

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' },
],
},
{
@@ -41,6 +42,23 @@ export const sidebar = [
},
],
},
{
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',
},
],
},
{
label: 'Resources',
items: [

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,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,165 +0,0 @@
# @papra/app-client
## 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,7 +1,7 @@
{
"name": "@papra/app-client",
"type": "module",
"version": "0.9.2",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra frontend client",
@@ -28,6 +28,7 @@
"script:sync-i18n-key-order": "tsx src/scripts/sync-i18n-key-order.script.ts"
},
"dependencies": {
"@branchlet/core": "^1.0.0",
"@corentinth/chisels": "^1.3.1",
"@kobalte/core": "^0.13.10",
"@kobalte/utils": "^0.9.1",
@@ -35,7 +36,7 @@
"@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:",
@@ -43,11 +44,10 @@
"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",
@@ -59,16 +59,15 @@
"@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.3",
"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';
@@ -28,17 +28,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 +58,9 @@ render(
</ConfirmModalProvider>
</I18nProvider>
</Suspense>
</QueryClientProvider>
)}
/>
)}
/>
</QueryClientProvider>
);
},
document.getElementById('root')!,

View File

@@ -102,6 +102,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 +152,21 @@ 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.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',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Alle auswählen',
'api-keys.permissions.deselect-all': 'Alle abwählen',
'api-keys.permissions.organizations.title': 'Organisationen',
'api-keys.permissions.organizations.organizations:create': 'Organisationen erstellen',
'api-keys.permissions.organizations.organizations:read': 'Organisationen lesen',
'api-keys.permissions.organizations.organizations:update': 'Organisationen aktualisieren',
'api-keys.permissions.organizations.organizations:delete': 'Organisationen löschen',
'api-keys.permissions.documents.title': 'Dokumente',
'api-keys.permissions.documents.documents:create': 'Dokumente erstellen',
'api-keys.permissions.documents.documents:read': 'Dokumente lesen',
@@ -512,11 +543,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',
@@ -541,7 +577,8 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Das Dokument existiert bereits',
'api-errors.document.size_too_large': 'Die Datei ist zu groß',
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingangse-Mails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
'api-errors.intake-emails.already_exists': 'Eine Eingang-Email mit dieser Adresse existiert bereits.',
'api-errors.intake_email.limit_reached': 'Die maximale Anzahl an Eingang-EMails für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Eingangse-Mails zu erstellen.',
'api-errors.user.max_organization_count_reached': 'Sie haben die maximale Anzahl an Organisationen erreicht, die Sie erstellen können. Wenn Sie weitere erstellen möchten, kontaktieren Sie bitte den Support.',
'api-errors.default': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten.',
'api-errors.organization.invitation_already_exists': 'Eine Einladung für diese E-Mail existiert bereits in dieser Organisation.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -100,6 +100,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 +150,21 @@ 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.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',
@@ -415,6 +439,13 @@ export const translations = {
// API keys
'api-keys.permissions.select-all': 'Select all',
'api-keys.permissions.deselect-all': 'Deselect all',
'api-keys.permissions.organizations.title': 'Organizations',
'api-keys.permissions.organizations.organizations:create': 'Create organizations',
'api-keys.permissions.organizations.organizations:read': 'Read organizations',
'api-keys.permissions.organizations.organizations:update': 'Update organizations',
'api-keys.permissions.organizations.organizations:delete': 'Delete organizations',
'api-keys.permissions.documents.title': 'Documents',
'api-keys.permissions.documents.documents:create': 'Create documents',
'api-keys.permissions.documents.documents:read': 'Read documents',
@@ -510,11 +541,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',
@@ -539,6 +575,7 @@ export const translations = {
'api-errors.document.already_exists': 'The document already exists',
'api-errors.document.size_too_large': 'The file size is too large',
'api-errors.intake-emails.already_exists': 'An intake email with this address already exists.',
'api-errors.intake_email.limit_reached': 'The maximum number of intake emails for this organization has been reached. Please upgrade your plan to create more intake emails.',
'api-errors.user.max_organization_count_reached': 'You have reached the maximum number of organizations you can create, if you need to create more, please contact support.',
'api-errors.default': 'An error occurred while processing your request.',
@@ -549,6 +586,7 @@ 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.',
// Not found
@@ -572,4 +610,54 @@ 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.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

@@ -102,6 +102,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 +152,21 @@ 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.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',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Seleccionar todo',
'api-keys.permissions.deselect-all': 'Deseleccionar todo',
'api-keys.permissions.organizations.title': 'Organizaciones',
'api-keys.permissions.organizations.organizations:create': 'Crear organizaciones',
'api-keys.permissions.organizations.organizations:read': 'Leer organizaciones',
'api-keys.permissions.organizations.organizations:update': 'Actualizar organizaciones',
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizaciones',
'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Crear documentos',
'api-keys.permissions.documents.documents:read': 'Leer documentos',
@@ -512,11 +543,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',
@@ -541,6 +577,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'El documento ya existe',
'api-errors.document.size_too_large': 'El archivo es demasiado grande',
'api-errors.intake-emails.already_exists': 'Ya existe un correo de ingreso con esta dirección.',
'api-errors.intake_email.limit_reached': 'Se ha alcanzado el número máximo de correos de ingreso para esta organización. Por favor, mejora tu plan para crear más correos de ingreso.',
'api-errors.user.max_organization_count_reached': 'Has alcanzado el número máximo de organizaciones que puedes crear, si necesitas crear más, contacta al soporte.',
'api-errors.default': 'Ocurrió un error al procesar tu solicitud.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -102,6 +102,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 +152,21 @@ 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.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.',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Tout sélectionner',
'api-keys.permissions.deselect-all': 'Tout désélectionner',
'api-keys.permissions.organizations.title': 'Organisations',
'api-keys.permissions.organizations.organizations:create': 'Créer des organisations',
'api-keys.permissions.organizations.organizations:read': 'Lire des organisations',
'api-keys.permissions.organizations.organizations:update': 'Mettre à jour des organisations',
'api-keys.permissions.organizations.organizations:delete': 'Supprimer des organisations',
'api-keys.permissions.documents.title': 'Documents',
'api-keys.permissions.documents.documents:create': 'Créer des documents',
'api-keys.permissions.documents.documents:read': 'Lire des documents',
@@ -512,11 +543,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',
@@ -541,6 +577,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Le document existe déjà',
'api-errors.document.size_too_large': 'Le fichier est trop volumineux',
'api-errors.intake-emails.already_exists': 'Un email de réception avec cette adresse existe déjà.',
'api-errors.intake_email.limit_reached': 'Le nombre maximum d\'emails de réception pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour créer plus d\'emails de réception.',
'api-errors.user.max_organization_count_reached': 'Vous avez atteint le nombre maximum d\'organisations que vous pouvez créer, si vous avez besoin de créer plus, veuillez contacter le support.',
'api-errors.default': 'Une erreur est survenue lors du traitement de votre requête.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -102,6 +102,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 +152,21 @@ 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.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',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Seleziona tutto',
'api-keys.permissions.deselect-all': 'Deseleziona tutto',
'api-keys.permissions.organizations.title': 'Organizzazioni',
'api-keys.permissions.organizations.organizations:create': 'Crea organizzazioni',
'api-keys.permissions.organizations.organizations:read': 'Leggi organizzazioni',
'api-keys.permissions.organizations.organizations:update': 'Aggiorna organizzazioni',
'api-keys.permissions.organizations.organizations:delete': 'Elimina organizzazioni',
'api-keys.permissions.documents.title': 'Documenti',
'api-keys.permissions.documents.documents:create': 'Crea documenti',
'api-keys.permissions.documents.documents:read': 'Leggi documenti',
@@ -512,11 +543,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',
@@ -541,6 +577,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Il documento esiste già',
'api-errors.document.size_too_large': 'Il file è troppo grande',
'api-errors.intake-emails.already_exists': 'Un\'email di acquisizione con questo indirizzo esiste già.',
'api-errors.intake_email.limit_reached': 'È stato raggiunto il numero massimo di email di acquisizione per questa organizzazione. Aggiorna il tuo piano per crearne altre.',
'api-errors.user.max_organization_count_reached': 'Hai raggiunto il numero massimo di organizzazioni che puoi creare, se hai bisogno di crearne altre contatta il supporto.',
'api-errors.default': 'Si è verificato un errore durante l\'elaborazione della richiesta.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -102,6 +102,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 +152,21 @@ 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.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',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Zaznacz wszystko',
'api-keys.permissions.deselect-all': 'Odznacz wszystko',
'api-keys.permissions.organizations.title': 'Organizacje',
'api-keys.permissions.organizations.organizations:create': 'Tworzenie organizacji',
'api-keys.permissions.organizations.organizations:read': 'Odczyt organizacji',
'api-keys.permissions.organizations.organizations:update': 'Aktualizacja organizacji',
'api-keys.permissions.organizations.organizations:delete': 'Usuwanie organizacji',
'api-keys.permissions.documents.title': 'Dokumenty',
'api-keys.permissions.documents.documents:create': 'Tworzenie dokumentów',
'api-keys.permissions.documents.documents:read': 'Odczyt dokumentów',
@@ -512,11 +543,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',
@@ -541,6 +577,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Dokument już istnieje',
'api-errors.document.size_too_large': 'Plik jest zbyt duży',
'api-errors.intake-emails.already_exists': 'Adres e-mail do przyjęć z tym adresem już istnieje.',
'api-errors.intake_email.limit_reached': 'Osiągnięto maksymalną liczbę adresów e-mail do przyjęć dla tej organizacji. Aby utworzyć więcej adresów e-mail do przyjęć, zaktualizuj swój plan.',
'api-errors.user.max_organization_count_reached': 'Osiągnięto maksymalną liczbę organizacji, które możesz utworzyć. Jeśli potrzebujesz utworzyć więcej, skontaktuj się z pomocą techniczną.',
'api-errors.default': 'Wystąpił błąd podczas przetwarzania żądania.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -102,6 +102,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 +152,21 @@ 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.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',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Selecionar tudo',
'api-keys.permissions.deselect-all': 'Desmarcar tudo',
'api-keys.permissions.organizations.title': 'Organizações',
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
'api-keys.permissions.organizations.organizations:delete': 'Excluir organizações',
'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Criar documentos',
'api-keys.permissions.documents.documents:read': 'Ler documentos',
@@ -512,11 +543,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',
@@ -541,6 +577,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.size_too_large': 'O arquivo é muito grande',
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
'api-errors.user.max_organization_count_reached': 'Você atingiu o número máximo de organizações que pode criar. Se precisar criar mais, entre em contato com o suporte.',
'api-errors.default': 'Ocorreu um erro ao processar sua solicitação.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -102,6 +102,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 +152,21 @@ 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.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',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Selecionar tudo',
'api-keys.permissions.deselect-all': 'Desselecionar tudo',
'api-keys.permissions.organizations.title': 'Organizações',
'api-keys.permissions.organizations.organizations:create': 'Criar organizações',
'api-keys.permissions.organizations.organizations:read': 'Ler organizações',
'api-keys.permissions.organizations.organizations:update': 'Atualizar organizações',
'api-keys.permissions.organizations.organizations:delete': 'Eliminar organizações',
'api-keys.permissions.documents.title': 'Documentos',
'api-keys.permissions.documents.documents:create': 'Criar documentos',
'api-keys.permissions.documents.documents:read': 'Ler documentos',
@@ -512,11 +543,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',
@@ -541,6 +577,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'O documento já existe',
'api-errors.document.size_too_large': 'O arquivo é muito grande',
'api-errors.intake-emails.already_exists': 'Um e-mail de entrada com este endereço já existe.',
'api-errors.intake_email.limit_reached': 'O número máximo de e-mails de entrada para esta organização foi atingido. Faça um upgrade no seu plano para criar mais e-mails de entrada.',
'api-errors.user.max_organization_count_reached': 'Atingiu o número máximo de organizações que pode criar. Se precisar de criar mais, entre em contato com o suporte.',
'api-errors.default': 'Ocorreu um erro ao processar a solicitação.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -102,6 +102,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 +152,21 @@ 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.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',
@@ -417,6 +441,13 @@ export const translations: Partial<TranslationsDictionary> = {
// API keys
'api-keys.permissions.select-all': 'Selectează tot',
'api-keys.permissions.deselect-all': 'Deselectează tot',
'api-keys.permissions.organizations.title': 'Organizații',
'api-keys.permissions.organizations.organizations:create': 'Creează organizații',
'api-keys.permissions.organizations.organizations:read': 'Citește organizații',
'api-keys.permissions.organizations.organizations:update': 'Actualizează organizații',
'api-keys.permissions.organizations.organizations:delete': 'Șterge organizații',
'api-keys.permissions.documents.title': 'Documente',
'api-keys.permissions.documents.documents:create': 'Creează documente',
'api-keys.permissions.documents.documents:read': 'Citește documente',
@@ -512,11 +543,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',
@@ -541,6 +577,7 @@ export const translations: Partial<TranslationsDictionary> = {
'api-errors.document.already_exists': 'Documentul există deja',
'api-errors.document.size_too_large': 'Fișierul este prea mare',
'api-errors.intake-emails.already_exists': 'Un email de primire cu această adresă există deja.',
'api-errors.intake_email.limit_reached': 'Numărul maxim de email-uri de primire pentru această organizație a fost atins. Te rugăm să-ți îmbunătățești planul pentru a crea mai multe email-uri de primire.',
'api-errors.user.max_organization_count_reached': 'Ai atins numărul maxim de organizații pe care le poți crea. Dacă ai nevoie să creezi mai multe, te rugăm să contactezi asistența.',
'api-errors.default': 'A apărut o eroare la procesarea cererii.',
@@ -551,6 +588,7 @@ 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.',
// Not found
@@ -574,4 +612,54 @@ 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.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

@@ -5,6 +5,15 @@
// } as const;
export const API_KEY_PERMISSIONS = [
{
section: 'organizations',
permissions: [
'organizations:create',
'organizations:read',
'organizations:update',
'organizations:delete',
],
},
{
section: 'documents',
permissions: [

View File

@@ -2,6 +2,7 @@ import type { Component } from 'solid-js';
import type { TranslationKeys } from '@/modules/i18n/locales.types';
import { createSignal, For } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/components/checkbox';
import { API_KEY_PERMISSIONS } from '../api-keys.constants';
@@ -42,34 +43,78 @@ export const ApiKeyPermissionsPicker: Component<{ permissions: string[]; onChang
props.onChange(permissions());
};
return (
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<For each={getPermissionsSections()}>
{section => (
<div>
<p class="text-muted-foreground text-xs">{section.title}</p>
const toggleSection = (sectionName: typeof API_KEY_PERMISSIONS[number]['section']) => {
const section = API_KEY_PERMISSIONS.find(s => s.section === sectionName);
if (!section) {
return;
}
<div class="pl-4 flex flex-col gap-4 mt-4">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
const sectionPermissions: ReadonlyArray<string> = section.permissions;
const currentPermissions = permissions();
const allSelected = sectionPermissions.every(p => currentPermissions.includes(p));
setPermissions((prev) => {
if (allSelected) {
return [...prev.filter(p => !sectionPermissions.includes(p))];
}
return [...new Set([...prev, ...sectionPermissions])];
});
};
return (
<div class="p-6 pb-8 border rounded-md mt-2">
<div class="flex flex-col gap-6">
<For each={getPermissionsSections()}>
{section => (
<div>
<Button variant="link" class="text-muted-foreground text-xs p-0 h-auto hover:no-underline" onClick={() => toggleSection(section.section)}>{section.title}</Button>
<div class="pl-4 flex flex-col mt-2">
<For each={section.permissions}>
{permission => (
<Checkbox
class="flex items-center gap-2"
checked={isPermissionSelected(permission.name)}
onChange={() => togglePermission(permission.name)}
>
<CheckboxControl />
<div class="flex flex-col gap-1">
<CheckboxLabel class="text-sm leading-none py-1">
{permission.description}
</CheckboxLabel>
</div>
</Checkbox>
)}
</For>
</div>
</div>
</div>
)}
</For>
)}
</For>
</div>
<div class="flex items-center gap-2 mt-6 border-t pt-6">
<Button
variant="outline"
size="sm"
class="disabled:(op-100! border-op-50 text-muted-foreground)"
onClick={() => setPermissions(API_KEY_PERMISSIONS.flatMap(section => section.permissions))}
disabled={permissions().length === API_KEY_PERMISSIONS.flatMap(section => section.permissions).length}
>
{t('api-keys.permissions.select-all')}
</Button>
<Button
variant="outline"
size="sm"
class="disabled:(op-100! border-op-50 text-muted-foreground)"
onClick={() => setPermissions([])}
disabled={permissions().length === 0}
>
{t('api-keys.permissions.deselect-all')}
</Button>
</div>
</div>
);
};

View File

@@ -96,9 +96,7 @@ export const CreateApiKeyPage: Component = () => {
<div>
<p class="text-sm font-bold">{t('api-keys.create.form.permissions.label')}</p>
<div class="p-6 pb-8 border rounded-md mt-2">
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
</div>
<ApiKeyPermissionsPicker permissions={field.value ?? []} onChange={permissions => setValue(form, 'permissions', permissions)} />
{field.error && <div class="text-red-500 text-sm">{field.error}</div>}
</div>

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

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

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

@@ -2,7 +2,7 @@ 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 { useI18n } from '@/modules/i18n/i18n.provider';
@@ -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">
@@ -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

@@ -1,5 +1,6 @@
import type { JSX } from 'solid-js';
import type { Locale } from './i18n.provider';
import { createBranchlet } from '@branchlet/core';
// 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 +30,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 +40,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;

View File

@@ -10,7 +10,7 @@ import { useConfig } from '@/modules/config/config.provider';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { useConfirmModal } from '@/modules/shared/confirm';
import { createForm } from '@/modules/shared/form/form';
import { isHttpErrorWithCode } from '@/modules/shared/http/http-errors';
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
import { queryClient } from '@/modules/shared/query/query-client';
import { cn } from '@/modules/shared/style/cn';
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
@@ -187,6 +187,7 @@ export const IntakeEmailsPage: Component = () => {
const params = useParams();
const { confirm } = useConfirmModal();
const { getErrorMessage } = useI18nApiErrors({ t });
const query = useQuery(() => ({
queryKey: ['organizations', params.organizationId, 'intake-emails'],
@@ -196,16 +197,12 @@ export const IntakeEmailsPage: Component = () => {
const createEmail = async () => {
const [,error] = await safely(createIntakeEmail({ organizationId: params.organizationId }));
if (isHttpErrorWithCode({ error, code: 'intake_email.limit_reached' })) {
if (error) {
createToast({
message: t('api-errors.intake_email.limit_reached'),
message: getErrorMessage({ error }),
type: 'error',
});
return;
}
if (error) {
throw error;
}

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,144 @@
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 } = 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 });
};
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
};
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

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

@@ -15,7 +15,7 @@ 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';
@@ -24,10 +24,12 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
const { confirm } = useConfirmModal();
const { t } = useI18n();
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
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,6 +37,7 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
cancelButton: {
text: t('organization.settings.delete.confirm.cancel-button'),
},
shouldType: props.organization.name,
});
if (confirmed) {
@@ -54,10 +57,16 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
</CardDescription>
</CardHeader>
<CardFooter class="pt-6">
<Button onClick={handleDelete} variant="destructive">
<CardFooter class="pt-6 gap-4">
<Button onClick={handleDelete} variant="destructive" disabled={!getIsOwner()}>
{t('organization.settings.delete.confirm.confirm-button')}
</Button>
<Show when={query.isSuccess && !getIsOwner()}>
<span class="text-sm text-muted-foreground">
{t('organization.settings.delete.only-owner')}
</span>
</Show>
</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

@@ -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,6 +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
@@ -22,14 +24,19 @@ 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;
}
// Fetch error message is not helpful
if (error instanceof FetchError) {
return getDefaultErrorMessage();
}
if (typeof error === 'object' && error && 'message' in error && typeof error.message === 'string') {
return error.message;
}

View File

@@ -26,5 +26,6 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}),
...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}),
...('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 { isRecord } from './object';
export function getErrorStatus(error: unknown): number | undefined {
if (isRecord(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,265 @@
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 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 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>
</DialogHeader>
<div class="mt-2 flex flex-col items-center">
<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 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,10 +1,10 @@
import posthog from 'posthog-js';
import { PostHog } from 'posthog-js-lite';
import { buildTimeConfig, isDev } from '../config/config';
type TrackingServices = {
capture: (args: {
event: string;
properties?: Record<string, unknown>;
properties?: Record<string, string | number | boolean>;
}) => void;
reset: () => void;
@@ -19,7 +19,7 @@ const dummyTrackingServices: TrackingServices = {
capture: ({ event, ...args }) => {
if (isDev) {
// eslint-disable-next-line no-console
console.log(`[dev] captured event ${event}`, args);
console.log(`[dev] captured event ${event}`, ...(Object.keys(args).length ? [args] : []));
}
},
reset: () => {},
@@ -38,13 +38,7 @@ function createTrackingServices(): TrackingServices {
return dummyTrackingServices;
}
posthog.init(
apiKey,
{
api_host: host,
capture_pageview: false,
},
);
const posthog = new PostHog(apiKey, { host });
return {
capture: ({ event, properties }) => {

View File

@@ -34,7 +34,7 @@ export function DialogContent<T extends ValidComponent = 'div'>(props: Polymorph
/>
<DialogPrimitive.Content
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-48% duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 slide-out-to-left-1/2 slide-out-to-top-48% duration-200) md:w-full sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[expanded]:(animate-in fade-in-0 zoom-in-95 slide-in-from-left-1/2 slide-in-from-top-48% duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 slide-out-to-left-1/2 slide-out-to-top-48% duration-200) md:w-full sm:rounded-lg max-h-100vh overflow-y-auto',
local.class,
)}
{...rest}

View File

@@ -0,0 +1,96 @@
import type { Component, ComponentProps } from 'solid-js';
import { mergeProps, splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
const sizes: Record<Size, { radius: number; strokeWidth: number }> = {
xs: { radius: 15, strokeWidth: 4 },
sm: { radius: 19, strokeWidth: 4 },
md: { radius: 32, strokeWidth: 6 },
lg: { radius: 52, strokeWidth: 8 },
xl: { radius: 80, strokeWidth: 10 },
};
type ProgressCircleProps = ComponentProps<'div'> & {
value?: number;
size?: Size;
radius?: number;
strokeWidth?: number;
showAnimation?: boolean;
};
const ProgressCircle: Component<ProgressCircleProps> = (rawProps) => {
const props = mergeProps({ size: 'md' as Size, showAnimation: true }, rawProps);
const [local, others] = splitProps(props, [
'class',
'children',
'value',
'size',
'radius',
'strokeWidth',
'showAnimation',
]);
const value = () => getLimitedValue(local.value);
const radius = () => local.radius ?? sizes[local.size].radius;
const strokeWidth = () => local.strokeWidth ?? sizes[local.size].strokeWidth;
const normalizedRadius = () => radius() - strokeWidth() / 2;
const circumference = () => normalizedRadius() * 2 * Math.PI;
const strokeDashoffset = () => (value() / 100) * circumference();
const offset = () => circumference() - strokeDashoffset();
return (
<div class={cn('flex flex-col items-center justify-center', local.class)} {...others}>
<svg
width={radius() * 2}
height={radius() * 2}
viewBox={`0 0 ${radius() * 2} ${radius() * 2}`}
class="-rotate-90"
aria-hidden="true"
>
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
fill="transparent"
stroke=""
stroke-linecap="round"
class={cn('stroke-secondary transition-colors ease-linear')}
/>
{value() >= 0
? (
<circle
r={normalizedRadius()}
cx={radius()}
cy={radius()}
stroke-width={strokeWidth()}
stroke-dasharray={`${circumference()} ${circumference()}`}
stroke-dashoffset={offset()}
fill="transparent"
stroke=""
stroke-linecap="round"
class={cn(
'stroke-primary transition-colors ease-linear',
local.showAnimation ? 'transition-all duration-300 ease-in-out' : '',
)}
/>
)
: null}
</svg>
<div class={cn('absolute flex')}>{local.children}</div>
</div>
);
};
function getLimitedValue(input: number | undefined) {
if (input === undefined) {
return 0;
} else if (input > 100) {
return 100;
}
return input;
}
export { ProgressCircle };

View File

@@ -4,7 +4,7 @@ import type {
SelectItemProps,
SelectTriggerProps,
} from '@kobalte/core/select';
import type { ParentProps, ValidComponent } from 'solid-js';
import type { JSX, ParentProps, ValidComponent } from 'solid-js';
import { Select as SelectPrimitive } from '@kobalte/core/select';
import { splitProps } from 'solid-js';
import { cn } from '@/modules/shared/style/cn';
@@ -17,12 +17,13 @@ export const SelectItemDescription = SelectPrimitive.ItemDescription;
export const SelectHiddenSelect = SelectPrimitive.HiddenSelect;
export const SelectSection = SelectPrimitive.Section;
type selectTriggerProps<T extends ValidComponent = 'button'> = ParentProps<SelectTriggerProps<T> & { class?: string }>;
type selectTriggerProps<T extends ValidComponent = 'button'> = ParentProps<SelectTriggerProps<T> & { class?: string; caretIcon?: JSX.Element }>;
export function SelectTrigger<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, selectTriggerProps<T>>) {
const [local, rest] = splitProps(props as selectTriggerProps, [
'class',
'children',
'caretIcon',
]);
return (
@@ -34,23 +35,27 @@ export function SelectTrigger<T extends ValidComponent = 'button'>(props: Polymo
{...rest}
>
{local.children}
<SelectPrimitive.Icon
as="svg"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="size-4 opacity-50 flex items-center justify-center"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
/>
</SelectPrimitive.Icon>
{local.caretIcon !== undefined
? local.caretIcon
: (
<SelectPrimitive.Icon
as="svg"
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="size-4 opacity-50 flex items-center justify-center"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m8 9l4-4l4 4m0 6l-4 4l-4-4"
/>
</SelectPrimitive.Icon>
)}
</SelectPrimitive.Trigger>
);
}

View File

@@ -14,6 +14,11 @@ export const OrganizationSettingsLayout: ParentComponent = (props) => {
href: `/organizations/${params.organizationId}/settings`,
icon: 'i-tabler-settings',
},
{
label: t('layout.menu.usage'),
href: `/organizations/${params.organizationId}/settings/usage`,
icon: 'i-tabler-chart-bar',
},
{
label: t('layout.menu.intake-emails'),
href: `/organizations/${params.organizationId}/settings/intake-emails`,
@@ -28,7 +33,7 @@ export const OrganizationSettingsLayout: ParentComponent = (props) => {
return (
<div class="flex flex-row h-screen min-h-0">
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<SideNav
mainMenu={getNavigationItems()}

View File

@@ -3,12 +3,16 @@ import type { Component, ParentComponent } from 'solid-js';
import type { Organization } from '@/modules/organizations/organizations.types';
import { useNavigate, useParams } from '@solidjs/router';
import { createQueries, useQuery } from '@tanstack/solid-query';
import { get } from 'lodash-es';
import { createEffect, on } from 'solid-js';
import { useQuery } from '@tanstack/solid-query';
import { createEffect, on, Show } from 'solid-js';
import { useConfig } from '@/modules/config/config.provider';
import { DocumentUploadProvider } from '@/modules/documents/components/document-import-status.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { fetchOrganization, fetchOrganizations } from '@/modules/organizations/organizations.services';
import { getErrorStatus } from '@/modules/shared/utils/errors';
import { UpgradeDialog } from '@/modules/subscriptions/components/upgrade-dialog.component';
import { fetchOrganizationSubscription } from '@/modules/subscriptions/subscriptions.services';
import { Button } from '../components/button';
import {
Select,
SelectContent,
@@ -18,6 +22,49 @@ import {
} from '../components/select';
import { SideNav, SidenavLayout } from './sidenav.layout';
const UpgradeCTAFooter: Component<{ organizationId: string }> = (props) => {
const { t } = useI18n();
const { config } = useConfig();
const query = useQuery(() => ({
queryKey: ['organizations', props.organizationId, 'subscription'],
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
}));
const shouldShowUpgradeCTA = () => {
if (!config.isSubscriptionsEnabled) {
return false;
}
return query.data && query.data.plan.id === 'free';
};
return (
<div>
<Show when={shouldShowUpgradeCTA()}>
<div class="p-4 mx-4 mt-4 bg-background bg-gradient-to-br from-primary/15 to-transparent rounded-lg">
<div class="flex items-center gap-2 text-sm font-medium">
<div class="i-tabler-sparkles size-4 text-primary"></div>
{t('layout.upgrade-cta.title')}
</div>
<div class="text-xs mt-1 mb-3 text-muted-foreground">
{t('layout.upgrade-cta.description')}
</div>
<UpgradeDialog organizationId={props.organizationId}>
{dialogProps => (
<Button size="sm" class="w-full font-semibold" {...dialogProps}>
{t('layout.upgrade-cta.button')}
<div class="i-tabler-arrow-right size-4 ml-1"></div>
</Button>
)}
</UpgradeDialog>
</div>
</Show>
</div>
);
};
const OrganizationLayoutSideNav: Component = () => {
const navigate = useNavigate();
const params = useParams();
@@ -65,24 +112,21 @@ const OrganizationLayoutSideNav: Component = () => {
},
];
const queries = createQueries(() => ({
queries: [
{
queryKey: ['organizations'],
queryFn: fetchOrganizations,
},
{
queryKey: ['organizations', params.organizationId],
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
},
],
const organizationsQuery = useQuery(() => ({
queryKey: ['organizations'],
queryFn: fetchOrganizations,
}));
const organizationQuery = useQuery(() => ({
queryKey: ['organizations', params.organizationId],
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
}));
createEffect(on(
() => queries[1].error,
() => organizationQuery.error,
(error) => {
if (error) {
const status = get(error, 'status');
const status = getErrorStatus(error);
if (status && [
400, // when the id of the organization is not valid
@@ -98,14 +142,16 @@ const OrganizationLayoutSideNav: Component = () => {
<SideNav
mainMenu={getMainMenuItems()}
footerMenu={getFooterMenuItems()}
footer={() => <UpgradeCTAFooter organizationId={params.organizationId} />}
header={() =>
(
<div class="px-6 pt-4 max-w-285px min-w-0">
<div class="p-4 pb-0 min-w-0 max-w-full">
<Select
options={[...queries[0].data?.organizations ?? [], { id: 'create' }]}
class="w-full"
options={[...organizationsQuery.data?.organizations ?? [], { id: 'create' }]}
optionValue="id"
optionTextValue="name"
value={queries[0].data?.organizations.find(organization => organization.id === params.organizationId)}
value={organizationsQuery.data?.organizations.find(organization => organization.id === params.organizationId)}
onChange={(value) => {
if (!value || value.id === params.organizationId) {
return;
@@ -129,11 +175,23 @@ const OrganizationLayoutSideNav: Component = () => {
<SelectItem class="cursor-pointer" item={props.item}>{props.item.rawValue.name}</SelectItem>
)}
>
<SelectTrigger>
<SelectValue<Organization> class="truncate">
{state => state.selectedOption().name}
<SelectTrigger class="hover:bg-accent/50 transition rounded-lg h-auto pl-2" caretIcon={<div class="i-tabler-chevron-down size-4 opacity-50 ml-2 flex-shrink-0" />}>
<SelectValue<Organization | undefined> class="flex items-center gap-2 min-w-0">
{state => (
<>
<span class="p-1.5 rounded text-lg font-bold flex items-center bg-muted light:border dark:bg-primary/10 text-primary transition flex-shrink-0">
<div class="i-tabler-file-text size-5.5"></div>
</span>
<span class="truncate text-base font-medium">
{state.selectedOption()?.name}
</span>
</>
)}
</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
@@ -157,7 +215,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
() => query.error,
(error) => {
if (error) {
const status = get(error, 'status');
const status = getErrorStatus(error);
if (status && [401, 403].includes(status)) {
navigate('/');
@@ -167,7 +225,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
));
return (
<DocumentUploadProvider>
<DocumentUploadProvider organizationId={params.organizationId}>
<SidenavLayout
children={props.children}
sideNav={OrganizationLayoutSideNav}

View File

@@ -27,7 +27,7 @@ export const SettingsLayout: ParentComponent = (props) => {
return (
<div class="flex flex-row h-screen min-h-0">
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<SideNav
mainMenu={getMainMenuItems()}

View File

@@ -1,4 +1,3 @@
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { Component, ComponentProps, JSX, ParentComponent } from 'solid-js';
import { A, useNavigate, useParams } from '@solidjs/router';
import { Show, Suspense } from 'solid-js';
@@ -6,17 +5,16 @@ import { Show, Suspense } from 'solid-js';
import { signOut } from '@/modules/auth/auth.services';
import { useCommandPalette } from '@/modules/command-palette/command-palette.provider';
import { useConfig } from '@/modules/config/config.provider';
import { useDocumentUpload } from '@/modules/documents/components/document-import-status.component';
import { GlobalDropArea } from '@/modules/documents/components/global-drop-area.component';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { usePendingInvitationsCount } from '@/modules/invitations/composables/usePendingInvitationsCount';
import { cn } from '@/modules/shared/style/cn';
import { UsageWarningCard } from '@/modules/subscriptions/components/usage-warning-card';
import { useThemeStore } from '@/modules/theme/theme.store';
import { Button } from '@/modules/ui/components/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { Sheet, SheetContent, SheetTrigger } from '../components/sheet';
import { Tooltip, TooltipContent, TooltipTrigger } from '../components/tooltip';
type MenuItem = {
label: string;
@@ -36,78 +34,17 @@ const MenuItemButton: Component<MenuItem> = (props) => {
);
};
function getReleaseUrl({ version, packageName = '@papra/app-server' }: { version: string; packageName?: string }) {
const encodedVersion = encodeURIComponent(`${packageName}@${version}`);
return `https://github.com/papra-hq/papra/releases/tag/${encodedVersion}`;
}
export const SideNav: Component<{
mainMenu?: MenuItem[];
footerMenu?: MenuItem[];
header?: Component;
footer?: Component;
preFooter?: Component;
}> = (props) => {
const { config } = useConfig();
const getShortSideNavItems = () => [
{
label: 'All organizations',
to: '/organizations',
icon: 'i-tabler-building-community',
},
{
label: 'GitHub repository',
href: 'https://github.com/papra-hq/papra',
icon: 'i-tabler-brand-github',
},
{
label: 'Bluesky',
href: 'https://bsky.app/profile/papra.app',
icon: 'i-tabler-brand-bluesky',
},
];
const version = `v${config.papraVersion}`;
return (
<div class="flex h-full">
<div class="w-65px border-r bg-card pt-4 pb-6 flex flex-col">
<Button variant="link" size="icon" as={A} href="/" class="text-lg font-bold hover:no-underline flex items-center text-primary mb-4 mx-auto">
<div class="i-tabler-file-text size-10 transform rotate-12deg hover:rotate-25deg transition"></div>
</Button>
<div class="flex flex-col gap-0.5 flex-1">
{getShortSideNavItems().map(menuItem => (
<Tooltip>
<TooltipTrigger
as={(tooltipProps: TooltipTriggerProps) => (
<Button
variant="link"
class="text-lg font-bold hover:no-underline flex items-center text-foreground dark:text-muted-foreground hover:text-primary"
{...tooltipProps}
aria-label={menuItem.label}
{...(menuItem.href
? { as: 'a', href: menuItem.href, target: '_blank', rel: 'noopener noreferrer' }
: { as: A, href: menuItem.to })}
>
<div class={cn(menuItem.icon, 'size-5')} />
</Button>
)}
/>
<TooltipContent>{menuItem.label}</TooltipContent>
</Tooltip>
))}
</div>
<a class="text-xs text-muted-foreground text-center mt-auto transition-colors hover:(text-primary underline)" href={getReleaseUrl({ version: config.papraVersion })} target="_blank" rel="noopener noreferrer">
{version}
</a>
</div>
{(props.header || props.mainMenu || props.footerMenu || props.footer) && (
<div class="h-full flex flex-col pb-6 flex-1">
{(props.header || props.mainMenu || props.footerMenu || props.footer || props.preFooter) && (
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
{props.header && <props.header />}
{props.mainMenu && (
@@ -118,6 +55,8 @@ export const SideNav: Component<{
<div class="flex-1"></div>
{props.preFooter && <props.preFooter />}
{props.footerMenu && (
<nav class="flex flex-col gap-0.5 px-4">
{props.footerMenu.map(menuItem => <MenuItemButton {...menuItem} />)}
@@ -191,15 +130,18 @@ export const SidenavLayout: ParentComponent<{
const { getPendingInvitationsCount } = usePendingInvitationsCount();
const { t } = useI18n();
const { promptImport, uploadDocuments } = useDocumentUpload({ getOrganizationId: () => params.organizationId });
const { promptImport, uploadDocuments } = useDocumentUpload();
return (
<div class="flex flex-row h-screen min-h-0">
<div class="w-350px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<div class="w-280px border-r border-r-border flex-shrink-0 hidden md:block bg-card">
<props.sideNav />
</div>
<div class="flex-1 min-h-0 flex flex-col">
<UsageWarningCard organizationId={params.organizationId} />
<div class="flex justify-between px-6 pt-4">
<div class="flex items-center">
@@ -301,6 +243,7 @@ export const SidenavLayout: ParentComponent<{
<div class="flex-1 overflow-auto max-w-screen">
<Suspense>
{props.children}
</Suspense>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import type { ParentComponent } from 'solid-js';
import type { UserMe } from '../users.types';
import { makePersisted } from '@solid-primitives/storage';
import { createQueries } from '@tanstack/solid-query';
import { useQuery } from '@tanstack/solid-query';
import { createContext, createSignal, Show, useContext } from 'solid-js';
import { fetchCurrentUser } from '../users.services';
@@ -26,23 +26,18 @@ export function useCurrentUser() {
export const CurrentUserProvider: ParentComponent = (props) => {
const [getLatestOrganizationId, setLatestOrganizationId] = makePersisted(createSignal<string | null>(null), { name: 'papra_current_organization_id', storage: localStorage });
const queries = createQueries(() => ({
queries: [
{
queryKey: ['users', 'me'],
queryFn: fetchCurrentUser,
},
],
const query = useQuery(() => ({
queryKey: ['users', 'me'],
queryFn: fetchCurrentUser,
}));
return (
<Show when={queries[0].data}>
<Show when={query.data}>
<currentUserContext.Provider
value={{
user: queries[0].data!.user,
user: query.data!.user,
refreshCurrentUser: async () => {
queries[0].refetch();
query.refetch();
},
getLatestOrganizationId,

View File

@@ -18,13 +18,17 @@ import { InvitationsPage } from './modules/invitations/pages/invitations.page';
import { fetchOrganizations } from './modules/organizations/organizations.services';
import { CreateFirstOrganizationPage } from './modules/organizations/pages/create-first-organization.page';
import { CreateOrganizationPage } from './modules/organizations/pages/create-organization.page';
import { DeletedOrganizationsPage } from './modules/organizations/pages/deleted-organizations.page';
import { InvitationsListPage } from './modules/organizations/pages/invitations-list.page';
import { InviteMemberPage } from './modules/organizations/pages/invite-member.page';
import { MembersPage } from './modules/organizations/pages/members.page';
import { OrganizationUsagePage } from './modules/organizations/pages/organization-usage.page';
import { OrganizationPage } from './modules/organizations/pages/organization.page';
import { OrganizationsSettingsPage } from './modules/organizations/pages/organizations-settings.page';
import { OrganizationsPage } from './modules/organizations/pages/organizations.page';
import { NotFoundPage } from './modules/shared/pages/not-found.page';
import { CheckoutCancelPage } from './modules/subscriptions/pages/checkout-cancel.page';
import { CheckoutSuccessPage } from './modules/subscriptions/pages/checkout-success.page';
import { CreateTaggingRulePage } from './modules/tagging-rules/pages/create-tagging-rule.page';
import { TaggingRulesPage } from './modules/tagging-rules/pages/tagging-rules.page';
import { UpdateTaggingRulePage } from './modules/tagging-rules/pages/update-tagging-rule.page';
@@ -63,11 +67,11 @@ export const routes: RouteDefinition[] = [
<Navigate href={`/organizations/${getLatestOrganizationId()}`} />
</Match>
<Match when={query.data && query.data.organizations.length > 0}>
<Match when={getOrgs().length > 0}>
<Navigate href="/organizations" />
</Match>
<Match when={query.data && query.data.organizations.length === 0}>
<Match when={getOrgs().length === 0}>
<Navigate href="/organizations/first" />
</Match>
</Switch>
@@ -86,6 +90,10 @@ export const routes: RouteDefinition[] = [
path: '/',
component: OrganizationsPage,
},
{
path: '/deleted',
component: DeletedOrganizationsPage,
},
{
path: '/:organizationId',
component: (props) => {
@@ -155,6 +163,10 @@ export const routes: RouteDefinition[] = [
path: '/',
component: OrganizationsSettingsPage,
},
{
path: '/usage',
component: OrganizationUsagePage,
},
{
path: '/webhooks/create',
component: CreateWebhookPage,
@@ -227,6 +239,14 @@ export const routes: RouteDefinition[] = [
path: '/email-validation-required',
component: createProtectedPage({ authType: 'public-only', component: EmailValidationRequiredPage }),
},
{
path: '/checkout-success',
component: CheckoutSuccessPage,
},
{
path: '/checkout-cancel',
component: CheckoutCancelPage,
},
{
path: '*404',
component: NotFoundPage,

View File

@@ -1,4 +1,3 @@
import { uniq, values } from 'lodash-es';
import {
defineConfig,
presetIcons,
@@ -113,9 +112,9 @@ export default defineConfig({
},
},
safelist: [
...uniq([
...values(iconByFileType),
...values(documentActivityIcon),
...new Set([
...Object.values(iconByFileType),
...Object.values(documentActivityIcon),
...(ssoProviders.map(p => p.icon)),
]),
],

View File

@@ -2,16 +2,12 @@ import path from 'node:path';
import unoCssPlugin from 'unocss/vite';
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
import { version } from './package.json';
export default defineConfig({
plugins: [
unoCssPlugin(),
solidPlugin(),
],
define: {
'import.meta.env.VITE_PAPRA_VERSION': JSON.stringify(version),
},
server: {
port: 3000,
proxy: {

View File

@@ -0,0 +1,19 @@
/.git
/node_modules
.dockerignore
.env
Dockerfile
fly.toml
*.vars
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
local-documents
ingestion
.cursorrules
*.traineddata
*.md

View File

@@ -1,182 +0,0 @@
# @papra/app-server
## 0.9.2
### Patch Changes
- [#493](https://github.com/papra-hq/papra/pull/493) [`ed4d7e4`](https://github.com/papra-hq/papra/commit/ed4d7e4a00b2ca2c7fe808201c322f957d6ed990) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix to allow cross docker volume file moving when consumption is done
- [#500](https://github.com/papra-hq/papra/pull/500) [`208a561`](https://github.com/papra-hq/papra/commit/208a561668ed2d1019430a9f4f5c5d3fd4cde603) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added the possibility to define a Libsql/Sqlite driver for the tasks service
- [#499](https://github.com/papra-hq/papra/pull/499) [`40cb1d7`](https://github.com/papra-hq/papra/commit/40cb1d71d5e52c40aab7ea2c6bc222cea6d55b70) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Enhanced security by serving files as attachement and with an octet-stream content type
## 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
- [#491](https://github.com/papra-hq/papra/pull/491) [`bb9d555`](https://github.com/papra-hq/papra/commit/bb9d5556d3f16225ae40ca4d39600999e819b2c4) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix cleanup state when a too-big-file is uploaded
## 0.9.0
### Minor Changes
- [#472](https://github.com/papra-hq/papra/pull/472) [`b08241f`](https://github.com/papra-hq/papra/commit/b08241f20fc326a65a8de0551a7bfa91d9e4c71d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Dropped support for the dedicated backblaze b2 storage driver as b2 now fully support s3 client
- [#480](https://github.com/papra-hq/papra/pull/480) [`0a03f42`](https://github.com/papra-hq/papra/commit/0a03f42231f691d339c7ab5a5916c52385e31bd2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added documents encryption layer
- [#472](https://github.com/papra-hq/papra/pull/472) [`b08241f`](https://github.com/papra-hq/papra/commit/b08241f20fc326a65a8de0551a7bfa91d9e4c71d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Stream file upload instead of full in-memory loading
### Patch Changes
- [#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
- [#483](https://github.com/papra-hq/papra/pull/483) [`ec0a437`](https://github.com/papra-hq/papra/commit/ec0a437d86b4c8c0979ba9d0c2ff7b39f054cec0) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix a bug where the ingestion folder was not working when the done or error destination folder path (INGESTION_FOLDER_POST_PROCESSING_MOVE_FOLDER_PATH and INGESTION_FOLDER_ERROR_FOLDER_PATH) were absolute.
- [#475](https://github.com/papra-hq/papra/pull/475) [`ea9d90d`](https://github.com/papra-hq/papra/commit/ea9d90d6cff6954297152b3ad16f99170e8cd0dc) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Use node file streams in ingestion folder for smaller RAM footprint
- [#477](https://github.com/papra-hq/papra/pull/477) [`a62d376`](https://github.com/papra-hq/papra/commit/a62d3767729ab02ae203a1ac7b7fd6eb6e011d98) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed an issue where tags assigned to only deleted documents won't show up in the tag list
- [#472](https://github.com/papra-hq/papra/pull/472) [`b08241f`](https://github.com/papra-hq/papra/commit/b08241f20fc326a65a8de0551a7bfa91d9e4c71d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Properly handle missing files errors in storage drivers
- Updated dependencies [[`14bc2b8`](https://github.com/papra-hq/papra/commit/14bc2b8f8d0d6605062f37188e7c57bbc61b2c1a)]:
- @papra/webhooks@0.3.0
- @papra/lecture@0.2.0
## 0.8.2
### Patch Changes
- [#461](https://github.com/papra-hq/papra/pull/461) [`c085b9d`](https://github.com/papra-hq/papra/commit/c085b9d6766297943112601d3c634c716c4be440) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix a regression bug that executed tagging rules before the file content was extracted
## 0.8.1
### Patch Changes
- [#459](https://github.com/papra-hq/papra/pull/459) [`f20559e`](https://github.com/papra-hq/papra/commit/f20559e95d1dc7d7a099dfd9a9df42bf5ce1b0b2) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Removed dev-dependency needed in production build
## 0.8.0
### Minor Changes
- [#452](https://github.com/papra-hq/papra/pull/452) [`7f7e5bf`](https://github.com/papra-hq/papra/commit/7f7e5bffcbcfb843f3b2458400dfb44409a44867) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Completely rewrote the migration mechanism
- [#447](https://github.com/papra-hq/papra/pull/447) [`b5ccc13`](https://github.com/papra-hq/papra/commit/b5ccc135ba7f4359eaf85221bcb40ee63ba7d6c7) Thanks [@CorentinTh](https://github.com/CorentinTh)! - The file content extraction (like OCR) is now done asynchronously by the task runner
- [#448](https://github.com/papra-hq/papra/pull/448) [`5868800`](https://github.com/papra-hq/papra/commit/5868800bcec6ed69b5441b50e4445fae5cdb5bfb) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fixed the impossibility to delete a tag that has been assigned to a document
- [#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
- [#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
- Updated dependencies [[`a8cff8c`](https://github.com/papra-hq/papra/commit/a8cff8cedc062be3ed1d454e9de6e456553a4d8c), [`6723baf`](https://github.com/papra-hq/papra/commit/6723baf98ad46f989fe1e1e19ad0dd25622cca77), [`6723baf`](https://github.com/papra-hq/papra/commit/6723baf98ad46f989fe1e1e19ad0dd25622cca77), [`67b3b14`](https://github.com/papra-hq/papra/commit/67b3b14cdfa994874c695b9d854a93160ba6a911)]:
- @papra/webhooks@0.2.0
- @papra/lecture@0.1.0
## 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
- [#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
- [#392](https://github.com/papra-hq/papra/pull/392) [`21a5ccc`](https://github.com/papra-hq/papra/commit/21a5ccce6d42fde143fd3596918dfdfc9af577a1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix permission issue for non 1000:1000 rootless user
- [#387](https://github.com/papra-hq/papra/pull/387) [`73b8d08`](https://github.com/papra-hq/papra/commit/73b8d080765b6eb9b479db39740cdc6972f6585d) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added configuration for the ocr language using DOCUMENTS_OCR_LANGUAGES
- [#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
- Updated dependencies [[`6cedc30`](https://github.com/papra-hq/papra/commit/6cedc30716e320946f79a0a9fd8d3b26e834f4db)]:
- @papra/webhooks@0.1.1
## 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
- [#366](https://github.com/papra-hq/papra/pull/366) [`b8c2bd7`](https://github.com/papra-hq/papra/commit/b8c2bd70e3d0c215da34efcdcdf1b75da1ed96a1) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Allow for adding/removing tags to document using api keys
## 0.6.2
### Patch Changes
- [#337](https://github.com/papra-hq/papra/pull/337) [`1c574b8`](https://github.com/papra-hq/papra/commit/1c574b8305eb7bde4f1b75ac38a610ca0120a613) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Ensure database directory exists when running scripts (like migrations)
## 0.6.1
### Patch Changes
- [#326](https://github.com/papra-hq/papra/pull/326) [`17ca8f8`](https://github.com/papra-hq/papra/commit/17ca8f8f8110c3ffb550f67bfba817872370171c) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Fix content disposition header to support non-ascii filenames
## 0.6.0
### Minor Changes
- [#320](https://github.com/papra-hq/papra/pull/320) [`8ccdb74`](https://github.com/papra-hq/papra/commit/8ccdb748349a3cacf38f032fd4d3beebce202487) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Set CLIENT_BASE_URL default value to http://localhost:1221 in Dockerfiles
- [#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
- [#306](https://github.com/papra-hq/papra/pull/306) [`f0876fd`](https://github.com/papra-hq/papra/commit/f0876fdc638d596c5b7f5eeb2e6cd9beecab328f) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Added support for classic SMTP client for email sending
- [#304](https://github.com/papra-hq/papra/pull/304) [`cb38d66`](https://github.com/papra-hq/papra/commit/cb38d66485368429027826d7a1630e75fbe52e65) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Reworked the email sending system to be more flexible and allow for different drivers to be used.
`EMAILS_DRY_RUN` has been removed and you can now use `EMAILS_DRIVER=logger` config option to log emails instead of sending them.
## 0.5.1
### Patch Changes
- [#302](https://github.com/papra-hq/papra/pull/302) [`b62ddf2`](https://github.com/papra-hq/papra/commit/b62ddf2bc4d1b134b14c847ffa30b65cb29489af) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Set email setting to dry-run by default in docker
## 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
- [#294](https://github.com/papra-hq/papra/pull/294) [`b400b3f`](https://github.com/papra-hq/papra/commit/b400b3f18ddbeff33f8265f128d4bc8b67b27d77) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Ensure local database directory en boot
- [#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
## 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)! - Properly hard delete files in storage driver
- [#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 support for b2 document storage
- [#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
- [#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 support for azure blob document storage
### 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)! - Fix ingestion config coercion
- [#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)! - Excluded deleted documents from doc count
- Updated dependencies [[`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67)]:
- @papra/webhooks@0.1.0

View File

@@ -0,0 +1,66 @@
# syntax = docker/dockerfile:1
# Adjust NODE_VERSION as desired
ARG NODE_VERSION=22.19.0
FROM node:${NODE_VERSION}-slim AS base
LABEL fly_launch_runtime="Node.js"
# Install pnpm
ARG PNPM_VERSION=10.12.3
RUN npm install -g pnpm@${PNPM_VERSION}
# Node.js app lives here
WORKDIR /app
# Set production environment
ENV NODE_ENV="production"
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3
# Copy monorepo configuration files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
# Copy package.json files for all workspace packages
COPY packages/lecture/package.json ./packages/lecture/package.json
COPY packages/webhooks/package.json ./packages/webhooks/package.json
COPY apps/papra-server/package.json ./apps/papra-server/package.json
# Install all dependencies (workspace-aware)
RUN pnpm install --frozen-lockfile
# Copy source code for dependencies
COPY packages/lecture ./packages/lecture
COPY packages/webhooks ./packages/webhooks
# Build workspace dependencies
RUN pnpm --filter @papra/lecture build
RUN pnpm --filter @papra/webhooks build
# Copy server application code
COPY apps/papra-server ./apps/papra-server
# Build server application
RUN pnpm --filter @papra/app-server build
# Prune dev dependencies
RUN pnpm --filter @papra/app-server --prod --legacy deploy /app/production
# Final stage for app image
FROM base
ENV NODE_ENV="production"
ENV PORT=1221
# Copy built application from production deployment
COPY --from=build /app/production /app
# Start the server by default, this can be overwritten at runtime
EXPOSE 1221
CMD [ "pnpm", "--silent", "start:with-migrations" ]

View File

@@ -0,0 +1,35 @@
# https://fly.io/docs/reference/configuration/
app = 'papra-server'
primary_region = 'cdg'
kill_timeout = 10 # seconds
[build]
dockerfile = "./Dockerfile"
[deploy]
release_command = "pnpm --silent migrate:up:prod"
strategy = "rolling"
[processes]
web = "pnpm --silent start"
[http_service]
internal_port = 1221
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = [ 'web' ]
[checks]
[checks.http]
type = "http"
method = "get"
path = "/api/health"
port = 1221
interval = "15s"
grace_period = "20s"
timeout = "3s"
processes = [ "web" ]

View File

@@ -1,7 +1,7 @@
{
"name": "@papra/app-server",
"type": "module",
"version": "0.9.2",
"version": "0.0.0",
"private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra app server",
@@ -42,7 +42,7 @@
"@aws-sdk/lib-storage": "^3.835.0",
"@azure/storage-blob": "^12.27.0",
"@cadence-mq/core": "^0.2.1",
"@cadence-mq/driver-libsql": "^0.2.1",
"@cadence-mq/driver-libsql": "^0.2.4",
"@cadence-mq/driver-memory": "^0.2.0",
"@corentinth/chisels": "^1.3.1",
"@corentinth/friendly-ids": "^0.0.1",
@@ -55,6 +55,7 @@
"@papra/lecture": "workspace:*",
"@papra/webhooks": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2",
"@sindresorhus/slugify": "^3.0.0",
"better-auth": "catalog:",
"busboy": "^1.6.0",
"c12": "^3.0.4",
@@ -62,7 +63,7 @@
"date-fns": "^4.1.0",
"drizzle-kit": "^0.30.6",
"drizzle-orm": "^0.38.4",
"figue": "^2.2.3",
"figue": "^3.1.1",
"hono": "^4.8.2",
"lodash-es": "^4.17.21",
"mime-types": "^3.0.1",

View File

@@ -29,6 +29,7 @@ const server = serve(
{
fetch: app.fetch,
port: config.server.port,
hostname: config.server.hostname,
},
({ port }) => logger.info({ port }, 'Server started'),
);

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