mirror of
https://github.com/papra-hq/papra.git
synced 2025-12-17 20:25:42 -06:00
Compare commits
53 Commits
@papra/app
...
@papra/cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3903eed170 | ||
|
|
c70d7e419a | ||
|
|
2240f58f04 | ||
|
|
79e9bb1b61 | ||
|
|
6e18162435 | ||
|
|
16ae4617df | ||
|
|
1c46071e00 | ||
|
|
377c11c185 | ||
|
|
28c3c15cef | ||
|
|
0391a3bcd5 | ||
|
|
2c75eec862 | ||
|
|
ccf7602f19 | ||
|
|
b8a515a313 | ||
|
|
0aad88471b | ||
|
|
efd2ae1c73 | ||
|
|
e9a719d06a | ||
|
|
68714267ad | ||
|
|
75a13da526 | ||
|
|
59d5819018 | ||
|
|
a857370343 | ||
|
|
f4740ba59a | ||
|
|
b0abf7f78a | ||
|
|
182ccbb30b | ||
|
|
75340f0ce7 | ||
|
|
1228486f28 | ||
|
|
655a1c5475 | ||
|
|
d1797eb9be | ||
|
|
bd3e321eb7 | ||
|
|
be25de7721 | ||
|
|
e85403f9a1 | ||
|
|
7de5d0956b | ||
|
|
b1a88230cd | ||
|
|
55bb29582e | ||
|
|
d9263dc703 | ||
|
|
c3ffa8387e | ||
|
|
d40514c043 | ||
|
|
d7df2f095b | ||
|
|
afdcc1c5ba | ||
|
|
92daaa35bb | ||
|
|
e4295e14ab | ||
|
|
ae37d1db36 | ||
|
|
a7464f8b89 | ||
|
|
2dd9ca9835 | ||
|
|
54cc14052c | ||
|
|
f930e46dde | ||
|
|
df75e5accb | ||
|
|
f66a9f5d1b | ||
|
|
c5b337f3bb | ||
|
|
bb1ba3e15e | ||
|
|
ce839c4127 | ||
|
|
8aabd28168 | ||
|
|
1a7a14b3ed | ||
|
|
17cebde051 |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'papra-hq/papra'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -18,14 +19,18 @@ jobs:
|
||||
actions: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache: "pnpm"
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -41,7 +46,6 @@ jobs:
|
||||
title: "chore(release): update versions"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/docker')
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -166,10 +166,20 @@ pnpm dev # localhost:4321
|
||||
|
||||
- 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
|
||||
|
||||
### Writing Good Test Names
|
||||
|
||||
Test names should explain the **why** (business logic, user scenario, or expected behavior), not the **how** (implementation details or return values).
|
||||
|
||||
**Key principles:**
|
||||
- **Describe blocks** should explain the business goal or rule being tested
|
||||
- **Test names** should explain the scenario, context, and reason for the behavior
|
||||
- Avoid implementation details like "returns X", "should be Y", "calls Z method"
|
||||
- Focus on user scenarios and business rules
|
||||
- Make tests readable as documentation - someone unfamiliar with the code should understand what's being tested and why
|
||||
|
||||
## Code Style
|
||||
|
||||
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"type": "module",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -28,14 +27,15 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.13.0",
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.1.120",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^3.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
|
||||
102
apps/docs/src/content/docs/03-guides/06-tagging-rules.mdx
Normal file
102
apps/docs/src/content/docs/03-guides/06-tagging-rules.mdx
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Using Tagging Rules
|
||||
description: Learn how to automate document organization with tagging rules.
|
||||
slug: guides/tagging-rules
|
||||
---
|
||||
|
||||
## What are Tagging Rules?
|
||||
|
||||
Tagging rules allow you to automatically apply tags to documents based on specific conditions. This helps maintain consistent organization without manual effort, especially when dealing with large numbers of documents.
|
||||
|
||||
## How Tagging Rules Work
|
||||
|
||||
When a tagging rule is enabled, it automatically checks new documents as they're uploaded. If a document matches the rule's conditions, the specified tags are automatically applied.
|
||||
|
||||
### Rule Components
|
||||
|
||||
Each tagging rule consists of:
|
||||
|
||||
1. **Conditions**: Rules that determine which documents should be tagged
|
||||
- Field: The document property to check (e.g., name, content)
|
||||
- Operator: How to compare the field (e.g., contains, equals)
|
||||
- Value: The text to match against
|
||||
|
||||
2. **Actions**: The tags to apply when conditions are met
|
||||
|
||||
## Applying Rules to Existing Documents
|
||||
|
||||
### The "Run Now" Feature
|
||||
|
||||
When you create a new tagging rule, it only applies to documents uploaded *after* the rule is created. To apply the rule to documents that already exist in your organization, use the **"Apply to existing documents"** button.
|
||||
|
||||
This feature is particularly useful when:
|
||||
- You create a new rule and want to organize your existing documents
|
||||
- You modify a rule and want to reprocess documents
|
||||
- You're setting up your organization and want to retroactively organize imported documents
|
||||
|
||||
### How to Apply a Rule to Existing Documents
|
||||
|
||||
1. Navigate to your organization's Tagging Rules page
|
||||
2. Find the rule you want to apply
|
||||
3. Click the **"Apply to existing documents"** button
|
||||
4. Confirm the action in the dialog
|
||||
5. The task is queued and will be processed in the background
|
||||
|
||||
The system will:
|
||||
- Queue a background task to process all documents
|
||||
- Process documents in batches to avoid overloading the system
|
||||
- Check all existing documents in your organization
|
||||
- Apply tags where the rule's conditions match
|
||||
- Show you a success message once the task is queued
|
||||
|
||||
:::tip
|
||||
Applying a rule to existing documents runs as a background task, so you don't need to wait for it to complete. The processing happens asynchronously and efficiently handles large document collections by processing them in batches.
|
||||
:::
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Creating Effective Rules
|
||||
|
||||
1. **Be specific**: Use precise conditions to avoid over-tagging
|
||||
2. **Test first**: Create a rule and test it on a few documents before applying to all existing documents
|
||||
3. **Use multiple conditions**: Combine conditions for more accurate matching
|
||||
4. **Review regularly**: Periodically review your rules to ensure they're still relevant
|
||||
|
||||
### Example Rules
|
||||
|
||||
**Invoice Classification**
|
||||
- Condition: Document name contains "invoice"
|
||||
- Action: Apply "Invoice" tag
|
||||
|
||||
**Quarterly Reports**
|
||||
- Condition: Document name contains "Q1" or "Q2" or "Q3" or "Q4"
|
||||
- Action: Apply "Report" tag
|
||||
|
||||
## Using the API
|
||||
|
||||
You can also apply tagging rules programmatically using the API. The endpoint enqueues a background task and returns immediately:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://api.papra.app/api/organizations/YOUR_ORG_ID/tagging-rules/RULE_ID/apply
|
||||
```
|
||||
|
||||
Response (HTTP 202 Accepted):
|
||||
```json
|
||||
{
|
||||
"taskId": "task_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Where:
|
||||
- `taskId`: The ID of the background task processing your request
|
||||
|
||||
:::note
|
||||
The API returns a task ID immediately. The actual processing happens in the background and may take some time depending on the number of documents. Task status retrieval will be available in a future release.
|
||||
:::
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [API Endpoints Documentation](/resources/api-endpoints)
|
||||
- [CLI Documentation](/resources/cli)
|
||||
@@ -307,3 +307,13 @@ Remove a tag from a document.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response: empty (204 status code)
|
||||
|
||||
### Apply tagging rule to existing documents
|
||||
|
||||
**POST** `/api/organizations/:organizationId/tagging-rules/:taggingRuleId/apply`
|
||||
|
||||
Enqueue a background task to apply a tagging rule to all existing documents in the organization. This endpoint returns immediately with a task ID, and the processing happens asynchronously in the background. The task will check all documents and apply tags where the rule's conditions match.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response (JSON, HTTP 202)
|
||||
- `taskId`: The ID of the background task. You can use this to track the task's progress (task status retrieval coming in a future release).
|
||||
|
||||
@@ -40,6 +40,10 @@ export const sidebar = [
|
||||
label: 'Document Encryption',
|
||||
slug: 'guides/document-encryption',
|
||||
},
|
||||
{
|
||||
label: 'Tagging Rules',
|
||||
slug: 'guides/tagging-rules',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'astro:content';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra frontend client",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -29,30 +28,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@branchlet/core": "^1.0.0",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@corentinth/chisels": "catalog:",
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
"@tanstack/solid-query": "^5.81.2",
|
||||
"@tanstack/solid-query": "^5.90.3",
|
||||
"@tanstack/solid-table": "^8.21.3",
|
||||
"@unocss/reset": "^0.64.1",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-solid": "^1.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"posthog-js-lite": "^4.1.5",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-js": "^1.9.9",
|
||||
"solid-sonner": "^0.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"ts-pattern": "^5.7.1",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"unocss-preset-animations": "^1.3.0",
|
||||
"unstorage": "^1.16.0",
|
||||
"valibot": "1.0.0-beta.10"
|
||||
},
|
||||
@@ -60,16 +56,14 @@
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.2.19",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "^25.0.1",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"unocss": "^66.5.4",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-solid": "^2.11.9",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @refresh reload */
|
||||
|
||||
import type { ConfigColorMode } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { Router } from '@solidjs/router';
|
||||
import { QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
@@ -17,7 +17,6 @@ import { IdentifyUser } from './modules/tracking/components/identify-user.compon
|
||||
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
|
||||
import { Toaster } from './modules/ui/components/sonner';
|
||||
import { routes } from './routes';
|
||||
import '@unocss/reset/tailwind.css';
|
||||
import 'virtual:uno.css';
|
||||
import './app.css';
|
||||
|
||||
@@ -28,17 +27,15 @@ render(
|
||||
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
|
||||
|
||||
return (
|
||||
<Router
|
||||
children={routes}
|
||||
root={props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router
|
||||
children={routes}
|
||||
root={props => (
|
||||
<Suspense>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
<I18nProvider>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
@@ -60,9 +57,9 @@ render(
|
||||
</ConfirmModalProvider>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
document.getElementById('root')!,
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'E-Mail verifizieren',
|
||||
'auth.email-validation-required.description': 'Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-Mail verifiziert',
|
||||
'auth.email-verification.success.description': 'Ihre E-Mail wurde erfolgreich verifiziert. Sie können sich jetzt in Ihr Konto einloggen.',
|
||||
'auth.email-verification.success.login': 'Zur Anmeldung',
|
||||
'auth.email-verification.error.title': 'Verifizierung fehlgeschlagen',
|
||||
'auth.email-verification.error.description': 'Der Verifizierungslink ist ungültig oder abgelaufen. Bitte fordern Sie eine neue Verifizierungs-E-Mail an, indem Sie sich anmelden.',
|
||||
'auth.email-verification.error.back': 'Zurück zur Anmeldung',
|
||||
|
||||
'auth.legal-links.description': 'Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.',
|
||||
'auth.legal-links.terms': 'Nutzungsbedingungen',
|
||||
'auth.legal-links.privacy': 'Datenschutzrichtlinie',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Abbrechen',
|
||||
'organization.settings.delete.success': 'Organisation gelöscht',
|
||||
'organization.settings.delete.only-owner': 'Nur der Organisationsinhaber kann diese Organisation löschen.',
|
||||
'organization.settings.delete.has-active-subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden, bitte kündigen Sie zuerst Ihr Abonnement oben.',
|
||||
|
||||
'organization.usage.page.title': 'Nutzung',
|
||||
'organization.usage.page.description': 'Sehen Sie die aktuelle Nutzung und Limits Ihrer Organisation.',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Beispiel: Dokumente mit \'Rechnung\' im Namen taggen',
|
||||
'tagging-rules.form.description.max-length': 'Die Beschreibung muss weniger als 256 Zeichen lang sein',
|
||||
'tagging-rules.form.conditions.label': 'Bedingungen',
|
||||
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.',
|
||||
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Keine Bedingungen bedeutet, dass die Regel auf alle Dokumente angewendet wird',
|
||||
'tagging-rules.form.conditions.add-condition': 'Bedingung hinzufügen',
|
||||
'tagging-rules.form.conditions.connector.when': 'Wenn',
|
||||
'tagging-rules.form.conditions.connector.and': 'und',
|
||||
'tagging-rules.form.conditions.connector.or': 'oder',
|
||||
'tagging-rules.condition-match-mode.all': 'Alle Bedingungen müssen erfüllt sein',
|
||||
'tagging-rules.condition-match-mode.any': 'Mindestens eine Bedingung muss erfüllt sein',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Keine Bedingungen',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Regel ohne Bedingungen anwenden',
|
||||
@@ -400,9 +413,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.tags.add-tag': 'Tag erstellen',
|
||||
'tagging-rules.form.submit': 'Regel erstellen',
|
||||
'tagging-rules.update.title': 'Tagging-Regel aktualisieren',
|
||||
'tagging-rules.update.error': 'Tagging-Regel konnte nicht aktualisiert werden',
|
||||
'tagging-rules.update.error': 'Fehler beim Aktualisieren der Tagging-Regel',
|
||||
'tagging-rules.update.submit': 'Regel aktualisieren',
|
||||
'tagging-rules.update.cancel': 'Abbrechen',
|
||||
'tagging-rules.apply.button': 'Auf vorhandene Dokumente anwenden',
|
||||
'tagging-rules.apply.confirm.title': 'Regel auf vorhandene Dokumente anwenden?',
|
||||
'tagging-rules.apply.confirm.description': 'Dies überprüft alle vorhandenen Dokumente in Ihrer Organisation und wendet Tags an, wo Bedingungen übereinstimmen. Die Verarbeitung erfolgt im Hintergrund.',
|
||||
'tagging-rules.apply.confirm.button': 'Regel anwenden',
|
||||
'tagging-rules.apply.success': 'Regelanwendung im Hintergrund gestartet',
|
||||
'tagging-rules.apply.error': 'Fehler beim Starten der Regelanwendung',
|
||||
'tagging-rules.apply.processing': 'Wird gestartet...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +571,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
|
||||
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
|
||||
'layout.upgrade-cta.button': 'Auf Plus upgraden',
|
||||
'layout.upgrade-cta.button': 'Jetzt upgraden',
|
||||
|
||||
'layout.theme.light': 'Heller Modus',
|
||||
'layout.theme.dark': 'Dunkler Modus',
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.',
|
||||
'api-errors.organization.has_active_subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden. Bitte kündigen Sie zuerst Ihr Abonnement über die Schaltfläche Abonnement verwalten oben.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Fehler beim Erstellen des Benutzers',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Fehler beim Erstellen der Sitzung',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Fehler beim Aktualisieren des Benutzers',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Fehler beim Abrufen der Sitzung',
|
||||
'api-errors.INVALID_PASSWORD': 'Ungültiges Passwort',
|
||||
'api-errors.INVALID_EMAIL': 'Ungültige E-Mail',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Die E-Mail oder das Passwort ist falsch, oder das Konto existiert nicht.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social-Media-Konto bereits verknüpft',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Anbieter nicht gefunden',
|
||||
'api-errors.INVALID_TOKEN': 'Ungültiger Token',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID-Token wird nicht unterstützt',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Fehler beim Abrufen der Benutzerinformationen',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Benutzer-E-Mail nicht gefunden',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'E-Mail nicht verifiziert',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Passwort zu kurz',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Passwort zu lang',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Ein Benutzer mit dieser E-Mail existiert bereits',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'E-Mail kann nicht aktualisiert werden',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Anmeldekonto nicht gefunden',
|
||||
'api-errors.SESSION_EXPIRED': 'Sitzung abgelaufen',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Fehler beim Trennen des letzten Kontos',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Konto nicht gefunden',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Benutzer hat bereits ein Passwort',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +673,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Benötigen Sie Hilfe?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Support kontaktieren',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Auf Plus upgraden',
|
||||
'subscriptions.upgrade-dialog.title': 'Diese Organisation upgraden',
|
||||
'subscriptions.upgrade-dialog.description': 'Schalten Sie leistungsstarke Funktionen für Ihre Organisation frei',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Kontaktieren Sie uns',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'wenn Sie benutzerdefinierte Enterprise-Pläne benötigen.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Aktueller Plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Empfohlen',
|
||||
'subscriptions.upgrade-dialog.per-month': '/Monat',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} jährlich abgerechnet',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Jetzt upgraden',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Zeitlich begrenztes Angebot',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Erhalten Sie {{ percent }}% Rabatt pro Organisation auf alle Tarife für immer als Early Adopter! Angebot läuft ab in {{ days, >1:{days} Tagen, =1:1 Tag, weniger als 1 Tag }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Kostenloser Plan',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dokumentenspeichergröße',
|
||||
'subscriptions.features.members': 'Organisationsmitglieder',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -68,6 +68,13 @@ export const translations = {
|
||||
'auth.email-validation-required.title': 'Verify your email',
|
||||
'auth.email-validation-required.description': 'A verification email has been sent to your email address. Please verify your email address by clicking the link in the email.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email verified',
|
||||
'auth.email-verification.success.description': 'Your email has been successfully verified. You can now log in to your account.',
|
||||
'auth.email-verification.success.login': 'Go to login',
|
||||
'auth.email-verification.error.title': 'Verification failed',
|
||||
'auth.email-verification.error.description': 'The verification link has expired or is invalid. Please request a new verification email by logging in.',
|
||||
'auth.email-verification.error.back': 'Back to login',
|
||||
|
||||
'auth.legal-links.description': 'By continuing, you acknowledge that you understand and agree to the {{ terms }} and {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Terms of Service',
|
||||
'auth.legal-links.privacy': 'Privacy Policy',
|
||||
@@ -155,6 +162,7 @@ export const translations = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancel',
|
||||
'organization.settings.delete.success': 'Organization deleted',
|
||||
'organization.settings.delete.only-owner': 'Only the organization owner can delete this organization.',
|
||||
'organization.settings.delete.has-active-subscription': 'Cannot delete organization with an active subscription, please cancel your subscription above first.',
|
||||
|
||||
'organization.usage.page.title': 'Usage',
|
||||
'organization.usage.page.description': 'View your organization\'s current usage and limits.',
|
||||
@@ -384,8 +392,13 @@ export const translations = {
|
||||
'tagging-rules.form.description.placeholder': 'Example: Tag documents with \'invoice\' in the name',
|
||||
'tagging-rules.form.description.max-length': 'The description must be less than 256 characters',
|
||||
'tagging-rules.form.conditions.label': 'Conditions',
|
||||
'tagging-rules.form.conditions.description': 'Define the conditions that must be met for the rule to apply. All conditions must be met for the rule to apply.',
|
||||
'tagging-rules.form.conditions.description': 'Define the conditions that must be met for the rule to apply. No conditions means the rule will apply to all documents',
|
||||
'tagging-rules.form.conditions.add-condition': 'Add condition',
|
||||
'tagging-rules.form.conditions.connector.when': 'When',
|
||||
'tagging-rules.form.conditions.connector.and': 'and',
|
||||
'tagging-rules.form.conditions.connector.or': 'or',
|
||||
'tagging-rules.condition-match-mode.all': 'All conditions must match',
|
||||
'tagging-rules.condition-match-mode.any': 'Any condition must match',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'No conditions',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'You didn\'t add any conditions to this rule. This rule will apply its tags to all documents.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Apply rule without conditions',
|
||||
@@ -401,6 +414,13 @@ export const translations = {
|
||||
'tagging-rules.update.error': 'Failed to update tagging rule',
|
||||
'tagging-rules.update.submit': 'Update rule',
|
||||
'tagging-rules.update.cancel': 'Cancel',
|
||||
'tagging-rules.apply.button': 'Apply to existing documents',
|
||||
'tagging-rules.apply.confirm.title': 'Apply rule to existing documents?',
|
||||
'tagging-rules.apply.confirm.description': 'This will check all existing documents in your organization and apply tags where conditions match. The processing will happen in the background.',
|
||||
'tagging-rules.apply.confirm.button': 'Apply rule',
|
||||
'tagging-rules.apply.success': 'Rule application started in the background',
|
||||
'tagging-rules.apply.error': 'Failed to start rule application',
|
||||
'tagging-rules.apply.processing': 'Starting...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -549,7 +569,7 @@ export const translations = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Need more space?',
|
||||
'layout.upgrade-cta.description': 'Get 10x more storage + team collaboration',
|
||||
'layout.upgrade-cta.button': 'Upgrade to Plus',
|
||||
'layout.upgrade-cta.button': 'Upgrade now',
|
||||
|
||||
'layout.theme.light': 'Light mode',
|
||||
'layout.theme.dark': 'Dark mode',
|
||||
@@ -587,6 +607,32 @@ export const translations = {
|
||||
'api-errors.internal.error': 'An error occurred while processing your request. Please try again later.',
|
||||
'api-errors.auth.invalid_origin': 'Invalid application origin. If you are self-hosting Papra, ensure your APP_BASE_URL environment variable matches your current url. For more details see https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'The maximum number of members and pending invitations for this organization has been reached. Please upgrade your plan to add more members.',
|
||||
'api-errors.organization.has_active_subscription': 'Cannot delete organization with an active subscription. Please cancel your subscription first using the Manage Subscription button above.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'User not found',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Failed to create user',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Failed to create session',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Failed to update user',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Failed to get session',
|
||||
'api-errors.INVALID_PASSWORD': 'Invalid password',
|
||||
'api-errors.INVALID_EMAIL': 'Invalid email',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'The email or password is incorrect, or the account does not exist.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social account already linked',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Provider not found',
|
||||
'api-errors.INVALID_TOKEN': 'Invalid token',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID token not supported',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Failed to get user info',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'User email not found',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email not verified',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Password too short',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Password too long',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'A user with this email already exists',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email can not be updated',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Credential account not found',
|
||||
'api-errors.SESSION_EXPIRED': 'Session expired',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Failed to unlink last account',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Account not found',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'User already has password',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -625,17 +671,21 @@ export const translations = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Need help?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contact support',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Upgrade to Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Upgrade this organization',
|
||||
'subscriptions.upgrade-dialog.description': 'Unlock powerful features for your organization',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contact us',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'if you need custom enterprise plans.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Current Plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recommended',
|
||||
'subscriptions.upgrade-dialog.per-month': '/month',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} billed annually',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade now',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Limited Time Offer',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Get {{ percent }}% off all plans forever per organization as an early adopter! Offer expires in {{ days, >1:{days} days, =1:1 day, less than 1 day }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Free plan',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Document storage size',
|
||||
'subscriptions.features.members': 'Organization Members',
|
||||
@@ -647,6 +697,7 @@ export const translations = {
|
||||
'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',
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifica tu correo electrónico',
|
||||
'auth.email-validation-required.description': 'Se ha enviado un correo de verificación a tu dirección de correo electrónico. Por favor, verifica tu correo haciendo clic en el enlace del correo.',
|
||||
|
||||
'auth.email-verification.success.title': 'Correo verificado',
|
||||
'auth.email-verification.success.description': 'Tu correo ha sido verificado exitosamente. Ahora puedes iniciar sesión en tu cuenta.',
|
||||
'auth.email-verification.success.login': 'Ir a iniciar sesión',
|
||||
'auth.email-verification.error.title': 'Verificación fallida',
|
||||
'auth.email-verification.error.description': 'El enlace de verificación es inválido o ha expirado. Por favor, solicita un nuevo correo de verificación iniciando sesión.',
|
||||
'auth.email-verification.error.back': 'Volver a iniciar sesión',
|
||||
|
||||
'auth.legal-links.description': 'Al continuar, reconoces que entiendes y aceptas los {{ terms }} y la {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Términos de servicio',
|
||||
'auth.legal-links.privacy': 'Política de privacidad',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
|
||||
'organization.settings.delete.success': 'Organización eliminada',
|
||||
'organization.settings.delete.only-owner': 'Solo el propietario de la organización puede eliminar esta organización.',
|
||||
'organization.settings.delete.has-active-subscription': 'No se puede eliminar la organización con una suscripción activa, por favor cancela tu suscripción arriba primero.',
|
||||
|
||||
'organization.usage.page.title': 'Uso',
|
||||
'organization.usage.page.description': 'Ver el uso y los límites actuales de su organización.',
|
||||
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nombre del documento',
|
||||
'tagging-rules.field.content': 'contenido del documento',
|
||||
'tagging-rules.field.name': 'el nombre del documento',
|
||||
'tagging-rules.field.content': 'el contenido del documento',
|
||||
'tagging-rules.operator.equals': 'es igual a',
|
||||
'tagging-rules.operator.not-equals': 'no es igual a',
|
||||
'tagging-rules.operator.contains': 'contiene',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Ejemplo: Etiquetar documentos con \'factura\' en el nombre',
|
||||
'tagging-rules.form.description.max-length': 'La descripción debe tener menos de 256 caracteres',
|
||||
'tagging-rules.form.conditions.label': 'Condiciones',
|
||||
'tagging-rules.form.conditions.description': 'Define las condiciones que deben cumplirse para que la regla se aplique. Todas las condiciones deben cumplirse.',
|
||||
'tagging-rules.form.conditions.description': 'Define las condiciones que deben cumplirse para que la regla se aplique. Sin condiciones significa que la regla se aplicará a todos los documentos',
|
||||
'tagging-rules.form.conditions.add-condition': 'Añadir condición',
|
||||
'tagging-rules.form.conditions.connector.when': 'Cuando',
|
||||
'tagging-rules.form.conditions.connector.and': 'y que',
|
||||
'tagging-rules.form.conditions.connector.or': 'o que',
|
||||
'tagging-rules.condition-match-mode.all': 'Todas las condiciones deben coincidir',
|
||||
'tagging-rules.condition-match-mode.any': 'Cualquier condición debe coincidir',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Sin condiciones',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'No añadiste ninguna condición a esta regla. Esta regla aplicará sus etiquetas a todos los documentos.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regla sin condiciones',
|
||||
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Error al actualizar la regla de etiquetado',
|
||||
'tagging-rules.update.submit': 'Actualizar regla',
|
||||
'tagging-rules.update.cancel': 'Cancelar',
|
||||
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
|
||||
'tagging-rules.apply.confirm.title': '¿Aplicar regla a documentos existentes?',
|
||||
'tagging-rules.apply.confirm.description': 'Esto verificará todos los documentos existentes en tu organización y aplicará etiquetas donde las condiciones coincidan. El procesamiento se realizará en segundo plano.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicar regla',
|
||||
'tagging-rules.apply.success': 'Aplicación de regla iniciada en segundo plano',
|
||||
'tagging-rules.apply.error': 'Error al iniciar la aplicación de la regla',
|
||||
'tagging-rules.apply.processing': 'Iniciando...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +571,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'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 a Plus',
|
||||
'layout.upgrade-cta.button': 'Actualizar ahora',
|
||||
|
||||
'layout.theme.light': 'Modo claro',
|
||||
'layout.theme.dark': 'Modo oscuro',
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Ocurrió un error al procesar tu solicitud. Por favor, inténtalo de nuevo.',
|
||||
'api-errors.auth.invalid_origin': 'Origen de la aplicación inválido. Si estás alojando Papra, asegúrate de que la variable de entorno APP_BASE_URL coincida con tu URL actual. Para más detalles, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Se ha alcanzado el número máximo de miembros e invitaciones pendientes para esta organización. Por favor, actualiza tu plan para añadir más miembros.',
|
||||
'api-errors.organization.has_active_subscription': 'No se puede eliminar la organización con una suscripción activa. Por favor, cancela tu suscripción primero usando el botón Gestionar Suscripción arriba.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Usuario no encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Error al crear usuario',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Error al crear sesión',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Error al actualizar usuario',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Error al obtener sesión',
|
||||
'api-errors.INVALID_PASSWORD': 'Contraseña inválida',
|
||||
'api-errors.INVALID_EMAIL': 'Email inválido',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'El email o la contraseña es incorrecta, o la cuenta no existe.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Cuenta social ya vinculada',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Proveedor no encontrado',
|
||||
'api-errors.INVALID_TOKEN': 'Token inválido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID no soportado',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Error al obtener información del usuario',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email del usuario no encontrado',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email no verificado',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Contraseña demasiado corta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Contraseña demasiado larga',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Ya existe un usuario con este email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'El email no puede ser actualizado',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Cuenta de credenciales no encontrada',
|
||||
'api-errors.SESSION_EXPIRED': 'Sesión expirada',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Error al desvincular la última cuenta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Cuenta no encontrada',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'El usuario ya tiene contraseña',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +673,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': '¿Necesitas ayuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contactar soporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Actualizar a Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Actualizar esta organización',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloquea funciones poderosas para tu organización',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contáctanos',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'si necesitas planes empresariales personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan actual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mes',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Actualizar ahora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tiempo limitado',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': '¡Obtén {{ percent }}% de descuento por organización en todos los planes para siempre como early adopter! La oferta expira en {{ days, >1:{days} días, =1:1 día, menos de un día }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamaño de almacenamiento de documentos',
|
||||
'subscriptions.features.members': 'Miembros de la organización',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Vérifier votre email',
|
||||
'auth.email-validation-required.description': 'Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre adresse email en cliquant sur le lien dans l\'email.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email vérifié',
|
||||
'auth.email-verification.success.description': 'Votre email a été vérifié avec succès. Vous pouvez maintenant vous connecter à votre compte.',
|
||||
'auth.email-verification.success.login': 'Aller à la connexion',
|
||||
'auth.email-verification.error.title': 'Échec de la vérification',
|
||||
'auth.email-verification.error.description': 'Le lien de vérification est invalide ou a expiré. Veuillez demander un nouvel email de vérification en vous connectant.',
|
||||
'auth.email-verification.error.back': 'Retour à la connexion',
|
||||
|
||||
'auth.legal-links.description': 'En continuant, vous reconnaissez que vous comprenez et acceptez les {{ terms }} et {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Conditions d\'utilisation',
|
||||
'auth.legal-links.privacy': 'Politique de confidentialité',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Annuler',
|
||||
'organization.settings.delete.success': 'Organisation supprimée',
|
||||
'organization.settings.delete.only-owner': 'Seul le propriétaire de l\'organisation peut supprimer cette organisation.',
|
||||
'organization.settings.delete.has-active-subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif, veuillez d\'abord annuler votre abonnement ci-dessus.',
|
||||
|
||||
'organization.usage.page.title': 'Utilisation',
|
||||
'organization.usage.page.description': 'Consultez l\'utilisation actuelle et les limites de votre organisation.',
|
||||
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nom du document',
|
||||
'tagging-rules.field.content': 'contenu du document',
|
||||
'tagging-rules.field.name': 'le nom du document',
|
||||
'tagging-rules.field.content': 'le contenu du document',
|
||||
'tagging-rules.operator.equals': 'égal à',
|
||||
'tagging-rules.operator.not-equals': 'différent de',
|
||||
'tagging-rules.operator.contains': 'contient',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemple: Catégoriser les documents avec \'facture\' dans le nom',
|
||||
'tagging-rules.form.description.max-length': 'La description doit contenir moins de 256 caractères',
|
||||
'tagging-rules.form.conditions.label': 'Conditions',
|
||||
'tagging-rules.form.conditions.description': 'Définissez les conditions que doivent remplir la règle pour qu\'elle s\'applique. Toutes les conditions doivent être remplies pour que la règle s\'applique.',
|
||||
'tagging-rules.form.conditions.description': 'Définissez les conditions que doivent remplir la règle pour qu\'elle s\'applique. Si aucune condition n\'est définie, la règle s\'appliquera à tous les documents.',
|
||||
'tagging-rules.form.conditions.add-condition': 'Ajouter une condition',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quand',
|
||||
'tagging-rules.form.conditions.connector.and': 'et que',
|
||||
'tagging-rules.form.conditions.connector.or': 'ou que',
|
||||
'tagging-rules.condition-match-mode.all': 'Toutes les conditions doivent correspondre',
|
||||
'tagging-rules.condition-match-mode.any': 'Au moins une condition doit correspondre',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Aucune condition',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Vous n\'avez pas ajouté de conditions à cette règle. Cette règle appliquera ses tags à tous les documents.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Appliquer la règle sans conditions',
|
||||
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Échec de la mise à jour de la règle de catégorisation',
|
||||
'tagging-rules.update.submit': 'Mettre à jour la règle',
|
||||
'tagging-rules.update.cancel': 'Annuler',
|
||||
'tagging-rules.apply.button': 'Appliquer aux documents existants',
|
||||
'tagging-rules.apply.confirm.title': 'Appliquer la règle aux documents existants ?',
|
||||
'tagging-rules.apply.confirm.description': 'Cela vérifiera tous les documents existants dans votre organisation et appliquera les tags où les conditions correspondent. Le traitement se fera en arrière-plan.',
|
||||
'tagging-rules.apply.confirm.button': 'Appliquer la règle',
|
||||
'tagging-rules.apply.success': 'Application de la règle démarrée en arrière-plan',
|
||||
'tagging-rules.apply.error': 'Échec du démarrage de l\'application de la règle',
|
||||
'tagging-rules.apply.processing': 'Démarrage...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +571,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'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': 'Passer à Plus',
|
||||
'layout.upgrade-cta.button': 'Mettre à niveau maintenant',
|
||||
|
||||
'layout.theme.light': 'Mode clair',
|
||||
'layout.theme.dark': 'Mode sombre',
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Une erreur est survenue lors du traitement de votre requête. Veuillez réessayer.',
|
||||
'api-errors.auth.invalid_origin': 'Origine de l\'application invalide. Si vous hébergez Papra, assurez-vous que la variable d\'environnement APP_BASE_URL correspond à votre URL actuelle. Pour plus de détails, consultez https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Le nombre maximum de membres et d\'invitations en attente pour cette organisation a été atteint. Veuillez mettre à niveau votre plan pour ajouter plus de membres.',
|
||||
'api-errors.organization.has_active_subscription': 'Impossible de supprimer l\'organisation avec un abonnement actif. Veuillez d\'abord annuler votre abonnement en utilisant le bouton Gérer l\'abonnement ci-dessus.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utilisateur introuvable',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Échec de la création de l\'utilisateur',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Échec de la création de la session',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Échec de la mise à jour de l\'utilisateur',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Échec de la récupération de la session',
|
||||
'api-errors.INVALID_PASSWORD': 'Mot de passe invalide',
|
||||
'api-errors.INVALID_EMAIL': 'Email invalide',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'L\'email ou le mot de passe est incorrect, ou le compte n\'existe pas.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Compte social déjà associé',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Fournisseur introuvable',
|
||||
'api-errors.INVALID_TOKEN': 'Jeton invalide',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Jeton d\'identité non pris en charge',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Échec de la récupération des informations utilisateur',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email de l\'utilisateur introuvable',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email non vérifié',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Mot de passe trop court',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Mot de passe trop long',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Un utilisateur avec cet email existe déjà',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'L\'email ne peut pas être modifié',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Compte d\'identification introuvable',
|
||||
'api-errors.SESSION_EXPIRED': 'Session expirée',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Échec de la dissociation du dernier compte',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Compte introuvable',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utilisateur a déjà un mot de passe',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +673,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Besoin d\'aide ?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contacter le support',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Passer à Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Mettre à niveau cette organisation',
|
||||
'subscriptions.upgrade-dialog.description': 'Débloquez des fonctionnalités puissantes pour votre organisation',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contactez-nous',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'si vous avez besoin de plans d\'entreprise personnalisés.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan actuel',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recommandé',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mois',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturé annuellement',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Mettre à niveau',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Offre à durée limitée',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Bénéficiez de {{ percent }}% de réduction à vie par organisation sur tous les forfaits en tant qu\'early adopter ! L\'offre expire dans {{ days, >1:{days} jours, =1:1 jour, moins d\'un jour }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuit',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Taille de stockage de documents',
|
||||
'subscriptions.features.members': 'Membres de l\'organisation',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifica la tua email',
|
||||
'auth.email-validation-required.description': 'Una email di verifica è stata inviata al tuo indirizzo email. Verifica il tuo indirizzo cliccando il link nell\'email.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email verificata',
|
||||
'auth.email-verification.success.description': 'La tua email è stata verificata con successo. Ora puoi accedere al tuo account.',
|
||||
'auth.email-verification.success.login': 'Vai al login',
|
||||
'auth.email-verification.error.title': 'Verifica fallita',
|
||||
'auth.email-verification.error.description': 'Il link di verifica non è valido o è scaduto. Richiedi una nuova email di verifica effettuando l\'accesso.',
|
||||
'auth.email-verification.error.back': 'Torna al login',
|
||||
|
||||
'auth.legal-links.description': 'Continuando, confermi di aver letto e accettato i {{ terms }} e l\'{{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termini di servizio',
|
||||
'auth.legal-links.privacy': 'Informativa sulla privacy',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Annulla',
|
||||
'organization.settings.delete.success': 'Organizzazione eliminata',
|
||||
'organization.settings.delete.only-owner': 'Solo il proprietario dell\'organizzazione può eliminare questa organizzazione.',
|
||||
'organization.settings.delete.has-active-subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo, si prega di annullare prima l\'abbonamento sopra.',
|
||||
|
||||
'organization.usage.page.title': 'Utilizzo',
|
||||
'organization.usage.page.description': 'Visualizza l\'utilizzo attuale e i limiti della tua organizzazione.',
|
||||
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nome documento',
|
||||
'tagging-rules.field.content': 'contenuto documento',
|
||||
'tagging-rules.field.name': 'il nome del documento',
|
||||
'tagging-rules.field.content': 'il contenuto del documento',
|
||||
'tagging-rules.operator.equals': 'uguale a',
|
||||
'tagging-rules.operator.not-equals': 'diverso da',
|
||||
'tagging-rules.operator.contains': 'contiene',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Esempio: Tagga i documenti con \'fattura\' nel nome',
|
||||
'tagging-rules.form.description.max-length': 'La descrizione deve essere inferiore a 256 caratteri',
|
||||
'tagging-rules.form.conditions.label': 'Condizioni',
|
||||
'tagging-rules.form.conditions.description': 'Definisci le condizioni che devono essere soddisfatte affinché la regola si applichi. Tutte le condizioni devono essere soddisfatte.',
|
||||
'tagging-rules.form.conditions.description': 'Definisci le condizioni che devono essere soddisfatte affinché la regola si applichi. Nessuna condizione significa che la regola si applicherà a tutti i documenti',
|
||||
'tagging-rules.form.conditions.add-condition': 'Aggiungi condizione',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quando',
|
||||
'tagging-rules.form.conditions.connector.and': 'e che',
|
||||
'tagging-rules.form.conditions.connector.or': 'o che',
|
||||
'tagging-rules.condition-match-mode.all': 'Tutte le condizioni devono corrispondere',
|
||||
'tagging-rules.condition-match-mode.any': 'Qualsiasi condizione deve corrispondere',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Nessuna condizione',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Non hai aggiunto nessuna condizione a questa regola. Questa regola applicherà i suoi tag a tutti i documenti.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Applica regola senza condizioni',
|
||||
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Errore nell\'aggiornamento della regola di tagging',
|
||||
'tagging-rules.update.submit': 'Aggiorna regola',
|
||||
'tagging-rules.update.cancel': 'Annulla',
|
||||
'tagging-rules.apply.button': 'Applica ai documenti esistenti',
|
||||
'tagging-rules.apply.confirm.title': 'Applicare la regola ai documenti esistenti?',
|
||||
'tagging-rules.apply.confirm.description': 'Questo controllerà tutti i documenti esistenti nella tua organizzazione e applicherà i tag dove le condizioni corrispondono. L\'elaborazione avverrà in background.',
|
||||
'tagging-rules.apply.confirm.button': 'Applica regola',
|
||||
'tagging-rules.apply.success': 'Applicazione della regola avviata in background',
|
||||
'tagging-rules.apply.error': 'Impossibile avviare l\'applicazione della regola',
|
||||
'tagging-rules.apply.processing': 'Avvio in corso...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +571,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Serve più spazio?',
|
||||
'layout.upgrade-cta.description': 'Ottieni 10x più storage + collaborazione del team',
|
||||
'layout.upgrade-cta.button': 'Aggiorna a Plus',
|
||||
'layout.upgrade-cta.button': 'Aggiorna ora',
|
||||
|
||||
'layout.theme.light': 'Modalità chiara',
|
||||
'layout.theme.dark': 'Modalità scura',
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Si è verificato un errore durante l\'elaborazione della richiesta. Riprova.',
|
||||
'api-errors.auth.invalid_origin': 'Origine dell\'applicazione non valida. Se stai ospitando Papra, assicurati che la variabile di ambiente APP_BASE_URL corrisponda all\'URL corrente. Per maggiori dettagli, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'È stato raggiunto il numero massimo di membri e inviti in sospeso per questa organizzazione. Aggiorna il tuo piano per aggiungere altri membri.',
|
||||
'api-errors.organization.has_active_subscription': 'Impossibile eliminare l\'organizzazione con un abbonamento attivo. Si prega di annullare prima l\'abbonamento utilizzando il pulsante Gestisci abbonamento sopra.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utente non trovato',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Impossibile creare l\'utente',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Impossibile creare la sessione',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Impossibile aggiornare l\'utente',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Impossibile recuperare la sessione',
|
||||
'api-errors.INVALID_PASSWORD': 'Password non valida',
|
||||
'api-errors.INVALID_EMAIL': 'Email non valida',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'L\'email o la password non è corretta, oppure l\'account non esiste.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Account social già collegato',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Provider non trovato',
|
||||
'api-errors.INVALID_TOKEN': 'Token non valido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID non supportato',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Impossibile recuperare le informazioni utente',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email utente non trovata',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email non verificata',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Password troppo corta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Password troppo lunga',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Esiste già un utente con questa email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'L\'email non può essere aggiornata',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Account credenziali non trovato',
|
||||
'api-errors.SESSION_EXPIRED': 'Sessione scaduta',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Impossibile scollegare l\'ultimo account',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Account non trovato',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'L\'utente ha già una password',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +673,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Hai bisogno di aiuto?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contatta il supporto',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Passa a Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Aggiorna questa organizzazione',
|
||||
'subscriptions.upgrade-dialog.description': 'Sblocca funzionalità potenti per la tua organizzazione',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contattaci',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se hai bisogno di piani aziendali personalizzati.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Piano attuale',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Consigliato',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mese',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} fatturato annualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Aggiorna ora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Offerta a tempo limitato',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Ottieni {{ percent }}% di sconto per organizzazione su tutti i piani per sempre come early adopter! L\'offerta scade tra {{ days, >1:{days} giorni, =1:1 giorno, meno di un giorno }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Piano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dimensione archiviazione documenti',
|
||||
'subscriptions.features.members': 'Membri dell\'organizzazione',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Zweryfikuj swój adres e-mail',
|
||||
'auth.email-validation-required.description': 'Wiadomość weryfikacyjna została wysłana na Twój adres e-mail. Zweryfikuj swój adres e-mail, klikając link w wiadomości.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-mail zweryfikowany',
|
||||
'auth.email-verification.success.description': 'Twój adres e-mail został pomyślnie zweryfikowany. Możesz teraz zalogować się do swojego konta.',
|
||||
'auth.email-verification.success.login': 'Przejdź do logowania',
|
||||
'auth.email-verification.error.title': 'Weryfikacja nie powiodła się',
|
||||
'auth.email-verification.error.description': 'Link weryfikacyjny jest nieprawidłowy lub wygasł. Poproś o nową wiadomość weryfikacyjną, logując się.',
|
||||
'auth.email-verification.error.back': 'Powrót do logowania',
|
||||
|
||||
'auth.legal-links.description': 'Kontynuując, potwierdzasz, że rozumiesz i zgadzasz się na {{ terms }} oraz {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Warunki korzystania z usługi',
|
||||
'auth.legal-links.privacy': 'Polityka prywatności',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Anuluj',
|
||||
'organization.settings.delete.success': 'Organizacja została usunięta',
|
||||
'organization.settings.delete.only-owner': 'Tylko właściciel organizacji może usunąć tę organizację.',
|
||||
'organization.settings.delete.has-active-subscription': 'Nie można usunąć organizacji z aktywną subskrypcją, proszę najpierw anulować subskrypcję powyżej.',
|
||||
|
||||
'organization.usage.page.title': 'Użycie',
|
||||
'organization.usage.page.description': 'Zobacz aktualne użycie i limity Twojej organizacji.',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Przykład: Oznacz dokumenty ze słowem \'faktura\' w nazwie',
|
||||
'tagging-rules.form.description.max-length': 'Opis musi mieć mniej niż 256 znaków',
|
||||
'tagging-rules.form.conditions.label': 'Warunki',
|
||||
'tagging-rules.form.conditions.description': 'Zdefiniuj warunki, które muszą być spełnione, aby reguła mogła zostać zastosowana. Wszystkie warunki muszą być spełnione, aby reguła mogła zostać zastosowana.',
|
||||
'tagging-rules.form.conditions.description': 'Zdefiniuj warunki, które muszą być spełnione, aby reguła mogła zostać zastosowana. Brak warunków oznacza, że reguła zostanie zastosowana do wszystkich dokumentów',
|
||||
'tagging-rules.form.conditions.add-condition': 'Dodaj warunek',
|
||||
'tagging-rules.form.conditions.connector.when': 'Kiedy',
|
||||
'tagging-rules.form.conditions.connector.and': 'i',
|
||||
'tagging-rules.form.conditions.connector.or': 'lub',
|
||||
'tagging-rules.condition-match-mode.all': 'Wszystkie warunki muszą być spełnione',
|
||||
'tagging-rules.condition-match-mode.any': 'Dowolny warunek musi być spełniony',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Brak warunków',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Nie dodałeś żadnych warunków do tej reguły. Ta reguła zastosuje swoje tagi do wszystkich dokumentów.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Zastosuj regułę bez warunków',
|
||||
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Nie udało się zaktualizować reguły tagowania',
|
||||
'tagging-rules.update.submit': 'Zaktualizuj regułę',
|
||||
'tagging-rules.update.cancel': 'Anuluj',
|
||||
'tagging-rules.apply.button': 'Zastosuj do istniejących dokumentów',
|
||||
'tagging-rules.apply.confirm.title': 'Zastosować regułę do istniejących dokumentów?',
|
||||
'tagging-rules.apply.confirm.description': 'Sprawdzi to wszystkie istniejące dokumenty w organizacji i zastosuje tagi tam, gdzie warunki są spełnione. Przetwarzanie odbędzie się w tle.',
|
||||
'tagging-rules.apply.confirm.button': 'Zastosuj regułę',
|
||||
'tagging-rules.apply.success': 'Rozpoczęto stosowanie reguły w tle',
|
||||
'tagging-rules.apply.error': 'Nie udało się rozpocząć stosowania reguły',
|
||||
'tagging-rules.apply.processing': 'Uruchamianie...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +571,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'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': 'Przejdź na Plus',
|
||||
'layout.upgrade-cta.button': 'Ulepsz teraz',
|
||||
|
||||
'layout.theme.light': 'Tryb jasny',
|
||||
'layout.theme.dark': 'Tryb ciemny',
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Wystąpił błąd podczas przetwarzania żądania. Spróbuj ponownie później.',
|
||||
'api-errors.auth.invalid_origin': 'Nieprawidłowa lokalizacja aplikacji. Jeśli hostujesz Papra, upewnij się, że zmienna środowiskowa APP_BASE_URL odpowiada bieżącemu adresowi URL. Aby uzyskać więcej informacji, zobacz https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Osiągnięto maksymalną liczbę członków i oczekujących zaproszeń dla tej organizacji. Zaktualizuj swój plan, aby dodać więcej członków.',
|
||||
'api-errors.organization.has_active_subscription': 'Nie można usunąć organizacji z aktywną subskrypcją. Proszę najpierw anulować subskrypcję za pomocą przycisku Zarządzaj subskrypcją powyżej.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Nie znaleziono użytkownika',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Nie udało się utworzyć użytkownika',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Nie udało się utworzyć sesji',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Nie udało się zaktualizować użytkownika',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Nie udało się pobrać sesji',
|
||||
'api-errors.INVALID_PASSWORD': 'Nieprawidłowe hasło',
|
||||
'api-errors.INVALID_EMAIL': 'Nieprawidłowy email',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Email lub hasło jest nieprawidłowe, lub konto nie istnieje.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Konto społecznościowe już połączone',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Nie znaleziono dostawcy',
|
||||
'api-errors.INVALID_TOKEN': 'Nieprawidłowy token',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID nie jest obsługiwany',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Nie udało się pobrać informacji o użytkowniku',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Nie znaleziono emaila użytkownika',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email nie został zweryfikowany',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Hasło zbyt krótkie',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Hasło zbyt długie',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Użytkownik z tym emailem już istnieje',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email nie może być zaktualizowany',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Nie znaleziono konta uwierzytelniającego',
|
||||
'api-errors.SESSION_EXPIRED': 'Sesja wygasła',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Nie udało się odłączyć ostatniego konta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Nie znaleziono konta',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Użytkownik ma już hasło',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +673,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Potrzebujesz pomocy?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Skontaktuj się z pomocą techniczną',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Przejdź na Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Ulepsz tę organizację',
|
||||
'subscriptions.upgrade-dialog.description': 'Odblokuj zaawansowane funkcje dla swojej organizacji',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Skontaktuj się z nami',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'jeśli potrzebujesz niestandardowych planów biznesowych.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Obecny plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Polecane',
|
||||
'subscriptions.upgrade-dialog.per-month': '/miesiąc',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} rozliczane rocznie',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Ulepsz teraz',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta ograniczona czasowo',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Uzyskaj {{ percent }}% zniżki na organizację na wszystkie plany na zawsze jako early adopter! Oferta wygasa za {{ days, >1:{days} dni, =1:1 dzień, mniej niż 1 dzień }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan darmowy',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Rozmiar przechowywania dokumentów',
|
||||
'subscriptions.features.members': 'Członkowie organizacji',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifique seu e-mail',
|
||||
'auth.email-validation-required.description': 'Um e-mail de verificação foi enviado para seu endereço. Por favor, verifique seu e-mail clicando no link enviado.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-mail verificado',
|
||||
'auth.email-verification.success.description': 'Seu e-mail foi verificado com sucesso. Você já pode fazer login na sua conta.',
|
||||
'auth.email-verification.success.login': 'Ir para login',
|
||||
'auth.email-verification.error.title': 'Falha na verificação',
|
||||
'auth.email-verification.error.description': 'O link de verificação é inválido ou expirou. Por favor, solicite um novo e-mail de verificação ao fazer login.',
|
||||
'auth.email-verification.error.back': 'Voltar ao login',
|
||||
|
||||
'auth.legal-links.description': 'Ao continuar, você reconhece que leu e concorda com os {{ terms }} e a {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termos de Serviço',
|
||||
'auth.legal-links.privacy': 'Política de Privacidade',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
|
||||
'organization.settings.delete.success': 'Organização excluída',
|
||||
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode excluir esta organização.',
|
||||
'organization.settings.delete.has-active-subscription': 'Não é possível excluir a organização com uma assinatura ativa, por favor cancele sua assinatura acima primeiro.',
|
||||
|
||||
'organization.usage.page.title': 'Uso',
|
||||
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
|
||||
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nome do documento',
|
||||
'tagging-rules.field.content': 'conteúdo do documento',
|
||||
'tagging-rules.field.name': 'o nome do documento',
|
||||
'tagging-rules.field.content': 'o conteúdo do documento',
|
||||
'tagging-rules.operator.equals': 'é igual a',
|
||||
'tagging-rules.operator.not-equals': 'é diferente de',
|
||||
'tagging-rules.operator.contains': 'contém',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemplo: Marcar documentos com \'fatura\' no nome',
|
||||
'tagging-rules.form.description.max-length': 'A descrição deve ter menos de 256 caracteres',
|
||||
'tagging-rules.form.conditions.label': 'Condições',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser atendidas para que a regra seja aplicada. Todas as condições devem ser atendidas.',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser atendidas para que a regra seja aplicada. Sem condições significa que a regra será aplicada a todos os documentos',
|
||||
'tagging-rules.form.conditions.add-condition': 'Adicionar condição',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quando',
|
||||
'tagging-rules.form.conditions.connector.and': 'e que',
|
||||
'tagging-rules.form.conditions.connector.or': 'ou que',
|
||||
'tagging-rules.condition-match-mode.all': 'Todas as condições devem corresponder',
|
||||
'tagging-rules.condition-match-mode.any': 'Qualquer condição deve corresponder',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Nenhuma condição',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Você não adicionou nenhuma condição a esta regra. Ela será aplicada a todos os documentos.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regra sem condições',
|
||||
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Falha ao atualizar a regra de marcação',
|
||||
'tagging-rules.update.submit': 'Atualizar regra',
|
||||
'tagging-rules.update.cancel': 'Cancelar',
|
||||
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
|
||||
'tagging-rules.apply.confirm.title': 'Aplicar regra a documentos existentes?',
|
||||
'tagging-rules.apply.confirm.description': 'Isso verificará todos os documentos existentes em sua organização e aplicará tags onde as condições correspondam. O processamento será feito em segundo plano.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicar regra',
|
||||
'tagging-rules.apply.success': 'Aplicação da regra iniciada em segundo plano',
|
||||
'tagging-rules.apply.error': 'Falha ao iniciar a aplicação da regra',
|
||||
'tagging-rules.apply.processing': 'Iniciando...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +571,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'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 para Plus',
|
||||
'layout.upgrade-cta.button': 'Atualizar agora',
|
||||
|
||||
'layout.theme.light': 'Tema claro',
|
||||
'layout.theme.dark': 'Tema escuro',
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.',
|
||||
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize seu plano para adicionar mais membros.',
|
||||
'api-errors.organization.has_active_subscription': 'Não é possível excluir a organização com uma assinatura ativa. Por favor, cancele sua assinatura primeiro usando o botão Gerenciar Assinatura acima.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Usuário não encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar usuário',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Falha ao criar sessão',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Falha ao atualizar usuário',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Falha ao obter sessão',
|
||||
'api-errors.INVALID_PASSWORD': 'Senha inválida',
|
||||
'api-errors.INVALID_EMAIL': 'Email inválido',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'O email ou a senha está incorreta, ou a conta não existe.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Conta social já vinculada',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Provedor não encontrado',
|
||||
'api-errors.INVALID_TOKEN': 'Token inválido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID não suportado',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Falha ao obter informações do usuário',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email do usuário não encontrado',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email não verificado',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Senha muito curta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Senha muito longa',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Já existe um usuário com este email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'O email não pode ser atualizado',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Conta de credenciais não encontrada',
|
||||
'api-errors.SESSION_EXPIRED': 'Sessão expirada',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desvincular a última conta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O usuário já possui uma senha',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +673,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contatar suporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Fazer upgrade para Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar esta organização',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para sua organização',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Entre em contato',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se você precisar de planos empresariais personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mês',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Fazer upgrade agora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tempo limitado',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Ganhe {{ percent }}% de desconto por organização em todos os planos para sempre como early adopter! A oferta expira em {{ days, >1:{days} dias, =1:1 dia, menos de um dia }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
|
||||
'subscriptions.features.members': 'Membros da organização',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifique o seu e-mail',
|
||||
'auth.email-validation-required.description': 'Foi enviado um e-mail de verificação para o seu endereço de e-mail. Por favor, verifique o seu endereço de e-mail clicando na ligação no e-mail.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-mail verificado',
|
||||
'auth.email-verification.success.description': 'O seu e-mail foi verificado com sucesso. Pode agora iniciar sessão na sua conta.',
|
||||
'auth.email-verification.success.login': 'Ir para o login',
|
||||
'auth.email-verification.error.title': 'Falha na verificação',
|
||||
'auth.email-verification.error.description': 'A ligação de verificação é inválida ou expirou. Por favor, solicite um novo e-mail de verificação ao iniciar sessão.',
|
||||
'auth.email-verification.error.back': 'Voltar ao login',
|
||||
|
||||
'auth.legal-links.description': 'Ao continuar, reconhece que compreende e concorda com os {{ terms }} e a {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termos de Serviço',
|
||||
'auth.legal-links.privacy': 'Política de Privacidade',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Cancelar',
|
||||
'organization.settings.delete.success': 'Organização eliminada',
|
||||
'organization.settings.delete.only-owner': 'Apenas o proprietário da organização pode eliminar esta organização.',
|
||||
'organization.settings.delete.has-active-subscription': 'Não é possível eliminar a organização com uma subscrição ativa, por favor cancele a sua subscrição acima primeiro.',
|
||||
|
||||
'organization.usage.page.title': 'Uso',
|
||||
'organization.usage.page.description': 'Visualize o uso atual e os limites da sua organização.',
|
||||
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nome do documento',
|
||||
'tagging-rules.field.content': 'conteúdo do documento',
|
||||
'tagging-rules.field.name': 'o nome do documento',
|
||||
'tagging-rules.field.content': 'o conteúdo do documento',
|
||||
'tagging-rules.operator.equals': 'igual a',
|
||||
'tagging-rules.operator.not-equals': 'não igual a',
|
||||
'tagging-rules.operator.contains': 'contém',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemplo: Etiquetar documentos com \'fatura\' no nome',
|
||||
'tagging-rules.form.description.max-length': 'A descrição deve ter menos de 256 caracteres',
|
||||
'tagging-rules.form.conditions.label': 'Condições',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser cumpridas para a regra se aplicar. Todas as condições devem ser cumpridas para a regra se aplicar.',
|
||||
'tagging-rules.form.conditions.description': 'Defina as condições que devem ser cumpridas para a regra se aplicar. Sem condições significa que a regra será aplicada a todos os documentos',
|
||||
'tagging-rules.form.conditions.add-condition': 'Adicionar condição',
|
||||
'tagging-rules.form.conditions.connector.when': 'Quando',
|
||||
'tagging-rules.form.conditions.connector.and': 'e que',
|
||||
'tagging-rules.form.conditions.connector.or': 'ou que',
|
||||
'tagging-rules.condition-match-mode.all': 'Todas as condições devem corresponder',
|
||||
'tagging-rules.condition-match-mode.any': 'Qualquer condição deve corresponder',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Sem condições',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Não adicionou nenhuma condição a esta regra. Esta regra aplicará as suas etiquetas a todos os documentos.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplicar regra sem condições',
|
||||
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Falha ao atualizar regra de etiquetagem',
|
||||
'tagging-rules.update.submit': 'Atualizar regra',
|
||||
'tagging-rules.update.cancel': 'Cancelar',
|
||||
'tagging-rules.apply.button': 'Aplicar a documentos existentes',
|
||||
'tagging-rules.apply.confirm.title': 'Aplicar regra a documentos existentes?',
|
||||
'tagging-rules.apply.confirm.description': 'Isto irá verificar todos os documentos existentes na sua organização e aplicar etiquetas onde as condições correspondam. O processamento será feito em segundo plano.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicar regra',
|
||||
'tagging-rules.apply.success': 'Aplicação da regra iniciada em segundo plano',
|
||||
'tagging-rules.apply.error': 'Falha ao iniciar a aplicação da regra',
|
||||
'tagging-rules.apply.processing': 'A iniciar...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +571,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'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': 'Actualizar para Plus',
|
||||
'layout.upgrade-cta.button': 'Atualizar agora',
|
||||
|
||||
'layout.theme.light': 'Tema claro',
|
||||
'layout.theme.dark': 'Tema escuro',
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Ocorreu um erro ao processar a solicitação. Por favor, tente novamente.',
|
||||
'api-errors.auth.invalid_origin': 'Origem da aplicação inválida. Se você está hospedando o Papra, certifique-se de que a variável de ambiente APP_BASE_URL corresponde à sua URL atual. Para mais detalhes, consulte https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'O número máximo de membros e convites pendentes para esta organização foi atingido. Atualize o seu plano para adicionar mais membros.',
|
||||
'api-errors.organization.has_active_subscription': 'Não é possível eliminar a organização com uma subscrição ativa. Por favor, cancele a sua subscrição primeiro usando o botão Gerir Subscrição acima.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utilizador não encontrado',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Falha ao criar utilizador',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Falha ao criar sessão',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Falha ao atualizar utilizador',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Falha ao obter sessão',
|
||||
'api-errors.INVALID_PASSWORD': 'Palavra-passe inválida',
|
||||
'api-errors.INVALID_EMAIL': 'Email inválido',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'O email ou a palavra-passe está incorreta, ou a conta não existe.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Conta social já associada',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Fornecedor não encontrado',
|
||||
'api-errors.INVALID_TOKEN': 'Token inválido',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token de ID não suportado',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Falha ao obter informações do utilizador',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email do utilizador não encontrado',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email não verificado',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Palavra-passe demasiado curta',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Palavra-passe demasiado longa',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Já existe um utilizador com este email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'O email não pode ser atualizado',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Conta de credenciais não encontrada',
|
||||
'api-errors.SESSION_EXPIRED': 'Sessão expirada',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Falha ao desassociar a última conta',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Conta não encontrada',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'O utilizador já tem uma palavra-passe',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +673,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Precisa de ajuda?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Contactar suporte',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar para Plus',
|
||||
'subscriptions.upgrade-dialog.title': 'Atualizar esta organização',
|
||||
'subscriptions.upgrade-dialog.description': 'Desbloqueie recursos poderosos para a sua organização',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Contacte-nos',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'se precisar de planos empresariais personalizados.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plano atual',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomendado',
|
||||
'subscriptions.upgrade-dialog.per-month': '/mês',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} faturado anualmente',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Atualizar agora',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Oferta por tempo limitado',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Obtenha {{ percent }}% de desconto por organização em todos os planos para sempre como early adopter! A oferta expira em {{ days, >1:{days} dias, =1:1 dia, menos de um dia }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plano gratuito',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Tamanho de armazenamento de documentos',
|
||||
'subscriptions.features.members': 'Membros da organização',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'Verifică-ți email-ul',
|
||||
'auth.email-validation-required.description': 'A fost trimis un e-mail de verificare la adresa ta de e-mail. Te rugăm să îți verifici adresa de e-mail dând click pe linkul din e-mail.',
|
||||
|
||||
'auth.email-verification.success.title': 'Email verificat',
|
||||
'auth.email-verification.success.description': 'Email-ul tău a fost verificat cu succes. Acum te poți autentifica în contul tău.',
|
||||
'auth.email-verification.success.login': 'Mergi la autentificare',
|
||||
'auth.email-verification.error.title': 'Verificare eșuată',
|
||||
'auth.email-verification.error.description': 'Linkul de verificare este invalid sau a expirat. Te rugăm să soliciți un nou e-mail de verificare autentificându-te.',
|
||||
'auth.email-verification.error.back': 'Înapoi la autentificare',
|
||||
|
||||
'auth.legal-links.description': 'Continuând, confirmați că întelegeți și sunteti de acord cu {{ terms }} și {{ privacy }}.',
|
||||
'auth.legal-links.terms': 'Termenii și condițiile',
|
||||
'auth.legal-links.privacy': 'Politica de confidențialitate',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Anulează',
|
||||
'organization.settings.delete.success': 'Organizație ștearsă cu succes',
|
||||
'organization.settings.delete.only-owner': 'Doar proprietarul organizației poate șterge această organizație.',
|
||||
'organization.settings.delete.has-active-subscription': 'Nu se poate șterge organizația cu un abonament activ, vă rugăm să anulați mai întâi abonamentul de mai sus.',
|
||||
|
||||
'organization.usage.page.title': 'Utilizare',
|
||||
'organization.usage.page.description': 'Vizualizează utilizarea curentă și limitele organizației tale.',
|
||||
@@ -355,8 +363,8 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
// Tagging rules
|
||||
|
||||
'tagging-rules.field.name': 'nume document',
|
||||
'tagging-rules.field.content': 'conținut document',
|
||||
'tagging-rules.field.name': 'numele documentului',
|
||||
'tagging-rules.field.content': 'conținutul documentului',
|
||||
'tagging-rules.operator.equals': 'egal cu',
|
||||
'tagging-rules.operator.not-equals': 'nu este egal cu',
|
||||
'tagging-rules.operator.contains': 'conține',
|
||||
@@ -386,8 +394,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Exemplu: Etichetează documentele cu \'factură\' în nume',
|
||||
'tagging-rules.form.description.max-length': 'Descrierea trebuie să aibă mai puțin de 256 de caractere',
|
||||
'tagging-rules.form.conditions.label': 'Condiții',
|
||||
'tagging-rules.form.conditions.description': 'Definește condițiile care trebuie îndeplinite pentru ca regula să se aplice. Toate condițiile trebuie îndeplinite pentru ca regula să se aplice.',
|
||||
'tagging-rules.form.conditions.description': 'Definește condițiile care trebuie îndeplinite pentru ca regula să se aplice. Fără condiții înseamnă că regula se va aplica tuturor documentelor',
|
||||
'tagging-rules.form.conditions.add-condition': 'Adaugă condiție',
|
||||
'tagging-rules.form.conditions.connector.when': 'Când',
|
||||
'tagging-rules.form.conditions.connector.and': 'și că',
|
||||
'tagging-rules.form.conditions.connector.or': 'sau că',
|
||||
'tagging-rules.condition-match-mode.all': 'Toate condițiile trebuie îndeplinite',
|
||||
'tagging-rules.condition-match-mode.any': 'Orice condiție trebuie îndeplinită',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Nicio condiție',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Nu ai adăugat nicio condiție acestei reguli. Această regula va aplica etichetele sale tuturor documentelor.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Aplică regula fara condiții',
|
||||
@@ -403,6 +416,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.update.error': 'Nu s-a putut actualiza regula de etichetare',
|
||||
'tagging-rules.update.submit': 'Actualizează regula',
|
||||
'tagging-rules.update.cancel': 'Anulează',
|
||||
'tagging-rules.apply.button': 'Aplicați la documente existente',
|
||||
'tagging-rules.apply.confirm.title': 'Aplicați regula la documente existente?',
|
||||
'tagging-rules.apply.confirm.description': 'Aceasta va verifica toate documentele existente din organizația dvs. și va aplica etichetele unde condițiile corespund. Procesarea va avea loc în fundal.',
|
||||
'tagging-rules.apply.confirm.button': 'Aplicați regula',
|
||||
'tagging-rules.apply.success': 'Aplicarea regulii a fost pornită în fundal',
|
||||
'tagging-rules.apply.error': 'Eroare la pornirea aplicării regulii',
|
||||
'tagging-rules.apply.processing': 'Se pornește...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -589,6 +609,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'A apărut o eroare la procesarea cererii. Te rugăm să încerci din nou.',
|
||||
'api-errors.auth.invalid_origin': 'Origine invalidă a aplicației. Dacă hospedezi Papra, asigură-te că variabila de mediu APP_BASE_URL corespunde URL-ului actual. Pentru mai multe detalii, consulta https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Numărul maxim de membri și invitații în așteptare pentru această organizație a fost atins. Te rugăm să îți actualizezi planul pentru a adăuga mai mulți membri.',
|
||||
'api-errors.organization.has_active_subscription': 'Nu se poate șterge organizația cu un abonament activ. Vă rugăm să anulați mai întâi abonamentul folosind butonul Gestionați abonamentul de mai sus.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Utilizatorul nu a fost găsit',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Eroare la crearea utilizatorului',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Eroare la crearea sesiunii',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Eroare la actualizarea utilizatorului',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Eroare la obținerea sesiunii',
|
||||
'api-errors.INVALID_PASSWORD': 'Parolă invalidă',
|
||||
'api-errors.INVALID_EMAIL': 'Email invalid',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Email-ul sau parola este incorectă, sau contul nu există.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Contul social este deja asociat',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Furnizorul nu a fost găsit',
|
||||
'api-errors.INVALID_TOKEN': 'Token invalid',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'Token ID nu este suportat',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Eroare la obținerea informațiilor utilizatorului',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Email-ul utilizatorului nu a fost găsit',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'Email-ul nu este verificat',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Parolă prea scurtă',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Parolă prea lungă',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Există deja un utilizator cu acest email',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'Email-ul nu poate fi actualizat',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Contul de autentificare nu a fost găsit',
|
||||
'api-errors.SESSION_EXPIRED': 'Sesiunea a expirat',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Eroare la disocierea ultimului cont',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Contul nu a fost găsit',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Utilizatorul are deja o parolă',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -634,10 +680,14 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Plan curent',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Recomandat',
|
||||
'subscriptions.upgrade-dialog.per-month': '/lună',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} facturat anual',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Upgrade acum',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Ofertă pe durată limitată',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Obțineți {{ percent }}% reducere pe organizație la toate planurile pentru totdeauna ca early adopter! Oferta expiră în {{ days, >1:{days} zile, =1:1 zi, mai puțin de o zi }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Plan gratuit',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dimensiune stocare documente',
|
||||
'subscriptions.features.members': 'Membri ai organizației',
|
||||
@@ -649,6 +699,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'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',
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Component } from 'solid-js';
|
||||
import type { ApiKey } from '../api-keys.types';
|
||||
import { A } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { format } from 'date-fns';
|
||||
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
@@ -13,7 +12,7 @@ import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { deleteApiKey, fetchApiKeys } from '../api-keys.services';
|
||||
|
||||
export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
||||
const { t } = useI18n();
|
||||
const { t, formatRelativeTime, formatDate } = useI18n();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const deleteApiKeyMutation = useMutation(() => ({
|
||||
@@ -57,15 +56,15 @@ export const ApiKeyCard: Component<{ apiKey: ApiKey }> = ({ apiKey }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{/* <p class="text-muted-foreground text-xs">
|
||||
{t('api-keys.list.card.last-used')}
|
||||
{' '}
|
||||
{apiKey.lastUsedAt ? format(apiKey.lastUsedAt, 'MMM d, yyyy') : t('api-keys.list.card.never')}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{apiKey.lastUsedAt ? formatDate(apiKey.lastUsedAt) : t('api-keys.list.card.never')}
|
||||
</p> */}
|
||||
<p class="text-muted-foreground text-xs" title={formatDate(apiKey.createdAt, { dateStyle: 'short', timeStyle: 'long' })}>
|
||||
{t('api-keys.list.card.created')}
|
||||
{' '}
|
||||
{format(apiKey.createdAt, 'MMM d, yyyy')}
|
||||
{formatRelativeTime(apiKey.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,3 +10,7 @@ export const ssoProviders = [
|
||||
icon: 'i-tabler-brand-github',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const authPagesPaths = {
|
||||
emailVerification: '/email-verification',
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { Config } from '../config/config';
|
||||
import type { SsoProviderConfig } from './auth.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { get } from '../shared/utils/get';
|
||||
import { ssoProviders } from './auth.constants';
|
||||
|
||||
export function isAuthErrorWithCode({ error, code }: { error: unknown; code: string }) {
|
||||
return get(error, 'code') === code;
|
||||
return get(error, ['code']) === code;
|
||||
}
|
||||
|
||||
export const isEmailVerificationRequiredError = ({ error }: { error: unknown }) => isAuthErrorWithCode({ error, code: 'EMAIL_NOT_VERIFIED' });
|
||||
|
||||
export function getEnabledSsoProviderConfigs({ config }: { config: Config }): SsoProviderConfig[] {
|
||||
const enabledSsoProviders: SsoProviderConfig[] = [
|
||||
...ssoProviders.filter(({ key }) => get(config, `auth.providers.${key}.isEnabled`)),
|
||||
...ssoProviders.filter(({ key }) => config.auth.providers[key]?.isEnabled),
|
||||
...config.auth.providers.customs.map(({ providerId, providerName, providerIconUrl }) => ({
|
||||
key: providerId,
|
||||
name: providerName,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SsoProviderConfig } from './auth.types';
|
||||
import { genericOAuthClient } from 'better-auth/client/plugins';
|
||||
import { createAuthClient as createBetterAuthClient } from 'better-auth/solid';
|
||||
import { buildTimeConfig } from '../config/config';
|
||||
import { queryClient } from '../shared/query/query-client';
|
||||
import { trackingServices } from '../tracking/tracking.services';
|
||||
import { createDemoAuthClient } from './auth.demo.services';
|
||||
|
||||
@@ -28,6 +29,8 @@ export function createAuthClient() {
|
||||
const result = await client.signOut();
|
||||
trackingServices.reset();
|
||||
|
||||
queryClient.clear();
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { A, useSearchParams } from '@solidjs/router';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
|
||||
export const EmailVerificationPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const getHasError = () => Boolean(searchParams.error);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div class="flex items-center justify-center h-full p-6 sm:pb-32">
|
||||
<div class="max-w-xs w-full flex flex-col items-center text-center">
|
||||
{getHasError()
|
||||
? (
|
||||
<>
|
||||
<div class="i-tabler-alert-circle size-12 text-destructive mb-2" />
|
||||
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.email-verification.error.title')}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.email-verification.error.description')}
|
||||
</p>
|
||||
|
||||
<Button as={A} href="/login" class="gap-2" variant="secondary">
|
||||
<div class="i-tabler-arrow-left size-4" />
|
||||
{t('auth.email-verification.error.back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div class="i-tabler-circle-check size-12 text-primary mb-2" />
|
||||
|
||||
<h1 class="text-xl font-bold">
|
||||
{t('auth.email-verification.success.title')}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 mb-4">
|
||||
{t('auth.email-verification.success.description')}
|
||||
</p>
|
||||
|
||||
<Button as={A} href="/login" class="gap-2">
|
||||
{t('auth.email-verification.success.login')}
|
||||
<div class="i-tabler-arrow-right size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -12,6 +13,7 @@ import { Checkbox, CheckboxControl, CheckboxLabel } from '@/modules/ui/component
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { authPagesPaths } from '../auth.constants';
|
||||
import { getEnabledSsoProviderConfigs, isEmailVerificationRequiredError } from '../auth.models';
|
||||
import { authWithProvider, signIn } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
@@ -26,7 +28,13 @@ export const EmailLoginForm: Component = () => {
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ email, password, rememberMe }) => {
|
||||
const { error } = await signIn.email({ email, password, rememberMe, callbackURL: config.baseUrl });
|
||||
const { error } = await signIn.email({
|
||||
email,
|
||||
password,
|
||||
rememberMe,
|
||||
// This URL is where the user will be redirected after email verification
|
||||
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
|
||||
});
|
||||
|
||||
if (isEmailVerificationRequiredError({ error })) {
|
||||
navigate('/email-validation-required');
|
||||
@@ -35,6 +43,8 @@ export const EmailLoginForm: Component = () => {
|
||||
if (error) {
|
||||
throw createI18nApiError({ error });
|
||||
}
|
||||
|
||||
// If all good guard will redirect to dashboard
|
||||
},
|
||||
schema: v.object({
|
||||
email: v.pipe(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { SsoProviderConfig } from '../auth.types';
|
||||
import { buildUrl } from '@corentinth/chisels';
|
||||
import { A, useNavigate } from '@solidjs/router';
|
||||
import { createSignal, For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -11,6 +12,7 @@ import { Button } from '@/modules/ui/components/button';
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { AuthLayout } from '../../ui/layouts/auth-layout.component';
|
||||
import { authPagesPaths } from '../auth.constants';
|
||||
import { getEnabledSsoProviderConfigs } from '../auth.models';
|
||||
import { authWithProvider, signUp } from '../auth.services';
|
||||
import { AuthLegalLinks } from '../components/legal-links.component';
|
||||
@@ -29,7 +31,8 @@ export const EmailRegisterForm: Component = () => {
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
callbackURL: config.baseUrl,
|
||||
// This URL is where the user will be redirected after email verification
|
||||
callbackURL: buildUrl({ baseUrl: config.baseUrl, path: authPagesPaths.emailVerification }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -2,12 +2,12 @@ import type { Accessor, ParentComponent } from 'solid-js';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { createContext, createEffect, createSignal, For, on, onCleanup, onMount, Show, useContext } from 'solid-js';
|
||||
import { getDocumentIcon } from '../documents/document.models';
|
||||
import { searchDocuments } from '../documents/documents.services';
|
||||
import { useI18n } from '../i18n/i18n.provider';
|
||||
import { cn } from '../shared/style/cn';
|
||||
import { debounce } from '../shared/utils/timing';
|
||||
import { useThemeStore } from '../theme/theme.store';
|
||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandLoading } from '../ui/components/command';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ParentComponent } from 'solid-js';
|
||||
import type { Config, RuntimePublicConfig } from './config';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { merge } from 'lodash-es';
|
||||
import { createContext, Match, Switch, useContext } from 'solid-js';
|
||||
import { deepMerge } from '../shared/utils/object';
|
||||
import { Button } from '../ui/components/button';
|
||||
import { EmptyState } from '../ui/components/empty';
|
||||
import { createToast } from '../ui/components/sonner';
|
||||
@@ -31,7 +31,7 @@ export const ConfigProvider: ParentComponent = (props) => {
|
||||
}));
|
||||
|
||||
const mergeConfigs = (runtimeConfig: RuntimePublicConfig): Config => {
|
||||
return merge({}, buildTimeConfig, runtimeConfig);
|
||||
return deepMerge(buildTimeConfig, runtimeConfig);
|
||||
};
|
||||
|
||||
const retry = async () => {
|
||||
|
||||
@@ -40,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' | 'documents' | 'documentsStorage' | 'intakeEmails' | 'organizations'>;
|
||||
export type RuntimePublicConfig = Pick<Config, 'auth' | 'documents' | 'intakeEmails' | 'organizations'>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ApiKey } from '../api-keys/api-keys.types';
|
||||
import type { Document } from '../documents/documents.types';
|
||||
import type { Webhook } from '../webhooks/webhooks.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { createRouter } from 'radix3';
|
||||
import { get } from '../shared/utils/get';
|
||||
import { defineHandler } from './demo-api-mock.models';
|
||||
import {
|
||||
apiKeyStorage,
|
||||
@@ -94,7 +94,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
path: '/api/organizations/:organizationId/documents',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId }, query }) => {
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId && !document.deletedAt);
|
||||
@@ -197,7 +197,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
searchQuery: rawSearchQuery = '',
|
||||
} = query ?? {};
|
||||
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document?.organizationId === organizationId);
|
||||
@@ -221,7 +221,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
path: '/api/organizations/:organizationId/documents/deleted',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = organizationStorage.getItem(organizationId);
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const deletedDocuments = await findMany(
|
||||
@@ -341,9 +341,9 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const tag = {
|
||||
id: createId({ prefix: 'tag' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
color: get(body, 'color'),
|
||||
description: get(body, 'description'),
|
||||
name: get(body, ['name']) as string,
|
||||
color: get(body, ['color']) as string,
|
||||
description: (get(body, ['description']) ?? null) as string | null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -396,7 +396,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const tagId = get(body, 'tagId');
|
||||
const tagId = get(body, ['tagId']) as string;
|
||||
|
||||
assert(tagId, { status: 400 });
|
||||
|
||||
@@ -441,7 +441,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
handler: async ({ body }) => {
|
||||
const organization = {
|
||||
id: createId({ prefix: 'org' }),
|
||||
name: get(body, 'name'),
|
||||
name: get(body, ['name']) as string,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -480,7 +480,7 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
organization.name = get(body, 'name');
|
||||
organization.name = get(body, ['name']) as string;
|
||||
organization.updatedAt = new Date();
|
||||
|
||||
await organizationStorage.setItem(organizationId, organization);
|
||||
@@ -506,10 +506,10 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const taggingRule = {
|
||||
id: createId({ prefix: 'tr' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
description: get(body, 'description'),
|
||||
conditions: get(body, 'conditions'),
|
||||
actions: get(body, 'tagIds').map((tagId: string) => ({ tagId })),
|
||||
name: get(body, ['name']) as string,
|
||||
description: (get(body, ['description']) ?? '') as string,
|
||||
conditions: get(body, ['conditions']) as any,
|
||||
actions: (get(body, ['tagIds']) as string[]).map((tagId: string) => ({ tagId })),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -641,11 +641,11 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
|
||||
const apiKey = {
|
||||
id: createId({ prefix: 'apiKey' }),
|
||||
name: get(body, 'name'),
|
||||
permissions: get(body, 'permissions'),
|
||||
organizationIds: get(body, 'organizationIds'),
|
||||
allOrganizations: get(body, 'allOrganizations'),
|
||||
expiresAt: get(body, 'expiresAt'),
|
||||
name: get(body, ['name']),
|
||||
permissions: get(body, ['permissions']),
|
||||
organizationIds: get(body, ['organizationIds']),
|
||||
allOrganizations: get(body, ['allOrganizations']),
|
||||
expiresAt: get(body, ['expiresAt']),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
prefix: token.slice(0, 11),
|
||||
@@ -694,10 +694,10 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
const webhook: Webhook = {
|
||||
id: createId({ prefix: 'webhook' }),
|
||||
organizationId,
|
||||
name: get(body, 'name'),
|
||||
url: get(body, 'url'),
|
||||
name: get(body, ['name']) as string,
|
||||
url: get(body, ['url']) as string,
|
||||
enabled: true,
|
||||
events: get(body, 'events'),
|
||||
events: get(body, ['events']) as Webhook['events'],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -761,6 +761,72 @@ const inMemoryApiMock: Record<string, { handler: any }> = {
|
||||
return { document: newDocument };
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/subscription',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
// Demo mode uses free plan with no subscription
|
||||
return {
|
||||
subscription: null,
|
||||
plan: {
|
||||
id: 'free',
|
||||
name: 'Free',
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 500, // 500 MiB
|
||||
maxIntakeEmailsCount: 1,
|
||||
maxOrganizationsMembersCount: 3,
|
||||
maxFileSize: 1024 * 1024 * 50, // 50 MiB
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
...defineHandler({
|
||||
path: '/api/organizations/:organizationId/usage',
|
||||
method: 'GET',
|
||||
handler: async ({ params: { organizationId } }) => {
|
||||
const organization = await organizationStorage.getItem(organizationId);
|
||||
|
||||
assert(organization, { status: 403 });
|
||||
|
||||
const documents = await findMany(documentStorage, document => document.organizationId === organizationId);
|
||||
|
||||
const totalDocumentsSize = documents.reduce((acc, doc) => acc + (doc.originalSize ?? 0), 0);
|
||||
const deletedDocumentsSize = documents
|
||||
.filter(doc => doc.deletedAt)
|
||||
.reduce((acc, doc) => acc + (doc.originalSize ?? 0), 0);
|
||||
|
||||
return {
|
||||
usage: {
|
||||
documentsStorage: {
|
||||
used: totalDocumentsSize,
|
||||
deleted: deletedDocumentsSize,
|
||||
limit: 1024 * 1024 * 500, // 500 MiB
|
||||
},
|
||||
intakeEmailsCount: {
|
||||
used: 0,
|
||||
limit: 1,
|
||||
},
|
||||
membersCount: {
|
||||
used: 1,
|
||||
limit: 3,
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
maxDocumentStorageBytes: 1024 * 1024 * 500, // 500 MiB
|
||||
maxIntakeEmailsCount: 1,
|
||||
maxOrganizationsMembersCount: 3,
|
||||
maxFileSize: 1024 * 1024 * 50, // 50 MiB
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const router = createRouter({ routes: inMemoryApiMock, strictTrailingSlash: false });
|
||||
|
||||
@@ -2,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 });
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
|
||||
import type { ColumnDef } from '@tanstack/solid-table';
|
||||
import type { Accessor, Component, Setter } from 'solid-js';
|
||||
import type { Document } from '../documents.types';
|
||||
@@ -7,13 +6,12 @@ import { formatBytes } from '@corentinth/chisels';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { cn } from '@/modules/shared/style/cn';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/modules/ui/components/tooltip';
|
||||
import { getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension } from '../document.models';
|
||||
import { DocumentManagementDropdown } from './document-management-dropdown.component';
|
||||
|
||||
@@ -25,13 +23,13 @@ type Pagination = {
|
||||
export const createdAtColumn: ColumnDef<Document> = {
|
||||
header: () => (<span class="hidden sm:block">Created at</span>),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
cell: data => <RelativeTime class="text-muted-foreground hidden sm:block" date={data.getValue<Date>()} />,
|
||||
};
|
||||
|
||||
export const deletedAtColumn: ColumnDef<Document> = {
|
||||
header: () => (<span class="hidden sm:block">Deleted at</span>),
|
||||
accessorKey: 'deletedAt',
|
||||
cell: data => <div class="text-muted-foreground hidden sm:block" title={data.getValue<Date>().toLocaleString()}>{timeAgo({ date: data.getValue<Date>() })}</div>,
|
||||
cell: data => <RelativeTime class="text-muted-foreground hidden sm:block" date={data.getValue<Date>()} />,
|
||||
};
|
||||
|
||||
export const standardActionsColumn: ColumnDef<Document> = {
|
||||
@@ -92,17 +90,7 @@ export const DocumentsPaginatedList: Component<{
|
||||
{' '}
|
||||
-
|
||||
{' '}
|
||||
<Tooltip>
|
||||
<TooltipTrigger as={(tooltipProps: TooltipTriggerProps) => (
|
||||
<span {...tooltipProps}>
|
||||
{timeAgo({ date: data.row.original.createdAt })}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{data.row.original.createdAt.toLocaleString()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<RelativeTime date={data.row.original.createdAt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { icons as tablerIconSet } from '@iconify-json/tabler';
|
||||
import { values } from 'lodash-es';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { getDaysBeforePermanentDeletion, getDocumentIcon, getDocumentNameExtension, getDocumentNameWithoutExtension, iconByFileType } from './document.models';
|
||||
|
||||
describe('files models', () => {
|
||||
describe('iconByFileType', () => {
|
||||
const icons = values(iconByFileType);
|
||||
const icons = Object.values(iconByFileType);
|
||||
|
||||
test('they must at least have the default icon', () => {
|
||||
expect(iconByFileType['*']).toBeDefined();
|
||||
@@ -100,6 +99,98 @@ describe('files models', () => {
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(undefined);
|
||||
});
|
||||
|
||||
test('returns 0 when the permanent deletion date is today', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-31');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(0);
|
||||
});
|
||||
|
||||
test('returns negative days when the permanent deletion date has passed', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-02-15');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(-15);
|
||||
});
|
||||
|
||||
test('handles deletion that happened on the same day (considers time of day)', () => {
|
||||
const document = { deletedAt: new Date('2021-01-10T08:00:00') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-10T14:00:00');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
// Since differenceInDays counts full days, and there's only 6 hours difference,
|
||||
// the permanent deletion date (30 days from 08:00) is 29 full days from 14:00
|
||||
expect(daysBeforeDeletion).to.eql(29);
|
||||
});
|
||||
|
||||
test('handles very short retention periods', () => {
|
||||
const document = { deletedAt: new Date('2021-01-10') };
|
||||
const deletedDocumentsRetentionDays = 1;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(1);
|
||||
});
|
||||
|
||||
test('handles very long retention periods', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01') };
|
||||
const deletedDocumentsRetentionDays = 365;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(356);
|
||||
});
|
||||
|
||||
test('handles zero retention days (immediate deletion)', () => {
|
||||
const document = { deletedAt: new Date('2021-01-10') };
|
||||
const deletedDocumentsRetentionDays = 0;
|
||||
const now = new Date('2021-01-10');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(0);
|
||||
});
|
||||
|
||||
test('handles dates across year boundaries', () => {
|
||||
const document = { deletedAt: new Date('2020-12-20') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-05');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(14);
|
||||
});
|
||||
|
||||
test('handles dates across leap year February', () => {
|
||||
const document = { deletedAt: new Date('2020-02-15') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2020-02-20');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(25);
|
||||
});
|
||||
|
||||
test('handles timestamp precision with hours and minutes', () => {
|
||||
const document = { deletedAt: new Date('2021-01-01T23:59:59') };
|
||||
const deletedDocumentsRetentionDays = 30;
|
||||
const now = new Date('2021-01-02T00:00:01');
|
||||
|
||||
const daysBeforeDeletion = getDaysBeforePermanentDeletion({ document, deletedDocumentsRetentionDays, now });
|
||||
|
||||
expect(daysBeforeDeletion).to.eql(29);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentNameWithoutExtension', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DocumentActivityEvent } from './documents.types';
|
||||
import { addDays, differenceInDays } from 'date-fns';
|
||||
import { IN_MS } from '../shared/utils/units';
|
||||
|
||||
export const iconByFileType = {
|
||||
'*': 'i-tabler-file',
|
||||
@@ -44,9 +44,12 @@ export function getDaysBeforePermanentDeletion({ document, deletedDocumentsReten
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const deletionDate = addDays(document.deletedAt, deletedDocumentsRetentionDays);
|
||||
// Calculate the permanent deletion date by adding retention days to the deleted date
|
||||
const deletionDate = new Date(document.deletedAt);
|
||||
deletionDate.setDate(deletionDate.getDate() + deletedDocumentsRetentionDays);
|
||||
|
||||
const daysBeforeDeletion = differenceInDays(deletionDate, now);
|
||||
// Calculate the difference in milliseconds and convert to days
|
||||
const daysBeforeDeletion = Math.floor((deletionDate.getTime() - now.getTime()) / IN_MS.DAY);
|
||||
|
||||
return daysBeforeDeletion;
|
||||
}
|
||||
|
||||
@@ -168,7 +168,16 @@ export async function searchDocuments({
|
||||
}
|
||||
|
||||
export async function getOrganizationDocumentsStats({ organizationId }: { organizationId: string }) {
|
||||
const { organizationStats } = await apiClient<{ organizationStats: { documentsCount: number; documentsSize: number } }>({
|
||||
const { organizationStats } = await apiClient<{
|
||||
organizationStats: {
|
||||
documentsCount: number;
|
||||
documentsSize: number;
|
||||
deletedDocumentsSize: number;
|
||||
deletedDocumentsCount: number;
|
||||
totalDocumentsCount: number;
|
||||
totalDocumentsSize: number;
|
||||
};
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/documents/statistics`,
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useParams } from '@solidjs/router';
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
@@ -199,7 +199,7 @@ export const DeletedDocumentsPage: Component = () => {
|
||||
<div class="text-muted-foreground hidden sm:block">
|
||||
{t('documents.deleted.deleted-at')}
|
||||
{' '}
|
||||
<span class="text-foreground font-bold" title={data.row.original.deletedAt?.toLocaleString()}>{timeAgo({ date: data.row.original.deletedAt! })}</span>
|
||||
<RelativeTime class="text-foreground font-bold" date={data.row.original.deletedAt!} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -2,11 +2,11 @@ import type { Component, JSX } from 'solid-js';
|
||||
import type { DocumentActivity } from '../documents.types';
|
||||
import { formatBytes } from '@corentinth/chisels';
|
||||
import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, useInfiniteQuery } from '@tanstack/solid-query';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/solid-query';
|
||||
import { createEffect, createSignal, For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { downloadFile } from '@/modules/shared/files/download';
|
||||
import { DocumentTagPicker } from '@/modules/tags/components/tag-picker.component';
|
||||
import { TagLink } from '@/modules/tags/components/tag.component';
|
||||
@@ -83,7 +83,7 @@ const ActivityItem: Component<{ activity: DocumentActivity }> = (props) => {
|
||||
</Switch>
|
||||
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span title={props.activity.createdAt.toLocaleString()}>{timeAgo({ date: props.activity.createdAt })}</span>
|
||||
<RelativeTime date={props.activity.createdAt} />
|
||||
<Show when={props.activity.user}>
|
||||
{getUser => (
|
||||
<span>{te('activity.document.user.name', { name: <A href={`/organizations/${params.organizationId}/members`} class="underline hover:text-primary transition">{getUser().name}</A> })}</span>
|
||||
@@ -99,7 +99,7 @@ const tabs = ['info', 'content', 'activity'] as const;
|
||||
type Tab = typeof tabs[number];
|
||||
|
||||
export const DocumentPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const { t, formatRelativeTime } = useI18n();
|
||||
const params = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { deleteDocument } = useDeleteDocument();
|
||||
@@ -122,17 +122,14 @@ export const DocumentPage: Component = () => {
|
||||
setSearchParams({ tab: getTab() }, { replace: true });
|
||||
});
|
||||
|
||||
const queries = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
|
||||
queryFn: () => fetchDocument({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
const documentQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId],
|
||||
queryFn: () => fetchDocument({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const documentFileQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', params.documentId, 'file'],
|
||||
queryFn: () => fetchDocumentFile({ documentId: params.documentId, organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const activityPageSize = 20;
|
||||
@@ -160,14 +157,14 @@ export const DocumentPage: Component = () => {
|
||||
}));
|
||||
|
||||
const deleteDoc = async () => {
|
||||
if (!queries[0].data) {
|
||||
if (!documentQuery.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasDeleted } = await deleteDocument({
|
||||
documentId: params.documentId,
|
||||
organizationId: params.organizationId,
|
||||
documentName: queries[0].data.document.name,
|
||||
documentName: documentQuery.data.document.name,
|
||||
});
|
||||
|
||||
if (!hasDeleted) {
|
||||
@@ -177,13 +174,13 @@ export const DocumentPage: Component = () => {
|
||||
navigate(`/organizations/${params.organizationId}/documents`);
|
||||
};
|
||||
|
||||
const getDataUrl = () => queries[1].data ? URL.createObjectURL(queries[1].data) : undefined;
|
||||
const getDataUrl = () => documentFileQuery.data ? URL.createObjectURL(documentFileQuery.data) : undefined;
|
||||
|
||||
return (
|
||||
<div class="p-6 flex gap-6 h-full flex-col md:flex-row max-w-7xl mx-auto">
|
||||
<Suspense>
|
||||
<div class="md:flex-1 md:border-r">
|
||||
<Show when={queries[0].data?.document}>
|
||||
<Show when={documentQuery.data?.document}>
|
||||
{getDocument => (
|
||||
<div class="flex gap-4 md:pr-6">
|
||||
<div class="flex-1">
|
||||
@@ -328,12 +325,12 @@ export const DocumentPage: Component = () => {
|
||||
},
|
||||
{
|
||||
label: t('documents.info.created-at'),
|
||||
value: timeAgo({ date: getDocument().createdAt }),
|
||||
value: formatRelativeTime(getDocument().createdAt),
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
{
|
||||
label: t('documents.info.updated-at'),
|
||||
value: getDocument().updatedAt ? timeAgo({ date: getDocument().updatedAt! }) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
value: getDocument().updatedAt ? formatRelativeTime(getDocument().updatedAt!) : <span class="text-muted-foreground">{t('documents.info.never')}</span>,
|
||||
icon: 'i-tabler-calendar',
|
||||
},
|
||||
]}
|
||||
@@ -390,7 +387,7 @@ export const DocumentPage: Component = () => {
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-50vh">
|
||||
<Show when={queries[0].data?.document}>
|
||||
<Show when={documentQuery.data?.document}>
|
||||
{getDocument => (
|
||||
<DocumentPreview document={getDocument()} />
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { useParams, useSearchParams } from '@solidjs/router';
|
||||
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
|
||||
import { castArray } from 'lodash-es';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { fetchOrganization } from '@/modules/organizations/organizations.services';
|
||||
import { castArray } from '@/modules/shared/utils/array';
|
||||
import { Tag } from '@/modules/tags/components/tag.component';
|
||||
import { fetchTags } from '@/modules/tags/tags.services';
|
||||
import { DocumentUploadArea } from '../components/document-upload-area.component';
|
||||
@@ -19,37 +18,30 @@ export const DocumentsPage: Component = () => {
|
||||
|
||||
const getFiltererTagIds = () => searchParams.tags ? castArray(searchParams.tags) : [];
|
||||
|
||||
const query = createQueries(() => ({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
filters: {
|
||||
tags: getFiltererTagIds(),
|
||||
},
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
const documentsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
|
||||
queryFn: () => fetchOrganizationDocuments({
|
||||
organizationId: params.organizationId,
|
||||
...getPagination(),
|
||||
filters: {
|
||||
tags: getFiltererTagIds(),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId],
|
||||
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
|
||||
},
|
||||
{
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
}));
|
||||
|
||||
const getFilteredTags = () => query[2].data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
|
||||
const tagsQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', params.organizationId, 'tags'],
|
||||
queryFn: () => fetchTags({ organizationId: params.organizationId }),
|
||||
}));
|
||||
|
||||
const getFilteredTags = () => tagsQuery.data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
|
||||
const hasFilters = () => getFiltererTagIds().length > 0;
|
||||
|
||||
return (
|
||||
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
|
||||
<Suspense>
|
||||
{query[0].data?.documents?.length === 0 && !hasFilters()
|
||||
{documentsQuery.data?.documents?.length === 0 && !hasFilters()
|
||||
? (
|
||||
<>
|
||||
<h2 class="text-xl font-bold ">
|
||||
@@ -83,15 +75,15 @@ export const DocumentsPage: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
|
||||
<Show when={hasFilters() && documentsQuery.data?.documentsCount === 0}>
|
||||
<p class="text-muted-foreground mt-1 mb-6">
|
||||
{t('documents.list.no-results')}
|
||||
</p>
|
||||
</Show>
|
||||
|
||||
<DocumentsPaginatedList
|
||||
documents={query[0].data?.documents ?? []}
|
||||
documentsCount={query[0].data?.documentsCount ?? 0}
|
||||
documents={documentsQuery.data?.documents ?? []}
|
||||
documentsCount={documentsQuery.data?.documentsCount ?? 0}
|
||||
getPagination={getPagination}
|
||||
setPagination={setPagination}
|
||||
extraColumns={[
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Component, JSX } from 'solid-js';
|
||||
import type { CoercibleDate } from '@/modules/shared/date/date.types';
|
||||
import { splitProps } from 'solid-js';
|
||||
import { coerceDate } from '@/modules/shared/date/coerce-date';
|
||||
import { useI18n } from '../i18n.provider';
|
||||
|
||||
export const RelativeTime: Component<{ date: CoercibleDate } & JSX.IntrinsicElements['time']> = (props) => {
|
||||
const [local, rest] = splitProps(props, ['date', 'title', 'dateTime']);
|
||||
const { formatRelativeTime, formatDate } = useI18n();
|
||||
|
||||
return (
|
||||
<time
|
||||
title={local.title ?? formatDate(local.date, { dateStyle: 'short', timeStyle: 'short' })}
|
||||
dateTime={local.dateTime ?? coerceDate(local.date).toISOString()}
|
||||
{...rest}
|
||||
>
|
||||
{formatRelativeTime(local.date)}
|
||||
</time>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createTranslator, findMatchingLocale } from './i18n.models';
|
||||
import { createDateFormatter, createRelativeTimeFormatter, createTranslator, findMatchingLocale } from './i18n.models';
|
||||
|
||||
describe('i18n models', () => {
|
||||
describe('findMatchingLocale', () => {
|
||||
@@ -125,4 +125,99 @@ describe('i18n models', () => {
|
||||
expect(t('hello', { name: 'John' })).to.eql('John, John, John and John!');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDateFormatter', () => {
|
||||
test('formats date according to locale, by default in short format', () => {
|
||||
expect(
|
||||
createDateFormatter({ getLocale: () => 'en' })(new Date('2025-01-15')),
|
||||
).to.eql('Jan 15, 2025');
|
||||
|
||||
expect(
|
||||
createDateFormatter({ getLocale: () => 'fr' })(new Date('2025-01-15')),
|
||||
).to.eql('15 janv. 2025');
|
||||
|
||||
expect(
|
||||
createDateFormatter({ getLocale: () => 'pt-BR' })(new Date('2025-01-15')),
|
||||
).to.eql('15 de jan. de 2025');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRelativeTimeFormatter', () => {
|
||||
test('formats relative time according to locale', () => {
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('now');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:00:06Z') }),
|
||||
).to.eql('6 seconds ago');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'fr' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-01T00:02:00Z') }),
|
||||
).to.eql('il y a 2 minutes');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'pt-BR' })(new Date('2021-01-01T00:00:00Z'), { now: new Date('2021-01-03T00:00:00Z') }),
|
||||
).to.eql('anteontem');
|
||||
});
|
||||
|
||||
test('use the best unit for relative time', () => {
|
||||
const formatter = createRelativeTimeFormatter({ getLocale: () => 'en' });
|
||||
const timeAgo = (now: Date) => formatter(new Date('2021-01-01T00:00:00Z'), { now });
|
||||
|
||||
expect(timeAgo(new Date('2021-01-01T00:00:00Z'))).toBe('now');
|
||||
expect(timeAgo(new Date('2021-01-01T00:00:06Z'))).toBe('6 seconds ago');
|
||||
expect(timeAgo(new Date('2021-01-01T00:01:00Z'))).toBe('1 minute ago');
|
||||
expect(timeAgo(new Date('2021-01-01T00:02:00Z'))).toBe('2 minutes ago');
|
||||
expect(timeAgo(new Date('2021-01-01T01:00:00Z'))).toBe('1 hour ago');
|
||||
expect(timeAgo(new Date('2021-01-01T02:00:00Z'))).toBe('2 hours ago');
|
||||
expect(timeAgo(new Date('2021-01-02T00:00:00Z'))).toBe('yesterday');
|
||||
expect(timeAgo(new Date('2021-01-03T00:00:00Z'))).toBe('2 days ago');
|
||||
expect(timeAgo(new Date('2021-02-01T00:00:00Z'))).toBe('last month');
|
||||
expect(timeAgo(new Date('2021-03-02T00:00:00Z'))).toBe('2 months ago');
|
||||
expect(timeAgo(new Date('2022-01-12T00:00:00Z'))).toBe('last year');
|
||||
expect(timeAgo(new Date('2023-01-01T00:00:00Z'))).toBe('2 years ago');
|
||||
});
|
||||
|
||||
test('handles future dates correctly', () => {
|
||||
const formatter = createRelativeTimeFormatter({ getLocale: () => 'en' });
|
||||
const timeUntil = (now: Date) => formatter(new Date('2021-01-01T00:00:00Z'), { now });
|
||||
|
||||
expect(timeUntil(new Date('2020-12-31T23:59:54Z'))).toBe('in 6 seconds');
|
||||
expect(timeUntil(new Date('2020-12-31T23:59:00Z'))).toBe('in 1 minute');
|
||||
expect(timeUntil(new Date('2020-12-31T23:58:00Z'))).toBe('in 2 minutes');
|
||||
expect(timeUntil(new Date('2020-12-31T23:00:00Z'))).toBe('in 1 hour');
|
||||
expect(timeUntil(new Date('2020-12-31T22:00:00Z'))).toBe('in 2 hours');
|
||||
expect(timeUntil(new Date('2020-12-31T00:00:00Z'))).toBe('tomorrow');
|
||||
expect(timeUntil(new Date('2020-12-30T00:00:00Z'))).toBe('in 2 days');
|
||||
expect(timeUntil(new Date('2020-12-01T00:00:00Z'))).toBe('next month');
|
||||
expect(timeUntil(new Date('2020-11-01T00:00:00Z'))).toBe('in 2 months');
|
||||
expect(timeUntil(new Date('2020-01-01T00:00:00Z'))).toBe('next year');
|
||||
expect(timeUntil(new Date('2019-01-01T00:00:00Z'))).toBe('in 2 years');
|
||||
});
|
||||
|
||||
test('formats future dates according to locale', () => {
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })(new Date('2021-01-01T00:02:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('in 2 minutes');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'fr' })(new Date('2021-01-01T00:02:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('dans 2 minutes');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'pt-BR' })(new Date('2021-01-03T00:00:00Z'), { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('depois de amanhã');
|
||||
});
|
||||
|
||||
test('the date can be a parsable string', () => {
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })('2021-01-01T00:00:00Z', { now: new Date('2021-01-01T00:00:00Z') }),
|
||||
).to.eql('now');
|
||||
|
||||
expect(
|
||||
createRelativeTimeFormatter({ getLocale: () => 'en' })('2021-01-01', { now: new Date('2021-01-01T00:02:00Z') }),
|
||||
).to.eql('2 minutes ago');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { JSX } from 'solid-js';
|
||||
import type { CoercibleDate } from '../shared/date/date.types';
|
||||
import type { Locale } from './i18n.provider';
|
||||
import { createBranchlet } from '@branchlet/core';
|
||||
import { coerceDate } from '../shared/date/coerce-date';
|
||||
import { IN_MS } from '../shared/utils/units';
|
||||
|
||||
// This tries to get the most preferred language compatible with the supported languages
|
||||
// It tries to find a supported language by comparing both region and language, if not, then just language
|
||||
@@ -71,3 +74,46 @@ export function createFragmentTranslator<Dict extends Record<string, string>>({
|
||||
return translation;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDateFormatter({ getLocale }: { getLocale: () => string }) {
|
||||
return (date: CoercibleDate, options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }) => {
|
||||
return new Intl.DateTimeFormat(getLocale(), options).format(coerceDate(date));
|
||||
};
|
||||
}
|
||||
|
||||
export function createRelativeTimeFormatter({ getLocale }: { getLocale: () => string }) {
|
||||
return (rawDate: CoercibleDate, { now = new Date(), numeric = 'auto', style = 'long' }: { now?: Date; numeric?: 'auto' | 'always'; style?: 'long' | 'short' } = {}) => {
|
||||
const formatter = new Intl.RelativeTimeFormat(getLocale(), { numeric, style });
|
||||
|
||||
const date = coerceDate(rawDate);
|
||||
const msDiff = now.getTime() - date.getTime();
|
||||
const absDiff = Math.abs(msDiff);
|
||||
const sign = msDiff >= 0 ? -1 : 1;
|
||||
|
||||
if (absDiff < IN_MS.MINUTE) {
|
||||
return formatter.format(sign * Math.round(absDiff / 1_000), 'second');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.HOUR) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.MINUTE), 'minute');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.DAY) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.HOUR), 'hour');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.WEEK) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.DAY), 'day');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.MONTH) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.WEEK), 'week');
|
||||
}
|
||||
|
||||
if (absDiff < IN_MS.YEAR) {
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.MONTH), 'month');
|
||||
}
|
||||
|
||||
return formatter.format(sign * Math.round(absDiff / IN_MS.YEAR), 'year');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { makePersisted } from '@solid-primitives/storage';
|
||||
import { createContext, createEffect, createResource, createSignal, Show, useContext } from 'solid-js';
|
||||
import { translations as defaultTranslations } from '../../locales/en.dictionary';
|
||||
import { locales } from './i18n.constants';
|
||||
import { createFragmentTranslator, createTranslator, findMatchingLocale } from './i18n.models';
|
||||
import { createDateFormatter, createFragmentTranslator, createRelativeTimeFormatter, createTranslator, findMatchingLocale } from './i18n.models';
|
||||
|
||||
export type Locale = typeof locales[number]['key'];
|
||||
|
||||
@@ -14,6 +14,8 @@ const I18nContext = createContext<{
|
||||
getLocale: Accessor<Locale>;
|
||||
setLocale: Setter<Locale>;
|
||||
locales: typeof locales;
|
||||
formatDate: ReturnType<typeof createDateFormatter>;
|
||||
formatRelativeTime: ReturnType<typeof createRelativeTimeFormatter>;
|
||||
}>();
|
||||
|
||||
export function useI18n() {
|
||||
@@ -58,6 +60,8 @@ export const I18nProvider: ParentComponent = (props) => {
|
||||
getLocale,
|
||||
setLocale,
|
||||
locales,
|
||||
formatDate: createDateFormatter({ getLocale }),
|
||||
formatRelativeTime: createRelativeTimeFormatter({ getLocale }),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { Component } from 'solid-js';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Show } from 'solid-js';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
@@ -56,7 +56,7 @@ export const InvitationsPage: Component = () => {
|
||||
{
|
||||
header: t('invitations.list.headers.created'),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <time dateTime={data.getValue()}>{timeAgo({ date: data.getValue() })}</time>,
|
||||
cell: data => <RelativeTime date={data.getValue()} />,
|
||||
},
|
||||
{
|
||||
header: () => <div class="text-right">{t('invitations.list.headers.actions')}</div>,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { fetchDeletedOrganizations, restoreOrganization } from '../organizations.services';
|
||||
|
||||
export const DeletedOrganizationsPage: Component = () => {
|
||||
const { t } = useI18n();
|
||||
const { t, formatDate } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { config } = useConfig();
|
||||
@@ -49,14 +49,6 @@ export const DeletedOrganizationsPage: Component = () => {
|
||||
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">
|
||||
|
||||
@@ -4,10 +4,10 @@ import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { createSolidTable, flexRender, getCoreRowModel, getPaginationRowModel } from '@tanstack/solid-table';
|
||||
import { For, Match, onMount, Show, Switch } from 'solid-js';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { cancelInvitation, resendInvitation } from '@/modules/invitations/invitations.services';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Badge } from '@/modules/ui/components/badge';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
@@ -148,7 +148,7 @@ const InvitationsList: Component = () => {
|
||||
{
|
||||
header: t('organizations.members.table.headers.created'),
|
||||
accessorKey: 'createdAt',
|
||||
cell: data => <span title={data.getValue<Date>().toLocaleString()} class="text-muted-foreground">{timeAgo({ date: data.getValue<Date>() })}</span>,
|
||||
cell: data => <RelativeTime date={data.getValue<Date>()} class="text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { Organization } from '../organizations.types';
|
||||
import { safely } from '@corentinth/chisels';
|
||||
import { useParams } from '@solidjs/router';
|
||||
import { useNavigate, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, Show, Suspense } from 'solid-js';
|
||||
import { createSignal, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { buildTimeConfig } from '@/modules/config/config';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { fetchOrganizationSubscription, getCustomerPortalUrl } from '@/modules/subscriptions/subscriptions.services';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/modules/ui/components/card';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
@@ -23,9 +24,39 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
const { deleteOrganization } = useDeleteOrganization();
|
||||
const { confirm } = useConfirmModal();
|
||||
const { t } = useI18n();
|
||||
const { getErrorMessage } = useI18nApiErrors();
|
||||
const navigate = useNavigate();
|
||||
const { config } = useConfig();
|
||||
|
||||
const { getIsOwner, query } = useCurrentUserRole({ organizationId: props.organization.id });
|
||||
|
||||
// Fetch subscription to check if organization has an active subscription
|
||||
const subscriptionQuery = useQuery(() => ({
|
||||
queryKey: ['organizations', props.organization.id, 'subscription'],
|
||||
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organization.id }),
|
||||
enabled: config.isSubscriptionsEnabled,
|
||||
}));
|
||||
|
||||
// Check if subscription blocks deletion (active and not scheduled to cancel)
|
||||
const getHasBlockingSubscription = () => {
|
||||
if (!config.isSubscriptionsEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscription = subscriptionQuery.data?.subscription;
|
||||
if (!subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow deletion if subscription is canceled or scheduled to cancel
|
||||
if (subscription.status === 'canceled' || subscription.cancelAtPeriodEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block deletion for all other active subscription statuses
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmed = await confirm({
|
||||
title: t('organization.settings.delete.confirm.title'),
|
||||
@@ -40,11 +71,19 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
shouldType: props.organization.name,
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
await deleteOrganization({ organizationId: props.organization.id });
|
||||
|
||||
createToast({ type: 'success', message: t('organization.settings.delete.success') });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [, error] = await safely(deleteOrganization({ organizationId: props.organization.id }));
|
||||
|
||||
if (error) {
|
||||
createToast({ type: 'error', message: getErrorMessage({ error }) });
|
||||
return;
|
||||
}
|
||||
|
||||
createToast({ type: 'success', message: t('organization.settings.delete.success') });
|
||||
navigate('/organizations');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,16 +96,25 @@ const DeleteOrganizationCard: Component<{ organization: Organization }> = (props
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardFooter class="pt-6 gap-4">
|
||||
<Button onClick={handleDelete} variant="destructive" disabled={!getIsOwner()}>
|
||||
<CardFooter class="pt-6 gap-4 flex-col items-start sm:flex-row sm:items-center">
|
||||
<Button class="flex-shrink-0" onClick={handleDelete} variant="destructive" disabled={!getIsOwner() || getHasBlockingSubscription()}>
|
||||
{t('organization.settings.delete.confirm.confirm-button')}
|
||||
</Button>
|
||||
|
||||
<Show when={query.isSuccess && !getIsOwner()}>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{t('organization.settings.delete.only-owner')}
|
||||
</span>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={query.isSuccess && !getIsOwner()}>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{t('organization.settings.delete.only-owner')}
|
||||
</span>
|
||||
</Match>
|
||||
|
||||
<Match when={getHasBlockingSubscription()}>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{t('organization.settings.delete.has-active-subscription')}
|
||||
</span>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const FREE_PLAN_ID = 'free';
|
||||
export const PLUS_PLAN_ID = 'plus';
|
||||
export const PRO_PLAN_ID = 'pro';
|
||||
|
||||
6
apps/papra-client/src/modules/plans/plans.types.ts
Normal file
6
apps/papra-client/src/modules/plans/plans.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type PlanLimits = {
|
||||
maxDocumentStorageBytes: number | null;
|
||||
maxIntakeEmailsCount: number | null;
|
||||
maxOrganizationsMembersCount: number | null;
|
||||
maxFileSize: number | null;
|
||||
};
|
||||
13
apps/papra-client/src/modules/shared/date/coerce-date.ts
Normal file
13
apps/papra-client/src/modules/shared/date/coerce-date.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { CoercibleDate } from './date.types';
|
||||
|
||||
export function coerceDate(date: CoercibleDate): Date {
|
||||
if (date instanceof Date) {
|
||||
return date;
|
||||
}
|
||||
|
||||
if (typeof date === 'string' || typeof date === 'number') {
|
||||
return new Date(date);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid date: expected Date, string, or number, but received value "${date}" of type "${typeof date}"`);
|
||||
}
|
||||
1
apps/papra-client/src/modules/shared/date/date.types.ts
Normal file
1
apps/papra-client/src/modules/shared/date/date.types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type CoercibleDate = Date | string | number;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { timeAgo } from './time-ago';
|
||||
|
||||
describe('time-ago', () => {
|
||||
describe('timeAgo', () => {
|
||||
test('formats the relative time', () => {
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:00:00Z') })).toBe('just now');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:00:06Z') })).toBe('a few seconds ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:01:00Z') })).toBe('a minute ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T00:02:00Z') })).toBe('2 minutes ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T01:00:00Z') })).toBe('an hour ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-01T02:00:00Z') })).toBe('2 hours ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-02T00:00:00Z') })).toBe('a day ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-01-03T00:00:00Z') })).toBe('2 days ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-02-01T00:00:00Z') })).toBe('a month ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2021-03-02T00:00:00Z') })).toBe('2 months ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2022-01-01T00:00:00Z') })).toBe('a year ago');
|
||||
expect(timeAgo({ date: new Date('2021-01-01T00:00:00Z'), now: new Date('2023-01-01T00:00:00Z') })).toBe('2 years ago');
|
||||
});
|
||||
|
||||
test('the date can be a parsable string', () => {
|
||||
expect(timeAgo({ date: '2021-01-01T00:00:00Z', now: new Date('2021-01-01T00:00:00Z') })).toBe('just now');
|
||||
expect(timeAgo({ date: '2021-01-01T00:00:00Z', now: new Date('2021-03-02T00:00:00Z') })).toBe('2 months ago');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
export { timeAgo };
|
||||
|
||||
function timeAgo({ date: maybeRawDate, now = new Date() }: { date: Date | string; now?: Date }): string {
|
||||
const date = typeof maybeRawDate === 'string' ? new Date(maybeRawDate) : maybeRawDate;
|
||||
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffMonths / 12);
|
||||
|
||||
if (diffSeconds < 5) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
if (diffSeconds < 10) {
|
||||
return 'a few seconds ago';
|
||||
}
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
return `${diffSeconds} seconds ago`;
|
||||
}
|
||||
|
||||
if (diffMinutes === 1) {
|
||||
return 'a minute ago';
|
||||
}
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes} minutes ago`;
|
||||
}
|
||||
|
||||
if (diffHours === 1) {
|
||||
return 'an hour ago';
|
||||
}
|
||||
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours} hours ago`;
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return 'a day ago';
|
||||
}
|
||||
|
||||
if (diffDays < 30) {
|
||||
return `${diffDays} days ago`;
|
||||
}
|
||||
|
||||
if (diffMonths === 1) {
|
||||
return 'a month ago';
|
||||
}
|
||||
|
||||
if (diffMonths < 12) {
|
||||
return `${diffMonths} months ago`;
|
||||
}
|
||||
|
||||
if (diffYears === 1) {
|
||||
return 'a year ago';
|
||||
}
|
||||
|
||||
return `${diffYears} years ago`;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TranslationKeys } from '@/modules/i18n/locales.types';
|
||||
import { get } from 'lodash-es';
|
||||
import { castError } from '@corentinth/chisels';
|
||||
import { FetchError } from 'ofetch';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { get } from '@/modules/shared/utils/get';
|
||||
|
||||
function codeToKey(code: string): TranslationKeys {
|
||||
// Better auth may returns different error codes like INVALID_ORIGIN, INVALID_CALLBACKURL when the origin is invalid
|
||||
@@ -23,9 +24,9 @@ export function useI18nApiErrors({ t = useI18n().t }: { t?: ReturnType<typeof us
|
||||
}
|
||||
|
||||
if ('error' in args) {
|
||||
const { error } = args;
|
||||
const code = get(error, 'data.error.code') ?? get(error, 'code');
|
||||
const translation = code ? t(codeToKey(code)) : undefined;
|
||||
const error = castError(args.error);
|
||||
const code = get(error, ['data', 'error', 'code'], ['code']);
|
||||
const translation = code && typeof code === 'string' ? t(codeToKey(code)) : undefined;
|
||||
|
||||
if (translation) {
|
||||
return translation;
|
||||
|
||||
@@ -25,6 +25,7 @@ export function coerceDates<T extends Record<string, any>>(obj: T): CoerceDates<
|
||||
...('updatedAt' in obj ? { updatedAt: toDate(obj.updatedAt) } : {}),
|
||||
...('deletedAt' in obj ? { deletedAt: toDate(obj.deletedAt) } : {}),
|
||||
...('expiresAt' in obj ? { expiresAt: toDate(obj.expiresAt) } : {}),
|
||||
...('lastTriggeredAt' in obj ? { lastTriggeredAt: toDate(obj.lastTriggeredAt) } : {}),
|
||||
...('lastUsedAt' in obj ? { lastUsedAt: toDate(obj.lastUsedAt) } : {}),
|
||||
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: toDate(obj.scheduledPurgeAt) } : {}),
|
||||
};
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
21
apps/papra-client/src/modules/shared/utils/array.test.ts
Normal file
21
apps/papra-client/src/modules/shared/utils/array.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
3
apps/papra-client/src/modules/shared/utils/array.ts
Normal file
3
apps/papra-client/src/modules/shared/utils/array.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function castArray<T>(value: T | T[]): T[] {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
9
apps/papra-client/src/modules/shared/utils/errors.ts
Normal file
9
apps/papra-client/src/modules/shared/utils/errors.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { isObject } from './object';
|
||||
|
||||
export function getErrorStatus(error: unknown): number | undefined {
|
||||
if (isObject(error) && 'status' in error && typeof error.status === 'number') {
|
||||
return error.status;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
160
apps/papra-client/src/modules/shared/utils/get.test.ts
Normal file
160
apps/papra-client/src/modules/shared/utils/get.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
42
apps/papra-client/src/modules/shared/utils/get.ts
Normal file
42
apps/papra-client/src/modules/shared/utils/get.ts
Normal 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;
|
||||
}
|
||||
231
apps/papra-client/src/modules/shared/utils/object.test.ts
Normal file
231
apps/papra-client/src/modules/shared/utils/object.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
66
apps/papra-client/src/modules/shared/utils/object.ts
Normal file
66
apps/papra-client/src/modules/shared/utils/object.ts
Normal 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;
|
||||
312
apps/papra-client/src/modules/shared/utils/timing.test.ts
Normal file
312
apps/papra-client/src/modules/shared/utils/timing.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
87
apps/papra-client/src/modules/shared/utils/timing.ts
Normal file
87
apps/papra-client/src/modules/shared/utils/timing.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
9
apps/papra-client/src/modules/shared/utils/units.ts
Normal file
9
apps/papra-client/src/modules/shared/utils/units.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const IN_MS = {
|
||||
SECOND: 1_000,
|
||||
MINUTE: 60_000, // 60 * 1_000
|
||||
HOUR: 3_600_000, // 60 * 60 * 1_000
|
||||
DAY: 86_400_000, // 24 * 60 * 60 * 1_000
|
||||
WEEK: 604_800_000, // 7 * 24 * 60 * 60 * 1_000
|
||||
MONTH: 2_630_016_000, // 30.44 * 24 * 60 * 60 * 1_000 -- approximation using average month length
|
||||
YEAR: 31_556_736_000, // 365.24 * 24 * 60 * 60 * 1_000 -- approximation using average year length
|
||||
};
|
||||
@@ -3,12 +3,22 @@ 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 } from '@/modules/plans/plans.constants';
|
||||
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: {
|
||||
@@ -20,8 +30,10 @@ type PlanCardProps = {
|
||||
};
|
||||
isRecommended?: boolean;
|
||||
isCurrent?: boolean;
|
||||
price: number;
|
||||
onUpgrade?: () => Promise<void>;
|
||||
billingInterval: BillingInterval;
|
||||
monthlyPrice: number;
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
const PlanCard: Component<PlanCardProps> = (props) => {
|
||||
@@ -66,21 +78,47 @@ const PlanCard: Component<PlanCardProps> = (props) => {
|
||||
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-4">
|
||||
<div class="text-sm font-medium text-muted-foreground flex items-center gap-2 justify-between">
|
||||
<span>{props.name}</span>
|
||||
{props.isCurrent && <span class="text-xs font-medium text-muted-foreground bg-muted rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.current-plan')}</span>}
|
||||
{props.isRecommended && <div class="text-xs font-medium text-primary bg-primary/10 rounded-md px-2 py-1">{t('subscriptions.upgrade-dialog.recommended')}</div>}
|
||||
</div>
|
||||
<div class="text-xl font-semibold flex items-center gap-2">
|
||||
$
|
||||
{props.price}
|
||||
<span class="text-sm font-normal text-muted-foreground">{t('subscriptions.upgrade-dialog.per-month')}</span>
|
||||
|
||||
<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>
|
||||
|
||||
<hr class="my-4" />
|
||||
{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 => (
|
||||
@@ -100,7 +138,7 @@ const PlanCard: Component<PlanCardProps> = (props) => {
|
||||
|
||||
{ props.onUpgrade && (
|
||||
<>
|
||||
<hr class="my-4" />
|
||||
<hr class="my-6" />
|
||||
|
||||
<Button onClick={upgrade} class="w-full" autofocus isLoading={getIsUpgradeLoading()}>
|
||||
{t('subscriptions.upgrade-dialog.upgrade-now')}
|
||||
@@ -121,11 +159,22 @@ type UpgradeDialogProps = {
|
||||
export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const [getIsOpen, setIsOpen] = createSignal(false);
|
||||
const defaultBillingInterval: 'monthly' | 'annual' = 'annual';
|
||||
const [getBillingInterval, setBillingInterval] = createSignal<'monthly' | 'annual'>(defaultBillingInterval);
|
||||
const defaultBillingInterval: BillingInterval = 'annual';
|
||||
const [getBillingInterval, setBillingInterval] = createSignal<BillingInterval>(defaultBillingInterval);
|
||||
|
||||
const onUpgrade = async () => {
|
||||
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId: PLUS_PLAN_ID, billingInterval: getBillingInterval() });
|
||||
const getIsReductionActive = ({ now = new Date() }: { now?: Date } = {}) => globalReduction.enabled && now < globalReduction.untilDate;
|
||||
const getReductionMultiplier = ({ now = new Date() }: { now?: Date } = {}) => getIsReductionActive({ now }) ? globalReduction.multiplier : 1;
|
||||
const getReductionPercent = () => 100 * (1 - getReductionMultiplier());
|
||||
const getDaysUntilReductionExpiry = ({ now = new Date() }: { now?: Date } = {}) => {
|
||||
if (!getIsReductionActive({ now })) {
|
||||
return 0;
|
||||
}
|
||||
const timeDiff = globalReduction.untilDate.getTime() - now.getTime();
|
||||
return Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
const onUpgrade = async (planId: string) => {
|
||||
const { checkoutUrl } = await getCheckoutUrl({ organizationId: props.organizationId, planId, billingInterval: getBillingInterval() });
|
||||
window.location.href = checkoutUrl;
|
||||
};
|
||||
|
||||
@@ -158,62 +207,92 @@ export const UpgradeDialog: Component<UpgradeDialogProps> = (props) => {
|
||||
isRecommended: true,
|
||||
};
|
||||
|
||||
const getPlanPrice = (plan: { monthlyPrice: number; annualPrice: number }) => {
|
||||
return getBillingInterval() === 'monthly' ? plan.monthlyPrice : Math.round(100 * plan.annualPrice / 12) / 100;
|
||||
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-xl">
|
||||
<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 class="flex flex-col sm:flex-row items-center gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<div class="i-tabler-sparkles size-7 text-primary"></div>
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle class="text-xl mb-0">{t('subscriptions.upgrade-dialog.title')}</DialogTitle>
|
||||
<DialogDescription class="text-sm text-muted-foreground">
|
||||
{t('subscriptions.upgrade-dialog.description')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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 class="flex flex-col items-center flex-1">
|
||||
<div class="inline-flex items-center justify-center border rounded-lg bg-muted p-1 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'monthly' })}
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
>
|
||||
{t('subscriptions.billing-interval.monthly')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class={cn('text-sm', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
>
|
||||
{t('subscriptions.billing-interval.annual')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<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 pr-1.5', { 'bg-primary/10 text-primary hover:(bg-primary/10 text-primary)': getBillingInterval() === 'annual' })}
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
>
|
||||
{t('subscriptions.billing-interval.annual')}
|
||||
<span class="ml-2 text-xs text-muted-foreground rounded bg-primary/10 text-primary px-1 py-0.5">-20%</span>
|
||||
</Button>
|
||||
{getIsReductionActive() && (
|
||||
<div class="mt-4 bg-gradient-to-r from-primary/20 to-primary/2 border border-primary/30 rounded-lg p-4 flex-shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-2.5 bg-primary/20 rounded-lg border border-primary/30 flex-shrink-0">
|
||||
<div class="i-tabler-gift size-6 text-primary"></div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-base font-semibold text-foreground">{t('subscriptions.upgrade-dialog.promo-banner.title')}</h4>
|
||||
<div class="px-2 py-0.5 bg-primary text-primary-foreground text-xs font-bold rounded-md">
|
||||
{`-${getReductionPercent()}%`}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mb-1">
|
||||
{t('subscriptions.upgrade-dialog.promo-banner.description', { percent: getReductionPercent(), days: getDaysUntilReductionExpiry() })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<PlanCard {...currentPlan} billingInterval={getBillingInterval()} />
|
||||
<PlanCard {...plusPlan} onUpgrade={() => onUpgrade(PLUS_PLAN_ID)} billingInterval={getBillingInterval()} />
|
||||
<PlanCard {...proPlan} onUpgrade={() => onUpgrade(PRO_PLAN_ID)} billingInterval={getBillingInterval()} />
|
||||
</div>
|
||||
|
||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-2 ">
|
||||
<div>
|
||||
<PlanCard {...currentPlan} price={getPlanPrice(currentPlan)} />
|
||||
|
||||
<p class="text-muted-foreground text-xs p-4 ml-1">
|
||||
<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>
|
||||
</div>
|
||||
<PlanCard {...plusPlan} onUpgrade={onUpgrade} price={getPlanPrice(plusPlan)} />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -24,11 +24,11 @@ export const UsageWarningCard: Component<{ organizationId: string }> = (props) =
|
||||
const getStorageSizeUsedPercent = () => {
|
||||
const { data: usageData } = query;
|
||||
|
||||
if (!usageData || usageData.limits.maxDocumentsSize === null) {
|
||||
if (!usageData || usageData.usage.documentsStorage.limit === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (usageData.usage.documentsStorage.used / usageData.limits.maxDocumentsSize) * 100;
|
||||
return (usageData.usage.documentsStorage.used / usageData.usage.documentsStorage.limit) * 100;
|
||||
};
|
||||
|
||||
const shouldShow = () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlanLimits } from '../plans/plans.types';
|
||||
import type { OrganizationSubscription } from './subscriptions.types';
|
||||
import { apiClient } from '../shared/http/api-client';
|
||||
|
||||
@@ -24,7 +25,14 @@ export async function getCustomerPortalUrl({ organizationId }: { organizationId:
|
||||
}
|
||||
|
||||
export async function fetchOrganizationSubscription({ organizationId }: { organizationId: string }) {
|
||||
const { subscription, plan } = await apiClient<{ subscription: OrganizationSubscription; plan: { id: string; name: string } }>({
|
||||
const { subscription, plan } = await apiClient<{
|
||||
subscription: OrganizationSubscription;
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
limits: PlanLimits;
|
||||
};
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/subscription`,
|
||||
});
|
||||
@@ -35,11 +43,11 @@ export async function fetchOrganizationSubscription({ organizationId }: { organi
|
||||
export async function fetchOrganizationUsage({ organizationId }: { organizationId: string }) {
|
||||
const { usage, limits } = await apiClient<{
|
||||
usage: {
|
||||
documentsStorage: { used: number; limit: number | null };
|
||||
documentsStorage: { used: number; deleted: number; limit: number | null };
|
||||
intakeEmailsCount: { used: number; limit: number | null };
|
||||
membersCount: { used: number; limit: number | null };
|
||||
};
|
||||
limits: { maxDocumentsSize: number | null; maxIntakeEmailsCount: number | null; maxOrganizationsMembersCount: number | null };
|
||||
limits: PlanLimits;
|
||||
}>({
|
||||
method: 'GET',
|
||||
path: `/api/organizations/${organizationId}/usage`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import type { TaggingRule, TaggingRuleForCreation } from '../tagging-rules.types';
|
||||
import { insert, remove, setValue } from '@modular-forms/solid';
|
||||
import { getValue, insert, remove, setValue } from '@modular-forms/solid';
|
||||
import { A } from '@solidjs/router';
|
||||
import { For, Show } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
@@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Separator } from '@/modules/ui/components/separator';
|
||||
import { TextArea } from '@/modules/ui/components/textarea';
|
||||
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
|
||||
import { TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
|
||||
import { CONDITION_MATCH_MODES, CONDITION_MATCH_MODES_LOCALIZATION_KEYS, TAGGING_RULE_FIELDS, TAGGING_RULE_FIELDS_LOCALIZATION_KEYS, TAGGING_RULE_OPERATORS, TAGGING_RULE_OPERATORS_LOCALIZATION_KEYS } from '../tagging-rules.constants';
|
||||
|
||||
export const TaggingRuleForm: Component<{
|
||||
onSubmit: (args: { taggingRule: TaggingRuleForCreation }) => Promise<void> | void;
|
||||
@@ -26,7 +26,7 @@ export const TaggingRuleForm: Component<{
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const { form, Form, Field, FieldArray } = createForm({
|
||||
onSubmit: async ({ name, conditions = [], tagIds, description }) => {
|
||||
onSubmit: async ({ name, conditions = [], tagIds, description, conditionMatchMode }) => {
|
||||
if (conditions.length === 0) {
|
||||
const confirmed = await confirm({
|
||||
title: t('tagging-rules.form.conditions.no-conditions.title'),
|
||||
@@ -45,7 +45,7 @@ export const TaggingRuleForm: Component<{
|
||||
}
|
||||
}
|
||||
|
||||
props.onSubmit({ taggingRule: { name, conditions, tagIds, description } });
|
||||
props.onSubmit({ taggingRule: { name, conditions, tagIds, description, conditionMatchMode } });
|
||||
},
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
@@ -57,6 +57,7 @@ export const TaggingRuleForm: Component<{
|
||||
v.string(),
|
||||
v.maxLength(256, t('tagging-rules.form.description.max-length')),
|
||||
),
|
||||
conditionMatchMode: v.optional(v.picklist(Object.values(CONDITION_MATCH_MODES))),
|
||||
conditions: v.optional(
|
||||
v.array(v.object({
|
||||
field: v.picklist(Object.values(TAGGING_RULE_FIELDS)),
|
||||
@@ -77,6 +78,7 @@ export const TaggingRuleForm: Component<{
|
||||
tagIds: props.taggingRule?.actions.map(action => action.tagId) ?? [],
|
||||
name: props.taggingRule?.name,
|
||||
description: props.taggingRule?.description,
|
||||
conditionMatchMode: props.taggingRule?.conditionMatchMode ?? CONDITION_MATCH_MODES.ALL,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,6 +90,20 @@ export const TaggingRuleForm: Component<{
|
||||
return t(TAGGING_RULE_FIELDS_LOCALIZATION_KEYS[field as keyof typeof TAGGING_RULE_FIELDS_LOCALIZATION_KEYS]);
|
||||
};
|
||||
|
||||
const getConditionConnector = (index: number) => {
|
||||
if (index === 0) {
|
||||
return t('tagging-rules.form.conditions.connector.when');
|
||||
}
|
||||
|
||||
const conditionMatchMode = getValue(form, 'conditionMatchMode');
|
||||
|
||||
if (conditionMatchMode === CONDITION_MATCH_MODES.ALL) {
|
||||
return t('tagging-rules.form.conditions.connector.and');
|
||||
}
|
||||
|
||||
return t('tagging-rules.form.conditions.connector.or');
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<Field name="name">
|
||||
@@ -126,13 +142,34 @@ export const TaggingRuleForm: Component<{
|
||||
<p class="mb-1 font-medium">{t('tagging-rules.form.conditions.label')}</p>
|
||||
<p class="mb-2 text-sm text-muted-foreground">{t('tagging-rules.form.conditions.description')}</p>
|
||||
|
||||
<Field name="conditionMatchMode">
|
||||
{field => (
|
||||
<Select
|
||||
id="conditionMatchMode"
|
||||
defaultValue={field.value ?? CONDITION_MATCH_MODES.ALL}
|
||||
onChange={value => value && setValue(form, 'conditionMatchMode', value)}
|
||||
options={Object.values(CONDITION_MATCH_MODES)}
|
||||
itemComponent={props => (
|
||||
<SelectItem item={props.item}>
|
||||
{t(CONDITION_MATCH_MODES_LOCALIZATION_KEYS[props.item.rawValue as keyof typeof CONDITION_MATCH_MODES_LOCALIZATION_KEYS])}
|
||||
</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="w-full mb-4">
|
||||
<SelectValue<string>>{state => t(CONDITION_MATCH_MODES_LOCALIZATION_KEYS[state.selectedOption() as keyof typeof CONDITION_MATCH_MODES_LOCALIZATION_KEYS])}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<FieldArray name="conditions">
|
||||
{fieldArray => (
|
||||
<div>
|
||||
<For each={fieldArray.items}>
|
||||
{(_, index) => (
|
||||
<div class="px-4 py-4 mb-1 flex gap-2 items-center bg-card border rounded-md">
|
||||
<div>When</div>
|
||||
<div>{getConditionConnector(index())}</div>
|
||||
|
||||
<Field name={`conditions.${index()}.field`}>
|
||||
{field => (
|
||||
|
||||
@@ -5,14 +5,17 @@ import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { For, Match, Show, Switch } from 'solid-js';
|
||||
import { useConfig } from '@/modules/config/config.provider';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
import { Alert } from '@/modules/ui/components/alert';
|
||||
import { Button } from '@/modules/ui/components/button';
|
||||
import { EmptyState } from '@/modules/ui/components/empty';
|
||||
import { deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
|
||||
import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { applyTaggingRuleToExistingDocuments, deleteTaggingRule, fetchTaggingRules } from '../tagging-rules.services';
|
||||
|
||||
const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirmModal();
|
||||
|
||||
const getConditionsLabel = () => {
|
||||
const count = props.taggingRule.conditions.length;
|
||||
@@ -37,6 +40,43 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
|
||||
},
|
||||
}));
|
||||
|
||||
const applyRuleMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
return applyTaggingRuleToExistingDocuments({
|
||||
organizationId: props.taggingRule.organizationId,
|
||||
taggingRuleId: props.taggingRule.id,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
createToast({
|
||||
message: t('tagging-rules.apply.success'),
|
||||
type: 'success',
|
||||
});
|
||||
// Note: Documents will be processed in the background
|
||||
// We'll invalidate this once task status retrieval is implemented
|
||||
},
|
||||
onError: () => {
|
||||
createToast({
|
||||
message: t('tagging-rules.apply.error'),
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const handleApplyRule = async () => {
|
||||
const isConfirmed = await confirm({
|
||||
title: t('tagging-rules.apply.confirm.title'),
|
||||
message: t('tagging-rules.apply.confirm.description'),
|
||||
confirmButton: {
|
||||
text: t('tagging-rules.apply.confirm.button'),
|
||||
},
|
||||
});
|
||||
|
||||
if (isConfirmed) {
|
||||
applyRuleMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-2 bg-card py-4 px-6 rounded-md border">
|
||||
<A href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}>
|
||||
@@ -52,12 +92,24 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleApplyRule}
|
||||
disabled={applyRuleMutation.isPending}
|
||||
aria-label={t('tagging-rules.apply.button')}
|
||||
>
|
||||
<div class="i-tabler-player-play size-4 mr-1" />
|
||||
{applyRuleMutation.isPending ? t('tagging-rules.apply.processing') : t('tagging-rules.apply.button')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
as={A}
|
||||
href={`/organizations/${props.taggingRule.organizationId}/tagging-rules/${props.taggingRule.id}`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={t('tagging-rules.list.card.edit')}
|
||||
class="size-8"
|
||||
>
|
||||
<div class="i-tabler-edit size-4" />
|
||||
</Button>
|
||||
@@ -68,6 +120,7 @@ const TaggingRuleCard: Component<{ taggingRule: TaggingRule }> = (props) => {
|
||||
onClick={() => deleteTaggingRuleMutation.mutate()}
|
||||
disabled={deleteTaggingRuleMutation.isPending}
|
||||
aria-label={t('tagging-rules.list.card.delete')}
|
||||
class="size-8"
|
||||
>
|
||||
<div class="i-tabler-trash size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -27,3 +27,13 @@ export const TAGGING_RULE_FIELDS_LOCALIZATION_KEYS: Record<(typeof TAGGING_RULE_
|
||||
[TAGGING_RULE_FIELDS.DOCUMENT_NAME]: 'tagging-rules.field.name',
|
||||
[TAGGING_RULE_FIELDS.DOCUMENT_CONTENT]: 'tagging-rules.field.content',
|
||||
} as const;
|
||||
|
||||
export const CONDITION_MATCH_MODES = {
|
||||
ALL: 'all',
|
||||
ANY: 'any',
|
||||
} as const;
|
||||
|
||||
export const CONDITION_MATCH_MODES_LOCALIZATION_KEYS: Record<(typeof CONDITION_MATCH_MODES)[keyof typeof CONDITION_MATCH_MODES], TranslationKeys> = {
|
||||
[CONDITION_MATCH_MODES.ALL]: 'tagging-rules.condition-match-mode.all',
|
||||
[CONDITION_MATCH_MODES.ANY]: 'tagging-rules.condition-match-mode.any',
|
||||
} as const;
|
||||
|
||||
@@ -43,3 +43,18 @@ export async function updateTaggingRule({ organizationId, taggingRuleId, tagging
|
||||
body: taggingRule,
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyTaggingRuleToExistingDocuments({
|
||||
organizationId,
|
||||
taggingRuleId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
taggingRuleId: string;
|
||||
}) {
|
||||
const result = await apiClient<{ taskId: string }>({
|
||||
path: `/api/organizations/${organizationId}/tagging-rules/${taggingRuleId}/apply`,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { TAGGING_RULE_FIELDS, TAGGING_RULE_OPERATORS } from './tagging-rules.constants';
|
||||
import type { CONDITION_MATCH_MODES, TAGGING_RULE_FIELDS, TAGGING_RULE_OPERATORS } from './tagging-rules.constants';
|
||||
|
||||
export type ConditionMatchMode = (typeof CONDITION_MATCH_MODES)[keyof typeof CONDITION_MATCH_MODES];
|
||||
|
||||
export type TaggingRuleForCreation = {
|
||||
name: string;
|
||||
description: string;
|
||||
conditionMatchMode?: ConditionMatchMode;
|
||||
conditions: TaggingRuleCondition[];
|
||||
tagIds: string[];
|
||||
};
|
||||
@@ -17,6 +20,7 @@ export type TaggingRule = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
conditionMatchMode?: ConditionMatchMode;
|
||||
conditions: TaggingRuleCondition[];
|
||||
actions: { tagId: string }[];
|
||||
organizationId: string;
|
||||
|
||||
@@ -7,9 +7,9 @@ import { A, useParams } from '@solidjs/router';
|
||||
import { useQuery } from '@tanstack/solid-query';
|
||||
import { createSignal, For, Show, Suspense } from 'solid-js';
|
||||
import * as v from 'valibot';
|
||||
import { RelativeTime } from '@/modules/i18n/components/RelativeTime';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
import { timeAgo } from '@/modules/shared/date/time-ago';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
import { useI18nApiErrors } from '@/modules/shared/http/composables/i18n-api-errors';
|
||||
import { queryClient } from '@/modules/shared/query/query-client';
|
||||
@@ -355,7 +355,7 @@ export const TagsPage: Component = () => {
|
||||
</A>
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground" title={tag.createdAt.toLocaleString()}>
|
||||
{timeAgo({ date: tag.createdAt })}
|
||||
<RelativeTime date={tag.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div class="flex gap-2 justify-end">
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 max-h-100vh overflow-y-auto',
|
||||
'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 duration-200) data-[closed]:(animate-out fade-out-0 zoom-out-95 duration-200) md:w-full sm:rounded-lg max-h-100vh overflow-y-auto',
|
||||
local.class,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function Toaster(props: Parameters<typeof Sonner>[0]) {
|
||||
class="toaster group"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast: 'group toast group-[.toaster]:(bg-background text-foreground border border-border shadow-lg)',
|
||||
toast: 'group toast group-[.toaster]:(bg-background text-foreground border border-border shadow-lg) px-4 py-3 gap-4',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton: 'group-[.toast]:(bg-primary text-primary-foreground)',
|
||||
cancelButton: 'group-[.toast]:(bg-muted text-muted-foreground)',
|
||||
|
||||
@@ -33,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()}
|
||||
|
||||
@@ -3,13 +3,14 @@ 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 { 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 { queryClient } from '@/modules/shared/query/query-client';
|
||||
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';
|
||||
@@ -27,7 +28,7 @@ const UpgradeCTAFooter: Component<{ organizationId: string }> = (props) => {
|
||||
const { config } = useConfig();
|
||||
|
||||
const query = useQuery(() => ({
|
||||
queryKey: ['organizations', 'subscription'],
|
||||
queryKey: ['organizations', props.organizationId, 'subscription'],
|
||||
queryFn: () => fetchOrganizationSubscription({ organizationId: props.organizationId }),
|
||||
}));
|
||||
|
||||
@@ -40,27 +41,28 @@ const UpgradeCTAFooter: Component<{ organizationId: string }> = (props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={shouldShowUpgradeCTA()}>
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,24 +113,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
|
||||
@@ -147,12 +146,13 @@ const OrganizationLayoutSideNav: Component = () => {
|
||||
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;
|
||||
@@ -176,11 +176,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>
|
||||
|
||||
@@ -204,9 +216,10 @@ 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)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
@@ -214,7 +227,7 @@ export const OrganizationLayout: ParentComponent = (props) => {
|
||||
));
|
||||
|
||||
return (
|
||||
<DocumentUploadProvider>
|
||||
<DocumentUploadProvider organizationId={params.organizationId}>
|
||||
<SidenavLayout
|
||||
children={props.children}
|
||||
sideNav={OrganizationLayoutSideNav}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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';
|
||||
@@ -16,7 +15,6 @@ 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;
|
||||
@@ -43,57 +41,10 @@ export const SideNav: Component<{
|
||||
footer?: Component;
|
||||
preFooter?: Component;
|
||||
}> = (props) => {
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
</div>
|
||||
{(props.header || props.mainMenu || props.footerMenu || props.footer || props.preFooter) && (
|
||||
<div class="h-full flex flex-col pb-6 flex-1">
|
||||
<div class="h-full flex flex-col pb-6 flex-1 min-w-0">
|
||||
{props.header && <props.header />}
|
||||
|
||||
{props.mainMenu && (
|
||||
@@ -179,11 +130,11 @@ 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Component } from 'solid-js';
|
||||
import { setValue } from '@modular-forms/solid';
|
||||
import { A, useNavigate, useParams } from '@solidjs/router';
|
||||
import { useMutation } from '@tanstack/solid-query';
|
||||
import * as v from 'valibot';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { createForm } from '@/modules/shared/form/form';
|
||||
@@ -17,17 +18,9 @@ export const CreateWebhookPage: Component = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ name, url, secret, enabled, events }) => {
|
||||
await createWebhook({
|
||||
name,
|
||||
url,
|
||||
secret,
|
||||
enabled,
|
||||
events,
|
||||
organizationId: params.organizationId,
|
||||
});
|
||||
|
||||
const createWebhookMutation = useMutation(() => ({
|
||||
mutationFn: createWebhook,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['webhooks', params.organizationId] });
|
||||
|
||||
createToast({
|
||||
@@ -37,6 +30,19 @@ export const CreateWebhookPage: Component = () => {
|
||||
|
||||
navigate(`/organizations/${params.organizationId}/settings/webhooks`);
|
||||
},
|
||||
}));
|
||||
|
||||
const { form, Form, Field } = createForm({
|
||||
onSubmit: async ({ name, url, secret, enabled, events }) => {
|
||||
await createWebhookMutation.mutateAsync({
|
||||
name,
|
||||
url,
|
||||
secret: secret === '' ? undefined : secret,
|
||||
enabled,
|
||||
events,
|
||||
organizationId: params.organizationId,
|
||||
});
|
||||
},
|
||||
schema: v.object({
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Component } from 'solid-js';
|
||||
import type { Webhook } from '../webhooks.types';
|
||||
import { A, useParams } from '@solidjs/router';
|
||||
import { useMutation, useQuery } from '@tanstack/solid-query';
|
||||
import { format } from 'date-fns';
|
||||
import { For, Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { useI18n } from '@/modules/i18n/i18n.provider';
|
||||
import { useConfirmModal } from '@/modules/shared/confirm';
|
||||
@@ -13,7 +12,7 @@ import { createToast } from '@/modules/ui/components/sonner';
|
||||
import { deleteWebhook, fetchWebhooks } from '../webhooks.services';
|
||||
|
||||
export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
|
||||
const { t } = useI18n();
|
||||
const { t, formatRelativeTime } = useI18n();
|
||||
const { confirm } = useConfirmModal();
|
||||
const params = useParams();
|
||||
|
||||
@@ -61,12 +60,12 @@ export const WebhookCard: Component<{ webhook: Webhook }> = ({ webhook }) => {
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{t('webhooks.list.card.last-triggered')}
|
||||
{' '}
|
||||
{webhook.lastTriggeredAt ? format(webhook.lastTriggeredAt, 'MMM d, yyyy') : t('webhooks.list.card.never')}
|
||||
{webhook.lastTriggeredAt ? formatRelativeTime(webhook.lastTriggeredAt) : t('webhooks.list.card.never')}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
{t('webhooks.list.card.created')}
|
||||
{' '}
|
||||
{format(webhook.createdAt, 'MMM d, yyyy')}
|
||||
{formatRelativeTime(webhook.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useQuery } from '@tanstack/solid-query';
|
||||
import { Match, Show, Suspense, Switch } from 'solid-js';
|
||||
import { ApiKeysPage } from './modules/api-keys/pages/api-keys.page';
|
||||
import { CreateApiKeyPage } from './modules/api-keys/pages/create-api-key.page';
|
||||
import { authPagesPaths } from './modules/auth/auth.constants';
|
||||
import { createProtectedPage } from './modules/auth/middleware/protected-page.middleware';
|
||||
import { EmailValidationRequiredPage } from './modules/auth/pages/email-validation-required.page';
|
||||
import { EmailVerificationPage } from './modules/auth/pages/email-verification.page';
|
||||
import { LoginPage } from './modules/auth/pages/login.page';
|
||||
import { RegisterPage } from './modules/auth/pages/register.page';
|
||||
import { RequestPasswordResetPage } from './modules/auth/pages/request-password-reset.page';
|
||||
@@ -239,6 +241,10 @@ export const routes: RouteDefinition[] = [
|
||||
path: '/email-validation-required',
|
||||
component: createProtectedPage({ authType: 'public-only', component: EmailValidationRequiredPage }),
|
||||
},
|
||||
{
|
||||
path: authPagesPaths.emailVerification,
|
||||
component: createProtectedPage({ authType: 'public-only', component: EmailVerificationPage }),
|
||||
},
|
||||
{
|
||||
path: '/checkout-success',
|
||||
component: CheckoutSuccessPage,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { uniq, values } from 'lodash-es';
|
||||
import {
|
||||
defineConfig,
|
||||
presetIcons,
|
||||
presetUno,
|
||||
presetWebFonts,
|
||||
presetWind4,
|
||||
transformerDirectives,
|
||||
transformerVariantGroup,
|
||||
} from 'unocss';
|
||||
@@ -13,11 +12,14 @@ import { documentActivityIcon, iconByFileType } from './src/modules/documents/do
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetUno({
|
||||
presetWind4({
|
||||
dark: {
|
||||
dark: '[data-kb-theme="dark"]',
|
||||
light: '[data-kb-theme="light"]',
|
||||
},
|
||||
preflights: {
|
||||
reset: true,
|
||||
},
|
||||
prefix: '',
|
||||
}),
|
||||
presetAnimations(),
|
||||
@@ -113,9 +115,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
safelist: [
|
||||
...uniq([
|
||||
...values(iconByFileType),
|
||||
...values(documentActivityIcon),
|
||||
...new Set([
|
||||
...Object.values(iconByFileType),
|
||||
...Object.values(documentActivityIcon),
|
||||
...(ssoProviders.map(p => p.icon)),
|
||||
]),
|
||||
],
|
||||
|
||||
19
apps/papra-server/.dockerignore
Normal file
19
apps/papra-server/.dockerignore
Normal 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
|
||||
66
apps/papra-server/Dockerfile
Normal file
66
apps/papra-server/Dockerfile
Normal 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.19.0
|
||||
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" ]
|
||||
36
apps/papra-server/fly.toml
Normal file
36
apps/papra-server/fly.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
# 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 = "canary"
|
||||
|
||||
[processes]
|
||||
web = "env PROCESS_MODE=web pnpm --silent start"
|
||||
worker = "env PROCESS_MODE=worker pnpm --silent start"
|
||||
|
||||
[http_service]
|
||||
internal_port = 1221
|
||||
force_https = true
|
||||
auto_stop_machines = 'stop'
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = [ 'web' ]
|
||||
|
||||
[checks]
|
||||
|
||||
[checks.http]
|
||||
type = "http"
|
||||
method = "get"
|
||||
path = "/api/health"
|
||||
port = 1221
|
||||
interval = "15s"
|
||||
grace_period = "20s"
|
||||
timeout = "3s"
|
||||
processes = [ "web" ]
|
||||
@@ -3,7 +3,6 @@
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra app server",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -24,6 +23,7 @@
|
||||
"test:int:pull-images": "tsx --env-file-if-exists=.env src/scripts/pull-test-container-images.script.ts | crowlog-pretty",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"migrate:up": "tsx --env-file-if-exists=.env src/scripts/migrate-up.script.ts | crowlog-pretty",
|
||||
"migrate:down": "tsx --env-file-if-exists=.env src/scripts/migrate-down.script.ts | crowlog-pretty",
|
||||
"migrate:up:prod": "tsx src/scripts/migrate-up.script.ts",
|
||||
"migrate:push": "drizzle-kit push",
|
||||
"migrate:create": "sh -c 'drizzle-kit generate --name \"$1\" && tsx --env-file-if-exists=.env src/scripts/create-migration.ts \"$1\" | crowlog-pretty' --",
|
||||
@@ -44,10 +44,10 @@
|
||||
"@cadence-mq/core": "^0.2.1",
|
||||
"@cadence-mq/driver-libsql": "^0.2.4",
|
||||
"@cadence-mq/driver-memory": "^0.2.0",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@corentinth/chisels": "catalog:",
|
||||
"@corentinth/friendly-ids": "^0.0.1",
|
||||
"@crowlog/async-context-plugin": "^1.2.1",
|
||||
"@crowlog/logger": "^1.2.1",
|
||||
"@crowlog/async-context-plugin": "^2.0.0",
|
||||
"@crowlog/logger": "^2.0.0",
|
||||
"@hono/node-server": "^1.14.4",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@owlrelay/api-sdk": "^0.0.2",
|
||||
@@ -82,7 +82,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@crowlog/pretty": "^1.2.1",
|
||||
"@crowlog/pretty": "^2.0.1",
|
||||
"@testcontainers/azurite": "^11.5.1",
|
||||
"@testcontainers/localstack": "^11.5.1",
|
||||
"@total-typescript/ts-reset": "^0.6.1",
|
||||
|
||||
@@ -3,6 +3,7 @@ import process, { env } from 'node:process';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { setupDatabase } from './modules/app/database/database';
|
||||
import { ensureLocalDatabaseDirectoryExists } from './modules/app/database/database.services';
|
||||
import { createGracefulShutdownService } from './modules/app/graceful-shutdown/graceful-shutdown.services';
|
||||
import { createServer } from './modules/app/server';
|
||||
import { parseConfig } from './modules/config/config';
|
||||
import { createDocumentStorageService } from './modules/documents/storage/documents.storage.services';
|
||||
@@ -15,42 +16,81 @@ const logger = createLogger({ namespace: 'app-server' });
|
||||
|
||||
const { config } = await parseConfig({ env });
|
||||
|
||||
const isWebMode = config.processMode === 'all' || config.processMode === 'web';
|
||||
const isWorkerMode = config.processMode === 'all' || config.processMode === 'worker';
|
||||
|
||||
logger.info({ processMode: config.processMode, isWebMode, isWorkerMode }, 'Starting application');
|
||||
|
||||
// Shutdown callback collector
|
||||
const shutdownService = createGracefulShutdownService({ logger });
|
||||
const { registerShutdownHandler } = shutdownService;
|
||||
|
||||
await ensureLocalDatabaseDirectoryExists({ config });
|
||||
const { db, client } = setupDatabase(config.database);
|
||||
const { db } = setupDatabase({ ...config.database, registerShutdownHandler });
|
||||
|
||||
const documentsStorageService = createDocumentStorageService({ documentStorageConfig: config.documentsStorage });
|
||||
|
||||
const taskServices = createTaskServices({ config });
|
||||
await taskServices.initialize();
|
||||
|
||||
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
|
||||
if (isWebMode) {
|
||||
const { app } = await createServer({ config, db, taskServices, documentsStorageService });
|
||||
|
||||
const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: config.server.port,
|
||||
},
|
||||
({ port }) => logger.info({ port }, 'Server started'),
|
||||
);
|
||||
const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port: config.server.port,
|
||||
hostname: config.server.hostname,
|
||||
},
|
||||
({ port }) => logger.info({ port }, 'Server started'),
|
||||
);
|
||||
|
||||
if (config.ingestionFolder.isEnabled) {
|
||||
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
|
||||
taskServices,
|
||||
config,
|
||||
db,
|
||||
documentsStorageService,
|
||||
registerShutdownHandler({
|
||||
id: 'web-server-close',
|
||||
handler: () => {
|
||||
server.close();
|
||||
},
|
||||
});
|
||||
|
||||
await startWatchingIngestionFolders();
|
||||
}
|
||||
|
||||
await registerTaskDefinitions({ taskServices, db, config, documentsStorageService });
|
||||
if (isWorkerMode) {
|
||||
if (config.ingestionFolder.isEnabled) {
|
||||
const { startWatchingIngestionFolders } = createIngestionFolderWatcher({
|
||||
taskServices,
|
||||
config,
|
||||
db,
|
||||
documentsStorageService,
|
||||
});
|
||||
|
||||
taskServices.start();
|
||||
await startWatchingIngestionFolders();
|
||||
}
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
server.close();
|
||||
client.close();
|
||||
await registerTaskDefinitions({ taskServices, db, config, documentsStorageService });
|
||||
|
||||
process.exit(0);
|
||||
taskServices.start();
|
||||
logger.info('Worker started');
|
||||
}
|
||||
|
||||
// Global error handlers
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error({ error }, 'Uncaught exception');
|
||||
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (error) => {
|
||||
logger.error({ error }, 'Unhandled promise rejection');
|
||||
setTimeout(() => process.exit(1), 1000); // Give the logger time to flush before exiting
|
||||
});
|
||||
|
||||
// Graceful shutdown handler
|
||||
async function gracefulShutdown(signal: string) {
|
||||
logger.info({ signal }, 'Received shutdown signal, shutting down gracefully...');
|
||||
|
||||
await shutdownService.executeShutdownHandlers();
|
||||
|
||||
logger.info('Shutdown complete, exiting process');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => void gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void gracefulShutdown('SIGTERM'));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Migration } from '../migrations.types';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
export const taggingRuleConditionMatchModeMigration = {
|
||||
name: 'tagging-rule-condition-match-mode',
|
||||
|
||||
up: async ({ db }) => {
|
||||
const tableInfo = await db.run(sql`PRAGMA table_info(tagging_rules)`);
|
||||
const existingColumns = tableInfo.rows.map(row => row.name);
|
||||
const hasColumn = (columnName: string) => existingColumns.includes(columnName);
|
||||
|
||||
if (!hasColumn('condition_match_mode')) {
|
||||
await db.run(sql`ALTER TABLE "tagging_rules" ADD "condition_match_mode" text DEFAULT 'all' NOT NULL;`);
|
||||
}
|
||||
},
|
||||
|
||||
down: async ({ db }) => {
|
||||
await db.run(sql`ALTER TABLE "tagging_rules" DROP COLUMN "condition_match_mode";`);
|
||||
},
|
||||
} satisfies Migration;
|
||||
2058
apps/papra-server/src/migrations/meta/0011_snapshot.json
Normal file
2058
apps/papra-server/src/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1760016118956,
|
||||
"tag": "0010_soft-delete-organizations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1761645190314,
|
||||
"tag": "0011_tagging-rule-condition-match-mode",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user