Compare commits

...

42 Commits

Author SHA1 Message Date
Corentin Thomasset
f6eae043fa chore(release): update versions (#611)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-11-29 23:28:13 +01:00
Corentin Thomasset
e1b0555202 fix(changeset): use proper package for changeset (#663) 2025-11-29 22:25:22 +00:00
Corentin Thomasset
93517d0f13 chore(changesets): update changesets to minor for calver monthly bump (#662) 2025-11-29 23:16:02 +01:00
Corentin Thomasset
d967fa6cef test(documents): add test for uploading document to non-member organization (#661) 2025-11-29 22:10:49 +00:00
Bartek Kwiecien
9b43bafe33 fix(documents): user must be in org to upload (#660)
* fix(documents): user must be in org to upload

* chore(versioning): added changeset

Removed the possibility for unauthorized upload to another organization you're not a member of

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-11-29 22:52:49 +01:00
Corentin Thomasset
334fcbdee4 refactor(search): mutualize search function in dedicated fts5 repository (#658) 2025-11-29 00:04:30 +01:00
Corentin Thomasset
981731bbe5 refactor(server): use more performant custom uniq method instead of lodash (#656) 2025-11-25 21:09:44 +01:00
Corentin Thomasset
96403c0047 fix(server): use booleanish schema for forcePathStyle validation (#657)
* fix(server): use booleanish schema for forcePathStyle validation

* Update .changeset/yummy-tips-search.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-25 19:31:36 +00:00
Corentin Thomasset
08f4a1cd05 feat(server): use original destination addresses when available for intake emails (#655) 2025-11-25 00:14:46 +00:00
Corentin Thomasset
ca808064fa feat(server): add logging context for intake email ingestion (#653) 2025-11-24 17:19:30 +01:00
Corentin Thomasset
dc6ee5b228 refactor(search): mutualize the document search in adapter pattern (#650) 2025-11-20 23:38:58 +01:00
Corentin Thomasset
14071b0bc9 feat(apps): add mobile base boilerplate (#606) 2025-11-19 22:00:34 +01:00
Corentin Thomasset
ae3abe9ec7 feat(server): prevent certain emails domain from registering (#638)
* feat(server): prevent certain emails domain from registering

* refactor(client): improved api errors
2025-11-18 20:32:01 +01:00
Corentin Thomasset
479a603001 fix(tags): add text wrapping for long tag descriptions to prevent overflow (#637) 2025-11-17 10:13:42 +00:00
Corentin Thomasset
19f96a1625 feat(server): added global log context (#636) 2025-11-15 23:52:52 +01:00
Corentin Thomasset
a03eae79a0 chore(deps): update tsx dependency to version 4.20.6 (#635) 2025-11-15 02:15:42 +01:00
Corentin Thomasset
4bcfb878f1 chore(deps): update typescript version to ^5.9.3 in pnpm-workspace.yaml (#634) 2025-11-15 02:05:01 +01:00
Corentin Thomasset
d2676052c3 refactor(client): lazy load demo http client (#633) 2025-11-15 00:31:49 +01:00
Corentin Thomasset
ec33ae6294 refactor(auth): replace ts-pattern with solid-js Switch for navigation logic (#632) 2025-11-15 00:07:02 +01:00
Corentin Thomasset
432a192b94 feat(cli): paperless-ngx exports import command (#622) 2025-11-14 13:36:48 +01:00
Corentin Thomasset
98d272fb60 refactor(lecture): enhance logging details for image buffer conversion (#631) 2025-11-13 22:35:22 +00:00
Corentin Thomasset
1d20c0cfe3 feat(lecture): added global pdf ocr log (#630) 2025-11-13 20:56:16 +01:00
Corentin Thomasset
07a42da57a refactor(lecture): added page count in pdf extractor logs (#629) 2025-11-13 20:56:02 +01:00
Corentin Thomasset
9dee142948 feat(lecture): log in pdf extractor (#628) 2025-11-13 20:24:17 +01:00
Corentin Thomasset
5ccdf446f0 feat(extractors): add logger support to text extraction functions (#627) 2025-11-13 17:41:22 +00:00
Corentin Thomasset
11ad13058e feat(server): install tesseract cli in production image (#626) 2025-11-13 18:23:54 +01:00
Corentin Thomasset
ee9eff4914 feat(logging): add logging context for API key and session authentication (#625) 2025-11-13 16:30:10 +00:00
Corentin Thomasset
499b2cdba7 refactor(client): added eslint solid rules (#624) 2025-11-13 03:29:58 +01:00
Corentin Thomasset
b0877645a8 fix(errors): enhance isUniqueConstraintError to handle hosted libsql dbs (#623) 2025-11-12 01:53:54 +01:00
Corentin Thomasset
8308e93fdf feat(lecture): add support for native Tesseract CLI extraction (#621) 2025-11-11 16:59:33 +01:00
Corentin Thomasset
1dce0ace41 feat(i18n): add tables pagination translations (#620) 2025-11-09 21:18:09 +00:00
Corentin Thomasset
868281bcff fix(i18n): added translations for document table headers (#618)
* fix documents table headers not being translated

* fix docuement list again

* Update documents-list.component.tsx

* fix(documents): update table header visibility and alignment

* feat(locales): add table header translations for multiple languages

* chore(changeset): document header localization

---------

Co-authored-by: iRazz <hi@irazz.lol>
Co-authored-by: Razvan M. <76774976+iRazvan2745@users.noreply.github.com>
2025-11-09 22:01:33 +01:00
Corentin Thomasset
5b5ce85061 feat(client): limit concurrent upload (#619) 2025-11-09 21:33:23 +01:00
Corentin Thomasset
157a5cadd1 fix(deps): removed unnecessary packages locks (#617) 2025-11-09 16:55:42 +00:00
Corentin Thomasset
1922f24c0a feat(node): switched to node v24 (#616) 2025-11-09 17:52:20 +01:00
Corentin Thomasset
7ac06a0649 docs(readme): include CadenceMQ and change backend to Fly.io (#615)
* docs(readme): include CadenceMQ and change backend to Fly.io

* Update README.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-08 22:40:57 +00:00
Corentin Thomasset
c150e231aa feat(documents): improve logging for text extraction errors (#614) 2025-11-08 21:53:55 +00:00
Corentin Thomasset
0c235031d2 feat(documents): include mimeType in document creation log (#613) 2025-11-08 21:25:14 +00:00
Corentin Thomasset
8a7c1c8368 test(organizations): un-order logs testing (#612) 2025-11-07 22:42:59 +00:00
Daniel Barenholz
cb1f1b5b01 feat(tags): allow clicking on tags in tags page (#609)
* feat: Allow clicking on tags in Tags page

In the Home and Documents pages one can click on a particular tag that a
document has to search for all documents with that tag, but in the Tags
page that functionality is missing. This commit replaces the `<Tag>`
with a `<TagLink>`, so that one can click on it to initiate a search.

* Enable tag clicks on Tags page

Made the tags clickable in the tag list

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-11-07 21:34:26 +01:00
Daniel Barenholz
abc463f751 feat(client): added Dutch translation (#607)
* feat: Add Dutch translation

This commit adds a Dutch translation by providing a `nl.dictionary.ts`
dictionary file, and adding itself to the i18n constants.

* fix: Add missing type to translations constant

This commit adds the missing type to the translations constant. This
type is not present on the English dictionary, but is present in all
others, and thus should also be present for the Dutch translation.

* fix: Make the linter happy

This commit makes the linter happy so that the PR can land.

* Update Dutch translation in thick-panthers-wash

---------

Co-authored-by: Corentin Thomasset <corentin.thomasset74@gmail.com>
2025-11-07 20:20:10 +00:00
Daniel Barenholz
8edfd48ceb fix(demo) typo in English demo popup description (#608)
This commit fixes a typo I happened to see whilst creating the Dutch translations.
2025-11-07 20:33:35 +01:00
242 changed files with 16776 additions and 8589 deletions

View File

@@ -12,14 +12,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm i
@@ -44,4 +44,4 @@ jobs:
run: pnpm -r --parallel -F "./apps/*" build
- 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

@@ -25,7 +25,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 22
node-version: 24
cache: "pnpm"
# Ensure npm 11.5.1 or later is installed

2
.nvmrc
View File

@@ -1 +1 @@
22
24

View File

@@ -118,6 +118,7 @@ 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.
- **[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.
- **[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)**
- **Documentation**
- **[Astro](https://astro.build)**: A great static site generator.
@@ -128,7 +129,7 @@ Papra would not have been possible without the following open-source projects:
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
- **Infrastructure**
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
- **[Render](https://render.com/)**: For backend hosting.
- **[Fly.io](https://fly.io/)**: For backend hosting.
- **[Turso](https://turso.tech/)**: For production database.
### Inspiration

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@
"figue": "^3.1.1",
"lodash-es": "^4.17.21",
"marked": "^15.0.6",
"typescript": "^5.7.3",
"typescript": "catalog:",
"unocss": "0.65.0-beta.2",
"vitest": "catalog:"
}

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
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.
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.
```bash
# Clone the repository

View File

@@ -201,7 +201,10 @@ Search documents in the organization by name or content.
- `pageIndex`: (optional, default: 0) The page index to start from.
- `pageSize`: (optional, default: 100) The number of documents to return.
- Response (JSON)
- `documents`: The list of documents.
- `searchResults`: The search results.
- `documents`: The list of matching documents.
- `id`: The document ID.
- `name`: The document name.
### Get organization documents statistics

43
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# 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

5
apps/mobile/README.md Normal file
View File

@@ -0,0 +1,5 @@
# 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.

55
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,55 @@
{
"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

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,5 @@
// 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

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

View File

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

View File

@@ -0,0 +1,13 @@
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

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

View File

@@ -0,0 +1,18 @@
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

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

View File

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

View File

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,25 @@
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

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

28
apps/mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,28 @@
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()}
</>
);
}

29
apps/mobile/app/modal.tsx Normal file
View File

@@ -0,0 +1,29 @@
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>
);
}

21
apps/mobile/eas.json Normal file
View File

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

View File

@@ -0,0 +1,29 @@
import antfu from '@antfu/eslint-config';
export default antfu({
typescript: {
tsconfigPath: './tsconfig.json',
overridesTypeAware: {
'ts/no-misused-promises': ['error', { checksVoidReturn: false }],
'ts/strict-boolean-expressions': ['error', { allowNullableObject: true }],
},
},
stylistic: {
semi: true,
},
rules: {
// To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
'curly': ['error', 'all'],
'vitest/consistent-test-it': ['error', { fn: 'test' }],
'ts/consistent-type-definitions': ['error', 'type'],
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'unused-imports/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
},
});

View File

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

65
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,65 @@
{
"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.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,69 @@
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

@@ -0,0 +1,24 @@
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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,346 @@
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

@@ -0,0 +1,293 @@
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

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

View File

@@ -0,0 +1,9 @@
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

@@ -0,0 +1,44 @@
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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,238 @@
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

@@ -0,0 +1,233 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,74 @@
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

@@ -0,0 +1,14 @@
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

@@ -0,0 +1,240 @@
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

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

View File

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

View File

@@ -0,0 +1,8 @@
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

@@ -0,0 +1,62 @@
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

@@ -0,0 +1,175 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,102 @@
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

@@ -0,0 +1,24 @@
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

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

View File

@@ -0,0 +1,175 @@
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

@@ -0,0 +1,146 @@
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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,27 @@
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

@@ -0,0 +1,19 @@
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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,33 @@
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

@@ -0,0 +1,41 @@
// 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

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

View File

@@ -0,0 +1,29 @@
import * as Haptics from 'expo-haptics';
import { useState } from 'react';
import { ImportDrawer } from '@/modules/documents/components/import-drawer';
import { Icon } from '@/modules/ui/components/icon';
import { useThemeColor } from '../providers/use-theme-color';
import { HapticTab } from './haptic-tab';
export function ImportTabButton() {
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
const themeColors = useThemeColor();
const handlePress = () => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
setIsDrawerVisible(true);
};
return (
<>
<HapticTab onPress={handlePress} style={{ flex: 1, alignItems: 'center', padding: 5 }}>
<Icon name="plus" size={30} style={{ height: 30 }} color={themeColors.mutedForeground} />
</HapticTab>
<ImportDrawer
visible={isDrawerVisible}
onClose={() => setIsDrawerVisible(false)}
/>
</>
);
}

View File

@@ -0,0 +1,81 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/modules/ui/components/themed-view';
import { useColorScheme } from '@/modules/ui/providers/use-color-scheme';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
const HEADER_HEIGHT = 250;
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const colors = useThemeColor();
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor: colors.background, flex: 1 }}
scrollEventThrottle={16}
>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}

View File

@@ -0,0 +1,52 @@
import type { TextProps } from 'react-native';
import { StyleSheet, Text } from 'react-native';
export type ThemedTextProps = TextProps & {
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});
export function ThemedText({
style,
type = 'default',
...rest
}: ThemedTextProps) {
return (
<Text
style={[
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}

View File

@@ -0,0 +1,15 @@
import type { ViewProps } from 'react-native';
import { View } from 'react-native';
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, ...otherProps }: ThemedViewProps) {
const theme = useThemeColor();
return <View style={[{ backgroundColor: theme.background }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useState } from 'react';
import { AlertDialog } from '@/modules/ui/components/alert-dialog';
type AlertButton = {
text: string;
onPress?: () => void;
style?: 'default' | 'cancel' | 'destructive';
};
type AlertOptions = {
title: string;
message?: string | ReactNode;
buttons?: AlertButton[];
};
type AlertContextType = {
showAlert: (options: AlertOptions) => void;
};
const AlertContext = createContext<AlertContextType | null>(null);
export function AlertProvider({ children }: { children: ReactNode }) {
const [alertState, setAlertState] = useState<AlertOptions & { visible: boolean }>({
visible: false,
title: '',
message: '',
buttons: [],
});
const showAlert = (options: AlertOptions) => {
setAlertState({
visible: true,
title: options.title,
message: options.message,
buttons: options.buttons ?? [{ text: 'OK', style: 'default' }],
});
};
const handleDismiss = () => {
setAlertState(prev => ({ ...prev, visible: false }));
};
return (
<AlertContext.Provider value={{ showAlert }}>
{children}
<AlertDialog
visible={alertState.visible}
title={alertState.title}
message={alertState.message}
buttons={alertState.buttons ?? []}
onDismiss={handleDismiss}
/>
</AlertContext.Provider>
);
}
export function useAlert() {
const context = useContext(AlertContext);
if (!context) {
throw new Error('useAlert must be used within an AlertProvider');
}
return context;
}

View File

@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -0,0 +1,14 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import type { ThemeColors } from '../theme.constants';
import { colors } from '../theme.constants';
import { useColorScheme } from './use-color-scheme';
export function useThemeColor(): ThemeColors {
const theme = useColorScheme() ?? 'light';
return colors[theme];
}

View File

@@ -0,0 +1,64 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const lightColors = {
foreground: '#0a0a0a',
background: '#fafafa',
primary: '#fe7d4d',
primaryForeground: '#0a0a0a',
muted: '#f3f3f3',
mutedForeground: '#737373',
border: '#e5e5e5',
secondaryBackground: '#f3f3f3',
destructive: '#d32f2f',
destructiveBackground: '#ffe0e0',
};
const darkColors: ThemeColors = {
foreground: '#fafafa',
background: '#141414',
primary: '#d9ff7a',
primaryForeground: '#0a0a0a',
muted: '#262626',
mutedForeground: '#a3a3a3',
border: '#262626',
secondaryBackground: '#111111',
destructive: '#ff6b6b',
destructiveBackground: '#2a1a1a',
};
export type ThemeColors = typeof lightColors;
export const colors = {
light: lightColors,
dark: darkColors,
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: 'system-ui, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Helvetica, Arial, sans-serif',
serif: 'Georgia, \'Times New Roman\', serif',
rounded: '\'SF Pro Rounded\', \'Hiragino Maru Gothic ProN\', Meiryo, \'MS PGothic\', sans-serif',
mono: 'SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace',
},
});

View File

@@ -0,0 +1,149 @@
import type { ThemeColors } from '@/modules/ui/theme.constants';
import { useRouter } from 'expo-router';
import {
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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';
export default function SettingsScreen() {
const router = useRouter();
const themeColors = useThemeColor();
const authClient = useAuthClient();
const session = authClient.useSession();
const { showAlert } = useAlert();
const handleSignOut = () => {
showAlert({
title: 'Sign Out',
message: 'Are you sure you want to sign out?',
buttons: [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: async () => {
await authClient.signOut();
router.replace('/auth/login');
},
},
],
});
};
const styles = createStyles({ themeColors });
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Settings</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
{session.data?.user && (
<>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Name</Text>
<Text style={styles.infoValue}>{session.data.user.name}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Email</Text>
<Text style={styles.infoValue}>{session.data.user.email}</Text>
</View>
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>Email Verified</Text>
<Text style={styles.infoValue}>
{session.data.user.emailVerified ? 'Yes' : 'No'}
</Text>
</View>
</>
)}
</View>
<View style={styles.section}>
<TouchableOpacity
style={[styles.actionButton, styles.dangerButton]}
onPress={handleSignOut}
>
<Text style={[styles.actionButtonText, styles.dangerText]}>
Sign Out
</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: themeColors.background,
},
header: {
padding: 24,
paddingBottom: 16,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: themeColors.foreground,
},
section: {
marginBottom: 24,
paddingHorizontal: 16,
},
sectionTitle: {
fontSize: 14,
fontWeight: '600',
color: themeColors.mutedForeground,
textTransform: 'uppercase',
marginBottom: 12,
paddingHorizontal: 8,
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 8,
marginBottom: 8,
},
infoLabel: {
fontSize: 14,
color: themeColors.mutedForeground,
},
infoValue: {
fontSize: 14,
fontWeight: '500',
color: themeColors.foreground,
},
actionButton: {
paddingVertical: 14,
paddingHorizontal: 16,
backgroundColor: themeColors.secondaryBackground,
borderRadius: 8,
marginBottom: 8,
},
actionButtonText: {
fontSize: 16,
fontWeight: '500',
color: themeColors.foreground,
textAlign: 'center',
},
dangerButton: {
backgroundColor: themeColors.destructiveBackground,
},
dangerText: {
color: themeColors.destructive,
},
});
}

17
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUncheckedIndexedAccess": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
env: {
TZ: 'UTC',
},
},
});

View File

@@ -1 +1 @@
22
24

View File

@@ -5,6 +5,8 @@ export default antfu({
semi: true,
},
solid: true,
ignores: [
'public/manifest.json',
],

View File

@@ -11,7 +11,7 @@
"url": "https://github.com/papra-hq/papra"
},
"engines": {
"node": ">=22.0.0"
"node": ">=24.0.0"
},
"scripts": {
"start": "vite",
@@ -42,12 +42,12 @@
"clsx": "^2.1.1",
"cmdk-solid": "^1.1.2",
"ofetch": "^1.4.1",
"p-limit": "^6.2.0",
"posthog-js-lite": "^4.1.5",
"radix3": "^1.1.2",
"solid-js": "^1.9.9",
"solid-sonner": "^0.2.8",
"tailwind-merge": "^2.6.0",
"ts-pattern": "^5.7.1",
"unocss-preset-animations": "^1.3.0",
"unstorage": "^1.16.0",
"valibot": "1.0.0-beta.10"
@@ -58,8 +58,9 @@
"@playwright/test": "^1.53.1",
"@types/node": "catalog:",
"eslint": "catalog:",
"eslint-plugin-solid": "^0.14.5",
"tinyglobby": "^0.2.14",
"tsx": "^4.20.3",
"tsx": "catalog:",
"typescript": "catalog:",
"unocss": "^66.5.4",
"vite": "^7.1.9",

View File

@@ -5,7 +5,8 @@ import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/colo
import { Router } from '@solidjs/router';
import { QueryClientProvider } from '@tanstack/solid-query';
import { render, Suspense } from 'solid-js/web';
import { Suspense } from 'solid-js';
import { render } from 'solid-js/web';
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
import { ConfigProvider } from './modules/config/config.provider';
import { DemoIndicator } from './modules/demo/demo.provider';

View File

@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.list.no-documents.title': 'Keine Dokumente',
'documents.list.no-documents.description': 'Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.',
'documents.list.no-results': 'Keine Dokumente gefunden',
'documents.list.table.headers.file-name': 'Dateiname',
'documents.list.table.headers.created': 'Erstellt am',
'documents.list.table.headers.deleted': 'Gelöscht am',
'documents.list.table.headers.actions': 'Aktionen',
'documents.list.table.headers.tags': 'Tags',
'documents.tabs.info': 'Info',
'documents.tabs.content': 'Inhalt',
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
// Common / Shared
'common.confirm-modal.type-to-confirm': 'Geben Sie "{{ text }}" ein zur Bestätigung',
'common.tables.rows-per-page': 'Zeilen pro Seite',
'common.tables.pagination-info': 'Seite {{ currentPage }} von {{ totalPages }}',
};

View File

@@ -247,6 +247,11 @@ export const translations = {
'documents.list.no-documents.title': 'No documents',
'documents.list.no-documents.description': 'There are no documents in this organization yet. Start by uploading some documents.',
'documents.list.no-results': 'No documents found',
'documents.list.table.headers.file-name': 'File name',
'documents.list.table.headers.created': 'Created at',
'documents.list.table.headers.deleted': 'Deleted at',
'documents.list.table.headers.actions': 'Actions',
'documents.list.table.headers.tags': 'Tags',
'documents.tabs.info': 'Info',
'documents.tabs.content': 'Content',
@@ -642,7 +647,7 @@ export const translations = {
// Demo
'demo.popup.description': 'This is a demo environment, all data is save to your browser local storage.',
'demo.popup.description': 'This is a demo environment, all data is saved to your browser local storage.',
'demo.popup.discord': 'Join the {{ discordLink }} to get support, propose features or just chat.',
'demo.popup.discord-link-label': 'Discord server',
'demo.popup.reset': 'Reset demo data',
@@ -708,4 +713,6 @@ export const translations = {
// Common / Shared
'common.confirm-modal.type-to-confirm': 'Type "{{ text }}" to confirm',
'common.tables.rows-per-page': 'Rows per page',
'common.tables.pagination-info': 'Page {{ currentPage }} of {{ totalPages }}',
} as const;

View File

@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.list.no-documents.title': 'Sin documentos',
'documents.list.no-documents.description': 'Aún no hay documentos en esta organización. Comienza subiendo algunos documentos.',
'documents.list.no-results': 'No se encontraron documentos',
'documents.list.table.headers.file-name': 'Nombre de archivo',
'documents.list.table.headers.created': 'Creado el',
'documents.list.table.headers.deleted': 'Eliminado el',
'documents.list.table.headers.actions': 'Acciones',
'documents.list.table.headers.tags': 'Etiquetas',
'documents.tabs.info': 'Información',
'documents.tabs.content': 'Contenido',
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
// Common / Shared
'common.confirm-modal.type-to-confirm': 'Escriba "{{ text }}" para confirmar',
'common.tables.rows-per-page': 'Filas por página',
'common.tables.pagination-info': 'Página {{ currentPage }} de {{ totalPages }}',
};

View File

@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.list.no-documents.title': 'Aucun document',
'documents.list.no-documents.description': 'Il n\'y a pas de documents dans cette organisation. Commencez par télécharger des documents.',
'documents.list.no-results': 'Aucun document trouvé',
'documents.list.table.headers.file-name': 'Nom du fichier',
'documents.list.table.headers.created': 'Créé le',
'documents.list.table.headers.deleted': 'Supprimé le',
'documents.list.table.headers.actions': 'Actions',
'documents.list.table.headers.tags': 'Étiquettes',
'documents.tabs.info': 'Info',
'documents.tabs.content': 'Contenu',
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
// Common / Shared
'common.confirm-modal.type-to-confirm': 'Saisissez "{{ text }}" pour confirmer',
'common.tables.rows-per-page': 'Lignes par page',
'common.tables.pagination-info': 'Page {{ currentPage }} sur {{ totalPages }}',
};

View File

@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
'documents.list.no-documents.title': 'Nessun documento',
'documents.list.no-documents.description': 'Non ci sono ancora documenti in questa organizzazione. Inizia caricando dei documenti.',
'documents.list.no-results': 'Nessun documento trovato',
'documents.list.table.headers.file-name': 'Nome file',
'documents.list.table.headers.created': 'Creato il',
'documents.list.table.headers.deleted': 'Eliminato il',
'documents.list.table.headers.actions': 'Azioni',
'documents.list.table.headers.tags': 'Tag',
'documents.tabs.info': 'Info',
'documents.tabs.content': 'Contenuto',
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
// Common / Shared
'common.confirm-modal.type-to-confirm': 'Digita "{{ text }}" per confermare',
'common.tables.rows-per-page': 'Righe per pagina',
'common.tables.pagination-info': 'Pagina {{ currentPage }} di {{ totalPages }}',
};

View File

@@ -0,0 +1,720 @@
import type { TranslationsDictionary } from '@/modules/i18n/locales.types';
export const translations: Partial<TranslationsDictionary> = {
// Authentication
'auth.request-password-reset.title': 'Wachtwoord opnieuw instellen',
'auth.request-password-reset.description': 'Voer uw e-mailadres in om uw wachtwoord opnieuw in te stellen.',
'auth.request-password-reset.requested': 'Als er een account bestaat voor dit e-mailadres, hebben we u een e-mail gestuurd om uw wachtwoord opnieuw in te stellen.',
'auth.request-password-reset.back-to-login': 'Terug naar inloggen',
'auth.request-password-reset.form.email.label': 'E-mail',
'auth.request-password-reset.form.email.placeholder': 'Voorbeeld: ada@papra.app',
'auth.request-password-reset.form.email.required': 'Voer uw e-mailadres in',
'auth.request-password-reset.form.email.invalid': 'Dit e-mailadres is ongeldig',
'auth.request-password-reset.form.submit': 'Wachtwoord opnieuw instellen',
'auth.reset-password.title': 'Wachtwoord opnieuw instellen',
'auth.reset-password.description': 'Voer uw nieuwe wachtwoord in om uw wachtwoord opnieuw in te stellen.',
'auth.reset-password.reset': 'Uw wachtwoord is opnieuw ingesteld.',
'auth.reset-password.back-to-login': 'Terug naar inloggen',
'auth.reset-password.form.new-password.label': 'Nieuw wachtwoord',
'auth.reset-password.form.new-password.placeholder': 'Voorbeeld: **********',
'auth.reset-password.form.new-password.required': 'Voer uw nieuwe wachtwoord in',
'auth.reset-password.form.new-password.min-length': 'Wachtwoord moet minimaal {{ minLength }} tekens bevatten',
'auth.reset-password.form.new-password.max-length': 'Wachtwoord mag maximaal {{ maxLength }} tekens bevatten',
'auth.reset-password.form.submit': 'Wachtwoord opnieuw instellen',
'auth.email-provider.open': 'Open {{ provider }}',
'auth.login.title': 'Inloggen bij Papra',
'auth.login.description': 'Voer uw e-mailadres in of gebruik sociale media om toegang te krijgen tot uw Papra-account.',
'auth.login.login-with-provider': 'Inloggen met {{ provider }}',
'auth.login.no-account': 'Nog geen account?',
'auth.login.register': 'Registreren',
'auth.login.form.email.label': 'E-mail',
'auth.login.form.email.placeholder': 'Voorbeeld: ada@papra.app',
'auth.login.form.email.required': 'Voer uw e-mailadres in',
'auth.login.form.email.invalid': 'Dit e-mailadres is ongeldig',
'auth.login.form.password.label': 'Wachtwoord',
'auth.login.form.password.placeholder': 'Voer een wachtwoord in',
'auth.login.form.password.required': 'Voer uw wachtwoord in',
'auth.login.form.remember-me.label': 'Onthoud mij',
'auth.login.form.forgot-password.label': 'Wachtwoord vergeten?',
'auth.login.form.submit': 'Inloggen',
'auth.register.title': 'Registreren bij Papra',
'auth.register.description': 'Maak een account aan om Papra te gebruiken.',
'auth.register.register-with-email': 'Registreren met e-mail',
'auth.register.register-with-provider': 'Registreren met {{ provider }}',
'auth.register.providers.google': 'Google',
'auth.register.providers.github': 'GitHub',
'auth.register.have-account': 'Heeft u al een account?',
'auth.register.login': 'Inloggen',
'auth.register.registration-disabled.title': 'Registratie is uitgeschakeld',
'auth.register.registration-disabled.description': 'Het aanmaken van nieuwe accounts is momenteel uitgeschakeld op deze Papra-instantie. Alleen gebruikers met bestaande accounts kunnen inloggen. Als u denkt dat dit een vergissing is, neem dan contact op met de beheerder van deze instantie.',
'auth.register.form.email.label': 'E-mail',
'auth.register.form.email.placeholder': 'Voorbeeld: ada@papra.app',
'auth.register.form.email.required': 'Voer uw e-mailadres in',
'auth.register.form.email.invalid': 'Dit e-mailadres is ongeldig',
'auth.register.form.password.label': 'Wachtwoord',
'auth.register.form.password.placeholder': 'Kies een wachtwoord',
'auth.register.form.password.required': 'Voer uw wachtwoord in',
'auth.register.form.password.min-length': 'Wachtwoord moet minimaal {{ minLength }} tekens bevatten',
'auth.register.form.password.max-length': 'Wachtwoord mag maximaal {{ maxLength }} tekens bevatten',
'auth.register.form.name.label': 'Naam',
'auth.register.form.name.placeholder': 'Voorbeeld: Ada Lovelace',
'auth.register.form.name.required': 'Voer uw naam in',
'auth.register.form.name.max-length': 'Naam mag maximaal {{ maxLength }} tekens bevatten',
'auth.register.form.submit': 'Registreren',
'auth.email-validation-required.title': 'Bevestig uw e-mail',
'auth.email-validation-required.description': 'We hebben een e-mail naar u gestuurd. Bevestig uw e-mailadres door op de link in de e-mail te klikken.',
'auth.email-verification.success.title': 'E-mail bevestigd',
'auth.email-verification.success.description': 'Uw e-mail is succesvol bevestigd. U kunt nu inloggen op uw account.',
'auth.email-verification.success.login': 'Ga naar inloggen',
'auth.email-verification.error.title': 'Bevestiging mislukt',
'auth.email-verification.error.description': 'De bevestigingslink is verlopen of ongeldig. Vraag een nieuwe e-mail aan door in te loggen.',
'auth.email-verification.error.back': 'Terug naar inloggen',
'auth.legal-links.description': 'Door door te gaan erkent u dat u de {{ terms }} en {{ privacy }} begrijpt en ermee instemt.',
'auth.legal-links.terms': 'Servicevoorwaarden',
'auth.legal-links.privacy': 'Privacybeleid',
'auth.no-auth-provider.title': 'Geen authenticatieprovider',
'auth.no-auth-provider.description': 'Er zijn geen authenticatieproviders ingeschakeld op deze Papra-instantie. Neem contact op met de beheerder om deze in te schakelen.',
// User settings
'user.settings.title': 'Gebruikersinstellingen',
'user.settings.description': 'Beheer hier uw accountinstellingen.',
'user.settings.email.title': 'E-mailadres',
'user.settings.email.description': 'Uw e-mailadres kan niet worden gewijzigd.',
'user.settings.email.label': 'E-mailadres',
'user.settings.name.title': 'Volledige naam',
'user.settings.name.description': 'Uw volledige naam wordt weergegeven aan andere organisatiedeelnemers.',
'user.settings.name.label': 'Volledige naam',
'user.settings.name.placeholder': 'Bijv. John Doe',
'user.settings.name.update': 'Naam bijwerken',
'user.settings.name.updated': 'Uw volledige naam is bijgewerkt',
'user.settings.logout.title': 'Uitloggen',
'user.settings.logout.description': 'Log uit van uw account. U kunt later weer inloggen.',
'user.settings.logout.button': 'Uitloggen',
// Organizations
'organizations.list.title': 'Uw organisaties',
'organizations.list.description': 'Organisaties zijn een manier om uw documenten te groeperen en toegang te beheren. U kunt meerdere organisaties aanmaken en uw teamleden uitnodigen om samen te werken.',
'organizations.list.create-new': 'Nieuwe organisatie aanmaken',
'organizations.list.back': 'Terug naar organisaties',
'organizations.list.deleted.title': 'Verwijderde organisaties',
'organizations.list.deleted.description': 'Verwijderde organisaties worden nog {{ days }} dagen bewaard voordat ze permanent worden verwijderd. U kunt ze in deze periode herstellen.',
'organizations.list.deleted.empty': 'Geen verwijderde organisaties',
'organizations.list.deleted.empty-description': 'Wanneer u een organisatie verwijdert, verschijnt deze hier gedurende {{ days }} dagen voordat deze permanent wordt verwijderd.',
'organizations.list.deleted.restore': 'Herstellen',
'organizations.list.deleted.restore-success': 'Organisatie succesvol hersteld',
'organizations.list.deleted.restore-confirm.title': 'Organisatie herstellen',
'organizations.list.deleted.restore-confirm.message': 'Weet u zeker dat u deze organisatie wilt herstellen? Deze wordt teruggezet naar uw lijst met actieve organisaties.',
'organizations.list.deleted.restore-confirm.confirm-button': 'Organisatie herstellen',
'organizations.list.deleted.deleted-at': 'Verwijderd {{ date }}',
'organizations.list.deleted.purge-at': 'Wordt permanent verwijderd op {{ date }}',
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dag, {daysUntilPurge} dagen }} resterend)',
'organizations.details.no-documents.title': 'Geen documenten',
'organizations.details.no-documents.description': 'Er staan nog geen documenten in deze organisatie. Begin met het uploaden van documenten.',
'organizations.details.upload-documents': 'Documenten uploaden',
'organizations.details.documents-count': 'documenten in totaal',
'organizations.details.total-size': 'totale grootte',
'organizations.details.latest-documents': 'Laatst geïmporteerde documenten',
'organizations.create.title': 'Maak een nieuwe organisatie',
'organizations.create.description': 'Uw documenten worden per organisatie gegroepeerd. U kunt meerdere organisaties aanmaken om uw documenten te scheiden, bijvoorbeeld privé en werk.',
'organizations.create.back': 'Terug',
'organizations.create.error.max-count-reached': 'U heeft het maximale aantal organisaties bereikt dat u kunt aanmaken. Neem contact op met support als u meer nodig heeft.',
'organizations.create.form.name.label': 'Organisatienaam',
'organizations.create.form.name.placeholder': 'Bijv. Acme Inc.',
'organizations.create.form.name.required': 'Voer een organisatienaam in',
'organizations.create.form.submit': 'Organisatie aanmaken',
'organizations.create.success': 'Organisatie succesvol aangemaakt',
'organizations.create-first.title': 'Maak uw organisatie',
'organizations.create-first.description': 'Uw documenten worden per organisatie gegroepeerd. U kunt meerdere organisaties aanmaken om uw documenten te scheiden, bijvoorbeeld privé en werk.',
'organizations.create-first.default-name': 'Mijn organisatie',
'organizations.create-first.user-name': '{{ name }}\'s organisatie',
'organization.settings.title': 'Organisatie-instellingen',
'organization.settings.page.title': 'Organisatie-instellingen',
'organization.settings.page.description': 'Beheer hier uw organisatie-instellingen.',
'organization.settings.name.title': 'Organisatienaam',
'organization.settings.name.update': 'Naam bijwerken',
'organization.settings.name.placeholder': 'Bijv. Acme Inc.',
'organization.settings.name.updated': 'Organisatienaam bijgewerkt',
'organization.settings.subscription.title': 'Abonnement',
'organization.settings.subscription.description': 'Beheer uw facturering, facturen en betaalmethoden.',
'organization.settings.subscription.manage': 'Abonnement beheren',
'organization.settings.subscription.error': 'Kan customer portal-URL niet ophalen',
'organization.settings.delete.title': 'Organisatie verwijderen',
'organization.settings.delete.description': 'Het verwijderen van deze organisatie zal alle bijbehorende gegevens permanent verwijderen.',
'organization.settings.delete.confirm.title': 'Organisatie verwijderen',
'organization.settings.delete.confirm.message': 'Weet u zeker dat u deze organisatie wilt verwijderen? De organisatie wordt gemarkeerd voor verwijdering en na {{ days }} dagen permanent verwijderd. Tijdens deze periode kunt u deze herstellen vanuit uw organisatieslijst. Alle documenten en gegevens worden na deze termijn permanent verwijderd.',
'organization.settings.delete.confirm.confirm-button': 'Organisatie verwijderen',
'organization.settings.delete.confirm.cancel-button': 'Annuleren',
'organization.settings.delete.success': 'Organisatie verwijderd',
'organization.settings.delete.only-owner': 'Alleen de eigenaar van de organisatie kan deze verwijderen.',
'organization.settings.delete.has-active-subscription': 'Kan organisatie met een actief abonnement niet verwijderen. Annuleer eerst uw abonnement hierboven.',
'organization.usage.page.title': 'Gebruik',
'organization.usage.page.description': 'Bekijk het huidige gebruik en de limieten van uw organisatie.',
'organization.usage.storage.title': 'Documentopslag',
'organization.usage.storage.description': 'Totale opslag die door uw documenten wordt gebruikt',
'organization.usage.intake-emails.title': 'Intake-e-mails',
'organization.usage.intake-emails.description': 'Aantal intake-e-mailadressen',
'organization.usage.members.title': 'Leden',
'organization.usage.members.description': 'Aantal leden in de organisatie',
'organization.usage.unlimited': 'Onbeperkt',
'organizations.members.title': 'Leden',
'organizations.members.description': 'Beheer de leden van uw organisatie',
'organizations.members.invite-member': 'Lid uitnodigen',
'organizations.members.invite-member-disabled-tooltip': 'Alleen beheerders of eigenaren kunnen leden uitnodigen voor de organisatie',
'organizations.members.remove-from-organization': 'Verwijderen uit organisatie',
'organizations.members.role': 'Rol',
'organizations.members.roles.owner': 'Eigenaar',
'organizations.members.roles.admin': 'Beheerder',
'organizations.members.roles.member': 'Lid',
'organizations.members.delete.confirm.title': 'Lid verwijderen',
'organizations.members.delete.confirm.message': 'Weet u zeker dat u dit lid uit de organisatie wilt verwijderen?',
'organizations.members.delete.confirm.confirm-button': 'Verwijderen',
'organizations.members.delete.confirm.cancel-button': 'Annuleren',
'organizations.members.delete.success': 'Lid verwijderd uit organisatie',
'organizations.members.update-role.success': 'Rol van lid bijgewerkt',
'organizations.members.table.headers.name': 'Naam',
'organizations.members.table.headers.email': 'E-mail',
'organizations.members.table.headers.role': 'Rol',
'organizations.members.table.headers.created': 'Aangemaakt',
'organizations.members.table.headers.actions': 'Acties',
'organizations.invite-member.title': 'Lid uitnodigen',
'organizations.invite-member.description': 'Nodig een lid uit voor uw organisatie',
'organizations.invite-member.form.email.label': 'E-mail',
'organizations.invite-member.form.email.placeholder': 'Voorbeeld: ada@papra.app',
'organizations.invite-member.form.email.required': 'Voer een geldig e-mailadres in',
'organizations.invite-member.form.role.label': 'Rol',
'organizations.invite-member.form.submit': 'Uitnodigen voor organisatie',
'organizations.invite-member.success.message': 'Lid uitgenodigd',
'organizations.invite-member.success.description': 'Het e-mailadres is uitgenodigd voor de organisatie.',
'organizations.invite-member.error.message': 'Uitnodiging mislukt',
'organizations.invitations.title': 'Uitnodigingen',
'organizations.invitations.description': 'Beheer uitnodigingen voor uw organisatie',
'organizations.invitations.list.cta': 'Lid uitnodigen',
'organizations.invitations.list.empty.title': 'Geen openstaande uitnodigingen',
'organizations.invitations.list.empty.description': 'U bent nog voor geen organisaties uitgenodigd.',
'organizations.invitations.status.pending': 'In afwachting',
'organizations.invitations.status.accepted': 'Geaccepteerd',
'organizations.invitations.status.rejected': 'Geweigerd',
'organizations.invitations.status.expired': 'Verlopen',
'organizations.invitations.status.cancelled': 'Geannuleerd',
'organizations.invitations.resend': 'Uitnodiging opnieuw verzenden',
'organizations.invitations.cancel.title': 'Uitnodiging annuleren',
'organizations.invitations.cancel.description': 'Weet u zeker dat u deze uitnodiging wilt annuleren?',
'organizations.invitations.cancel.confirm': 'Uitnodiging annuleren',
'organizations.invitations.cancel.cancel': 'Annuleren',
'organizations.invitations.resend.title': 'Uitnodiging opnieuw verzenden',
'organizations.invitations.resend.description': 'Weet u zeker dat u deze uitnodiging opnieuw wilt verzenden? Er wordt dan een nieuwe e-mail naar de ontvanger gestuurd.',
'organizations.invitations.resend.confirm': 'Opnieuw verzenden',
'organizations.invitations.resend.cancel': 'Annuleren',
'invitations.list.title': 'Uitnodigingen',
'invitations.list.description': 'Beheer uw organisatie-uitnodigingen',
'invitations.list.empty.title': 'Geen openstaande uitnodigingen',
'invitations.list.empty.description': 'U bent nog door geen organisaties uitgenodigd.',
'invitations.list.headers.organization': 'Organisatie',
'invitations.list.headers.status': 'Status',
'invitations.list.headers.created': 'Aangemaakt',
'invitations.list.headers.actions': 'Acties',
'invitations.list.actions.accept': 'Accepteer',
'invitations.list.actions.reject': 'Weiger',
'invitations.list.actions.accept.success.message': 'Uitnodiging geaccepteerd',
'invitations.list.actions.accept.success.description': 'De uitnodiging is geaccepteerd.',
'invitations.list.actions.reject.success.message': 'Uitnodiging geweigerd',
'invitations.list.actions.reject.success.description': 'De uitnodiging is geweigerd.',
// Documents
'documents.list.title': 'Documenten',
'documents.list.no-documents.title': 'Geen documenten',
'documents.list.no-documents.description': 'Er staan nog geen documenten in deze organisatie. Begin met het uploaden van documenten.',
'documents.list.no-results': 'Geen documenten gevonden',
'documents.list.table.headers.file-name': 'Bestandsnaam',
'documents.list.table.headers.created': 'Aangemaakt op',
'documents.list.table.headers.deleted': 'Verwijderd op',
'documents.list.table.headers.actions': 'Acties',
'documents.list.table.headers.tags': 'Tags',
'documents.tabs.info': 'Info',
'documents.tabs.content': 'Inhoud',
'documents.tabs.activity': 'Activiteit',
'documents.deleted.message': 'Dit document is verwijderd en wordt permanent verwijderd over {{ days }} dagen.',
'documents.actions.download': 'Downloaden',
'documents.actions.open-in-new-tab': 'Openen in nieuw tabblad',
'documents.actions.restore': 'Herstellen',
'documents.actions.delete': 'Verwijderen',
'documents.actions.edit': 'Bewerken',
'documents.actions.cancel': 'Annuleren',
'documents.actions.save': 'Opslaan',
'documents.actions.saving': 'Bezig met opslaan...',
'documents.content.alert': 'De inhoud van het document wordt automatisch geëxtraheerd bij het uploaden. Deze wordt alleen gebruikt voor zoeken en indexering.',
'documents.content.empty-placeholder': 'Dit document heeft geen geëxtraheerde inhoud; u kunt deze hier handmatig instellen.',
'documents.info.id': 'ID',
'documents.info.name': 'Naam',
'documents.info.type': 'Type',
'documents.info.size': 'Grootte',
'documents.info.created-at': 'Aangemaakt op',
'documents.info.updated-at': 'Bijgewerkt op',
'documents.info.never': 'Nooit',
'documents.rename.title': 'Document hernoemen',
'documents.rename.form.name.label': 'Naam',
'documents.rename.form.name.placeholder': 'Voorbeeld: Factuur 2024',
'documents.rename.form.name.required': 'Voer een naam voor het document in',
'documents.rename.form.name.max-length': 'De naam mag minder dan 255 tekens bevatten',
'documents.rename.form.submit': 'Document hernoemen',
'documents.rename.success': 'Document succesvol hernoemd',
'documents.rename.cancel': 'Annuleren',
'import-documents.title.error': '{{ count }} documenten mislukt',
'import-documents.title.success': '{{ count }} documenten geïmporteerd',
'import-documents.title.pending': '{{ count }} / {{ total }} documenten geïmporteerd',
'import-documents.title.none': 'Documenten importeren',
'import-documents.no-import-in-progress': 'Geen documentimport in uitvoering',
'documents.deleted.title': 'Verwijderde documenten',
'documents.deleted.empty.title': 'Geen verwijderde documenten',
'documents.deleted.empty.description': 'U heeft geen verwijderde documenten. Verwijderde documenten worden gedurende {{ days }} dagen naar de prullenbak verplaatst.',
'documents.deleted.retention-notice': 'Alle verwijderde documenten worden gedurende {{ days }} dagen in de prullenbak bewaard. Na deze periode worden ze permanent verwijderd en kunt u ze niet meer herstellen.',
'documents.deleted.deleted-at': 'Verwijderd',
'documents.deleted.restoring': 'Bezig met herstellen...',
'documents.deleted.deleting': 'Bezig met verwijderen...',
'documents.preview.unknown-file-type': 'Geen voorbeeld beschikbaar voor dit bestandstype',
'documents.preview.binary-file': 'Dit lijkt een binaire bestandsinhoud te zijn en kan niet als tekst worden weergegeven',
'trash.delete-all.button': 'Alles verwijderen',
'trash.delete-all.confirm.title': 'Alle documenten permanent verwijderen?',
'trash.delete-all.confirm.description': 'Weet u zeker dat u alle documenten uit de prullenbak permanent wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
'trash.delete-all.confirm.label': 'Verwijderen',
'trash.delete-all.confirm.cancel': 'Annuleren',
'trash.delete.button': 'Verwijderen',
'trash.delete.confirm.title': 'Document permanent verwijderen?',
'trash.delete.confirm.description': 'Weet u zeker dat u dit document permanent uit de prullenbak wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
'trash.delete.confirm.label': 'Verwijderen',
'trash.delete.confirm.cancel': 'Annuleren',
'trash.deleted.success.title': 'Document verwijderd',
'trash.deleted.success.description': 'Het document is permanent verwijderd.',
'activity.document.created': 'Het document is aangemaakt',
'activity.document.updated.single': 'Het {{ field }} is bijgewerkt',
'activity.document.updated.multiple': 'De {{ fields }} zijn bijgewerkt',
'activity.document.updated': 'Het document is bijgewerkt',
'activity.document.deleted': 'Het document is verwijderd',
'activity.document.restored': 'Het document is hersteld',
'activity.document.tagged': 'Label {{ tag }} is toegevoegd',
'activity.document.untagged': 'Label {{ tag }} is verwijderd',
'activity.document.user.name': 'door {{ name }}',
'activity.load-more': 'Meer laden',
'activity.no-more-activities': 'Geen verdere activiteiten voor dit document',
// Tags
'tags.no-tags.title': 'Nog geen labels',
'tags.no-tags.description': 'Deze organisatie heeft nog geen labels. Labels worden gebruikt om documenten te categoriseren. U kunt labels toevoegen aan uw documenten om ze eenvoudiger te vinden en te organiseren.',
'tags.no-tags.create-tag': 'Label aanmaken',
'tags.title': 'Documentlabels',
'tags.description': 'Labels worden gebruikt om documenten te categoriseren. U kunt labels toevoegen aan uw documenten om ze eenvoudiger te vinden en te organiseren.',
'tags.create': 'Label aanmaken',
'tags.update': 'Label bijwerken',
'tags.delete': 'Label verwijderen',
'tags.delete.confirm.title': 'Label verwijderen',
'tags.delete.confirm.message': 'Weet u zeker dat u dit label wilt verwijderen? Het verwijderen van een label verwijdert het van alle documenten.',
'tags.delete.confirm.confirm-button': 'Verwijderen',
'tags.delete.confirm.cancel-button': 'Annuleren',
'tags.delete.success': 'Label succesvol verwijderd',
'tags.create.success': 'Label "{{ name }}" succesvol aangemaakt.',
'tags.update.success': 'Label "{{ name }}" succesvol bijgewerkt.',
'tags.form.name.label': 'Naam',
'tags.form.name.placeholder': 'Bijv. Contracten',
'tags.form.name.required': 'Voer een labelnaam in',
'tags.form.name.max-length': 'Labelnaam moet minder dan 64 tekens bevatten',
'tags.form.color.label': 'Kleur',
'tags.form.color.required': 'Voer een kleur in',
'tags.form.color.invalid': 'De hex-kleur heeft een onjuist formaat.',
'tags.form.description.label': 'Beschrijving',
'tags.form.description.optional': '(optioneel)',
'tags.form.description.placeholder': 'Bijv. Alle door het bedrijf ondertekende contracten',
'tags.form.description.max-length': 'Beschrijving moet minder dan 256 tekens bevatten',
'tags.form.no-description': 'Geen beschrijving',
'tags.table.headers.tag': 'Label',
'tags.table.headers.description': 'Beschrijving',
'tags.table.headers.documents': 'Documenten',
'tags.table.headers.created': 'Aangemaakt',
'tags.table.headers.actions': 'Acties',
// Tagging rules
'tagging-rules.field.name': 'documentnaam',
'tagging-rules.field.content': 'documentinhoud',
'tagging-rules.operator.equals': 'is gelijk aan',
'tagging-rules.operator.not-equals': 'is niet gelijk aan',
'tagging-rules.operator.contains': 'bevat',
'tagging-rules.operator.not-contains': 'bevat niet',
'tagging-rules.operator.starts-with': 'begint met',
'tagging-rules.operator.ends-with': 'eindigt met',
'tagging-rules.list.title': 'Labelregels',
'tagging-rules.list.description': 'Beheer de labelregels van uw organisatie om documenten automatisch te labelen op basis van door u gedefinieerde voorwaarden.',
'tagging-rules.list.demo-warning': 'Opmerking: Dit is een demoomgeving (zonder server); labelregels worden niet toegepast op nieuw toegevoegde documenten.',
'tagging-rules.list.no-tagging-rules.title': 'Geen labelregels',
'tagging-rules.list.no-tagging-rules.description': 'Maak een labelregel om automatisch labels toe te passen op documenten die voldoen aan uw voorwaarden.',
'tagging-rules.list.no-tagging-rules.create-tagging-rule': 'Labelregel aanmaken',
'tagging-rules.list.card.no-conditions': 'Geen voorwaarden',
'tagging-rules.list.card.one-condition': '1 voorwaarde',
'tagging-rules.list.card.conditions': '{{ count }} voorwaarden',
'tagging-rules.list.card.delete': 'Regel verwijderen',
'tagging-rules.list.card.edit': 'Regel bewerken',
'tagging-rules.create.title': 'Labelregel aanmaken',
'tagging-rules.create.success': 'Labelregel succesvol aangemaakt',
'tagging-rules.create.error': 'Kan labelregel niet aanmaken',
'tagging-rules.create.submit': 'Regel aanmaken',
'tagging-rules.form.name.label': 'Naam',
'tagging-rules.form.name.placeholder': 'Voorbeeld: Label facturen',
'tagging-rules.form.name.min-length': 'Voer een naam voor de regel in',
'tagging-rules.form.name.max-length': 'De naam mag minder dan 64 tekens bevatten',
'tagging-rules.form.description.label': 'Beschrijving',
'tagging-rules.form.description.placeholder': 'Bijv. Label documenten met \'factuur\' in de naam',
'tagging-rules.form.description.max-length': 'De beschrijving mag minder dan 256 tekens bevatten',
'tagging-rules.form.conditions.label': 'Voorwaarden',
'tagging-rules.form.conditions.description': 'Definieer de voorwaarden waaraan moet worden voldaan zodat de regel wordt toegepast. Geen voorwaarden betekent dat de regel op alle documenten wordt toegepast',
'tagging-rules.form.conditions.add-condition': 'Voorwaarde toevoegen',
'tagging-rules.form.conditions.connector.when': 'Wanneer',
'tagging-rules.form.conditions.connector.and': 'en',
'tagging-rules.form.conditions.connector.or': 'of',
'tagging-rules.condition-match-mode.all': 'Alle voorwaarden moeten overeenkomen',
'tagging-rules.condition-match-mode.any': 'Een van de voorwaarden moet overeenkomen',
'tagging-rules.form.conditions.no-conditions.title': 'Geen voorwaarden',
'tagging-rules.form.conditions.no-conditions.description': 'U heeft geen voorwaarden toegevoegd aan deze regel. Deze regel zal zijn labels op alle documenten toepassen.',
'tagging-rules.form.conditions.no-conditions.confirm': 'Regel toepassen zonder voorwaarden',
'tagging-rules.form.conditions.no-conditions.cancel': 'Annuleren',
'tagging-rules.form.conditions.value.placeholder': 'Voorbeeld: factuur',
'tagging-rules.form.conditions.value.min-length': 'Voer een waarde in voor de voorwaarde',
'tagging-rules.form.tags.label': 'Labels',
'tagging-rules.form.tags.description': 'Selecteer de labels die moeten worden toegepast op documenten die aan de voorwaarden voldoen',
'tagging-rules.form.tags.min-length': 'Minstens één label is vereist om toe te passen',
'tagging-rules.form.tags.add-tag': 'Label aanmaken',
'tagging-rules.form.submit': 'Regel aanmaken',
'tagging-rules.update.title': 'Labelregel bijwerken',
'tagging-rules.update.error': 'Kan labelregel niet bijwerken',
'tagging-rules.update.submit': 'Regel bijwerken',
'tagging-rules.update.cancel': 'Annuleren',
'tagging-rules.apply.button': 'Toepassen op bestaande documenten',
'tagging-rules.apply.confirm.title': 'Regel toepassen op bestaande documenten?',
'tagging-rules.apply.confirm.description': 'Dit controleert alle bestaande documenten in uw organisatie en past labels toe waar voorwaarden overeenkomen. De verwerking gebeurt op de achtergrond.',
'tagging-rules.apply.confirm.button': 'Regel toepassen',
'tagging-rules.apply.success': 'Het toepassen van de regel is op de achtergrond gestart',
'tagging-rules.apply.error': 'Kan het toepassen van de regel niet starten',
'tagging-rules.apply.processing': 'Starten...',
// Intake emails
'intake-emails.title': 'Intake-e-mails',
'intake-emails.description': 'Intake-e-mailadressen worden gebruikt om e-mails automatisch in Papra te importeren. Stuur e-mails door naar het intake-adres en de bijlagen worden toegevoegd aan de documenten van uw organisatie.',
'intake-emails.disabled.title': 'Intake-e-mails zijn uitgeschakeld',
'intake-emails.disabled.description': 'Intake-e-mails zijn uitgeschakeld op deze instantie. Neem contact op met uw beheerder om ze in te schakelen. Zie de {{ documentation }} voor meer informatie.',
'intake-emails.disabled.documentation': 'documentatie',
'intake-emails.info': 'Alleen ingeschakelde intake-e-mails van toegestane bronnen worden verwerkt. U kunt een intake-e-mail op elk moment in- of uitschakelen.',
'intake-emails.empty.title': 'Geen intake-e-mails',
'intake-emails.empty.description': 'Genereer een intake-adres om eenvoudig e-mailbijlagen te importeren.',
'intake-emails.empty.generate': 'Intake-e-mail genereren',
'intake-emails.count': '{{ count }} intake-e-mail{{ plural }} voor deze organisatie',
'intake-emails.new': 'Nieuwe intake-e-mail',
'intake-emails.disabled-label': '(Uitgeschakeld)',
'intake-emails.no-origins': 'Geen toegestane bronnen',
'intake-emails.allowed-origins': 'Toegestaan vanaf {{ count }} adres{{ plural }}',
'intake-emails.actions.enable': 'Inschakelen',
'intake-emails.actions.disable': 'Uitschakelen',
'intake-emails.actions.manage-origins': 'Bronadressen beheren',
'intake-emails.actions.delete': 'Verwijderen',
'intake-emails.delete.confirm.title': 'Intake-e-mail verwijderen?',
'intake-emails.delete.confirm.message': 'Weet u zeker dat u deze intake-e-mail wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
'intake-emails.delete.confirm.confirm-button': 'Intake-e-mail verwijderen',
'intake-emails.delete.confirm.cancel-button': 'Annuleren',
'intake-emails.delete.success': 'Intake-e-mail verwijderd',
'intake-emails.create.success': 'Intake-e-mail aangemaakt',
'intake-emails.update.success.enabled': 'Intake-e-mail ingeschakeld',
'intake-emails.update.success.disabled': 'Intake-e-mail uitgeschakeld',
'intake-emails.allowed-origins.title': 'Toegestane bronnen',
'intake-emails.allowed-origins.description': 'Alleen e-mails verzonden naar {{ email }} vanaf deze bronnen worden verwerkt. Als er geen bronnen zijn opgegeven, worden alle e-mails genegeerd.',
'intake-emails.allowed-origins.add.label': 'Toegestaan origineel e-mailadres toevoegen',
'intake-emails.allowed-origins.add.placeholder': 'Bijv. ada@papra.app',
'intake-emails.allowed-origins.add.button': 'Toevoegen',
'intake-emails.allowed-origins.add.error.exists': 'Dit e-mailadres staat al in de toegestane bronnen voor deze intake-e-mail',
// API keys
'api-keys.permissions.select-all': 'Alles selecteren',
'api-keys.permissions.deselect-all': 'Alles deselecteren',
'api-keys.permissions.organizations.title': 'Organisaties',
'api-keys.permissions.organizations.organizations:create': 'Organisaties aanmaken',
'api-keys.permissions.organizations.organizations:read': 'Organisaties lezen',
'api-keys.permissions.organizations.organizations:update': 'Organisaties bijwerken',
'api-keys.permissions.organizations.organizations:delete': 'Organisaties verwijderen',
'api-keys.permissions.documents.title': 'Documenten',
'api-keys.permissions.documents.documents:create': 'Documenten aanmaken',
'api-keys.permissions.documents.documents:read': 'Documenten lezen',
'api-keys.permissions.documents.documents:update': 'Documenten bijwerken',
'api-keys.permissions.documents.documents:delete': 'Documenten verwijderen',
'api-keys.permissions.tags.title': 'Labels',
'api-keys.permissions.tags.tags:create': 'Labels aanmaken',
'api-keys.permissions.tags.tags:read': 'Labels lezen',
'api-keys.permissions.tags.tags:update': 'Labels bijwerken',
'api-keys.permissions.tags.tags:delete': 'Labels verwijderen',
'api-keys.create.title': 'API-sleutel aanmaken',
'api-keys.create.description': 'Maak een nieuwe API-sleutel om de Papra API te gebruiken.',
'api-keys.create.success': 'De API-sleutel is succesvol aangemaakt.',
'api-keys.create.back': 'Terug naar API-sleutels',
'api-keys.create.form.name.label': 'Naam',
'api-keys.create.form.name.placeholder': 'Bijv. Mijn API-sleutel',
'api-keys.create.form.name.required': 'Voer een naam in voor de API-sleutel',
'api-keys.create.form.permissions.label': 'Rechten',
'api-keys.create.form.permissions.required': 'Selecteer minimaal één machtiging',
'api-keys.create.form.submit': 'API-sleutel aanmaken',
'api-keys.create.created.title': 'API-sleutel aangemaakt',
'api-keys.create.created.description': 'De API-sleutel is succesvol aangemaakt. Sla deze op een veilige plek op, want deze wordt niet opnieuw weergegeven.',
'api-keys.list.title': 'API-sleutels',
'api-keys.list.description': 'Beheer hier uw API-sleutels.',
'api-keys.list.create': 'API-sleutel aanmaken',
'api-keys.list.empty.title': 'Geen API-sleutels',
'api-keys.list.empty.description': 'Maak een API-sleutel om de Papra API te gebruiken.',
'api-keys.list.card.last-used': 'Laatst gebruikt',
'api-keys.list.card.never': 'Nooit',
'api-keys.list.card.created': 'Aangemaakt',
'api-keys.delete.success': 'De API-sleutel is succesvol verwijderd',
'api-keys.delete.confirm.title': 'API-sleutel verwijderen',
'api-keys.delete.confirm.message': 'Weet u zeker dat u deze API-sleutel wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
'api-keys.delete.confirm.confirm-button': 'Verwijderen',
'api-keys.delete.confirm.cancel-button': 'Annuleren',
// Webhooks
'webhooks.list.title': 'Webhooks',
'webhooks.list.description': 'Beheer webhooks voor uw organisatie',
'webhooks.list.empty.title': 'Geen webhooks',
'webhooks.list.empty.description': 'Maak uw eerste webhook om evenementen te ontvangen',
'webhooks.list.create': 'Webhook aanmaken',
'webhooks.list.card.last-triggered': 'Laatst geactiveerd',
'webhooks.list.card.never': 'Nooit',
'webhooks.list.card.created': 'Aangemaakt',
'webhooks.create.title': 'Webhook aanmaken',
'webhooks.create.description': 'Maak een nieuwe webhook om evenementen te ontvangen',
'webhooks.create.success': 'Webhook succesvol aangemaakt',
'webhooks.create.back': 'Terug',
'webhooks.create.form.submit': 'Webhook aanmaken',
'webhooks.create.form.name.label': 'Webhooknaam',
'webhooks.create.form.name.placeholder': 'Voer een webhooknaam in',
'webhooks.create.form.name.required': 'Naam is verplicht',
'webhooks.create.form.url.label': 'Webhook-URL',
'webhooks.create.form.url.placeholder': 'Voer een webhook-URL in',
'webhooks.create.form.url.required': 'URL is verplicht',
'webhooks.create.form.url.invalid': 'URL is ongeldig',
'webhooks.create.form.secret.label': 'Secret',
'webhooks.create.form.secret.placeholder': 'Voer webhook-secret in',
'webhooks.create.form.events.label': 'Evenementen',
'webhooks.create.form.events.required': 'Minimaal één evenement is verplicht',
'webhooks.update.title': 'Webhook bewerken',
'webhooks.update.description': 'Werk uw webhookgegevens bij',
'webhooks.update.success': 'Webhook succesvol bijgewerkt',
'webhooks.update.submit': 'Webhook bijwerken',
'webhooks.update.cancel': 'Annuleren',
'webhooks.update.form.secret.placeholder': 'Voer nieuw secret in',
'webhooks.update.form.secret.placeholder-redacted': '[Geredigeerd secret]',
'webhooks.update.form.rotate-secret.button': 'Secret roteren',
'webhooks.delete.success': 'Webhook succesvol verwijderd',
'webhooks.delete.confirm.title': 'Webhook verwijderen',
'webhooks.delete.confirm.message': 'Weet u zeker dat u deze webhook wilt verwijderen?',
'webhooks.delete.confirm.confirm-button': 'Verwijderen',
'webhooks.delete.confirm.cancel-button': 'Annuleren',
'webhooks.events.documents.title': 'Documentevenementen',
'webhooks.events.documents.document:created.description': 'Document aangemaakt',
'webhooks.events.documents.document:deleted.description': 'Document verwijderd',
'webhooks.events.documents.document:updated.description': 'Document bijgewerkt',
'webhooks.events.documents.document:tag:added.description': 'Er is een label toegevoegd aan een document',
'webhooks.events.documents.document:tag:removed.description': 'Er is een label verwijderd van een document',
// Navigation
'layout.menu.home': 'Start',
'layout.menu.documents': 'Documenten',
'layout.menu.tags': 'Labels',
'layout.menu.tagging-rules': 'Labelregels',
'layout.menu.deleted-documents': 'Verwijderde documenten',
'layout.menu.organization-settings': 'Instellingen',
'layout.menu.api-keys': 'API-sleutels',
'layout.menu.settings': 'Instellingen',
'layout.menu.account': 'Account',
'layout.menu.general-settings': 'Algemene instellingen',
'layout.menu.usage': 'Gebruik',
'layout.menu.intake-emails': 'Intake-e-mails',
'layout.menu.webhooks': 'Webhooks',
'layout.menu.members': 'Leden',
'layout.menu.invitations': 'Uitnodigingen',
'layout.upgrade-cta.title': 'Meer ruimte nodig?',
'layout.upgrade-cta.description': 'Krijg 10x meer opslag + team samenwerking',
'layout.upgrade-cta.button': 'Nu upgraden',
'layout.theme.light': 'Licht thema',
'layout.theme.dark': 'Donker thema',
'layout.theme.system': 'Systeemstandaard',
'layout.search.placeholder': 'Zoeken...',
'layout.menu.import-document': 'Een document importeren',
'user-menu.account-settings': 'Accountinstellingen',
'user-menu.api-keys': 'API-sleutels',
'user-menu.invitations': 'Uitnodigingen',
'user-menu.language': 'Taal',
'user-menu.logout': 'Uitloggen',
// Command palette
'command-palette.search.placeholder': 'Zoek opdrachten of documenten',
'command-palette.no-results': 'Geen resultaten gevonden',
'command-palette.sections.documents': 'Documenten',
'command-palette.sections.theme': 'Thema',
// API errors
'api-errors.document.already_exists': 'Het document bestaat al',
'api-errors.document.size_too_large': 'Het bestand is te groot',
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',
'api-errors.intake_email.limit_reached': 'Het maximum aantal intake-e-mails voor deze organisatie is bereikt. Upgrade uw plan om meer intake-e-mails aan te maken.',
'api-errors.user.max_organization_count_reached': 'U heeft het maximale aantal organisaties bereikt dat u kunt aanmaken. Neem contact op met support als u meer nodig heeft.',
'api-errors.default': 'Er is een fout opgetreden bij het verwerken van uw verzoek.',
'api-errors.organization.invitation_already_exists': 'Er bestaat al een uitnodiging voor dit e-mailadres in deze organisatie.',
'api-errors.user.already_in_organization': 'Deze gebruiker zit al in deze organisatie.',
'api-errors.user.organization_invitation_limit_reached': 'Het maximale aantal uitnodigingen voor vandaag is bereikt. Probeer het morgen opnieuw.',
'api-errors.demo.not_available': 'Deze functie is niet beschikbaar in de demo',
'api-errors.tags.already_exists': 'Er bestaat al een label met deze naam voor deze organisatie',
'api-errors.internal.error': 'Er is een fout opgetreden bij het verwerken van uw verzoek. Probeer het later opnieuw.',
'api-errors.auth.invalid_origin': 'Ongeldige applicatiebron. Als u Papra zelf host, zorg ervoor dat uw APP_BASE_URL-omgeving variabele overeenkomt met uw huidige URL. Zie https://docs.papra.app/resources/troubleshooting/#invalid-application-origin voor meer informatie.',
'api-errors.organization.max_members_count_reached': 'Het maximale aantal leden en openstaande uitnodigingen voor deze organisatie is bereikt. Upgrade uw plan om meer leden toe te voegen.',
'api-errors.organization.has_active_subscription': 'Kan de organisatie niet verwijderen vanwege een actief abonnement. Annuleer eerst uw abonnement via de knop Abonnement beheren.',
// Better auth api errors
'api-errors.USER_NOT_FOUND': 'Gebruiker niet gevonden',
'api-errors.FAILED_TO_CREATE_USER': 'Kan gebruiker niet aanmaken',
'api-errors.FAILED_TO_CREATE_SESSION': 'Kan sessie niet aanmaken',
'api-errors.FAILED_TO_UPDATE_USER': 'Kan gebruiker niet bijwerken',
'api-errors.FAILED_TO_GET_SESSION': 'Kan sessie niet ophalen',
'api-errors.INVALID_PASSWORD': 'Ongeldig wachtwoord',
'api-errors.INVALID_EMAIL': 'Ongeldig e-mailadres',
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Het e-mailadres of wachtwoord is onjuist, of het account bestaat niet.',
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social account is al gekoppeld',
'api-errors.PROVIDER_NOT_FOUND': 'Provider niet gevonden',
'api-errors.INVALID_TOKEN': 'Ongeldige token',
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID-token wordt niet ondersteund',
'api-errors.FAILED_TO_GET_USER_INFO': 'Kan gebruikersinformatie niet ophalen',
'api-errors.USER_EMAIL_NOT_FOUND': 'E-mail van gebruiker niet gevonden',
'api-errors.EMAIL_NOT_VERIFIED': 'E-mail niet geverifieerd',
'api-errors.PASSWORD_TOO_SHORT': 'Wachtwoord te kort',
'api-errors.PASSWORD_TOO_LONG': 'Wachtwoord te lang',
'api-errors.USER_ALREADY_EXISTS': 'Er bestaat al een gebruiker met dit e-mailadres',
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'E-mail kan niet worden bijgewerkt',
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Credentie-account niet gevonden',
'api-errors.SESSION_EXPIRED': 'Sessie verlopen',
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Kan laatste account niet loskoppelen',
'api-errors.ACCOUNT_NOT_FOUND': 'Account niet gevonden',
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Gebruiker heeft al een wachtwoord',
// Not found
'not-found.title': '404 - Niet gevonden',
'not-found.description': 'Sorry, de door u gezochte pagina lijkt niet te bestaan. Controleer de URL en probeer het opnieuw.',
'not-found.back-to-home': 'Ga terug naar start',
// Demo
'demo.popup.description': 'Dit is een demoomgeving; alle gegevens worden opgeslagen in uw browserlokale opslag.',
'demo.popup.discord': 'Sluit u aan bij de {{ discordLink }} voor ondersteuning, het voorstellen van functies of gewoon om te chatten.',
'demo.popup.discord-link-label': 'Discord-server',
'demo.popup.reset': 'Demo-gegevens opnieuw instellen',
'demo.popup.hide': 'Verbergen',
// Color picker
'color-picker.hue': 'Kleurtoon',
'color-picker.saturation': 'Verzadiging',
'color-picker.lightness': 'Helderheid',
'color-picker.select-color': 'Kleur selecteren',
'color-picker.select-a-color': 'Selecteer een kleur',
// Subscriptions
'subscriptions.checkout-success.title': 'Betaling geslaagd!',
'subscriptions.checkout-success.description': 'Uw abonnement is succesvol geactiveerd.',
'subscriptions.checkout-success.thank-you': 'Dank u voor het upgraden naar Papra Plus. U heeft nu toegang tot alle premiumfuncties.',
'subscriptions.checkout-success.go-to-organizations': 'Ga naar organisaties',
'subscriptions.checkout-success.redirecting': 'Omleiden over {{ count }} seconde{{ plural }}...',
'subscriptions.checkout-cancel.title': 'Betaling geannuleerd',
'subscriptions.checkout-cancel.description': 'Uw abonnementsupgrade is geannuleerd.',
'subscriptions.checkout-cancel.no-charges': 'Er zijn geen kosten in rekening gebracht. Probeer het opnieuw wanneer u er klaar voor bent.',
'subscriptions.checkout-cancel.back-to-organizations': 'Terug naar organisaties',
'subscriptions.checkout-cancel.need-help': 'Heeft u hulp nodig?',
'subscriptions.checkout-cancel.contact-support': 'Contact opnemen met support',
'subscriptions.upgrade-dialog.title': 'Deze organisatie upgraden',
'subscriptions.upgrade-dialog.description': 'Ontgrendel krachtige functies voor uw organisatie',
'subscriptions.upgrade-dialog.contact-us': 'Neem contact op',
'subscriptions.upgrade-dialog.enterprise-plans': 'als u aangepaste enterprise-plannen nodig heeft.',
'subscriptions.upgrade-dialog.current-plan': 'Huidig plan',
'subscriptions.upgrade-dialog.recommended': 'Aanbevolen',
'subscriptions.upgrade-dialog.per-month': '/maand',
'subscriptions.upgrade-dialog.billed-annually': '€{{ price }} jaarlijks in rekening gebracht',
'subscriptions.upgrade-dialog.upgrade-now': 'Nu upgraden',
'subscriptions.upgrade-dialog.promo-banner.title': 'Tijdelijk aanbod',
'subscriptions.upgrade-dialog.promo-banner.description': 'Krijg {{ percent }}% korting op alle plannen voor altijd per organisatie als vroege gebruiker! Aanbod verloopt over {{ days, >1:{days} days, =1:1 day, less than 1 day }}.',
'subscriptions.plan.free.name': 'Gratis plan',
'subscriptions.plan.plus.name': 'Plus',
'subscriptions.plan.pro.name': 'Pro',
'subscriptions.features.storage-size': 'Documentopslaggrootte',
'subscriptions.features.members': 'Organisatieleden',
'subscriptions.features.members-count': '{{ count }} leden',
'subscriptions.features.email-intakes': 'E-mailintakes',
'subscriptions.features.email-intakes-count-singular': '{{ count }} adres',
'subscriptions.features.email-intakes-count-plural': '{{ count }} adressen',
'subscriptions.features.max-upload-size': 'Maximale uploadbestandsgrootte',
'subscriptions.features.support': 'Ondersteuning',
'subscriptions.features.support-community': 'Community-ondersteuning',
'subscriptions.features.support-email': 'E-mailondersteuning',
'subscriptions.features.support-priority': 'Prioriteitsondersteuning',
'subscriptions.billing-interval.monthly': 'Maandelijks',
'subscriptions.billing-interval.annual': 'Jaarlijks',
'subscriptions.usage-warning.message': 'U heeft {{ percent }}% van uw documentopslag gebruikt. Overweeg uw plan te upgraden voor meer ruimte.',
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
// Common / Shared
'common.confirm-modal.type-to-confirm': 'Typ "{{ text }}" om te bevestigen',
'common.tables.rows-per-page': 'Rijen per pagina',
'common.tables.pagination-info': 'Pagina {{ currentPage }} van {{ totalPages }}',
};

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