Compare commits

..

1 Commits

Author SHA1 Message Date
Corentin Thomasset
32b4129e2c wip 2025-10-07 23:42:44 +02:00
413 changed files with 13669 additions and 30885 deletions

View File

@@ -0,0 +1,7 @@
---
type: feature
isBreaking: false
version: 25.10.2
date: '2025-10-07'
---
Switched to calver versioning

1
.changelog/version Normal file
View File

@@ -0,0 +1 @@
25.10.2

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Drop docker armv7 support

View File

@@ -5,11 +5,13 @@
{ "repo": "papra-hq/papra"} { "repo": "papra-hq/papra"}
], ],
"commit": false, "commit": false,
"fixed": [], "fixed": [
["@papra/app-client", "@papra/app-server"]
],
"access": "public", "access": "public",
"baseBranch": "main", "baseBranch": "main",
"updateInternalDependencies": "patch", "updateInternalDependencies": "patch",
"ignore": ["@papra/app-client", "@papra/app-server", "@papra/docs"], "ignore": [],
"privatePackages": { "privatePackages": {
"tag": true, "tag": true,
"version": true "version": true

View File

@@ -0,0 +1,6 @@
---
"@papra/app-client": patch
"@papra/app-server": patch
---
Added a page to view organization usage

View File

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

11
.dockerignore Normal file
View File

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

View File

@@ -0,0 +1,81 @@
name: Changelog Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
changelog-release:
name: Changelog Release
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
fetch-depth: 0 # Need full history for git log in changelog
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm i
- name: Check for pending changelogs
id: check_pending
run: |
if [ -d ".changelog/pending" ] && [ "$(ls -A .changelog/pending/*.md 2>/dev/null)" ]; then
echo "has_pending=true" >> $GITHUB_OUTPUT
echo "Found pending changelog entries"
else
echo "has_pending=false" >> $GITHUB_OUTPUT
echo "No pending changelog entries"
fi
- name: Get next version
if: steps.check_pending.outputs.has_pending == 'true'
id: next_version
run: |
cd packages/changelog
NEXT_VERSION=$(pnpm --silent changelog:next-version)
echo "version=$NEXT_VERSION" >> $GITHUB_OUTPUT
echo "Next version: $NEXT_VERSION"
- name: Release changelog
if: steps.check_pending.outputs.has_pending == 'true'
run: |
cd packages/changelog
pnpm changelog:release -v ${{ steps.next_version.outputs.version }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Commit changelog and version
if: steps.check_pending.outputs.has_pending == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .changelog
git commit -m "chore(release): ${{ steps.next_version.outputs.version }}"
git push
- name: Create git tag
if: steps.check_pending.outputs.has_pending == 'true'
run: |
git tag "v${{ steps.next_version.outputs.version }}"
git push origin "v${{ steps.next_version.outputs.version }}"
- name: Trigger Docker build
if: steps.check_pending.outputs.has_pending == 'true'
run: |
gh workflow run release-docker.yaml -f version="${{ steps.next_version.outputs.version }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -12,14 +12,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 22
cache: "pnpm" cache: 'pnpm'
- name: Install dependencies - name: Install dependencies
run: pnpm i run: pnpm i
@@ -44,4 +44,4 @@ jobs:
run: pnpm -r --parallel -F "./apps/*" build run: pnpm -r --parallel -F "./apps/*" build
- name: Ensure no non-excluded files are changed for the whole repo - name: Ensure no non-excluded files are changed for the whole repo
run: git diff --exit-code > /dev/null || (echo "After running the CI, some un-committed changes were detected. Please ensure cleanness before merging." && exit 1) run: git diff --exit-code > /dev/null || (echo "After running the CI, some un-committed changes were detected. Please ensure cleanness before merging." && exit 1)

View File

@@ -43,7 +43,7 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./packages/docker/Dockerfile file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
@@ -56,7 +56,7 @@ jobs:
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./packages/docker/Dockerfile.rootless file: ./docker/Dockerfile.rootless
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |

View File

@@ -11,7 +11,6 @@ jobs:
release: release:
name: Release name: Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.repository == 'papra-hq/papra'
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
@@ -19,18 +18,14 @@ jobs:
actions: write actions: write
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 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 - name: Install dependencies
run: pnpm i run: pnpm i
@@ -46,11 +41,12 @@ jobs:
title: "chore(release): update versions" title: "chore(release): update versions"
env: env:
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Docker build - name: Trigger Docker build
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/docker') if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/app-server')
run: | run: |
VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/docker") | .version') VERSION=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[] | select(.name=="@papra/app-server") | .version')
echo "VERSION: $VERSION" echo "VERSION: $VERSION"
gh workflow run release-docker.yaml -f version="$VERSION" gh workflow run release-docker.yaml -f version="$VERSION"
env: env:

3
.gitignore vendored
View File

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

2
.nvmrc
View File

@@ -1 +1 @@
24 22

View File

@@ -166,20 +166,10 @@ pnpm dev # localhost:4321
- Use **Vitest** for all testing - Use **Vitest** for all testing
- Test files: `*.test.ts` for unit tests, `*.int.test.ts` for integration tests - 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) - Integration tests may use Testcontainers (Azurite, LocalStack)
- All new features require test coverage - 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 ## Code Style
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended) - **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)
@@ -197,12 +187,6 @@ Test names should explain the **why** (business logic, user scenario, or expecte
- Fully type-safe with TypeScript - Fully type-safe with TypeScript
- Update `i18n.constants.ts` when adding new languages - Update `i18n.constants.ts` when adding new languages
- Use `pnpm script:sync-i18n-key-order` to sync key order - Use `pnpm script:sync-i18n-key-order` to sync key order
- **Branchlet/core**: Uses `@branchlet/core` for pluralization and conditional i18n string templates (variant of ICU message format)
- Basic interpolation: `'Hello {{ name }}!'` with `{ name: 'World' }`
- Conditionals: `'{{ count, =0:no items, =1:one item, many items }}'`
- Pluralization with variables: `'{{ count, =0:no items, =1:{count} item, {count} items }}'`
- Range conditions: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'`
- See [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details
## Contributing Flow ## Contributing Flow

View File

@@ -58,17 +58,6 @@ If you want to update an existing language file, you can do so directly in the c
> [!TIP] > [!TIP]
> You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the TypeScript i18n files, it'll also add the missing keys as comments. > You can use the command `pnpm script:sync-i18n-key-order` to sync the order of the keys in the TypeScript i18n files, it'll also add the missing keys as comments.
### Using Branchlet for Pluralization and Conditionals
Papra uses [`@branchlet/core`](https://github.com/CorentinTh/branchlet) for pluralization and conditional i18n string templates (a variant of ICU message format). Here are some common patterns:
- **Basic interpolation**: `'Hello {{ name }}!'` with `{ name: 'World' }`
- **Conditionals**: `'{{ count, =0:no items, =1:one item, many items }}'`
- **Pluralization with variables**: `'{{ count, =0:no items, =1:{count} item, {count} items }}'`
- **Range conditions**: `'{{ score, [0-50]:bad, [51-75]:good, [76-100]:excellent }}'`
See the [branchlet documentation](https://github.com/CorentinTh/branchlet) for more details on syntax and advanced usage.
## Development Setup ## Development Setup
### Local Environment Setup ### Local Environment Setup

View File

@@ -118,7 +118,6 @@ Papra would not have been possible without the following open-source projects:
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs. - **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
- **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js. - **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js.
- **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js. - **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js.
- **[CadenceMQ](https://github.com/papra-hq/cadence-mq)**: A self-hosted-friendly job queue for Node.js, made by Papra.
- And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)** - And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)**
- **Documentation** - **Documentation**
- **[Astro](https://astro.build)**: A great static site generator. - **[Astro](https://astro.build)**: A great static site generator.
@@ -129,7 +128,7 @@ Papra would not have been possible without the following open-source projects:
- **[Github Actions](https://github.com/features/actions)**: For CI/CD. - **[Github Actions](https://github.com/features/actions)**: For CI/CD.
- **Infrastructure** - **Infrastructure**
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting. - **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
- **[Fly.io](https://fly.io/)**: For backend hosting. - **[Render](https://render.com/)**: For backend hosting.
- **[Turso](https://turso.tech/)**: For production database. - **[Turso](https://turso.tech/)**: For production database.
### Inspiration ### Inspiration

1382
apps/docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
"type": "module", "type": "module",
"version": "0.6.1", "version": "0.6.1",
"private": true, "private": true,
"packageManager": "pnpm@10.12.3",
"description": "Papra documentation website", "description": "Papra documentation website",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)", "author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
@@ -27,21 +28,19 @@
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"unocss-preset-animations": "^1.2.1", "unocss-preset-animations": "^1.2.1",
"yaml": "^2.8.0", "yaml": "^2.8.0",
"zod": "^3.25.67",
"zod-to-json-schema": "^3.24.5" "zod-to-json-schema": "^3.24.5"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "catalog:", "@antfu/eslint-config": "^3.13.0",
"@iconify-json/tabler": "^1.1.120", "@iconify-json/tabler": "^1.1.120",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@unocss/reset": "^0.64.0", "@unocss/reset": "^0.64.0",
"eslint": "catalog:", "eslint": "^9.17.0",
"eslint-plugin-astro": "^1.3.1", "eslint-plugin-astro": "^1.3.1",
"figue": "^3.1.1", "figue": "^3.1.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"marked": "^15.0.6", "marked": "^15.0.6",
"typescript": "catalog:", "typescript": "^5.7.3",
"unocss": "0.65.0-beta.2", "unocss": "0.65.0-beta.2"
"vitest": "catalog:"
} }
} }

View File

@@ -1,48 +0,0 @@
const linesToRemove = [
/^# (.*)$/gm, // Remove main title
/^### (.*)$/gm, // Remove section titles
];
export function parseChangelog(changelog: string) {
const logs: { entries: {
pr: { number: number; url: string };
commit: { hash: string; url: string };
contributor: { username: string; url: string };
content: string;
}[]; version: string; }[] = [];
for (const lineToRemove of linesToRemove) {
changelog = changelog.replace(lineToRemove, '');
}
const sections = changelog.match(/## (.*)\n([\s\S]*?)(?=\n## |$)/g) ?? [];
for (const section of sections) {
const version = section.match(/## (.*)\n/)?.[1].trim() ?? 'unknown version';
const entries = section.split('\n- ').slice(1).map((entry) => {
// Example entry:
// [#280](https://github.com/papra-hq/papra/pull/280) [`85fa5c4`](https://github.com/papra-hq/papra/commit/85fa5c43424d139f5c2752a3ad644082e61d3d67) Thanks [@CorentinTh](https://github.com/CorentinTh)! - Maybe multiline content
// Thanks copilot! :sweat-smile:
const prMatch = entry.match(/\[#(\d+)\]\((https:\/\/github\.com\/papra-hq\/papra\/pull\/\d+)\)/);
const commitMatch = entry.match(/\[`([a-f0-9]{7,40})`\]\((https:\/\/github\.com\/papra-hq\/papra\/commit\/[a-f0-9]{7,40})\)/);
const contributorMatch = entry.match(/Thanks \[@([\w-]+)\]\((https:\/\/github\.com\/[\w-]+)\)/);
const contentMatch = entry.match(/\)! - (.*)$/s);
return {
pr: prMatch ? { number: Number.parseInt(prMatch[1], 10), url: prMatch[2] } : { number: 0, url: '' },
commit: commitMatch ? { hash: commitMatch[1], url: commitMatch[2] } : { hash: '', url: '' },
contributor: contributorMatch ? { username: contributorMatch[1], url: contributorMatch[2] } : { username: 'unknown', url: '' },
content: contentMatch ? contentMatch[1].trim() : entry.trim(),
};
});
logs.push({
version,
entries,
});
}
return logs;
}

View File

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

View File

@@ -52,7 +52,7 @@ The code for the Email Worker proxy is available in the [papra-hq/email-proxy](h
- **Option 2**: Build and deploy the Email Worker - **Option 2**: Build and deploy the Email Worker
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v24 and pnpm installed. Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v22 and pnpm installed.
```bash ```bash
# Clone the repository # Clone the repository

View File

@@ -1,102 +0,0 @@
---
title: Using Tagging Rules
description: Learn how to automate document organization with tagging rules.
slug: guides/tagging-rules
---
## What are Tagging Rules?
Tagging rules allow you to automatically apply tags to documents based on specific conditions. This helps maintain consistent organization without manual effort, especially when dealing with large numbers of documents.
## How Tagging Rules Work
When a tagging rule is enabled, it automatically checks new documents as they're uploaded. If a document matches the rule's conditions, the specified tags are automatically applied.
### Rule Components
Each tagging rule consists of:
1. **Conditions**: Rules that determine which documents should be tagged
- Field: The document property to check (e.g., name, content)
- Operator: How to compare the field (e.g., contains, equals)
- Value: The text to match against
2. **Actions**: The tags to apply when conditions are met
## Applying Rules to Existing Documents
### The "Run Now" Feature
When you create a new tagging rule, it only applies to documents uploaded *after* the rule is created. To apply the rule to documents that already exist in your organization, use the **"Apply to existing documents"** button.
This feature is particularly useful when:
- You create a new rule and want to organize your existing documents
- You modify a rule and want to reprocess documents
- You're setting up your organization and want to retroactively organize imported documents
### How to Apply a Rule to Existing Documents
1. Navigate to your organization's Tagging Rules page
2. Find the rule you want to apply
3. Click the **"Apply to existing documents"** button
4. Confirm the action in the dialog
5. The task is queued and will be processed in the background
The system will:
- Queue a background task to process all documents
- Process documents in batches to avoid overloading the system
- Check all existing documents in your organization
- Apply tags where the rule's conditions match
- Show you a success message once the task is queued
:::tip
Applying a rule to existing documents runs as a background task, so you don't need to wait for it to complete. The processing happens asynchronously and efficiently handles large document collections by processing them in batches.
:::
## Best Practices
### Creating Effective Rules
1. **Be specific**: Use precise conditions to avoid over-tagging
2. **Test first**: Create a rule and test it on a few documents before applying to all existing documents
3. **Use multiple conditions**: Combine conditions for more accurate matching
4. **Review regularly**: Periodically review your rules to ensure they're still relevant
### Example Rules
**Invoice Classification**
- Condition: Document name contains "invoice"
- Action: Apply "Invoice" tag
**Quarterly Reports**
- Condition: Document name contains "Q1" or "Q2" or "Q3" or "Q4"
- Action: Apply "Report" tag
## Using the API
You can also apply tagging rules programmatically using the API. The endpoint enqueues a background task and returns immediately:
```bash
curl -X POST \
-H "Authorization: Bearer YOUR_API_TOKEN" \
https://api.papra.app/api/organizations/YOUR_ORG_ID/tagging-rules/RULE_ID/apply
```
Response (HTTP 202 Accepted):
```json
{
"taskId": "task_abc123"
}
```
Where:
- `taskId`: The ID of the background task processing your request
:::note
The API returns a task ID immediately. The actual processing happens in the background and may take some time depending on the number of documents. Task status retrieval will be available in a future release.
:::
## Related Resources
- [API Endpoints Documentation](/resources/api-endpoints)
- [CLI Documentation](/resources/cli)

View File

@@ -201,10 +201,7 @@ Search documents in the organization by name or content.
- `pageIndex`: (optional, default: 0) The page index to start from. - `pageIndex`: (optional, default: 0) The page index to start from.
- `pageSize`: (optional, default: 100) The number of documents to return. - `pageSize`: (optional, default: 100) The number of documents to return.
- Response (JSON) - Response (JSON)
- `searchResults`: The search results. - `documents`: The list of documents.
- `documents`: The list of matching documents.
- `id`: The document ID.
- `name`: The document name.
### Get organization documents statistics ### Get organization documents statistics
@@ -310,13 +307,3 @@ Remove a tag from a document.
- Required API key permissions: `tags:read` and `documents:update` - Required API key permissions: `tags:read` and `documents:update`
- Response: empty (204 status code) - Response: empty (204 status code)
### Apply tagging rule to existing documents
**POST** `/api/organizations/:organizationId/tagging-rules/:taggingRuleId/apply`
Enqueue a background task to apply a tagging rule to all existing documents in the organization. This endpoint returns immediately with a task ID, and the processing happens asynchronously in the background. The task will check all documents and apply tags where the rule's conditions match.
- Required API key permissions: `tags:read` and `documents:update`
- Response (JSON, HTTP 202)
- `taskId`: The ID of the background task. You can use this to track the task's progress (task status retrieval coming in a future release).

View File

@@ -1,40 +0,0 @@
---
title: Document Deduplication
description: How Papra prevents duplicate documents and saves storage space.
slug: architecture/document-deduplication
---
## Overview
Papra automatically detects and prevents duplicate documents per organization using content hashing. This ensures that if the same file is uploaded multiple times, only one copy is stored, saving storage space and reducing clutter.
## How It Works
When a document is added to an organization (upload, email ingestion, folder sync, ...), the server computes a **SHA-256 hash** of the file content and checks if a document with the same hash already exists in that organization.
- If there is **no document with the same hash** in the organization, the new document is added as usual
- If a document **with same content exists**, the upload is rejected
- If a document **with same content was previously deleted** (in trash), it is restored instead of creating a new copy, the metadata is updated to match the newly added document
## Technical Details
### Hash Algorithm
- Papra uses **SHA-256** for content hashing.
- Computed during streaming upload (no extra I/O)
- 64-character hexadecimal string stored in the database
### Database Constraint
The database enforces uniqueness with a composite index:
```sql
UNIQUE (organization_id, original_sha256_hash)
```
This guarantees no two active documents in the same organization can have identical content.
### File Content Only
Only the **file content** is hashed and used for deduplication, filenames, upload dates, and metadata don't affect deduplication. Two files are considered duplicates if and only if their content is strictly identical.

View File

@@ -1,38 +0,0 @@
---
title: No-Mutation Principle
description: Why Papra never modifies your original documents and the architectural decisions behind this choice.
slug: architecture/no-mutation-principle
---
## Core Philosophy
Papra follows a fundamental principle: **documents are never mutated after upload**. When you input a document, you can always retrieve it exactly as it was uploaded.
## The Design Choice
An archiving platform should guarantee users they can retrieve their documents in their original form. This means:
- No conversion to different formats
- No metadata injection into the file itself
- No overlay of OCR-ed content on scanned PDFs
- No processing that modifies the original file
The simple mental model is: **"If I input X, I'll retrieve X"**
## Why This Matters
### Trust and Reliability
When archiving important documents, users need absolute confidence that their files remain untouched. Whether it's a legal document, a medical record, or a personal photo, the original should be sacrosanct.
### Simplicity
This approach eliminates the mental overhead of wondering "what happened to my file?" Users don't need to understand concepts like:
- Original vs. processed versions
- Format conversions
- OCR overlays
- Metadata injection
### Flexibility for the Future
While Papra currently doesn't mutate documents, the architecture leaves room for future enhancements. If needed, a "processed" version concept could be added alongside originals, giving users the choice without forcing a particular model.

View File

@@ -1,62 +0,0 @@
---
title: Organization Deletion & Purge
description: How Papra handles organization deletion with a grace period and eventual purge.
slug: architecture/organization-deletion-purge
---
## Overview
Papra implements a two-phase deletion process for organizations: soft deletion followed by hard deletion (purge). This provides a grace period for recovery while ensuring eventual cleanup of resources.
## Deletion Process
### Who Can Delete
Only the **organization owner** can delete an organization. Admins and members do not have this permission.
### What Happens During Deletion
When an organization is deleted:
1. **Members are removed** - All organization members are stripped from the organization, leaving them dangling
2. **Invitations are removed** - All pending invitations are deleted
3. **Metadata is recorded**:
- `deletedAt`: Timestamp when the deletion occurred
- `deletedBy`: ID of the user (owner) who deleted the organization
- `scheduledPurgeAt`: Future date when hard deletion will occur (default: 30 days)
The organization itself remains in the database in a soft-deleted state, allowing for potential restoration.
## Purge Process
### When Purge Occurs
Hard deletion (purge) happens when `scheduledPurgeAt` is reached. By default, this is **30 days** after the deletion date.
### What Gets Purged
When an organization is purged:
- **All documents** are deleted from storage
- **All database records** related to the organization are removed (cascade handles related records, like Tags, Intake Emails, etc.)
- The organization itself is permanently deleted
The process handles documents in batches using an iterator to avoid memory issues with large organizations.
### Background Task
Purging is handled by a periodic background task that:
1. Queries for organizations with `scheduledPurgeAt` in the past
2. For each expired organization:
- Deletes all document files from storage
- Hard deletes the organization (cascade handles related records)
3. Logs the process for monitoring and debugging
The task continues even if individual file deletions fail, logging errors without blocking the entire purge operation.
## Recovery
Organizations can be restored before the `scheduledPurgeAt` date is reached, but only by the user who deleted them (the previous owner). After this date, recovery is no longer possible, even if the purge has not yet occurred.
> Note: After recovery, the organization owner must re-invite members as they were removed during deletion.

View File

@@ -5,7 +5,6 @@ export const sidebar = [
label: 'Getting Started', label: 'Getting Started',
items: [ items: [
{ label: 'Introduction', slug: '' }, { label: 'Introduction', slug: '' },
{ label: 'Changelog', link: '/changelog' },
], ],
}, },
{ {
@@ -40,27 +39,6 @@ export const sidebar = [
label: 'Document Encryption', label: 'Document Encryption',
slug: 'guides/document-encryption', slug: 'guides/document-encryption',
}, },
{
label: 'Tagging Rules',
slug: 'guides/tagging-rules',
},
],
},
{
label: 'Architecture',
items: [
{
label: 'No-Mutation Principle',
slug: 'architecture/no-mutation-principle',
},
{
label: 'Document Deduplication',
slug: 'architecture/document-deduplication',
},
{
label: 'Organization Deletion',
slug: 'architecture/organization-deletion-purge',
},
], ],
}, },
{ {

View File

@@ -1,16 +0,0 @@
import { marked } from 'marked';
const renderer = new marked.Renderer();
renderer.heading = function ({ text, depth }) {
const slug = text.toLowerCase().replace(/\W+/g, '-');
return `
<div class="sl-heading-wrapper level-h${depth}">
<h${depth} id="${slug}">${text}</h${depth}>
<a class="sl-anchor-link" href="#${slug}"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24"><path fill="currentcolor" d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z"></path></svg></span><span class="sr-only">Section titled “Configuration files”</span></a>
</div>
`.trim().replace(/\n/g, '');
};
export function renderMarkdown(markdown: string) {
return marked.parse(markdown, { renderer });
}

View File

@@ -1,55 +0,0 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import rawChangelog from '../../../../packages/docker/CHANGELOG.md?raw';
import { parseChangelog } from '../changelog-parser';
import { renderMarkdown } from '../markdown';
const changelog = parseChangelog(rawChangelog);
---
<StarlightPage
frontmatter={{
title: 'Papra changelog',
description: 'View the changelogs of the docker images released by Papra.',
tableOfContents: false,
}}
>
<p>
Here are the changelogs of the docker images released by Papra.<br />
For version after v0.9.6, Papra uses Calver as a versioning system with the format YY.MM.N where N is the number of releases in the month starting at 0 (e.g. 25.06.0 is the first release of June 2025).
</p>
{
changelog.map(({ entries, version }) => (
<section>
<h2 id={version} class="pb-1 mt-14">v{version}</h2>
<ul>
{entries.map(entry => (
<li>
<div class="flex flex-col">
<div class="text-foreground lh-normal changelog-entry" set:html={renderMarkdown(entry.content)} />
<div class="text-xs mt-1 flex gap-1 flex-wrap">
<a href={entry.pr.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">PR #{entry.pr.number}</a>
<a href={entry.commit.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">{entry.commit.hash.slice(0, 7)}</a>
<a href={entry.contributor.url} class="text-muted-foreground hover:bg-muted transition border border-muted border-solid rounded-md no-underline px-1 py-0.5">
By @{entry.contributor.username}
</a>
</div>
</div>
</li>
))}
</ul>
</section>
))
}
</StarlightPage>
<style is:global>
.changelog-entry pre {
border-radius: 6px;
color: hsl(var(--muted-foreground) / var(--un-text-opacity));
}
</style>

View File

@@ -1,7 +1,7 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import type { ConfigDefinition } from 'figue'; import type { ConfigDefinition } from 'figue';
import { z } from 'astro:content';
import { mapValues } from 'lodash-es'; import { mapValues } from 'lodash-es';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema'; import { zodToJsonSchema } from 'zod-to-json-schema';
import { configDefinition } from '../../../papra-server/src/modules/config/config'; import { configDefinition } from '../../../papra-server/src/modules/config/config';

View File

@@ -1,6 +1,5 @@
import { import {
defineConfig, defineConfig,
presetTypography,
presetUno, presetUno,
transformerDirectives, transformerDirectives,
transformerVariantGroup, transformerVariantGroup,
@@ -17,7 +16,6 @@ export default defineConfig({
prefix: '', prefix: '',
}), }),
presetAnimations(), presetAnimations(),
presetTypography(),
], ],
transformers: [transformerVariantGroup(), transformerDirectives()], transformers: [transformerVariantGroup(), transformerDirectives()],
theme: { theme: {

View File

@@ -1,43 +0,0 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

View File

@@ -1,5 +0,0 @@
# Papra Mobile App
React Native mobile application for Papra document management platform, built with Expo.
// Todo: Add more details about setup, development, and usage instructions.

View File

@@ -1,55 +0,0 @@
{
"expo": {
"name": "mobile",
"slug": "mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./src/assets/images/icon.png",
"scheme": "papra",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./src/assets/images/android-icon-foreground.png",
"backgroundImage": "./src/assets/images/android-icon-background.png",
"monochromeImage": "./src/assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./src/assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./src/assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"expo-secure-store"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "f40c21f5-38e6-40d8-8627-528c1d3a533a"
}
}
}
}

View File

@@ -1,58 +0,0 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HapticTab } from '@/modules/ui/components/haptic-tab';
import { Icon } from '@/modules/ui/components/icon';
import { ImportTabButton } from '@/modules/ui/components/import-tab-button';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
export default function TabLayout() {
const colors = useThemeColor();
const insets = useSafeAreaInsets();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: colors.primary,
headerShown: false,
tabBarButton: HapticTab,
tabBarStyle: {
backgroundColor: colors.secondaryBackground,
borderTopColor: colors.border,
paddingTop: 15,
paddingBottom: insets.bottom,
height: 65 + insets.bottom,
},
}}
>
<Tabs.Screen
name="list"
options={{
title: 'Documents',
tabBarIcon: ({ color }) => <Icon name="home" size={30} color={color} style={{ height: 30 }} />,
tabBarLabel: () => null,
}}
/>
<Tabs.Screen
name="import"
options={{
title: 'Import',
tabBarButton: () => <ImportTabButton />,
tabBarLabel: () => null,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color }) => <Icon name="settings" size={30} color={color} style={{ height: 30 }} />,
tabBarLabel: () => null,
}}
/>
</Tabs>
);
}

View File

@@ -1,5 +0,0 @@
// This is a dummy screen that will never be rendered
// The import tab button intercepts the press and opens a drawer instead
export default function ImportScreen() {
return null;
}

View File

@@ -1,3 +0,0 @@
import { DocumentsListScreen } from '@/modules/documents/screens/documents-list.screen';
export default DocumentsListScreen;

View File

@@ -1,3 +0,0 @@
import SettingsScreen from '@/modules/users/screens/settings.screen';
export default SettingsScreen;

View File

@@ -1,13 +0,0 @@
import { Stack } from 'expo-router';
import { OrganizationsProvider } from '@/modules/organizations/organizations.provider';
export default function WithOrganizationsLayout() {
return (
<OrganizationsProvider>
<Stack>
<Stack.Screen name="organizations/create" options={{ headerShown: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</OrganizationsProvider>
);
}

View File

@@ -1,3 +0,0 @@
import { OrganizationCreateScreen } from '@/modules/organizations/screens/organization-create.screen';
export default OrganizationCreateScreen;

View File

@@ -1,18 +0,0 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { ApiProvider } from '@/modules/api/providers/api.provider';
import 'react-native-reanimated';
export default function RootLayout() {
return (
<ApiProvider>
<Stack>
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
<Stack.Screen name="auth/signup" options={{ headerShown: false }} />
<Stack.Screen name="(with-organizations)" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
</ApiProvider>
);
}

View File

@@ -1,3 +0,0 @@
import { LoginScreen } from '@/modules/auth/screens/login.screen';
export default LoginScreen;

View File

@@ -1,3 +0,0 @@
import { SignupScreen } from '@/modules/auth/screens/signup.screen';
export default SignupScreen;

View File

@@ -1,45 +0,0 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet, Text, useColorScheme, View } from 'react-native';
export default function NotFoundScreen() {
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const styles = createStylesNotFound(isDark);
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn&apos;t exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen</Text>
</Link>
</View>
</>
);
}
export function createStylesNotFound(isDark: boolean) {
return StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 20,
color: isDark ? '#fff' : '#000',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#007AFF',
},
});
}

View File

@@ -1,25 +0,0 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { AppProviders } from '@/modules/app/providers/app-providers';
import { useColorScheme } from '@/modules/ui/providers/use-color-scheme';
import 'react-native-reanimated';
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<AppProviders>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="config/server-selection" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
</AppProviders>
);
}

View File

@@ -1,3 +0,0 @@
import { ServerSelectionScreen } from '@/modules/config/screens/server-selection.screen';
export default ServerSelectionScreen;

View File

@@ -1,28 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { Redirect } from 'expo-router';
import { configLocalStorage } from '@/modules/config/config.local-storage';
export default function Index() {
const query = useQuery({
queryKey: ['api-server-url'],
queryFn: configLocalStorage.getApiServerBaseUrl,
});
const getRedirection = () => {
if (query.isLoading) {
return null;
}
if (query.isError || query.data == null) {
return <Redirect href="/config/server-selection" />;
}
return <Redirect href="/auth/login" />;
};
return (
<>
{getRedirection()}
</>
);
}

View File

@@ -1,29 +0,0 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/modules/ui/components/themed-text';
import { ThemedView } from '@/modules/ui/components/themed-view';
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}

View File

@@ -1,21 +0,0 @@
{
"cli": {
"version": ">= 16.27.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View File

@@ -1,8 +0,0 @@
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
// Enable package exports for Better Auth
config.resolver.unstable_enablePackageExports = true;
module.exports = config;

View File

@@ -1,65 +0,0 @@
{
"name": "mobile",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"dev": "pnpm start",
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest watch"
},
"dependencies": {
"@better-auth/expo": "catalog:",
"@corentinth/chisels": "catalog:",
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@tanstack/react-form": "^1.23.8",
"@tanstack/react-query": "^5.90.7",
"better-auth": "catalog:",
"expo": "~54.0.22",
"expo-constants": "~18.0.10",
"expo-document-picker": "^14.0.7",
"expo-file-system": "^19.0.19",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.14",
"expo-secure-store": "^15.0.7",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.8",
"expo-web-browser": "~15.0.9",
"ofetch": "^1.4.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1",
"valibot": "1.0.0-beta.10"
},
"devDependencies": {
"@antfu/eslint-config": "catalog:",
"@types/react": "~19.1.0",
"eas-cli": "^16.27.0",
"eslint": "catalog:",
"eslint-config-expo": "~10.0.0",
"typescript": "catalog:",
"vitest": "catalog:"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,30 +0,0 @@
import type { HttpClientOptions, ResponseType } from './http.client';
import { Platform } from 'react-native';
import { httpClient } from './http.client';
export type ApiClient = ReturnType<typeof createApiClient>;
export function createApiClient({
baseUrl,
getAuthCookie,
}: {
baseUrl: string;
getAuthCookie: () => string;
}) {
return async <T, R extends ResponseType = 'json'>({ path, ...rest}: { path: string } & Omit<HttpClientOptions<R>, 'url'>) => {
return httpClient<T, R>({
baseUrl,
url: path,
credentials: Platform.OS === 'web' ? 'include' : 'omit',
headers: {
...(Platform.OS === 'web'
? {}
: {
Cookie: getAuthCookie(),
}),
...rest.headers,
},
...rest,
});
};
}

View File

@@ -1,19 +0,0 @@
import { describe, expect, test } from 'vitest';
import { coerceDate } from './api.models';
describe('api models', () => {
describe('coerceDate', () => {
test('transforms date-ish values into Date instances', () => {
expect(coerceDate(new Date('2024-01-01T00:00:00Z'))).toEqual(new Date('2024-01-01T00:00:00Z'));
expect(coerceDate('2024-01-01T00:00:00Z')).toEqual(new Date('2024-01-01T00:00:00Z'));
expect(coerceDate('2024-01-01')).toEqual(new Date('2024-01-01T00:00:00Z'));
expect(coerceDate(1704067200000)).toEqual(new Date('2024-01-01T00:00:00Z'));
expect(() => coerceDate(null)).toThrow('Invalid date: expected Date, string, or number, but received value "null" of type "object"');
expect(() => coerceDate(undefined)).toThrow('Invalid date: expected Date, string, or number, but received value "undefined" of type "undefined"');
expect(() => coerceDate({})).toThrow('Invalid date: expected Date, string, or number, but received value "[object Object]" of type "object"');
expect(() => coerceDate(['foo'])).toThrow('Invalid date: expected Date, string, or number, but received value "foo" of type "object"');
expect(() => coerceDate(true)).toThrow('Invalid date: expected Date, string, or number, but received value "true" of type "boolean"');
});
});
});

View File

@@ -1,43 +0,0 @@
type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt' | 'expiresAt' | 'lastTriggeredAt' | 'lastUsedAt' | 'scheduledPurgeAt';
type CoerceDate<T> = T extends string | Date
? Date
: T extends string | Date | null | undefined
? Date | undefined
: T;
type CoerceDates<T> = {
[K in keyof T]: K extends DateKeys ? CoerceDate<T[K]> : T[K];
};
export function coerceDate(date: unknown): 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 "${String(date)}" of type "${typeof date}"`);
}
export function coerceDateOrUndefined(date: unknown): Date | undefined {
if (date == null) {
return undefined;
}
return coerceDate(date);
}
export function coerceDates<T extends Record<string, unknown>>(obj: T): CoerceDates<T> {
return {
...obj,
...('createdAt' in obj ? { createdAt: coerceDateOrUndefined(obj.createdAt) } : {}),
...('updatedAt' in obj ? { updatedAt: coerceDateOrUndefined(obj.updatedAt) } : {}),
...('deletedAt' in obj ? { deletedAt: coerceDateOrUndefined(obj.deletedAt) } : {}),
...('expiresAt' in obj ? { expiresAt: coerceDateOrUndefined(obj.expiresAt) } : {}),
...('lastTriggeredAt' in obj ? { lastTriggeredAt: coerceDateOrUndefined(obj.lastTriggeredAt) } : {}),
...('lastUsedAt' in obj ? { lastUsedAt: coerceDateOrUndefined(obj.lastUsedAt) } : {}),
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
} as CoerceDates<T>;
}

View File

@@ -1,12 +0,0 @@
import type { FetchOptions, ResponseType } from 'ofetch';
import { ofetch } from 'ofetch';
export { ResponseType };
export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string };
export async function httpClient<A, R extends ResponseType = 'json'>({ url, baseUrl, ...rest }: HttpClientOptions<R>) {
return ofetch<A, R>(url, {
baseURL: baseUrl,
...rest,
});
}

View File

@@ -1,69 +0,0 @@
import type { ReactNode } from 'react';
import type { ApiClient } from '@/modules/api/api.client';
import type { AuthClient } from '@/modules/auth/auth.client';
import { useQuery } from '@tanstack/react-query';
import { createContext, useContext, useEffect, useState } from 'react';
import { createApiClient } from '@/modules/api/api.client';
import { createAuthClient } from '@/modules/auth/auth.client';
import { configLocalStorage } from '@/modules/config/config.local-storage';
type ApiProviderProps = {
children: ReactNode;
};
const AuthClientContext = createContext<AuthClient | undefined>(undefined);
const ApiClientContext = createContext<ApiClient | undefined>(undefined);
export function ApiProvider({ children }: ApiProviderProps) {
const [authClient, setAuthClient] = useState<AuthClient | undefined>(undefined);
const [apiClient, setApiClient] = useState<ApiClient | undefined>(undefined);
const { data: baseUrl } = useQuery({
queryKey: ['api-server-url'],
queryFn: configLocalStorage.getApiServerBaseUrl,
});
useEffect(() => {
if (baseUrl == null) {
return;
}
const authClient = createAuthClient({ baseUrl });
setAuthClient(() => authClient);
const apiClient = createApiClient({ baseUrl, getAuthCookie: () => authClient.getCookie() });
setApiClient(() => apiClient);
}, [baseUrl]);
return (
<>
{ authClient && apiClient && (
<AuthClientContext.Provider value={authClient}>
<ApiClientContext.Provider value={apiClient}>
{children}
</ApiClientContext.Provider>
</AuthClientContext.Provider>
)}
</>
);
}
export function useAuthClient(): AuthClient {
const context = useContext(AuthClientContext);
if (!context) {
throw new Error('useAuthClient must be used within ApiProvider');
}
return context;
}
export function useApiClient(): ApiClient {
const context = useContext(ApiClientContext);
if (!context) {
throw new Error('useApiClient must be used within ApiProvider');
}
return context;
}

View File

@@ -1,24 +0,0 @@
import type { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes
},
},
});
type QueryProviderProps = {
children: ReactNode;
};
export function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

View File

@@ -1,17 +0,0 @@
import type { ReactNode } from 'react';
import { QueryProvider } from '../../api/providers/query.provider';
import { AlertProvider } from '../../ui/providers/alert-provider';
type AppProvidersProps = {
children: ReactNode;
};
export function AppProviders({ children }: AppProvidersProps) {
return (
<QueryProvider>
<AlertProvider>
{children}
</AlertProvider>
</QueryProvider>
);
}

View File

@@ -1,22 +0,0 @@
import { expoClient } from '@better-auth/expo/client';
import { createAuthClient as createBetterAuthClient } from 'better-auth/react';
import Constants from 'expo-constants';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
export type AuthClient = ReturnType<typeof createAuthClient>;
export function createAuthClient({ baseUrl}: { baseUrl: string }) {
return createBetterAuthClient({
baseURL: baseUrl,
plugins: [
expoClient({
scheme: String(Constants.expoConfig?.scheme ?? 'papra'),
storagePrefix: String(Constants.expoConfig?.scheme ?? 'papra'),
storage: Platform.OS === 'web'
? localStorage
: SecureStore,
}),
],
});
}

View File

@@ -1,44 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { router } from 'expo-router';
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
import { Icon } from '@/modules/ui/components/icon';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
export function BackToServerSelectionButton() {
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
return (
<TouchableOpacity
style={styles.backToServerButton}
onPress={() => router.push('/config/server-selection')}
>
<Icon name="arrow-left" size={20} color={themeColors.mutedForeground} />
<Text style={styles.backToServerText}>
Select server
</Text>
</TouchableOpacity>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
backToServerButton: {
marginBottom: 16,
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 14,
borderWidth: 1,
borderColor: themeColors.border,
},
backToServerText: {
color: themeColors.mutedForeground,
fontSize: 16,
},
});
}

View File

@@ -1,346 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useForm } from '@tanstack/react-form';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as v from 'valibot';
import { useAuthClient } from '@/modules/api/providers/api.provider';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { useServerConfig } from '../../config/hooks/use-server-config';
import { BackToServerSelectionButton } from '../components/back-to-server-selection';
const loginSchema = v.object({
email: v.pipe(v.string(), v.email('Please enter a valid email')),
password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters')),
});
export function LoginScreen() {
const router = useRouter();
const themeColors = useThemeColor();
const authClient = useAuthClient();
const { showAlert } = useAlert();
const insets = useSafeAreaInsets();
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
const form = useForm({
defaultValues: {
email: '',
password: '',
},
validators: {
onChange: loginSchema,
},
onSubmit: async ({ value }) => {
setIsSubmitting(true);
try {
const response = await authClient.signIn.email({ email: value.email, password: value.password, rememberMe: true });
if (response.error) {
throw new Error(response.error.message);
}
router.replace('/(app)/(with-organizations)/(tabs)/list');
} catch (error) {
showAlert({
title: 'Login Failed',
message: error instanceof Error ? error.message : 'An error occurred',
});
} finally {
setIsSubmitting(false);
}
},
});
const handleSocialSignIn = async (provider: string) => {
try {
const response = await authClient.signIn.social({ provider, callbackURL: '/' });
if (response.error) {
throw Object.assign(new Error(response.error.message), response.error);
}
} catch (error) {
showAlert({
title: 'Sign In Failed',
message: error instanceof Error ? error.message : 'An error occurred',
});
}
};
const authConfig = serverConfig?.config?.auth;
const isEmailEnabled = authConfig?.providers?.email?.isEnabled ?? false;
const isGoogleEnabled = authConfig?.providers?.google?.isEnabled ?? false;
const isGithubEnabled = authConfig?.providers?.github?.isEnabled ?? false;
const customProviders = authConfig?.providers?.customs ?? [];
const styles = createStyles({ themeColors });
if (isLoadingConfig) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={themeColors.primary} />
</View>
);
}
return (
<KeyboardAvoidingView
style={{ ...styles.container, paddingTop: insets.top }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<BackToServerSelectionButton />
<View style={styles.header}>
<Text style={styles.title}>Welcome Back</Text>
<Text style={styles.subtitle}>Sign in to your account</Text>
</View>
{isEmailEnabled && (
<View style={styles.formContainer}>
<form.Field name="email">
{field => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
placeholder="you@example.com"
placeholderTextColor={themeColors.mutedForeground}
value={field.state.value}
onChangeText={field.handleChange}
onBlur={field.handleBlur}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
editable={!isSubmitting}
/>
</View>
)}
</form.Field>
<form.Field name="password">
{field => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="Enter your password"
placeholderTextColor={themeColors.mutedForeground}
value={field.state.value}
onChangeText={field.handleChange}
onBlur={field.handleBlur}
secureTextEntry
editable={!isSubmitting}
/>
</View>
)}
</form.Field>
<TouchableOpacity
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={async () => form.handleSubmit()}
disabled={isSubmitting}
>
{isSubmitting
? (
<ActivityIndicator color="#fff" />
)
: (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
)}
{(isGoogleEnabled || isGithubEnabled || customProviders.length > 0) && (
<>
{isEmailEnabled && (
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>OR</Text>
<View style={styles.dividerLine} />
</View>
)}
<View style={styles.socialButtons}>
{isGoogleEnabled && (
<TouchableOpacity
style={styles.socialButton}
onPress={async () => handleSocialSignIn('google')}
>
<Text style={styles.socialButtonText}>Continue with Google</Text>
</TouchableOpacity>
)}
{isGithubEnabled && (
<TouchableOpacity
style={styles.socialButton}
onPress={async () => handleSocialSignIn('github')}
>
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
</TouchableOpacity>
)}
{customProviders.map(provider => (
<TouchableOpacity
key={provider.providerId}
style={styles.socialButton}
onPress={async () => handleSocialSignIn(provider.providerId)}
>
<Text style={styles.socialButtonText}>
Continue with
{' '}
{provider.providerName}
</Text>
</TouchableOpacity>
))}
</View>
</>
)}
{authConfig?.isRegistrationEnabled === true && (
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.push('/auth/signup')}
>
<Text style={styles.linkText}>
Don&apos;t have an account? Sign up
</Text>
</TouchableOpacity>
)}
</ScrollView>
</KeyboardAvoidingView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
centerContent: {
justifyContent: 'center',
alignItems: 'center',
},
scrollContent: {
flexGrow: 1,
padding: 24,
},
header: {
marginBottom: 48,
marginTop: 16,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: themeColors.foreground,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: themeColors.mutedForeground,
},
formContainer: {
gap: 16,
},
fieldContainer: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
color: themeColors.foreground,
},
input: {
height: 50,
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
color: themeColors.foreground,
backgroundColor: themeColors.secondaryBackground,
},
button: {
height: 50,
backgroundColor: themeColors.primary,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: themeColors.primaryForeground,
fontSize: 16,
fontWeight: '600',
},
divider: {
flexDirection: 'row',
alignItems: 'center',
marginVertical: 24,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: themeColors.border,
},
dividerText: {
marginHorizontal: 16,
color: themeColors.mutedForeground,
fontSize: 14,
},
socialButtons: {
gap: 12,
},
socialButton: {
height: 50,
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: themeColors.secondaryBackground,
},
socialButtonText: {
color: themeColors.foreground,
fontSize: 16,
fontWeight: '500',
},
linkButton: {
marginTop: 24,
alignItems: 'center',
},
linkText: {
color: themeColors.primary,
fontSize: 14,
},
backToServerButton: {
marginBottom: 16,
alignSelf: 'flex-start',
},
backToServerText: {
color: themeColors.primary,
fontSize: 14,
},
});
}

View File

@@ -1,293 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useForm } from '@tanstack/react-form';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as v from 'valibot';
import { useAuthClient } from '@/modules/api/providers/api.provider';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { useServerConfig } from '../../config/hooks/use-server-config';
import { BackToServerSelectionButton } from '../components/back-to-server-selection';
const signupSchema = v.object({
name: v.pipe(v.string(), v.minLength(1, 'Name is required')),
email: v.pipe(v.string(), v.email('Please enter a valid email')),
password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters')),
});
export function SignupScreen() {
const router = useRouter();
const themeColors = useThemeColor();
const authClient = useAuthClient();
const { showAlert } = useAlert();
const insets = useSafeAreaInsets();
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
const form = useForm({
defaultValues: {
name: '',
email: '',
password: '',
},
validators: {
onChange: signupSchema,
},
onSubmit: async ({ value }) => {
setIsSubmitting(true);
try {
const { name, email, password } = value;
await authClient.signUp.email({ name, email, password });
const isEmailVerificationRequired = serverConfig?.config?.auth?.isEmailVerificationRequired ?? false;
if (isEmailVerificationRequired) {
showAlert({
title: 'Check your email',
message: 'We sent you a verification link. Please check your email to verify your account.',
buttons: [{ text: 'OK', onPress: () => router.replace('/auth/login') }],
});
} else {
router.replace('/(app)/(with-organizations)/(tabs)/list');
}
} catch (error) {
showAlert({
title: 'Signup Failed',
message: error instanceof Error ? error.message : 'An error occurred',
});
} finally {
setIsSubmitting(false);
}
},
});
const authConfig = serverConfig?.config?.auth;
const isRegistrationEnabled = authConfig?.isRegistrationEnabled ?? false;
const styles = createStyles({ themeColors });
if (isLoadingConfig) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={themeColors.primary} />
</View>
);
}
if (!isRegistrationEnabled) {
return (
<View style={[styles.container, styles.centerContent]}>
<Text style={styles.errorText}>Registration is currently disabled</Text>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.back()}
>
<Text style={styles.linkText}>Go back to login</Text>
</TouchableOpacity>
</View>
);
}
return (
<KeyboardAvoidingView
style={{ ...styles.container, paddingTop: insets.top }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<BackToServerSelectionButton />
<View style={styles.header}>
<Text style={styles.title}>Create Account</Text>
<Text style={styles.subtitle}>Sign up to get started</Text>
</View>
<View style={styles.formContainer}>
<form.Field name="name">
{field => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
placeholder="Your name"
placeholderTextColor={themeColors.mutedForeground}
value={field.state.value}
onChangeText={field.handleChange}
onBlur={field.handleBlur}
autoCapitalize="words"
editable={!isSubmitting}
/>
</View>
)}
</form.Field>
<form.Field name="email">
{field => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
placeholder="you@example.com"
placeholderTextColor={themeColors.mutedForeground}
value={field.state.value}
onChangeText={field.handleChange}
onBlur={field.handleBlur}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
editable={!isSubmitting}
/>
</View>
)}
</form.Field>
<form.Field name="password">
{field => (
<View style={styles.fieldContainer}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
placeholder="At least 8 characters"
placeholderTextColor={themeColors.mutedForeground}
value={field.state.value}
onChangeText={field.handleChange}
onBlur={field.handleBlur}
secureTextEntry
editable={!isSubmitting}
/>
</View>
)}
</form.Field>
<TouchableOpacity
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={async () => form.handleSubmit()}
disabled={isSubmitting}
>
{isSubmitting
? (
<ActivityIndicator color="#fff" />
)
: (
<Text style={styles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</View>
<TouchableOpacity
style={styles.linkButton}
onPress={() => router.back()}
>
<Text style={styles.linkText}>Already have an account? Sign in</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
centerContent: {
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
scrollContent: {
flexGrow: 1,
padding: 24,
},
header: {
marginBottom: 48,
marginTop: 16,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: themeColors.foreground,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: themeColors.mutedForeground,
},
formContainer: {
gap: 16,
},
fieldContainer: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
color: themeColors.foreground,
},
input: {
height: 50,
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
color: themeColors.foreground,
backgroundColor: themeColors.secondaryBackground,
},
button: {
height: 50,
backgroundColor: themeColors.primary,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: themeColors.primaryForeground,
fontSize: 16,
fontWeight: '600',
},
linkButton: {
marginTop: 24,
alignItems: 'center',
},
linkText: {
color: themeColors.primary,
fontSize: 14,
},
errorText: {
fontSize: 16,
color: themeColors.primary,
marginBottom: 16,
textAlign: 'center',
},
backToServerButton: {
marginBottom: 16,
alignSelf: 'flex-start',
},
backToServerText: {
color: themeColors.primary,
fontSize: 14,
},
});
}

View File

@@ -1 +0,0 @@
export const MANAGED_SERVER_URL = 'https://api.papra.app';

View File

@@ -1,9 +0,0 @@
import { buildStorageKey } from '../lib/local-storage/local-storage.models';
import { storage } from '../lib/local-storage/local-storage.services';
const CONFIG_API_SERVER_URL_KEY = buildStorageKey(['config', 'api-server-url']);
export const configLocalStorage = {
getApiServerBaseUrl: async () => storage.getItem(CONFIG_API_SERVER_URL_KEY),
setApiServerBaseUrl: async ({ apiServerBaseUrl}: { apiServerBaseUrl: string }) => storage.setItem(CONFIG_API_SERVER_URL_KEY, apiServerBaseUrl),
};

View File

@@ -1,44 +0,0 @@
import type { ApiClient } from '../api/api.client';
import { httpClient } from '../api/http.client';
export async function fetchServerConfig({ apiClient}: { apiClient: ApiClient }) {
return apiClient<{
config: {
auth: {
isEmailVerificationRequired: boolean;
isPasswordResetEnabled: boolean;
isRegistrationEnabled: boolean;
showLegalLinksOnAuthPage: boolean;
providers: {
email: {
isEnabled: boolean;
};
github: {
isEnabled: boolean;
};
google: {
isEnabled: boolean;
};
customs: {
providerId: string;
providerName: string;
}[];
};
};
};
}>({
path: '/api/config',
});
}
export async function pingServer({ url}: { url: string }): Promise<true | never> {
const response = await httpClient<{ status: 'ok' | 'error' }>({ url: `/api/ping`, baseUrl: url })
.then(() => true)
.catch(() => false);
if (!response) {
throw new Error('Could not reach the server');
}
return true;
}

View File

@@ -1,12 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useApiClient } from '@/modules/api/providers/api.provider';
import { fetchServerConfig } from '../config.services';
export function useServerConfig() {
const apiClient = useApiClient();
return useQuery({
queryKey: ['server', 'config'],
queryFn: async () => fetchServerConfig({ apiClient }),
});
}

View File

@@ -1,238 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { queryClient } from '@/modules/api/providers/query.provider';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { MANAGED_SERVER_URL } from '../config.constants';
import { configLocalStorage } from '../config.local-storage';
import { pingServer } from '../config.services';
function getDefaultCustomServerUrl() {
if (!__DEV__) {
return '';
}
// eslint-disable-next-line node/prefer-global/process
return process.env.EXPO_PUBLIC_API_URL ?? '';
}
export function ServerSelectionScreen() {
const router = useRouter();
const themeColors = useThemeColor();
const { showAlert } = useAlert();
const styles = createStyles({ themeColors });
const [selectedOption, setSelectedOption] = useState<'managed' | 'self-hosted'>('managed');
const [customUrl, setCustomUrl] = useState(getDefaultCustomServerUrl());
const [isValidating, setIsValidating] = useState(false);
const handleValidateCustomUrl = async ({ url}: { url: string }) => {
setIsValidating(true);
try {
await pingServer({ url });
await configLocalStorage.setApiServerBaseUrl({ apiServerBaseUrl: url });
await queryClient.invalidateQueries({ queryKey: ['api-server-url'] });
router.replace('/auth/login');
} catch {
showAlert({
title: 'Connection Failed',
message: 'Could not reach the server.',
});
} finally {
setIsValidating(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.title}>Welcome to Papra</Text>
<Text style={styles.subtitle}>Choose your server</Text>
</View>
<View style={styles.options}>
<TouchableOpacity
style={[
styles.optionCard,
selectedOption === 'managed' && styles.optionCardSelected,
]}
onPress={() => setSelectedOption('managed')}
disabled={isValidating}
>
<Text style={styles.optionTitle}>Managed Cloud</Text>
<Text style={styles.optionDescription}>
Use the official Papra cloud service
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.optionCard,
selectedOption === 'self-hosted' && styles.optionCardSelected,
]}
onPress={() => setSelectedOption('self-hosted')}
disabled={isValidating}
>
<Text style={styles.optionTitle}>Self-Hosted</Text>
<Text style={styles.optionDescription}>
Connect to your own Papra server
</Text>
</TouchableOpacity>
</View>
{selectedOption === 'managed' && (
<TouchableOpacity
style={[styles.button, isValidating && styles.buttonDisabled]}
onPress={async () => handleValidateCustomUrl({ url: MANAGED_SERVER_URL })}
disabled={isValidating}
>
{isValidating
? (
<ActivityIndicator color="#fff" />
)
: (
<Text style={styles.buttonText}>Continue with Managed</Text>
)}
</TouchableOpacity>
)}
{selectedOption === 'self-hosted' && (
<View style={styles.customUrlContainer}>
<Text style={styles.inputLabel}>Server URL</Text>
<TextInput
style={styles.input}
placeholder="https://your-server.com"
placeholderTextColor={themeColors.mutedForeground}
value={customUrl}
onChangeText={setCustomUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
editable={!isValidating}
/>
<TouchableOpacity
style={[styles.button, isValidating && styles.buttonDisabled]}
onPress={async () => handleValidateCustomUrl({ url: customUrl })}
disabled={isValidating}
>
{isValidating
? (
<ActivityIndicator color="#fff" />
)
: (
<Text style={styles.buttonText}>Connect</Text>
)}
</TouchableOpacity>
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
scrollContent: {
flexGrow: 1,
padding: 24,
justifyContent: 'center',
},
header: {
marginBottom: 40,
alignItems: 'center',
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: themeColors.foreground,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: themeColors.mutedForeground,
},
options: {
gap: 16,
marginBottom: 24,
},
optionCard: {
paddingHorizontal: 20,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 2,
borderColor: themeColors.border,
backgroundColor: themeColors.secondaryBackground,
},
optionCardSelected: {
borderColor: themeColors.primary,
},
optionTitle: {
fontSize: 18,
fontWeight: '600',
color: themeColors.foreground,
margin: 0,
},
optionDescription: {
fontSize: 14,
color: themeColors.mutedForeground,
},
customUrlContainer: {
gap: 12,
},
inputLabel: {
fontSize: 14,
fontWeight: '600',
color: themeColors.foreground,
marginBottom: 4,
},
input: {
height: 50,
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
color: themeColors.foreground,
backgroundColor: themeColors.secondaryBackground,
},
button: {
height: 50,
backgroundColor: themeColors.primary,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: themeColors.primaryForeground,
fontSize: 16,
fontWeight: '600',
},
});
}

View File

@@ -1,233 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import * as DocumentPicker from 'expo-document-picker';
import { File } from 'expo-file-system';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useApiClient } from '@/modules/api/providers/api.provider';
import { queryClient } from '@/modules/api/providers/query.provider';
import { useOrganizations } from '@/modules/organizations/organizations.provider';
import { Icon } from '@/modules/ui/components/icon';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { uploadDocument } from '../documents.services';
type ImportDrawerProps = {
visible: boolean;
onClose: () => void;
};
export function ImportDrawer({ visible, onClose }: ImportDrawerProps) {
const themeColors = useThemeColor();
const { showAlert } = useAlert();
const styles = createStyles({ themeColors });
const apiClient = useApiClient();
const { currentOrganizationId } = useOrganizations();
const handleImportFromFiles = async () => {
onClose();
try {
if (currentOrganizationId == null) {
showAlert({
title: 'No Organization Selected',
message: 'Please select an organization before importing documents.',
});
return;
}
const result = await DocumentPicker.getDocumentAsync({
type: [
'application/pdf',
'image/*',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
],
copyToCacheDirectory: true,
multiple: false,
});
if (result.canceled) {
return;
}
const [pickerFile] = result.assets;
if (!pickerFile) {
return;
}
const file = new File(pickerFile.uri);
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });
showAlert({
title: 'Upload Successful',
message: `Successfully uploaded: ${file.name}`,
});
} catch (error) {
showAlert({
title: 'Error',
message: error instanceof Error ? error.message : 'Failed to pick document',
});
}
};
// const handleScanDocument = () => {
// onClose();
// showAlert({
// title: 'Coming Soon',
// message: 'Camera document scanning will be available soon!',
// });
// };
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={onClose}
>
<View style={styles.drawer}>
<View style={styles.header}>
<Text style={styles.title}>Import Document</Text>
</View>
<View style={styles.optionsContainer}>
<TouchableOpacity
style={styles.optionItem}
onPress={handleImportFromFiles}
>
<View style={styles.optionIconContainer}>
<Icon name="file-plus" size={24} style={styles.optionIcon} />
</View>
<View style={styles.optionTextContainer}>
<Text style={styles.optionTitle}>Import from Files</Text>
<Text style={styles.optionDescription}>
Choose a document from your device
</Text>
</View>
<Icon name="chevron-right" size={18} style={styles.chevronIcon} />
</TouchableOpacity>
{/* <TouchableOpacity
style={styles.optionItem}
onPress={handleScanDocument}
>
<View style={styles.optionIconContainer}>
<Icon name="camera" size={24} style={styles.optionIcon} />
</View>
<View style={styles.optionTextContainer}>
<Text style={styles.optionTitle}>Scan Document</Text>
<Text style={styles.optionDescription}>
Use camera to scan (Coming soon)
</Text>
</View>
<Icon name="chevron-right" size={18} style={styles.chevronIcon} />
</TouchableOpacity> */}
</View>
<TouchableOpacity
style={styles.cancelButton}
onPress={onClose}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
drawer: {
backgroundColor: themeColors.background,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingBottom: 20,
},
header: {
padding: 20,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: themeColors.foreground,
},
optionsContainer: {
paddingVertical: 8,
},
optionItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
optionIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: themeColors.secondaryBackground,
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
optionIcon: {
fontSize: 24,
color: themeColors.primary,
},
optionTextContainer: {
flex: 1,
},
optionTitle: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
marginBottom: 4,
},
optionDescription: {
fontSize: 14,
color: themeColors.mutedForeground,
},
chevronIcon: {
fontSize: 18,
color: themeColors.mutedForeground,
},
cancelButton: {
margin: 20,
marginTop: 12,
paddingVertical: 14,
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 8,
alignItems: 'center',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
},
});
}

View File

@@ -1,60 +0,0 @@
import type { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import * as Haptics from 'expo-haptics';
import { useState } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { ImportDrawer } from '@/modules/documents/components/import-drawer';
import { Icon } from '@/modules/ui/components/icon';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
button: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
});
export function ImportTabButton(props: BottomTabBarButtonProps) {
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
const themeColors = useThemeColor();
const insets = useSafeAreaInsets();
const handlePress = () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setIsDrawerVisible(true);
};
return (
<>
<Pressable
onPress={handlePress}
style={[styles.container, props.style]}
>
<View style={[styles.button, { backgroundColor: themeColors.primary, marginBottom: 20 + insets.bottom }]}>
<Icon name="plus" size={32} color={themeColors.primaryForeground} style={{ height: 32 }} />
</View>
</Pressable>
<ImportDrawer
visible={isDrawerVisible}
onClose={() => setIsDrawerVisible(false)}
/>
</>
);
}

View File

@@ -1,74 +0,0 @@
import type { ApiClient } from '../api/api.client';
import type { Document } from './documents.types';
import { coerceDates } from '../api/api.models';
export function getFormData(pojo: Record<string, string | Blob>): FormData {
const formData = new FormData();
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
return formData;
}
export async function uploadDocument({
file,
organizationId,
apiClient,
}: {
file: Blob;
organizationId: string;
apiClient: ApiClient;
}) {
const { document } = await apiClient<{ document: Document }>({
method: 'POST',
path: `/api/organizations/${organizationId}/documents`,
body: getFormData({ file }),
});
return {
document: coerceDates(document),
};
}
export async function fetchOrganizationDocuments({
organizationId,
pageIndex,
pageSize,
filters,
apiClient,
}: {
organizationId: string;
pageIndex: number;
pageSize: number;
filters?: {
tags?: string[];
};
apiClient: ApiClient;
}) {
const {
documents: apiDocuments,
documentsCount,
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
method: 'GET',
path: `/api/organizations/${organizationId}/documents`,
query: {
pageIndex,
pageSize,
...filters,
},
});
try {
const documents = apiDocuments.map(coerceDates);
return {
documentsCount,
documents,
};
} catch (error) {
console.error('Error fetching documents:', error);
throw error;
}
}

View File

@@ -1,14 +0,0 @@
export type Document = {
id: string;
name: string;
mimeType: string;
originalSize: number;
organizationId: string;
createdAt: string;
updatedAt: string;
tags: {
id: string;
name: string;
color: string;
}[];
};

View File

@@ -1,240 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import {
ActivityIndicator,
FlatList,
RefreshControl,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useApiClient } from '@/modules/api/providers/api.provider';
import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button';
import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer';
import { useOrganizations } from '@/modules/organizations/organizations.provider';
import { Icon } from '@/modules/ui/components/icon';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { fetchOrganizationDocuments } from '../documents.services';
export function DocumentsListScreen() {
const themeColors = useThemeColor();
const apiClient = useApiClient();
const { currentOrganizationId, isLoading: isLoadingOrganizations } = useOrganizations();
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
const pagination = { pageIndex: 0, pageSize: 20 };
const documentsQuery = useQuery({
queryKey: ['organizations', currentOrganizationId, 'documents', pagination],
queryFn: async () => {
if (currentOrganizationId == null) {
return { documents: [], documentsCount: 0 };
}
return fetchOrganizationDocuments({
organizationId: currentOrganizationId,
...pagination,
apiClient,
});
},
enabled: currentOrganizationId !== null && currentOrganizationId !== '',
});
const styles = createStyles({ themeColors });
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date);
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const onRefresh = async () => {
await documentsQuery.refetch();
};
if (isLoadingOrganizations) {
return (
<View style={[styles.container, styles.centerContent]}>
<ActivityIndicator size="large" color={themeColors.primary} />
</View>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Documents</Text>
<OrganizationPickerButton onPress={() => setIsDrawerVisible(true)} />
</View>
{documentsQuery.isLoading
? (
<View style={styles.centerContent}>
<ActivityIndicator size="large" color={themeColors.primary} />
</View>
)
: (
<FlatList
data={documentsQuery.data?.documents ?? []}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<View style={styles.documentCard}>
<View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
<Icon name="file-text" size={24} color={themeColors.primary} />
</View>
<View>
<Text style={styles.documentTitle} numberOfLines={2}>
{item.name}
</Text>
<View style={styles.documentMeta}>
<Text style={styles.metaText}>{formatFileSize(item.originalSize)}</Text>
<Text style={styles.metaSplitter}>-</Text>
<Text style={styles.metaText}>{formatDate(item.createdAt)}</Text>
{item.tags.length > 0 && (
<View style={styles.tagsContainer}>
{item.tags.map(tag => (
<View
key={tag.id}
style={[
styles.tag,
{ backgroundColor: `${tag.color}10` },
]}
>
<Text style={[styles.tagText, { color: tag.color }]}>
{tag.name}
</Text>
</View>
))}
</View>
)}
</View>
</View>
</View>
)}
ListEmptyComponent={(
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No documents yet</Text>
<Text style={styles.emptySubtext}>
Upload your first document to get started
</Text>
</View>
)}
contentContainerStyle={documentsQuery.data?.documents.length === 0 ? styles.emptyList : undefined}
refreshControl={(
<RefreshControl
refreshing={documentsQuery.isRefetching}
onRefresh={onRefresh}
/>
)}
/>
)}
<OrganizationPickerDrawer
visible={isDrawerVisible}
onClose={() => setIsDrawerVisible(false)}
/>
</SafeAreaView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
centerContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
padding: 16,
paddingTop: 20,
gap: 12,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: themeColors.foreground,
},
emptyList: {
flex: 1,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: 60,
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: themeColors.foreground,
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: themeColors.mutedForeground,
},
documentCard: {
padding: 16,
borderBottomWidth: 1,
borderColor: themeColors.border,
flexDirection: 'row',
alignItems: 'center',
},
documentTitle: {
flex: 1,
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
marginRight: 12,
},
documentMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 4,
},
metaText: {
fontSize: 13,
color: themeColors.mutedForeground,
},
metaSplitter: {
fontSize: 13,
color: themeColors.mutedForeground,
},
tagsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
tag: {
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 6,
},
tagText: {
fontSize: 12,
fontWeight: '500',
lineHeight: 12,
},
});
}

View File

@@ -1 +0,0 @@
export const STORAGE_KEY_BASE_PREFIX = '@papra';

View File

@@ -1,5 +0,0 @@
import { STORAGE_KEY_BASE_PREFIX } from './local-storage.constants';
export function buildStorageKey(sections: string[]): string {
return [STORAGE_KEY_BASE_PREFIX, ...sections].join(':');
}

View File

@@ -1,8 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export const storage = {
getItem: async (key: string) => AsyncStorage.getItem(key),
setItem: async (key: string, value: string) => AsyncStorage.setItem(key, value),
removeItem: async (key: string) => AsyncStorage.removeItem(key),
clear: async () => AsyncStorage.clear(),
};

View File

@@ -1,62 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Icon } from '@/modules/ui/components/icon';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { useOrganizations } from '../organizations.provider';
type OrganizationPickerButtonProps = {
onPress: () => void;
};
export function OrganizationPickerButton({ onPress }: OrganizationPickerButtonProps) {
const themeColors = useThemeColor();
const { organizations, currentOrganizationId } = useOrganizations();
const styles = createStyles({ themeColors });
const currentOrganization = organizations.find(org => org.id === currentOrganizationId);
return (
<TouchableOpacity style={styles.button} onPress={onPress}>
<View style={styles.content}>
<Text style={styles.orgName} numberOfLines={1}>
{currentOrganization?.name ?? 'Select Organization'}
</Text>
</View>
<Icon name="chevron-down" style={styles.caret} size={20} />
</TouchableOpacity>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 8,
borderWidth: 1,
borderColor: themeColors.border,
},
content: {
flex: 1,
marginRight: 8,
},
label: {
fontSize: 12,
color: themeColors.mutedForeground,
marginBottom: 2,
},
orgName: {
fontSize: 16,
fontWeight: '600',
color: themeColors.foreground,
},
caret: {
color: themeColors.mutedForeground,
},
});
}

View File

@@ -1,175 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
FlatList,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { Icon } from '@/modules/ui/components/icon';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { useOrganizations } from '../organizations.provider';
type OrganizationPickerDrawerProps = {
visible: boolean;
onClose: () => void;
};
export function OrganizationPickerDrawer({ visible, onClose }: OrganizationPickerDrawerProps) {
const themeColors = useThemeColor();
const router = useRouter();
const { organizations, currentOrganizationId, setCurrentOrganizationId, isLoading } = useOrganizations();
const styles = createStyles({ themeColors });
const handleSelectOrganization = async (organizationId: string) => {
await setCurrentOrganizationId(organizationId);
onClose();
};
const handleCreateOrganization = () => {
onClose();
router.push('/(app)/(with-organizations)/organizations/create');
};
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={onClose}
>
<View style={styles.drawer}>
<View style={styles.header}>
<Text style={styles.title}>Select Organization</Text>
</View>
{isLoading
? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={themeColors.primary} />
</View>
)
: (
<FlatList
data={organizations}
keyExtractor={item => item.id}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.orgItem,
item.id === currentOrganizationId && styles.orgItemSelected,
]}
onPress={() => {
void handleSelectOrganization(item.id);
}}
>
<Text
style={[
styles.orgName,
item.id === currentOrganizationId && styles.orgNameSelected,
]}
>
{item.name}
</Text>
{item.id === currentOrganizationId && (
<Icon name="check" style={styles.checkmark} />
)}
</TouchableOpacity>
)}
contentContainerStyle={styles.listContent}
/>
)}
<TouchableOpacity
style={styles.createButton}
onPress={handleCreateOrganization}
>
<Text style={styles.createButtonText}>+ Create New Organization</Text>
</TouchableOpacity>
</View>
</TouchableOpacity>
</Modal>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'flex-end',
},
drawer: {
backgroundColor: themeColors.background,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
maxHeight: '70%',
paddingBottom: 20,
},
header: {
padding: 20,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: themeColors.foreground,
},
loadingContainer: {
padding: 40,
alignItems: 'center',
},
listContent: {
paddingVertical: 8,
},
orgItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: themeColors.border,
},
orgItemSelected: {
backgroundColor: themeColors.secondaryBackground,
},
orgName: {
fontSize: 16,
color: themeColors.foreground,
},
orgNameSelected: {
fontWeight: '600',
color: themeColors.primary,
},
checkmark: {
fontSize: 18,
color: themeColors.primary,
fontWeight: 'bold',
},
createButton: {
margin: 20,
marginTop: 0,
paddingVertical: 14,
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 8,
alignItems: 'center',
},
createButtonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.primary,
},
});
}

View File

@@ -1,18 +0,0 @@
import { STORAGE_KEY_BASE_PREFIX } from '../lib/local-storage/local-storage.constants';
import { storage } from '../lib/local-storage/local-storage.services';
const CURRENT_ORGANIZATION_ID_KEY = `${STORAGE_KEY_BASE_PREFIX}:current-organization-id`;
export const organizationsLocalStorage = {
getCurrentOrganizationId: async (): Promise<string | null> => {
return storage.getItem(CURRENT_ORGANIZATION_ID_KEY);
},
setCurrentOrganizationId: async (organizationId: string): Promise<void> => {
await storage.setItem(CURRENT_ORGANIZATION_ID_KEY, organizationId);
},
clearCurrentOrganizationId: async (): Promise<void> => {
await storage.removeItem(CURRENT_ORGANIZATION_ID_KEY);
},
};

View File

@@ -1,102 +0,0 @@
import type { ReactNode } from 'react';
import type { Organization } from '@/modules/organizations/organizations.types';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { createContext, useContext, useEffect, useState } from 'react';
import { useApiClient } from '../api/providers/api.provider';
import { organizationsLocalStorage } from './organizations.local-storage';
import { fetchOrganizations } from './organizations.services';
type OrganizationsContextValue = {
currentOrganizationId: string | null;
setCurrentOrganizationId: (organizationId: string) => Promise<void>;
organizations: Organization[];
isLoading: boolean;
refetch: () => Promise<void>;
};
const OrganizationsContext = createContext<OrganizationsContextValue | null>(null);
type OrganizationsProviderProps = {
children: ReactNode;
};
export function OrganizationsProvider({ children }: OrganizationsProviderProps) {
const router = useRouter();
const apiClient = useApiClient();
const queryClient = useQueryClient();
const [currentOrganizationId, setCurrentOrganizationIdState] = useState<string | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
const organizationsQuery = useQuery({
queryKey: ['organizations'],
queryFn: async () => fetchOrganizations({ apiClient }),
});
// Load current organization ID from storage on mount
useEffect(() => {
const loadCurrentOrganizationId = async () => {
const storedOrgId = await organizationsLocalStorage.getCurrentOrganizationId();
setCurrentOrganizationIdState(storedOrgId);
setIsInitialized(true);
};
void loadCurrentOrganizationId();
}, []);
const setCurrentOrganizationId = async (organizationId: string) => {
await organizationsLocalStorage.setCurrentOrganizationId(organizationId);
setCurrentOrganizationIdState(organizationId);
};
// Redirect to organization selection if no organizations or no current org set
useEffect(() => {
if (!isInitialized || organizationsQuery.isLoading) {
return;
}
const organizations = organizationsQuery.data?.organizations ?? [];
if (organizations.length === 0) {
// No organizations, redirect to organization create to create one
router.replace('/(app)/(with-organizations)/organizations/create');
return;
}
// If there's no current org set, or the current org doesn't exist anymore, set to first org
if (currentOrganizationId == null || !organizations.some(org => org.id === currentOrganizationId)) {
const firstOrg = organizations[0];
if (firstOrg) {
void setCurrentOrganizationId(firstOrg.id);
}
}
}, [isInitialized, organizationsQuery.isLoading, organizationsQuery.data, currentOrganizationId, router]);
const refetch = async () => {
await queryClient.invalidateQueries({ queryKey: ['organizations'] });
};
const value: OrganizationsContextValue = {
currentOrganizationId,
setCurrentOrganizationId,
organizations: organizationsQuery.data?.organizations ?? [],
isLoading: organizationsQuery.isLoading || !isInitialized,
refetch,
};
return (
<OrganizationsContext.Provider value={value}>
{children}
</OrganizationsContext.Provider>
);
}
export function useOrganizations(): OrganizationsContextValue {
const context = useContext(OrganizationsContext);
if (!context) {
throw new Error('useOrganizations must be used within OrganizationsProvider');
}
return context;
}

View File

@@ -1,24 +0,0 @@
import type { ApiClient } from '../api/api.client';
import type { Organization } from '@/modules/organizations/organizations.types';
export async function fetchOrganizations({ apiClient }: { apiClient: ApiClient }) {
return apiClient<{
organizations: Organization[];
}>({
method: 'GET',
path: '/api/organizations',
});
}
export async function createOrganization({ name, apiClient }: { name: string; apiClient: ApiClient }) {
return apiClient<{
organization: {
id: string;
name: string;
};
}>({
method: 'POST',
path: '/api/organizations',
body: { name },
});
}

View File

@@ -1,7 +0,0 @@
export type Organization = {
id: string;
name: string;
slug: string;
createdAt: string;
updatedAt: string;
};

View File

@@ -1,175 +0,0 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useApiClient } from '@/modules/api/providers/api.provider';
import { useAlert } from '@/modules/ui/providers/alert-provider';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
import { useOrganizations } from '../organizations.provider';
import { createOrganization } from '../organizations.services';
export function OrganizationCreateScreen() {
const router = useRouter();
const themeColors = useThemeColor();
const apiClient = useApiClient();
const { showAlert } = useAlert();
const insets = useSafeAreaInsets();
const { setCurrentOrganizationId, refetch } = useOrganizations();
const [organizationName, setOrganizationName] = useState('');
const createMutation = useMutation({
mutationFn: async ({ name }: { name: string }) => createOrganization({ name, apiClient }),
onSuccess: async (data) => {
await refetch();
await setCurrentOrganizationId(data.organization.id);
router.replace('/(app)/(with-organizations)/(tabs)/list');
},
onError: (error) => {
showAlert({
title: 'Error',
message: error instanceof Error ? error.message : 'Failed to create organization',
});
},
});
const handleCreate = () => {
if (organizationName.trim().length === 0) {
showAlert({
title: 'Invalid Name',
message: 'Please enter a valid organization name',
});
return;
}
createMutation.mutate({ name: organizationName.trim() });
};
const styles = createStyles({ themeColors });
return (
<KeyboardAvoidingView
style={{ ...styles.container, paddingTop: insets.top }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.header}>
<Text style={styles.title}>Create organization</Text>
<Text style={styles.subtitle}>
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
</Text>
</View>
<View style={styles.formContainer}>
<View style={styles.fieldContainer}>
<Text style={styles.label}>Organization Name</Text>
<TextInput
style={styles.input}
placeholder="My Organization"
placeholderTextColor={themeColors.mutedForeground}
value={organizationName}
onChangeText={setOrganizationName}
autoFocus
autoCapitalize="words"
editable={!createMutation.isPending}
onSubmitEditing={handleCreate}
returnKeyType="done"
/>
</View>
<TouchableOpacity
style={[styles.button, createMutation.isPending && styles.buttonDisabled]}
onPress={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending
? (
<ActivityIndicator color="#fff" />
)
: (
<Text style={styles.buttonText}>Create Organization</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
scrollContent: {
flexGrow: 1,
padding: 24,
},
header: {
marginBottom: 48,
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: themeColors.foreground,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: themeColors.mutedForeground,
},
formContainer: {
gap: 16,
},
fieldContainer: {
gap: 8,
},
label: {
fontSize: 14,
fontWeight: '600',
color: themeColors.foreground,
},
input: {
height: 50,
borderWidth: 1,
borderColor: themeColors.border,
borderRadius: 8,
paddingHorizontal: 16,
fontSize: 16,
color: themeColors.foreground,
backgroundColor: themeColors.secondaryBackground,
},
button: {
height: 50,
backgroundColor: themeColors.primary,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
opacity: 0.5,
},
buttonText: {
color: themeColors.primaryForeground,
fontSize: 16,
fontWeight: '600',
},
});
}

View File

@@ -1,146 +0,0 @@
import type { ReactNode } from 'react';
import type { ThemeColors } from '@/modules/ui/theme.constants';
import {
Modal,
Pressable,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
type AlertButton = {
text: string;
onPress?: () => void;
style?: 'default' | 'cancel' | 'destructive';
};
type AlertDialogProps = {
visible: boolean;
title: string;
message?: string | ReactNode;
buttons: AlertButton[];
onDismiss?: () => void;
};
export function AlertDialog({ visible, title, message, buttons, onDismiss }: AlertDialogProps) {
const themeColors = useThemeColor();
const styles = createStyles({ themeColors });
const handleButtonPress = (button: AlertButton) => {
button.onPress?.();
onDismiss?.();
};
return (
<Modal
transparent
visible={visible}
animationType="fade"
onRequestClose={onDismiss}
>
<Pressable style={styles.overlay} onPress={onDismiss}>
<Pressable style={styles.container}>
<View style={styles.content}>
<Text style={styles.title}>{title}</Text>
{message !== undefined && (
<Text style={styles.message}>
{message}
</Text>
)}
<View style={styles.buttonContainer}>
{buttons.map((button, index) => (
<TouchableOpacity
key={index}
style={[
styles.button,
button.style === 'cancel' && styles.cancelButton,
button.style === 'destructive' && styles.destructiveButton,
]}
onPress={() => handleButtonPress(button)}
>
<Text
style={[
styles.buttonText,
button.style === 'cancel' && styles.cancelButtonText,
button.style === 'destructive' && styles.destructiveButtonText,
]}
>
{button.text}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
container: {
width: '85%',
maxWidth: 400,
},
content: {
backgroundColor: themeColors.background,
borderRadius: 16,
padding: 24,
borderWidth: 1,
borderColor: themeColors.border,
},
title: {
fontSize: 18,
fontWeight: '600',
color: themeColors.foreground,
marginBottom: 12,
},
message: {
fontSize: 14,
color: themeColors.mutedForeground,
marginBottom: 24,
lineHeight: 20,
},
buttonContainer: {
flexDirection: 'row',
gap: 12,
},
button: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
backgroundColor: themeColors.primary,
alignItems: 'center',
},
buttonText: {
fontSize: 16,
fontWeight: '600',
color: themeColors.primaryForeground,
},
cancelButton: {
backgroundColor: themeColors.secondaryBackground,
borderWidth: 1,
borderColor: themeColors.border,
},
cancelButtonText: {
color: themeColors.foreground,
},
destructiveButton: {
backgroundColor: themeColors.destructiveBackground,
},
destructiveButtonText: {
color: themeColors.destructive,
},
});
}

View File

@@ -1,46 +0,0 @@
import type { PropsWithChildren } from 'react';
import { useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { IconSymbol } from '@/modules/ui/components/icon-symbol';
import { ThemedText } from '@/modules/ui/components/themed-text';
import { ThemedView } from '@/modules/ui/components/themed-view';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const colors = useThemeColor();
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen(value => !value)}
activeOpacity={0.8}
>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={colors.foreground}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}

View File

@@ -1,27 +0,0 @@
import type { Href } from 'expo-router';
import type { ComponentProps } from 'react';
import { Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { Platform } from 'react-native';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -1,19 +0,0 @@
import type { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
import { Platform } from 'react-native';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={async (ev) => {
if (Platform.OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@@ -1,20 +0,0 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}
>
👋
</Animated.Text>
);
}

View File

@@ -1,33 +0,0 @@
import type { SymbolViewProps, SymbolWeight } from 'expo-symbols';
import type { StyleProp, ViewStyle } from 'react-native';
import { SymbolView } from 'expo-symbols';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -1,41 +0,0 @@
// Fallback for using MaterialIcons on Android and web.
import type { SymbolViewProps, SymbolWeight } from 'expo-symbols';
import type { ComponentProps } from 'react';
import type { OpaqueColorValue, StyleProp, TextStyle } from 'react-native';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View File

@@ -1,3 +0,0 @@
import { Feather } from '@expo/vector-icons';
export const Icon = Feather;

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