Compare commits

..

1 Commits

Author SHA1 Message Date
Corentin Thomasset
ca83ee3868 xplo(db): switch to kysely 2025-11-05 22:01:08 +01:00
352 changed files with 13250 additions and 38229 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: 24
cache: "pnpm"
node-version: 22
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: 24
node-version: 22
cache: "pnpm"
# Ensure npm 11.5.1 or later is installed

2
.nvmrc
View File

@@ -1 +1 @@
24
22

View File

@@ -118,7 +118,6 @@ Papra would not have been possible without the following open-source projects:
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
- **[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.
@@ -129,7 +128,7 @@ Papra would not have been possible without the following open-source projects:
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
- **Infrastructure**
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
- **[Fly.io](https://fly.io/)**: For backend hosting.
- **[Render](https://render.com/)**: For backend hosting.
- **[Turso](https://turso.tech/)**: For production database.
### Inspiration

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

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": "catalog:",
"typescript": "^5.7.3",
"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 v24 and pnpm installed.
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v22 and pnpm installed.
```bash
# Clone the repository

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

@@ -1 +1 @@
24
22

View File

@@ -5,8 +5,6 @@ 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": ">=24.0.0"
"node": ">=22.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,9 +58,8 @@
"@playwright/test": "^1.53.1",
"@types/node": "catalog:",
"eslint": "catalog:",
"eslint-plugin-solid": "^0.14.5",
"tinyglobby": "^0.2.14",
"tsx": "catalog:",
"tsx": "^4.20.3",
"typescript": "catalog:",
"unocss": "^66.5.4",
"vite": "^7.1.9",

View File

@@ -5,8 +5,7 @@ import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/colo
import { Router } from '@solidjs/router';
import { QueryClientProvider } from '@tanstack/solid-query';
import { Suspense } from 'solid-js';
import { render } from 'solid-js/web';
import { render, Suspense } 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,11 +249,6 @@ 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',
@@ -715,6 +710,4 @@ 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,11 +247,6 @@ 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',
@@ -647,7 +642,7 @@ export const translations = {
// Demo
'demo.popup.description': 'This is a demo environment, all data is saved to your browser local storage.',
'demo.popup.description': 'This is a demo environment, all data is save 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',
@@ -713,6 +708,4 @@ 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,11 +249,6 @@ 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',
@@ -715,6 +710,4 @@ 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,11 +249,6 @@ 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',
@@ -715,6 +710,4 @@ 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,11 +249,6 @@ 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',
@@ -715,6 +710,4 @@ 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

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