Compare commits
42 Commits
@papra/doc
...
@papra/cli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6eae043fa | ||
|
|
e1b0555202 | ||
|
|
93517d0f13 | ||
|
|
d967fa6cef | ||
|
|
9b43bafe33 | ||
|
|
334fcbdee4 | ||
|
|
981731bbe5 | ||
|
|
96403c0047 | ||
|
|
08f4a1cd05 | ||
|
|
ca808064fa | ||
|
|
dc6ee5b228 | ||
|
|
14071b0bc9 | ||
|
|
ae3abe9ec7 | ||
|
|
479a603001 | ||
|
|
19f96a1625 | ||
|
|
a03eae79a0 | ||
|
|
4bcfb878f1 | ||
|
|
d2676052c3 | ||
|
|
ec33ae6294 | ||
|
|
432a192b94 | ||
|
|
98d272fb60 | ||
|
|
1d20c0cfe3 | ||
|
|
07a42da57a | ||
|
|
9dee142948 | ||
|
|
5ccdf446f0 | ||
|
|
11ad13058e | ||
|
|
ee9eff4914 | ||
|
|
499b2cdba7 | ||
|
|
b0877645a8 | ||
|
|
8308e93fdf | ||
|
|
1dce0ace41 | ||
|
|
868281bcff | ||
|
|
5b5ce85061 | ||
|
|
157a5cadd1 | ||
|
|
1922f24c0a | ||
|
|
7ac06a0649 | ||
|
|
c150e231aa | ||
|
|
0c235031d2 | ||
|
|
8a7c1c8368 | ||
|
|
cb1f1b5b01 | ||
|
|
abc463f751 | ||
|
|
8edfd48ceb |
8
.github/workflows/ci.yaml
vendored
@@ -12,14 +12,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm i
|
run: pnpm i
|
||||||
@@ -44,4 +44,4 @@ jobs:
|
|||||||
run: pnpm -r --parallel -F "./apps/*" build
|
run: pnpm -r --parallel -F "./apps/*" build
|
||||||
|
|
||||||
- name: Ensure no non-excluded files are changed for the whole repo
|
- name: Ensure no non-excluded files are changed for the whole repo
|
||||||
run: git diff --exit-code > /dev/null || (echo "After running the CI, some un-committed changes were detected. Please ensure cleanness before merging." && exit 1)
|
run: git diff --exit-code > /dev/null || (echo "After running the CI, some un-committed changes were detected. Please ensure cleanness before merging." && exit 1)
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
# Ensure npm 11.5.1 or later is installed
|
# Ensure npm 11.5.1 or later is installed
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ Papra would not have been possible without the following open-source projects:
|
|||||||
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
|
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
|
||||||
- **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js.
|
- **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js.
|
||||||
- **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js.
|
- **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js.
|
||||||
|
- **[CadenceMQ](https://github.com/papra-hq/cadence-mq)**: A self-hosted-friendly job queue for Node.js, made by Papra.
|
||||||
- And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)**
|
- And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)**
|
||||||
- **Documentation**
|
- **Documentation**
|
||||||
- **[Astro](https://astro.build)**: A great static site generator.
|
- **[Astro](https://astro.build)**: A great static site generator.
|
||||||
@@ -128,7 +129,7 @@ Papra would not have been possible without the following open-source projects:
|
|||||||
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
|
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
|
||||||
- **Infrastructure**
|
- **Infrastructure**
|
||||||
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
|
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
|
||||||
- **[Render](https://render.com/)**: For backend hosting.
|
- **[Fly.io](https://fly.io/)**: For backend hosting.
|
||||||
- **[Turso](https://turso.tech/)**: For production database.
|
- **[Turso](https://turso.tech/)**: For production database.
|
||||||
|
|
||||||
### Inspiration
|
### Inspiration
|
||||||
|
|||||||
1382
apps/docs/package-lock.json
generated
@@ -40,7 +40,7 @@
|
|||||||
"figue": "^3.1.1",
|
"figue": "^3.1.1",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"marked": "^15.0.6",
|
"marked": "^15.0.6",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "catalog:",
|
||||||
"unocss": "0.65.0-beta.2",
|
"unocss": "0.65.0-beta.2",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The code for the Email Worker proxy is available in the [papra-hq/email-proxy](h
|
|||||||
|
|
||||||
- **Option 2**: Build and deploy the Email Worker
|
- **Option 2**: Build and deploy the Email Worker
|
||||||
|
|
||||||
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v22 and pnpm installed.
|
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v24 and pnpm installed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
|
|||||||
@@ -201,7 +201,10 @@ Search documents in the organization by name or content.
|
|||||||
- `pageIndex`: (optional, default: 0) The page index to start from.
|
- `pageIndex`: (optional, default: 0) The page index to start from.
|
||||||
- `pageSize`: (optional, default: 100) The number of documents to return.
|
- `pageSize`: (optional, default: 100) The number of documents to return.
|
||||||
- Response (JSON)
|
- Response (JSON)
|
||||||
- `documents`: The list of documents.
|
- `searchResults`: The search results.
|
||||||
|
- `documents`: The list of matching documents.
|
||||||
|
- `id`: The document ID.
|
||||||
|
- `name`: The document name.
|
||||||
|
|
||||||
### Get organization documents statistics
|
### Get organization documents statistics
|
||||||
|
|
||||||
|
|||||||
43
apps/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
5
apps/mobile/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Papra Mobile App
|
||||||
|
|
||||||
|
React Native mobile application for Papra document management platform, built with Expo.
|
||||||
|
|
||||||
|
// Todo: Add more details about setup, development, and usage instructions.
|
||||||
55
apps/mobile/app.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "mobile",
|
||||||
|
"slug": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./src/assets/images/icon.png",
|
||||||
|
"scheme": "papra",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"newArchEnabled": true,
|
||||||
|
"ios": {
|
||||||
|
"supportsTablet": true
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./src/assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./src/assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./src/assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"edgeToEdgeEnabled": true,
|
||||||
|
"predictiveBackGestureEnabled": false
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./src/assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"image": "./src/assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 200,
|
||||||
|
"resizeMode": "contain",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"dark": {
|
||||||
|
"backgroundColor": "#000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"router": {},
|
||||||
|
"eas": {
|
||||||
|
"projectId": "f40c21f5-38e6-40d8-8627-528c1d3a533a"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { HapticTab } from '@/modules/ui/components/haptic-tab';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { ImportTabButton } from '@/modules/ui/components/import-tab-button';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
const colors = useThemeColor();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: colors.primary,
|
||||||
|
headerShown: false,
|
||||||
|
tabBarButton: HapticTab,
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colors.secondaryBackground,
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
paddingTop: 15,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
height: 65 + insets.bottom,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="list"
|
||||||
|
options={{
|
||||||
|
title: 'Documents',
|
||||||
|
tabBarIcon: ({ color }) => <Icon name="home" size={30} color={color} style={{ height: 30 }} />,
|
||||||
|
tabBarLabel: () => null,
|
||||||
|
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="import"
|
||||||
|
options={{
|
||||||
|
title: 'Import',
|
||||||
|
tabBarButton: () => <ImportTabButton />,
|
||||||
|
tabBarLabel: () => null,
|
||||||
|
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
title: 'Settings',
|
||||||
|
tabBarIcon: ({ color }) => <Icon name="settings" size={30} color={color} style={{ height: 30 }} />,
|
||||||
|
tabBarLabel: () => null,
|
||||||
|
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// This is a dummy screen that will never be rendered
|
||||||
|
// The import tab button intercepts the press and opens a drawer instead
|
||||||
|
export default function ImportScreen() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { DocumentsListScreen } from '@/modules/documents/screens/documents-list.screen';
|
||||||
|
|
||||||
|
export default DocumentsListScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import SettingsScreen from '@/modules/users/screens/settings.screen';
|
||||||
|
|
||||||
|
export default SettingsScreen;
|
||||||
13
apps/mobile/app/(app)/(with-organizations)/_layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { OrganizationsProvider } from '@/modules/organizations/organizations.provider';
|
||||||
|
|
||||||
|
export default function WithOrganizationsLayout() {
|
||||||
|
return (
|
||||||
|
<OrganizationsProvider>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="organizations/create" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
|
</Stack>
|
||||||
|
</OrganizationsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { OrganizationCreateScreen } from '@/modules/organizations/screens/organization-create.screen';
|
||||||
|
|
||||||
|
export default OrganizationCreateScreen;
|
||||||
18
apps/mobile/app/(app)/_layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
|
||||||
|
import { ApiProvider } from '@/modules/api/providers/api.provider';
|
||||||
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<ApiProvider>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="auth/login" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="auth/signup" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(with-organizations)" options={{ headerShown: false }} />
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</ApiProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/mobile/app/(app)/auth/login.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { LoginScreen } from '@/modules/auth/screens/login.screen';
|
||||||
|
|
||||||
|
export default LoginScreen;
|
||||||
3
apps/mobile/app/(app)/auth/signup.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { SignupScreen } from '@/modules/auth/screens/signup.screen';
|
||||||
|
|
||||||
|
export default SignupScreen;
|
||||||
45
apps/mobile/app/+not-found.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { StyleSheet, Text, useColorScheme, View } from 'react-native';
|
||||||
|
|
||||||
|
export default function NotFoundScreen() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
|
||||||
|
const styles = createStylesNotFound(isDark);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={styles.title}>This screen doesn'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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
25
apps/mobile/app/_layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { StatusBar } from 'expo-status-bar';
|
||||||
|
import { AppProviders } from '@/modules/app/providers/app-providers';
|
||||||
|
|
||||||
|
import { useColorScheme } from '@/modules/ui/providers/use-color-scheme';
|
||||||
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProviders>
|
||||||
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="config/server-selection" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
|
||||||
|
</Stack>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
</ThemeProvider>
|
||||||
|
</AppProviders>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
apps/mobile/app/config/server-selection.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { ServerSelectionScreen } from '@/modules/config/screens/server-selection.screen';
|
||||||
|
|
||||||
|
export default ServerSelectionScreen;
|
||||||
28
apps/mobile/app/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Redirect } from 'expo-router';
|
||||||
|
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ['api-server-url'],
|
||||||
|
queryFn: configLocalStorage.getApiServerBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRedirection = () => {
|
||||||
|
if (query.isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isError || query.data == null) {
|
||||||
|
return <Redirect href="/config/server-selection" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Redirect href="/auth/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{getRedirection()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/mobile/app/modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Link } from 'expo-router';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/modules/ui/components/themed-text';
|
||||||
|
import { ThemedView } from '@/modules/ui/components/themed-view';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
marginTop: 15,
|
||||||
|
paddingVertical: 15,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ModalScreen() {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedText type="title">This is a modal</ThemedText>
|
||||||
|
<Link href="/" dismissTo style={styles.link}>
|
||||||
|
<ThemedText type="link">Go to home screen</ThemedText>
|
||||||
|
</Link>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/mobile/eas.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.27.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/mobile/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import antfu from '@antfu/eslint-config';
|
||||||
|
|
||||||
|
export default antfu({
|
||||||
|
typescript: {
|
||||||
|
tsconfigPath: './tsconfig.json',
|
||||||
|
overridesTypeAware: {
|
||||||
|
'ts/no-misused-promises': ['error', { checksVoidReturn: false }],
|
||||||
|
'ts/strict-boolean-expressions': ['error', { allowNullableObject: true }],
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
stylistic: {
|
||||||
|
semi: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
// To allow export on top of files
|
||||||
|
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
|
||||||
|
'curly': ['error', 'all'],
|
||||||
|
'vitest/consistent-test-it': ['error', { fn: 'test' }],
|
||||||
|
'ts/consistent-type-definitions': ['error', 'type'],
|
||||||
|
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
||||||
|
'unused-imports/no-unused-vars': ['error', {
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
8
apps/mobile/metro.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
// Enable package exports for Better Auth
|
||||||
|
config.resolver.unstable_enablePackageExports = true;
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
65
apps/mobile/package.json
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm start",
|
||||||
|
"start": "expo start",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/expo": "catalog:",
|
||||||
|
"@corentinth/chisels": "catalog:",
|
||||||
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
|
"@react-navigation/elements": "^2.6.3",
|
||||||
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
"@tanstack/react-form": "^1.23.8",
|
||||||
|
"@tanstack/react-query": "^5.90.7",
|
||||||
|
"better-auth": "catalog:",
|
||||||
|
"expo": "~54.0.22",
|
||||||
|
"expo-constants": "~18.0.10",
|
||||||
|
"expo-document-picker": "^14.0.7",
|
||||||
|
"expo-file-system": "^19.0.19",
|
||||||
|
"expo-font": "~14.0.9",
|
||||||
|
"expo-haptics": "~15.0.7",
|
||||||
|
"expo-image": "~3.0.10",
|
||||||
|
"expo-linking": "~8.0.8",
|
||||||
|
"expo-router": "~6.0.14",
|
||||||
|
"expo-secure-store": "^15.0.7",
|
||||||
|
"expo-splash-screen": "~31.0.10",
|
||||||
|
"expo-status-bar": "~3.0.8",
|
||||||
|
"expo-symbols": "~1.0.7",
|
||||||
|
"expo-system-ui": "~6.0.8",
|
||||||
|
"expo-web-browser": "~15.0.9",
|
||||||
|
"ofetch": "^1.4.1",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-native": "0.81.5",
|
||||||
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-reanimated": "~4.1.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
"react-native-screens": "~4.16.0",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.5.1",
|
||||||
|
"valibot": "1.0.0-beta.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "catalog:",
|
||||||
|
"@types/react": "~19.1.0",
|
||||||
|
"eas-cli": "^16.27.0",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"eslint-config-expo": "~10.0.0",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vitest": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/mobile/src/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/mobile/src/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/mobile/src/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/mobile/src/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/mobile/src/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/mobile/src/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/mobile/src/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/mobile/src/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/mobile/src/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/mobile/src/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
30
apps/mobile/src/modules/api/api.client.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { HttpClientOptions, ResponseType } from './http.client';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { httpClient } from './http.client';
|
||||||
|
|
||||||
|
export type ApiClient = ReturnType<typeof createApiClient>;
|
||||||
|
|
||||||
|
export function createApiClient({
|
||||||
|
baseUrl,
|
||||||
|
getAuthCookie,
|
||||||
|
}: {
|
||||||
|
baseUrl: string;
|
||||||
|
getAuthCookie: () => string;
|
||||||
|
}) {
|
||||||
|
return async <T, R extends ResponseType = 'json'>({ path, ...rest}: { path: string } & Omit<HttpClientOptions<R>, 'url'>) => {
|
||||||
|
return httpClient<T, R>({
|
||||||
|
baseUrl,
|
||||||
|
url: path,
|
||||||
|
credentials: Platform.OS === 'web' ? 'include' : 'omit',
|
||||||
|
headers: {
|
||||||
|
...(Platform.OS === 'web'
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
Cookie: getAuthCookie(),
|
||||||
|
}),
|
||||||
|
...rest.headers,
|
||||||
|
},
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
19
apps/mobile/src/modules/api/api.models.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { coerceDate } from './api.models';
|
||||||
|
|
||||||
|
describe('api models', () => {
|
||||||
|
describe('coerceDate', () => {
|
||||||
|
test('transforms date-ish values into Date instances', () => {
|
||||||
|
expect(coerceDate(new Date('2024-01-01T00:00:00Z'))).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||||
|
expect(coerceDate('2024-01-01T00:00:00Z')).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||||
|
expect(coerceDate('2024-01-01')).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||||
|
expect(coerceDate(1704067200000)).toEqual(new Date('2024-01-01T00:00:00Z'));
|
||||||
|
|
||||||
|
expect(() => coerceDate(null)).toThrow('Invalid date: expected Date, string, or number, but received value "null" of type "object"');
|
||||||
|
expect(() => coerceDate(undefined)).toThrow('Invalid date: expected Date, string, or number, but received value "undefined" of type "undefined"');
|
||||||
|
expect(() => coerceDate({})).toThrow('Invalid date: expected Date, string, or number, but received value "[object Object]" of type "object"');
|
||||||
|
expect(() => coerceDate(['foo'])).toThrow('Invalid date: expected Date, string, or number, but received value "foo" of type "object"');
|
||||||
|
expect(() => coerceDate(true)).toThrow('Invalid date: expected Date, string, or number, but received value "true" of type "boolean"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
apps/mobile/src/modules/api/api.models.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
type DateKeys = 'createdAt' | 'updatedAt' | 'deletedAt' | 'expiresAt' | 'lastTriggeredAt' | 'lastUsedAt' | 'scheduledPurgeAt';
|
||||||
|
|
||||||
|
type CoerceDate<T> = T extends string | Date
|
||||||
|
? Date
|
||||||
|
: T extends string | Date | null | undefined
|
||||||
|
? Date | undefined
|
||||||
|
: T;
|
||||||
|
|
||||||
|
type CoerceDates<T> = {
|
||||||
|
[K in keyof T]: K extends DateKeys ? CoerceDate<T[K]> : T[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function coerceDate(date: unknown): Date {
|
||||||
|
if (date instanceof Date) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof date === 'string' || typeof date === 'number') {
|
||||||
|
return new Date(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid date: expected Date, string, or number, but received value "${String(date)}" of type "${typeof date}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceDateOrUndefined(date: unknown): Date | undefined {
|
||||||
|
if (date == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return coerceDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coerceDates<T extends Record<string, unknown>>(obj: T): CoerceDates<T> {
|
||||||
|
return {
|
||||||
|
...obj,
|
||||||
|
...('createdAt' in obj ? { createdAt: coerceDateOrUndefined(obj.createdAt) } : {}),
|
||||||
|
...('updatedAt' in obj ? { updatedAt: coerceDateOrUndefined(obj.updatedAt) } : {}),
|
||||||
|
...('deletedAt' in obj ? { deletedAt: coerceDateOrUndefined(obj.deletedAt) } : {}),
|
||||||
|
...('expiresAt' in obj ? { expiresAt: coerceDateOrUndefined(obj.expiresAt) } : {}),
|
||||||
|
...('lastTriggeredAt' in obj ? { lastTriggeredAt: coerceDateOrUndefined(obj.lastTriggeredAt) } : {}),
|
||||||
|
...('lastUsedAt' in obj ? { lastUsedAt: coerceDateOrUndefined(obj.lastUsedAt) } : {}),
|
||||||
|
...('scheduledPurgeAt' in obj ? { scheduledPurgeAt: coerceDateOrUndefined(obj.scheduledPurgeAt) } : {}),
|
||||||
|
} as CoerceDates<T>;
|
||||||
|
}
|
||||||
12
apps/mobile/src/modules/api/http.client.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { FetchOptions, ResponseType } from 'ofetch';
|
||||||
|
import { ofetch } from 'ofetch';
|
||||||
|
|
||||||
|
export { ResponseType };
|
||||||
|
export type HttpClientOptions<R extends ResponseType = 'json'> = Omit<FetchOptions<R>, 'baseURL'> & { url: string; baseUrl?: string };
|
||||||
|
|
||||||
|
export async function httpClient<A, R extends ResponseType = 'json'>({ url, baseUrl, ...rest }: HttpClientOptions<R>) {
|
||||||
|
return ofetch<A, R>(url, {
|
||||||
|
baseURL: baseUrl,
|
||||||
|
...rest,
|
||||||
|
});
|
||||||
|
}
|
||||||
69
apps/mobile/src/modules/api/providers/api.provider.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { ApiClient } from '@/modules/api/api.client';
|
||||||
|
import type { AuthClient } from '@/modules/auth/auth.client';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { createApiClient } from '@/modules/api/api.client';
|
||||||
|
import { createAuthClient } from '@/modules/auth/auth.client';
|
||||||
|
import { configLocalStorage } from '@/modules/config/config.local-storage';
|
||||||
|
|
||||||
|
type ApiProviderProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthClientContext = createContext<AuthClient | undefined>(undefined);
|
||||||
|
const ApiClientContext = createContext<ApiClient | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ApiProvider({ children }: ApiProviderProps) {
|
||||||
|
const [authClient, setAuthClient] = useState<AuthClient | undefined>(undefined);
|
||||||
|
const [apiClient, setApiClient] = useState<ApiClient | undefined>(undefined);
|
||||||
|
|
||||||
|
const { data: baseUrl } = useQuery({
|
||||||
|
queryKey: ['api-server-url'],
|
||||||
|
queryFn: configLocalStorage.getApiServerBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (baseUrl == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authClient = createAuthClient({ baseUrl });
|
||||||
|
setAuthClient(() => authClient);
|
||||||
|
|
||||||
|
const apiClient = createApiClient({ baseUrl, getAuthCookie: () => authClient.getCookie() });
|
||||||
|
setApiClient(() => apiClient);
|
||||||
|
}, [baseUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ authClient && apiClient && (
|
||||||
|
<AuthClientContext.Provider value={authClient}>
|
||||||
|
<ApiClientContext.Provider value={apiClient}>
|
||||||
|
{children}
|
||||||
|
</ApiClientContext.Provider>
|
||||||
|
</AuthClientContext.Provider>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthClient(): AuthClient {
|
||||||
|
const context = useContext(AuthClientContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuthClient must be used within ApiProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApiClient(): ApiClient {
|
||||||
|
const context = useContext(ApiClientContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useApiClient must be used within ApiProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
24
apps/mobile/src/modules/api/providers/query.provider.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 2,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
gcTime: 1000 * 60 * 10, // 10 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type QueryProviderProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: QueryProviderProps) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/mobile/src/modules/app/providers/app-providers.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { QueryProvider } from '../../api/providers/query.provider';
|
||||||
|
import { AlertProvider } from '../../ui/providers/alert-provider';
|
||||||
|
|
||||||
|
type AppProvidersProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppProviders({ children }: AppProvidersProps) {
|
||||||
|
return (
|
||||||
|
<QueryProvider>
|
||||||
|
<AlertProvider>
|
||||||
|
{children}
|
||||||
|
</AlertProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/mobile/src/modules/auth/auth.client.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { expoClient } from '@better-auth/expo/client';
|
||||||
|
import { createAuthClient as createBetterAuthClient } from 'better-auth/react';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import * as SecureStore from 'expo-secure-store';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export type AuthClient = ReturnType<typeof createAuthClient>;
|
||||||
|
|
||||||
|
export function createAuthClient({ baseUrl}: { baseUrl: string }) {
|
||||||
|
return createBetterAuthClient({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
plugins: [
|
||||||
|
expoClient({
|
||||||
|
scheme: String(Constants.expoConfig?.scheme ?? 'papra'),
|
||||||
|
storagePrefix: String(Constants.expoConfig?.scheme ?? 'papra'),
|
||||||
|
storage: Platform.OS === 'web'
|
||||||
|
? localStorage
|
||||||
|
: SecureStore,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { router } from 'expo-router';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
export function BackToServerSelectionButton() {
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backToServerButton}
|
||||||
|
onPress={() => router.push('/config/server-selection')}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left" size={20} color={themeColors.mutedForeground} />
|
||||||
|
<Text style={styles.backToServerText}>
|
||||||
|
Select server
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
backToServerButton: {
|
||||||
|
marginBottom: 16,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
},
|
||||||
|
backToServerText: {
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
346
apps/mobile/src/modules/auth/screens/login.screen.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { useAuthClient } from '@/modules/api/providers/api.provider';
|
||||||
|
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { useServerConfig } from '../../config/hooks/use-server-config';
|
||||||
|
import { BackToServerSelectionButton } from '../components/back-to-server-selection';
|
||||||
|
|
||||||
|
const loginSchema = v.object({
|
||||||
|
email: v.pipe(v.string(), v.email('Please enter a valid email')),
|
||||||
|
password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters')),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { showAlert } = useAlert();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
validators: {
|
||||||
|
onChange: loginSchema,
|
||||||
|
},
|
||||||
|
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const response = await authClient.signIn.email({ email: value.email, password: value.password, rememberMe: true });
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw new Error(response.error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace('/(app)/(with-organizations)/(tabs)/list');
|
||||||
|
} catch (error) {
|
||||||
|
showAlert({
|
||||||
|
title: 'Login Failed',
|
||||||
|
message: error instanceof Error ? error.message : 'An error occurred',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSocialSignIn = async (provider: string) => {
|
||||||
|
try {
|
||||||
|
const response = await authClient.signIn.social({ provider, callbackURL: '/' });
|
||||||
|
if (response.error) {
|
||||||
|
throw Object.assign(new Error(response.error.message), response.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert({
|
||||||
|
title: 'Sign In Failed',
|
||||||
|
message: error instanceof Error ? error.message : 'An error occurred',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authConfig = serverConfig?.config?.auth;
|
||||||
|
const isEmailEnabled = authConfig?.providers?.email?.isEnabled ?? false;
|
||||||
|
const isGoogleEnabled = authConfig?.providers?.google?.isEnabled ?? false;
|
||||||
|
const isGithubEnabled = authConfig?.providers?.github?.isEnabled ?? false;
|
||||||
|
const customProviders = authConfig?.providers?.customs ?? [];
|
||||||
|
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
if (isLoadingConfig) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.centerContent]}>
|
||||||
|
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ ...styles.container, paddingTop: insets.top }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<BackToServerSelectionButton />
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Welcome Back</Text>
|
||||||
|
<Text style={styles.subtitle}>Sign in to your account</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isEmailEnabled && (
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
<form.Field name="email">
|
||||||
|
{field => (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.label}>Email</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
placeholderTextColor={themeColors.mutedForeground}
|
||||||
|
value={field.state.value}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
editable={!isSubmitting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="password">
|
||||||
|
{field => (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.label}>Password</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
placeholderTextColor={themeColors.mutedForeground}
|
||||||
|
value={field.state.value}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
secureTextEntry
|
||||||
|
editable={!isSubmitting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isSubmitting && styles.buttonDisabled]}
|
||||||
|
onPress={async () => form.handleSubmit()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Text style={styles.buttonText}>Sign In</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isGoogleEnabled || isGithubEnabled || customProviders.length > 0) && (
|
||||||
|
<>
|
||||||
|
{isEmailEnabled && (
|
||||||
|
<View style={styles.divider}>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
<Text style={styles.dividerText}>OR</Text>
|
||||||
|
<View style={styles.dividerLine} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.socialButtons}>
|
||||||
|
{isGoogleEnabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.socialButton}
|
||||||
|
onPress={async () => handleSocialSignIn('google')}
|
||||||
|
>
|
||||||
|
<Text style={styles.socialButtonText}>Continue with Google</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isGithubEnabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.socialButton}
|
||||||
|
onPress={async () => handleSocialSignIn('github')}
|
||||||
|
>
|
||||||
|
<Text style={styles.socialButtonText}>Continue with GitHub</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customProviders.map(provider => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={provider.providerId}
|
||||||
|
style={styles.socialButton}
|
||||||
|
onPress={async () => handleSocialSignIn(provider.providerId)}
|
||||||
|
>
|
||||||
|
<Text style={styles.socialButtonText}>
|
||||||
|
Continue with
|
||||||
|
{' '}
|
||||||
|
{provider.providerName}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authConfig?.isRegistrationEnabled === true && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.push('/auth/signup')}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>
|
||||||
|
Don'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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
293
apps/mobile/src/modules/auth/screens/signup.screen.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import * as v from 'valibot';
|
||||||
|
import { useAuthClient } from '@/modules/api/providers/api.provider';
|
||||||
|
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { useServerConfig } from '../../config/hooks/use-server-config';
|
||||||
|
import { BackToServerSelectionButton } from '../components/back-to-server-selection';
|
||||||
|
|
||||||
|
const signupSchema = v.object({
|
||||||
|
name: v.pipe(v.string(), v.minLength(1, 'Name is required')),
|
||||||
|
email: v.pipe(v.string(), v.email('Please enter a valid email')),
|
||||||
|
password: v.pipe(v.string(), v.minLength(8, 'Password must be at least 8 characters')),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function SignupScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const { showAlert } = useAlert();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const { data: serverConfig, isLoading: isLoadingConfig } = useServerConfig();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
validators: {
|
||||||
|
onChange: signupSchema,
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const { name, email, password } = value;
|
||||||
|
|
||||||
|
await authClient.signUp.email({ name, email, password });
|
||||||
|
|
||||||
|
const isEmailVerificationRequired = serverConfig?.config?.auth?.isEmailVerificationRequired ?? false;
|
||||||
|
|
||||||
|
if (isEmailVerificationRequired) {
|
||||||
|
showAlert({
|
||||||
|
title: 'Check your email',
|
||||||
|
message: 'We sent you a verification link. Please check your email to verify your account.',
|
||||||
|
buttons: [{ text: 'OK', onPress: () => router.replace('/auth/login') }],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.replace('/(app)/(with-organizations)/(tabs)/list');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert({
|
||||||
|
title: 'Signup Failed',
|
||||||
|
message: error instanceof Error ? error.message : 'An error occurred',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const authConfig = serverConfig?.config?.auth;
|
||||||
|
const isRegistrationEnabled = authConfig?.isRegistrationEnabled ?? false;
|
||||||
|
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
if (isLoadingConfig) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.centerContent]}>
|
||||||
|
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegistrationEnabled) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.centerContent]}>
|
||||||
|
<Text style={styles.errorText}>Registration is currently disabled</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>Go back to login</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ ...styles.container, paddingTop: insets.top }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<BackToServerSelectionButton />
|
||||||
|
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Create Account</Text>
|
||||||
|
<Text style={styles.subtitle}>Sign up to get started</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
<form.Field name="name">
|
||||||
|
{field => (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.label}>Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="Your name"
|
||||||
|
placeholderTextColor={themeColors.mutedForeground}
|
||||||
|
value={field.state.value}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
autoCapitalize="words"
|
||||||
|
editable={!isSubmitting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="email">
|
||||||
|
{field => (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.label}>Email</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
placeholderTextColor={themeColors.mutedForeground}
|
||||||
|
value={field.state.value}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
editable={!isSubmitting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="password">
|
||||||
|
{field => (
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.label}>Password</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
placeholderTextColor={themeColors.mutedForeground}
|
||||||
|
value={field.state.value}
|
||||||
|
onChangeText={field.handleChange}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
secureTextEntry
|
||||||
|
editable={!isSubmitting}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isSubmitting && styles.buttonDisabled]}
|
||||||
|
onPress={async () => form.handleSubmit()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Text style={styles.buttonText}>Sign Up</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.linkButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkText}>Already have an account? Sign in</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
},
|
||||||
|
centerContent: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 48,
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
formContainer: {
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
fieldContainer: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
height: 50,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.foreground,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: themeColors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: themeColors.primaryForeground,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
marginTop: 24,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: themeColors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.primary,
|
||||||
|
marginBottom: 16,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
backToServerButton: {
|
||||||
|
marginBottom: 16,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
backToServerText: {
|
||||||
|
color: themeColors.primary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
1
apps/mobile/src/modules/config/config.constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const MANAGED_SERVER_URL = 'https://api.papra.app';
|
||||||
9
apps/mobile/src/modules/config/config.local-storage.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { buildStorageKey } from '../lib/local-storage/local-storage.models';
|
||||||
|
import { storage } from '../lib/local-storage/local-storage.services';
|
||||||
|
|
||||||
|
const CONFIG_API_SERVER_URL_KEY = buildStorageKey(['config', 'api-server-url']);
|
||||||
|
|
||||||
|
export const configLocalStorage = {
|
||||||
|
getApiServerBaseUrl: async () => storage.getItem(CONFIG_API_SERVER_URL_KEY),
|
||||||
|
setApiServerBaseUrl: async ({ apiServerBaseUrl}: { apiServerBaseUrl: string }) => storage.setItem(CONFIG_API_SERVER_URL_KEY, apiServerBaseUrl),
|
||||||
|
};
|
||||||
44
apps/mobile/src/modules/config/config.services.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ApiClient } from '../api/api.client';
|
||||||
|
import { httpClient } from '../api/http.client';
|
||||||
|
|
||||||
|
export async function fetchServerConfig({ apiClient}: { apiClient: ApiClient }) {
|
||||||
|
return apiClient<{
|
||||||
|
config: {
|
||||||
|
auth: {
|
||||||
|
isEmailVerificationRequired: boolean;
|
||||||
|
isPasswordResetEnabled: boolean;
|
||||||
|
isRegistrationEnabled: boolean;
|
||||||
|
showLegalLinksOnAuthPage: boolean;
|
||||||
|
providers: {
|
||||||
|
email: {
|
||||||
|
isEnabled: boolean;
|
||||||
|
};
|
||||||
|
github: {
|
||||||
|
isEnabled: boolean;
|
||||||
|
};
|
||||||
|
google: {
|
||||||
|
isEnabled: boolean;
|
||||||
|
};
|
||||||
|
customs: {
|
||||||
|
providerId: string;
|
||||||
|
providerName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>({
|
||||||
|
path: '/api/config',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pingServer({ url}: { url: string }): Promise<true | never> {
|
||||||
|
const response = await httpClient<{ status: 'ok' | 'error' }>({ url: `/api/ping`, baseUrl: url })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
throw new Error('Could not reach the server');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
12
apps/mobile/src/modules/config/hooks/use-server-config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useApiClient } from '@/modules/api/providers/api.provider';
|
||||||
|
import { fetchServerConfig } from '../config.services';
|
||||||
|
|
||||||
|
export function useServerConfig() {
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['server', 'config'],
|
||||||
|
queryFn: async () => fetchServerConfig({ apiClient }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { queryClient } from '@/modules/api/providers/query.provider';
|
||||||
|
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { MANAGED_SERVER_URL } from '../config.constants';
|
||||||
|
import { configLocalStorage } from '../config.local-storage';
|
||||||
|
import { pingServer } from '../config.services';
|
||||||
|
|
||||||
|
function getDefaultCustomServerUrl() {
|
||||||
|
if (!__DEV__) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line node/prefer-global/process
|
||||||
|
return process.env.EXPO_PUBLIC_API_URL ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerSelectionScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const { showAlert } = useAlert();
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
const [selectedOption, setSelectedOption] = useState<'managed' | 'self-hosted'>('managed');
|
||||||
|
const [customUrl, setCustomUrl] = useState(getDefaultCustomServerUrl());
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
|
||||||
|
const handleValidateCustomUrl = async ({ url}: { url: string }) => {
|
||||||
|
setIsValidating(true);
|
||||||
|
try {
|
||||||
|
await pingServer({ url });
|
||||||
|
await configLocalStorage.setApiServerBaseUrl({ apiServerBaseUrl: url });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['api-server-url'] });
|
||||||
|
|
||||||
|
router.replace('/auth/login');
|
||||||
|
} catch {
|
||||||
|
showAlert({
|
||||||
|
title: 'Connection Failed',
|
||||||
|
message: 'Could not reach the server.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Welcome to Papra</Text>
|
||||||
|
<Text style={styles.subtitle}>Choose your server</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.options}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.optionCard,
|
||||||
|
selectedOption === 'managed' && styles.optionCardSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedOption('managed')}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
<Text style={styles.optionTitle}>Managed Cloud</Text>
|
||||||
|
<Text style={styles.optionDescription}>
|
||||||
|
Use the official Papra cloud service
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.optionCard,
|
||||||
|
selectedOption === 'self-hosted' && styles.optionCardSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedOption('self-hosted')}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
<Text style={styles.optionTitle}>Self-Hosted</Text>
|
||||||
|
<Text style={styles.optionDescription}>
|
||||||
|
Connect to your own Papra server
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{selectedOption === 'managed' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isValidating && styles.buttonDisabled]}
|
||||||
|
onPress={async () => handleValidateCustomUrl({ url: MANAGED_SERVER_URL })}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
{isValidating
|
||||||
|
? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Text style={styles.buttonText}>Continue with Managed</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedOption === 'self-hosted' && (
|
||||||
|
<View style={styles.customUrlContainer}>
|
||||||
|
<Text style={styles.inputLabel}>Server URL</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="https://your-server.com"
|
||||||
|
placeholderTextColor={themeColors.mutedForeground}
|
||||||
|
value={customUrl}
|
||||||
|
onChangeText={setCustomUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
editable={!isValidating}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isValidating && styles.buttonDisabled]}
|
||||||
|
onPress={async () => handleValidateCustomUrl({ url: customUrl })}
|
||||||
|
disabled={isValidating}
|
||||||
|
>
|
||||||
|
{isValidating
|
||||||
|
? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Text style={styles.buttonText}>Connect</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: 24,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
optionCard: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
},
|
||||||
|
optionCardSelected: {
|
||||||
|
borderColor: themeColors.primary,
|
||||||
|
},
|
||||||
|
optionTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
optionDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
customUrlContainer: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
height: 50,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.foreground,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: themeColors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: themeColors.primaryForeground,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
233
apps/mobile/src/modules/documents/components/import-drawer.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import * as DocumentPicker from 'expo-document-picker';
|
||||||
|
import { File } from 'expo-file-system';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useApiClient } from '@/modules/api/providers/api.provider';
|
||||||
|
import { queryClient } from '@/modules/api/providers/query.provider';
|
||||||
|
import { useOrganizations } from '@/modules/organizations/organizations.provider';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { uploadDocument } from '../documents.services';
|
||||||
|
|
||||||
|
type ImportDrawerProps = {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ImportDrawer({ visible, onClose }: ImportDrawerProps) {
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const { showAlert } = useAlert();
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
const { currentOrganizationId } = useOrganizations();
|
||||||
|
|
||||||
|
const handleImportFromFiles = async () => {
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentOrganizationId == null) {
|
||||||
|
showAlert({
|
||||||
|
title: 'No Organization Selected',
|
||||||
|
message: 'Please select an organization before importing documents.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: [
|
||||||
|
'application/pdf',
|
||||||
|
'image/*',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'text/plain',
|
||||||
|
],
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
multiple: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [pickerFile] = result.assets;
|
||||||
|
if (!pickerFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = new File(pickerFile.uri);
|
||||||
|
|
||||||
|
await uploadDocument({ file, apiClient, organizationId: currentOrganizationId });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['organizations', currentOrganizationId, 'documents'] });
|
||||||
|
|
||||||
|
showAlert({
|
||||||
|
title: 'Upload Successful',
|
||||||
|
message: `Successfully uploaded: ${file.name}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showAlert({
|
||||||
|
title: 'Error',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to pick document',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// const handleScanDocument = () => {
|
||||||
|
// onClose();
|
||||||
|
// showAlert({
|
||||||
|
// title: 'Coming Soon',
|
||||||
|
// message: 'Camera document scanning will be available soon!',
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backdrop}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.drawer}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Import Document</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.optionsContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.optionItem}
|
||||||
|
onPress={handleImportFromFiles}
|
||||||
|
>
|
||||||
|
<View style={styles.optionIconContainer}>
|
||||||
|
<Icon name="file-plus" size={24} style={styles.optionIcon} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.optionTextContainer}>
|
||||||
|
<Text style={styles.optionTitle}>Import from Files</Text>
|
||||||
|
<Text style={styles.optionDescription}>
|
||||||
|
Choose a document from your device
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Icon name="chevron-right" size={18} style={styles.chevronIcon} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* <TouchableOpacity
|
||||||
|
style={styles.optionItem}
|
||||||
|
onPress={handleScanDocument}
|
||||||
|
>
|
||||||
|
<View style={styles.optionIconContainer}>
|
||||||
|
<Icon name="camera" size={24} style={styles.optionIcon} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.optionTextContainer}>
|
||||||
|
<Text style={styles.optionTitle}>Scan Document</Text>
|
||||||
|
<Text style={styles.optionDescription}>
|
||||||
|
Use camera to scan (Coming soon)
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Icon name="chevron-right" size={18} style={styles.chevronIcon} />
|
||||||
|
</TouchableOpacity> */}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
drawer: {
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: themeColors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
optionsContainer: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
optionItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: themeColors.border,
|
||||||
|
},
|
||||||
|
optionIconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
optionIcon: {
|
||||||
|
fontSize: 24,
|
||||||
|
color: themeColors.primary,
|
||||||
|
},
|
||||||
|
optionTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
optionTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
optionDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
chevronIcon: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
margin: 20,
|
||||||
|
marginTop: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Pressable, StyleSheet, View } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { ImportDrawer } from '@/modules/documents/components/import-drawer';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: {
|
||||||
|
width: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ImportTabButton(props: BottomTabBarButtonProps) {
|
||||||
|
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
setIsDrawerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Pressable
|
||||||
|
onPress={handlePress}
|
||||||
|
style={[styles.container, props.style]}
|
||||||
|
>
|
||||||
|
<View style={[styles.button, { backgroundColor: themeColors.primary, marginBottom: 20 + insets.bottom }]}>
|
||||||
|
<Icon name="plus" size={32} color={themeColors.primaryForeground} style={{ height: 32 }} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<ImportDrawer
|
||||||
|
visible={isDrawerVisible}
|
||||||
|
onClose={() => setIsDrawerVisible(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/mobile/src/modules/documents/documents.services.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ApiClient } from '../api/api.client';
|
||||||
|
import type { Document } from './documents.types';
|
||||||
|
import { coerceDates } from '../api/api.models';
|
||||||
|
|
||||||
|
export function getFormData(pojo: Record<string, string | Blob>): FormData {
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.entries(pojo).forEach(([key, value]) => formData.append(key, value));
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDocument({
|
||||||
|
file,
|
||||||
|
organizationId,
|
||||||
|
|
||||||
|
apiClient,
|
||||||
|
}: {
|
||||||
|
file: Blob;
|
||||||
|
organizationId: string;
|
||||||
|
|
||||||
|
apiClient: ApiClient;
|
||||||
|
}) {
|
||||||
|
const { document } = await apiClient<{ document: Document }>({
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/organizations/${organizationId}/documents`,
|
||||||
|
body: getFormData({ file }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
document: coerceDates(document),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOrganizationDocuments({
|
||||||
|
organizationId,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
filters,
|
||||||
|
|
||||||
|
apiClient,
|
||||||
|
}: {
|
||||||
|
organizationId: string;
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
filters?: {
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
apiClient: ApiClient;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
documents: apiDocuments,
|
||||||
|
documentsCount,
|
||||||
|
} = await apiClient<{ documents: Document[]; documentsCount: number }>({
|
||||||
|
method: 'GET',
|
||||||
|
path: `/api/organizations/${organizationId}/documents`,
|
||||||
|
query: {
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const documents = apiDocuments.map(coerceDates);
|
||||||
|
|
||||||
|
return {
|
||||||
|
documentsCount,
|
||||||
|
documents,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching documents:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/mobile/src/modules/documents/documents.types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type Document = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
originalSize: number;
|
||||||
|
organizationId: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
tags: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useApiClient } from '@/modules/api/providers/api.provider';
|
||||||
|
import { OrganizationPickerButton } from '@/modules/organizations/components/organization-picker-button';
|
||||||
|
import { OrganizationPickerDrawer } from '@/modules/organizations/components/organization-picker-drawer';
|
||||||
|
import { useOrganizations } from '@/modules/organizations/organizations.provider';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { fetchOrganizationDocuments } from '../documents.services';
|
||||||
|
|
||||||
|
export function DocumentsListScreen() {
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
const { currentOrganizationId, isLoading: isLoadingOrganizations } = useOrganizations();
|
||||||
|
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
|
||||||
|
const pagination = { pageIndex: 0, pageSize: 20 };
|
||||||
|
|
||||||
|
const documentsQuery = useQuery({
|
||||||
|
queryKey: ['organizations', currentOrganizationId, 'documents', pagination],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (currentOrganizationId == null) {
|
||||||
|
return { documents: [], documentsCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchOrganizationDocuments({
|
||||||
|
organizationId: currentOrganizationId,
|
||||||
|
...pagination,
|
||||||
|
apiClient,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: currentOrganizationId !== null && currentOrganizationId !== '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRefresh = async () => {
|
||||||
|
await documentsQuery.refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingOrganizations) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, styles.centerContent]}>
|
||||||
|
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Documents</Text>
|
||||||
|
<OrganizationPickerButton onPress={() => setIsDrawerVisible(true)} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{documentsQuery.isLoading
|
||||||
|
? (
|
||||||
|
<View style={styles.centerContent}>
|
||||||
|
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<FlatList
|
||||||
|
data={documentsQuery.data?.documents ?? []}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={styles.documentCard}>
|
||||||
|
<View style={{ backgroundColor: themeColors.muted, padding: 10, borderRadius: 6, marginRight: 12 }}>
|
||||||
|
<Icon name="file-text" size={24} color={themeColors.primary} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.documentTitle} numberOfLines={2}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.documentMeta}>
|
||||||
|
<Text style={styles.metaText}>{formatFileSize(item.originalSize)}</Text>
|
||||||
|
<Text style={styles.metaSplitter}>-</Text>
|
||||||
|
<Text style={styles.metaText}>{formatDate(item.createdAt)}</Text>
|
||||||
|
{item.tags.length > 0 && (
|
||||||
|
<View style={styles.tagsContainer}>
|
||||||
|
{item.tags.map(tag => (
|
||||||
|
<View
|
||||||
|
key={tag.id}
|
||||||
|
style={[
|
||||||
|
styles.tag,
|
||||||
|
{ backgroundColor: `${tag.color}10` },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.tagText, { color: tag.color }]}>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={(
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No documents yet</Text>
|
||||||
|
<Text style={styles.emptySubtext}>
|
||||||
|
Upload your first document to get started
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={documentsQuery.data?.documents.length === 0 ? styles.emptyList : undefined}
|
||||||
|
refreshControl={(
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={documentsQuery.isRefetching}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OrganizationPickerDrawer
|
||||||
|
visible={isDrawerVisible}
|
||||||
|
onClose={() => setIsDrawerVisible(false)}
|
||||||
|
/>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
},
|
||||||
|
centerContent: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: 20,
|
||||||
|
gap: 12,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: themeColors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
emptyList: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 60,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
emptySubtext: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
documentCard: {
|
||||||
|
padding: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
documentTitle: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
documentMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
metaText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
metaSplitter: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
tagsContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 6,
|
||||||
|
},
|
||||||
|
tagText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
lineHeight: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const STORAGE_KEY_BASE_PREFIX = '@papra';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { STORAGE_KEY_BASE_PREFIX } from './local-storage.constants';
|
||||||
|
|
||||||
|
export function buildStorageKey(sections: string[]): string {
|
||||||
|
return [STORAGE_KEY_BASE_PREFIX, ...sections].join(':');
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
export const storage = {
|
||||||
|
getItem: async (key: string) => AsyncStorage.getItem(key),
|
||||||
|
setItem: async (key: string, value: string) => AsyncStorage.setItem(key, value),
|
||||||
|
removeItem: async (key: string) => AsyncStorage.removeItem(key),
|
||||||
|
clear: async () => AsyncStorage.clear(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { useOrganizations } from '../organizations.provider';
|
||||||
|
|
||||||
|
type OrganizationPickerButtonProps = {
|
||||||
|
onPress: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrganizationPickerButton({ onPress }: OrganizationPickerButtonProps) {
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const { organizations, currentOrganizationId } = useOrganizations();
|
||||||
|
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
const currentOrganization = organizations.find(org => org.id === currentOrganizationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity style={styles.button} onPress={onPress}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.orgName} numberOfLines={1}>
|
||||||
|
{currentOrganization?.name ?? 'Select Organization'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Icon name="chevron-down" style={styles.caret} size={20} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
orgName: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
caret: {
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
FlatList,
|
||||||
|
Modal,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { useOrganizations } from '../organizations.provider';
|
||||||
|
|
||||||
|
type OrganizationPickerDrawerProps = {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrganizationPickerDrawer({ visible, onClose }: OrganizationPickerDrawerProps) {
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const router = useRouter();
|
||||||
|
const { organizations, currentOrganizationId, setCurrentOrganizationId, isLoading } = useOrganizations();
|
||||||
|
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
const handleSelectOrganization = async (organizationId: string) => {
|
||||||
|
await setCurrentOrganizationId(organizationId);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateOrganization = () => {
|
||||||
|
onClose();
|
||||||
|
router.push('/(app)/(with-organizations)/organizations/create');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={onClose}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backdrop}
|
||||||
|
activeOpacity={1}
|
||||||
|
onPress={onClose}
|
||||||
|
>
|
||||||
|
<View style={styles.drawer}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Select Organization</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isLoading
|
||||||
|
? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={themeColors.primary} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<FlatList
|
||||||
|
data={organizations}
|
||||||
|
keyExtractor={item => item.id}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.orgItem,
|
||||||
|
item.id === currentOrganizationId && styles.orgItemSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => {
|
||||||
|
void handleSelectOrganization(item.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.orgName,
|
||||||
|
item.id === currentOrganizationId && styles.orgNameSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
{item.id === currentOrganizationId && (
|
||||||
|
<Icon name="check" style={styles.checkmark} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.createButton}
|
||||||
|
onPress={handleCreateOrganization}
|
||||||
|
>
|
||||||
|
<Text style={styles.createButtonText}>+ Create New Organization</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
backdrop: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
drawer: {
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
borderTopLeftRadius: 20,
|
||||||
|
borderTopRightRadius: 20,
|
||||||
|
maxHeight: '70%',
|
||||||
|
paddingBottom: 20,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: themeColors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
padding: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
orgItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 16,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: themeColors.border,
|
||||||
|
},
|
||||||
|
orgItemSelected: {
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
},
|
||||||
|
orgName: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
orgNameSelected: {
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.primary,
|
||||||
|
},
|
||||||
|
checkmark: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: themeColors.primary,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
createButton: {
|
||||||
|
margin: 20,
|
||||||
|
marginTop: 0,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
createButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { STORAGE_KEY_BASE_PREFIX } from '../lib/local-storage/local-storage.constants';
|
||||||
|
import { storage } from '../lib/local-storage/local-storage.services';
|
||||||
|
|
||||||
|
const CURRENT_ORGANIZATION_ID_KEY = `${STORAGE_KEY_BASE_PREFIX}:current-organization-id`;
|
||||||
|
|
||||||
|
export const organizationsLocalStorage = {
|
||||||
|
getCurrentOrganizationId: async (): Promise<string | null> => {
|
||||||
|
return storage.getItem(CURRENT_ORGANIZATION_ID_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentOrganizationId: async (organizationId: string): Promise<void> => {
|
||||||
|
await storage.setItem(CURRENT_ORGANIZATION_ID_KEY, organizationId);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCurrentOrganizationId: async (): Promise<void> => {
|
||||||
|
await storage.removeItem(CURRENT_ORGANIZATION_ID_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
102
apps/mobile/src/modules/organizations/organizations.provider.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
import { useApiClient } from '../api/providers/api.provider';
|
||||||
|
import { organizationsLocalStorage } from './organizations.local-storage';
|
||||||
|
import { fetchOrganizations } from './organizations.services';
|
||||||
|
|
||||||
|
type OrganizationsContextValue = {
|
||||||
|
currentOrganizationId: string | null;
|
||||||
|
setCurrentOrganizationId: (organizationId: string) => Promise<void>;
|
||||||
|
organizations: Organization[];
|
||||||
|
isLoading: boolean;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationsContext = createContext<OrganizationsContextValue | null>(null);
|
||||||
|
|
||||||
|
type OrganizationsProviderProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrganizationsProvider({ children }: OrganizationsProviderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [currentOrganizationId, setCurrentOrganizationIdState] = useState<string | null>(null);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
|
||||||
|
const organizationsQuery = useQuery({
|
||||||
|
queryKey: ['organizations'],
|
||||||
|
queryFn: async () => fetchOrganizations({ apiClient }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load current organization ID from storage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCurrentOrganizationId = async () => {
|
||||||
|
const storedOrgId = await organizationsLocalStorage.getCurrentOrganizationId();
|
||||||
|
setCurrentOrganizationIdState(storedOrgId);
|
||||||
|
setIsInitialized(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadCurrentOrganizationId();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCurrentOrganizationId = async (organizationId: string) => {
|
||||||
|
await organizationsLocalStorage.setCurrentOrganizationId(organizationId);
|
||||||
|
setCurrentOrganizationIdState(organizationId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Redirect to organization selection if no organizations or no current org set
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized || organizationsQuery.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizations = organizationsQuery.data?.organizations ?? [];
|
||||||
|
|
||||||
|
if (organizations.length === 0) {
|
||||||
|
// No organizations, redirect to organization create to create one
|
||||||
|
router.replace('/(app)/(with-organizations)/organizations/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's no current org set, or the current org doesn't exist anymore, set to first org
|
||||||
|
if (currentOrganizationId == null || !organizations.some(org => org.id === currentOrganizationId)) {
|
||||||
|
const firstOrg = organizations[0];
|
||||||
|
if (firstOrg) {
|
||||||
|
void setCurrentOrganizationId(firstOrg.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isInitialized, organizationsQuery.isLoading, organizationsQuery.data, currentOrganizationId, router]);
|
||||||
|
|
||||||
|
const refetch = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['organizations'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: OrganizationsContextValue = {
|
||||||
|
currentOrganizationId,
|
||||||
|
setCurrentOrganizationId,
|
||||||
|
organizations: organizationsQuery.data?.organizations ?? [],
|
||||||
|
isLoading: organizationsQuery.isLoading || !isInitialized,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OrganizationsContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</OrganizationsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrganizations(): OrganizationsContextValue {
|
||||||
|
const context = useContext(OrganizationsContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useOrganizations must be used within OrganizationsProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ApiClient } from '../api/api.client';
|
||||||
|
import type { Organization } from '@/modules/organizations/organizations.types';
|
||||||
|
|
||||||
|
export async function fetchOrganizations({ apiClient }: { apiClient: ApiClient }) {
|
||||||
|
return apiClient<{
|
||||||
|
organizations: Organization[];
|
||||||
|
}>({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/organizations',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrganization({ name, apiClient }: { name: string; apiClient: ApiClient }) {
|
||||||
|
return apiClient<{
|
||||||
|
organization: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}>({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/organizations',
|
||||||
|
body: { name },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type Organization = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useApiClient } from '@/modules/api/providers/api.provider';
|
||||||
|
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
import { useOrganizations } from '../organizations.provider';
|
||||||
|
import { createOrganization } from '../organizations.services';
|
||||||
|
|
||||||
|
export function OrganizationCreateScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
const { showAlert } = useAlert();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { setCurrentOrganizationId, refetch } = useOrganizations();
|
||||||
|
|
||||||
|
const [organizationName, setOrganizationName] = useState('');
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: async ({ name }: { name: string }) => createOrganization({ name, apiClient }),
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await refetch();
|
||||||
|
await setCurrentOrganizationId(data.organization.id);
|
||||||
|
router.replace('/(app)/(with-organizations)/(tabs)/list');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
showAlert({
|
||||||
|
title: 'Error',
|
||||||
|
message: error instanceof Error ? error.message : 'Failed to create organization',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (organizationName.trim().length === 0) {
|
||||||
|
showAlert({
|
||||||
|
title: 'Invalid Name',
|
||||||
|
message: 'Please enter a valid organization name',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate({ name: organizationName.trim() });
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={{ ...styles.container, paddingTop: insets.top }}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Create organization</Text>
|
||||||
|
<Text style={styles.subtitle}>
|
||||||
|
Your documents will be grouped by organization. You can create multiple organizations to separate your documents, for example, for personal and work documents.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.formContainer}>
|
||||||
|
<View style={styles.fieldContainer}>
|
||||||
|
<Text style={styles.label}>Organization Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="My Organization"
|
||||||
|
placeholderTextColor={themeColors.mutedForeground}
|
||||||
|
value={organizationName}
|
||||||
|
onChangeText={setOrganizationName}
|
||||||
|
autoFocus
|
||||||
|
autoCapitalize="words"
|
||||||
|
editable={!createMutation.isPending}
|
||||||
|
onSubmitEditing={handleCreate}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, createMutation.isPending && styles.buttonDisabled]}
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending
|
||||||
|
? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Text style={styles.buttonText}>Create Organization</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: 24,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 48,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
formContainer: {
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
fieldContainer: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
height: 50,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
fontSize: 16,
|
||||||
|
color: themeColors.foreground,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: themeColors.primary,
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
color: themeColors.primaryForeground,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
146
apps/mobile/src/modules/ui/components/alert-dialog.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
type AlertButton = {
|
||||||
|
text: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
style?: 'default' | 'cancel' | 'destructive';
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertDialogProps = {
|
||||||
|
visible: boolean;
|
||||||
|
title: string;
|
||||||
|
message?: string | ReactNode;
|
||||||
|
buttons: AlertButton[];
|
||||||
|
onDismiss?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AlertDialog({ visible, title, message, buttons, onDismiss }: AlertDialogProps) {
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
const handleButtonPress = (button: AlertButton) => {
|
||||||
|
button.onPress?.();
|
||||||
|
onDismiss?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
transparent
|
||||||
|
visible={visible}
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={onDismiss}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.overlay} onPress={onDismiss}>
|
||||||
|
<Pressable style={styles.container}>
|
||||||
|
<View style={styles.content}>
|
||||||
|
<Text style={styles.title}>{title}</Text>
|
||||||
|
{message !== undefined && (
|
||||||
|
<Text style={styles.message}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
{buttons.map((button, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
button.style === 'cancel' && styles.cancelButton,
|
||||||
|
button.style === 'destructive' && styles.destructiveButton,
|
||||||
|
]}
|
||||||
|
onPress={() => handleButtonPress(button)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.buttonText,
|
||||||
|
button.style === 'cancel' && styles.cancelButtonText,
|
||||||
|
button.style === 'destructive' && styles.destructiveButtonText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{button.text}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
width: '85%',
|
||||||
|
maxWidth: 400,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 24,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
marginBottom: 24,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: themeColors.primary,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.primaryForeground,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: themeColors.border,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
destructiveButton: {
|
||||||
|
backgroundColor: themeColors.destructiveBackground,
|
||||||
|
},
|
||||||
|
destructiveButtonText: {
|
||||||
|
color: themeColors.destructive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
46
apps/mobile/src/modules/ui/components/collapsible.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
|
import { IconSymbol } from '@/modules/ui/components/icon-symbol';
|
||||||
|
import { ThemedText } from '@/modules/ui/components/themed-text';
|
||||||
|
import { ThemedView } from '@/modules/ui/components/themed-view';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heading: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const colors = useThemeColor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.heading}
|
||||||
|
onPress={() => setIsOpen(value => !value)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<IconSymbol
|
||||||
|
name="chevron.right"
|
||||||
|
size={18}
|
||||||
|
weight="medium"
|
||||||
|
color={colors.foreground}
|
||||||
|
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ThemedText type="defaultSemiBold">{title}</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/mobile/src/modules/ui/components/external-link.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Href } from 'expo-router';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import { Link } from 'expo-router';
|
||||||
|
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||||
|
|
||||||
|
export function ExternalLink({ href, ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...rest}
|
||||||
|
href={href}
|
||||||
|
onPress={async (event) => {
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
event.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
await openBrowserAsync(href, {
|
||||||
|
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/mobile/src/modules/ui/components/haptic-tab.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
|
||||||
|
import { PlatformPressable } from '@react-navigation/elements';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export function HapticTab(props: BottomTabBarButtonProps) {
|
||||||
|
return (
|
||||||
|
<PlatformPressable
|
||||||
|
{...props}
|
||||||
|
onPressIn={async (ev) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
// Add a soft haptic feedback when pressing down on the tabs.
|
||||||
|
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
}
|
||||||
|
props.onPressIn?.(ev);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/mobile/src/modules/ui/components/hello-wave.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Animated from 'react-native-reanimated';
|
||||||
|
|
||||||
|
export function HelloWave() {
|
||||||
|
return (
|
||||||
|
<Animated.Text
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
lineHeight: 32,
|
||||||
|
marginTop: -6,
|
||||||
|
animationName: {
|
||||||
|
'50%': { transform: [{ rotate: '25deg' }] },
|
||||||
|
},
|
||||||
|
animationIterationCount: 4,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
👋
|
||||||
|
</Animated.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
apps/mobile/src/modules/ui/components/icon-symbol.ios.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||||
|
import type { StyleProp, ViewStyle } from 'react-native';
|
||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
weight = 'regular',
|
||||||
|
}: {
|
||||||
|
name: SymbolViewProps['name'];
|
||||||
|
size?: number;
|
||||||
|
color: string;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SymbolView
|
||||||
|
weight={weight}
|
||||||
|
tintColor={color}
|
||||||
|
resizeMode="scaleAspectFit"
|
||||||
|
name={name}
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/mobile/src/modules/ui/components/icon-symbol.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Fallback for using MaterialIcons on Android and web.
|
||||||
|
|
||||||
|
import type { SymbolViewProps, SymbolWeight } from 'expo-symbols';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
import type { OpaqueColorValue, StyleProp, TextStyle } from 'react-native';
|
||||||
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
|
||||||
|
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||||
|
type IconSymbolName = keyof typeof MAPPING;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add your SF Symbols to Material Icons mappings here.
|
||||||
|
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
|
||||||
|
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
|
||||||
|
*/
|
||||||
|
const MAPPING = {
|
||||||
|
'house.fill': 'home',
|
||||||
|
'paperplane.fill': 'send',
|
||||||
|
'chevron.left.forwardslash.chevron.right': 'code',
|
||||||
|
'chevron.right': 'chevron-right',
|
||||||
|
} as IconMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
|
||||||
|
* This ensures a consistent look across platforms, and optimal resource usage.
|
||||||
|
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
|
||||||
|
*/
|
||||||
|
export function IconSymbol({
|
||||||
|
name,
|
||||||
|
size = 24,
|
||||||
|
color,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
name: IconSymbolName;
|
||||||
|
size?: number;
|
||||||
|
color: string | OpaqueColorValue;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
weight?: SymbolWeight;
|
||||||
|
}) {
|
||||||
|
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
|
||||||
|
}
|
||||||
3
apps/mobile/src/modules/ui/components/icon.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { Feather } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
export const Icon = Feather;
|
||||||
29
apps/mobile/src/modules/ui/components/import-tab-button.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ImportDrawer } from '@/modules/documents/components/import-drawer';
|
||||||
|
import { Icon } from '@/modules/ui/components/icon';
|
||||||
|
import { useThemeColor } from '../providers/use-theme-color';
|
||||||
|
import { HapticTab } from './haptic-tab';
|
||||||
|
|
||||||
|
export function ImportTabButton() {
|
||||||
|
const [isDrawerVisible, setIsDrawerVisible] = useState(false);
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
setIsDrawerVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HapticTab onPress={handlePress} style={{ flex: 1, alignItems: 'center', padding: 5 }}>
|
||||||
|
<Icon name="plus" size={30} style={{ height: 30 }} color={themeColors.mutedForeground} />
|
||||||
|
</HapticTab>
|
||||||
|
|
||||||
|
<ImportDrawer
|
||||||
|
visible={isDrawerVisible}
|
||||||
|
onClose={() => setIsDrawerVisible(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { PropsWithChildren, ReactElement } from 'react';
|
||||||
|
import { StyleSheet } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
interpolate,
|
||||||
|
useAnimatedRef,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useScrollOffset,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { ThemedView } from '@/modules/ui/components/themed-view';
|
||||||
|
import { useColorScheme } from '@/modules/ui/providers/use-color-scheme';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
const HEADER_HEIGHT = 250;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
height: HEADER_HEIGHT,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 32,
|
||||||
|
gap: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type Props = PropsWithChildren<{
|
||||||
|
headerImage: ReactElement;
|
||||||
|
headerBackgroundColor: { dark: string; light: string };
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default function ParallaxScrollView({
|
||||||
|
children,
|
||||||
|
headerImage,
|
||||||
|
headerBackgroundColor,
|
||||||
|
}: Props) {
|
||||||
|
const colors = useThemeColor();
|
||||||
|
const colorScheme = useColorScheme() ?? 'light';
|
||||||
|
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
||||||
|
const scrollOffset = useScrollOffset(scrollRef);
|
||||||
|
const headerAnimatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: interpolate(
|
||||||
|
scrollOffset.value,
|
||||||
|
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
|
||||||
|
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.ScrollView
|
||||||
|
ref={scrollRef}
|
||||||
|
style={{ backgroundColor: colors.background, flex: 1 }}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.header,
|
||||||
|
{ backgroundColor: headerBackgroundColor[colorScheme] },
|
||||||
|
headerAnimatedStyle,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{headerImage}
|
||||||
|
</Animated.View>
|
||||||
|
<ThemedView style={styles.content}>{children}</ThemedView>
|
||||||
|
</Animated.ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/mobile/src/modules/ui/components/themed-text.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { TextProps } from 'react-native';
|
||||||
|
import { StyleSheet, Text } from 'react-native';
|
||||||
|
|
||||||
|
export type ThemedTextProps = TextProps & {
|
||||||
|
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
default: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
defaultSemiBold: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
lineHeight: 32,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
lineHeight: 30,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#0a7ea4',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemedText({
|
||||||
|
style,
|
||||||
|
type = 'default',
|
||||||
|
...rest
|
||||||
|
}: ThemedTextProps) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
type === 'default' ? styles.default : undefined,
|
||||||
|
type === 'title' ? styles.title : undefined,
|
||||||
|
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||||
|
type === 'subtitle' ? styles.subtitle : undefined,
|
||||||
|
type === 'link' ? styles.link : undefined,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/mobile/src/modules/ui/components/themed-view.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ViewProps } from 'react-native';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
export type ThemedViewProps = ViewProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedView({ style, ...otherProps }: ThemedViewProps) {
|
||||||
|
const theme = useThemeColor();
|
||||||
|
|
||||||
|
return <View style={[{ backgroundColor: theme.background }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
64
apps/mobile/src/modules/ui/providers/alert-provider.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { createContext, useContext, useState } from 'react';
|
||||||
|
import { AlertDialog } from '@/modules/ui/components/alert-dialog';
|
||||||
|
|
||||||
|
type AlertButton = {
|
||||||
|
text: string;
|
||||||
|
onPress?: () => void;
|
||||||
|
style?: 'default' | 'cancel' | 'destructive';
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertOptions = {
|
||||||
|
title: string;
|
||||||
|
message?: string | ReactNode;
|
||||||
|
buttons?: AlertButton[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlertContextType = {
|
||||||
|
showAlert: (options: AlertOptions) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AlertContext = createContext<AlertContextType | null>(null);
|
||||||
|
|
||||||
|
export function AlertProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [alertState, setAlertState] = useState<AlertOptions & { visible: boolean }>({
|
||||||
|
visible: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
buttons: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAlert = (options: AlertOptions) => {
|
||||||
|
setAlertState({
|
||||||
|
visible: true,
|
||||||
|
title: options.title,
|
||||||
|
message: options.message,
|
||||||
|
buttons: options.buttons ?? [{ text: 'OK', style: 'default' }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
setAlertState(prev => ({ ...prev, visible: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertContext.Provider value={{ showAlert }}>
|
||||||
|
{children}
|
||||||
|
<AlertDialog
|
||||||
|
visible={alertState.visible}
|
||||||
|
title={alertState.title}
|
||||||
|
message={alertState.message}
|
||||||
|
buttons={alertState.buttons ?? []}
|
||||||
|
onDismiss={handleDismiss}
|
||||||
|
/>
|
||||||
|
</AlertContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAlert() {
|
||||||
|
const context = useContext(AlertContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAlert must be used within an AlertProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
1
apps/mobile/src/modules/ui/providers/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useColorScheme } from 'react-native';
|
||||||
21
apps/mobile/src/modules/ui/providers/use-color-scheme.web.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||||
|
*/
|
||||||
|
export function useColorScheme() {
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorScheme = useRNColorScheme();
|
||||||
|
|
||||||
|
if (hasHydrated) {
|
||||||
|
return colorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
14
apps/mobile/src/modules/ui/providers/use-theme-color.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Learn more about light and dark modes:
|
||||||
|
* https://docs.expo.dev/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ThemeColors } from '../theme.constants';
|
||||||
|
import { colors } from '../theme.constants';
|
||||||
|
import { useColorScheme } from './use-color-scheme';
|
||||||
|
|
||||||
|
export function useThemeColor(): ThemeColors {
|
||||||
|
const theme = useColorScheme() ?? 'light';
|
||||||
|
|
||||||
|
return colors[theme];
|
||||||
|
}
|
||||||
64
apps/mobile/src/modules/ui/theme.constants.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
|
||||||
|
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
const lightColors = {
|
||||||
|
foreground: '#0a0a0a',
|
||||||
|
background: '#fafafa',
|
||||||
|
primary: '#fe7d4d',
|
||||||
|
primaryForeground: '#0a0a0a',
|
||||||
|
muted: '#f3f3f3',
|
||||||
|
mutedForeground: '#737373',
|
||||||
|
border: '#e5e5e5',
|
||||||
|
secondaryBackground: '#f3f3f3',
|
||||||
|
destructive: '#d32f2f',
|
||||||
|
destructiveBackground: '#ffe0e0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const darkColors: ThemeColors = {
|
||||||
|
foreground: '#fafafa',
|
||||||
|
background: '#141414',
|
||||||
|
primary: '#d9ff7a',
|
||||||
|
primaryForeground: '#0a0a0a',
|
||||||
|
muted: '#262626',
|
||||||
|
mutedForeground: '#a3a3a3',
|
||||||
|
border: '#262626',
|
||||||
|
secondaryBackground: '#111111',
|
||||||
|
destructive: '#ff6b6b',
|
||||||
|
destructiveBackground: '#2a1a1a',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThemeColors = typeof lightColors;
|
||||||
|
|
||||||
|
export const colors = {
|
||||||
|
light: lightColors,
|
||||||
|
dark: darkColors,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Fonts = Platform.select({
|
||||||
|
ios: {
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||||
|
sans: 'system-ui',
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||||
|
serif: 'ui-serif',
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||||
|
rounded: 'ui-rounded',
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||||
|
mono: 'ui-monospace',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
sans: 'normal',
|
||||||
|
serif: 'serif',
|
||||||
|
rounded: 'normal',
|
||||||
|
mono: 'monospace',
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
sans: 'system-ui, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Helvetica, Arial, sans-serif',
|
||||||
|
serif: 'Georgia, \'Times New Roman\', serif',
|
||||||
|
rounded: '\'SF Pro Rounded\', \'Hiragino Maru Gothic ProN\', Meiryo, \'MS PGothic\', sans-serif',
|
||||||
|
mono: 'SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace',
|
||||||
|
},
|
||||||
|
});
|
||||||
149
apps/mobile/src/modules/users/screens/settings.screen.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import type { ThemeColors } from '@/modules/ui/theme.constants';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { useAuthClient } from '@/modules/api/providers/api.provider';
|
||||||
|
import { useAlert } from '@/modules/ui/providers/alert-provider';
|
||||||
|
import { useThemeColor } from '@/modules/ui/providers/use-theme-color';
|
||||||
|
|
||||||
|
export default function SettingsScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const themeColors = useThemeColor();
|
||||||
|
const authClient = useAuthClient();
|
||||||
|
const session = authClient.useSession();
|
||||||
|
const { showAlert } = useAlert();
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
showAlert({
|
||||||
|
title: 'Sign Out',
|
||||||
|
message: 'Are you sure you want to sign out?',
|
||||||
|
buttons: [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Sign Out',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
router.replace('/auth/login');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = createStyles({ themeColors });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Settings</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>Account</Text>
|
||||||
|
{session.data?.user && (
|
||||||
|
<>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>Name</Text>
|
||||||
|
<Text style={styles.infoValue}>{session.data.user.name}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>Email</Text>
|
||||||
|
<Text style={styles.infoValue}>{session.data.user.email}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>Email Verified</Text>
|
||||||
|
<Text style={styles.infoValue}>
|
||||||
|
{session.data.user.emailVerified ? 'Yes' : 'No'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.dangerButton]}
|
||||||
|
onPress={handleSignOut}
|
||||||
|
>
|
||||||
|
<Text style={[styles.actionButtonText, styles.dangerText]}>
|
||||||
|
Sign Out
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStyles({ themeColors }: { themeColors: ThemeColors }) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: themeColors.background,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
padding: 24,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
marginBottom: 24,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: themeColors.mutedForeground,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: themeColors.secondaryBackground,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: themeColors.foreground,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
dangerButton: {
|
||||||
|
backgroundColor: themeColors.destructiveBackground,
|
||||||
|
},
|
||||||
|
dangerText: {
|
||||||
|
color: themeColors.destructive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
17
apps/mobile/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
9
apps/mobile/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
env: {
|
||||||
|
TZ: 'UTC',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1 +1 @@
|
|||||||
22
|
24
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export default antfu({
|
|||||||
semi: true,
|
semi: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
solid: true,
|
||||||
|
|
||||||
ignores: [
|
ignores: [
|
||||||
'public/manifest.json',
|
'public/manifest.json',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"url": "https://github.com/papra-hq/papra"
|
"url": "https://github.com/papra-hq/papra"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
@@ -42,12 +42,12 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk-solid": "^1.1.2",
|
"cmdk-solid": "^1.1.2",
|
||||||
"ofetch": "^1.4.1",
|
"ofetch": "^1.4.1",
|
||||||
|
"p-limit": "^6.2.0",
|
||||||
"posthog-js-lite": "^4.1.5",
|
"posthog-js-lite": "^4.1.5",
|
||||||
"radix3": "^1.1.2",
|
"radix3": "^1.1.2",
|
||||||
"solid-js": "^1.9.9",
|
"solid-js": "^1.9.9",
|
||||||
"solid-sonner": "^0.2.8",
|
"solid-sonner": "^0.2.8",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"ts-pattern": "^5.7.1",
|
|
||||||
"unocss-preset-animations": "^1.3.0",
|
"unocss-preset-animations": "^1.3.0",
|
||||||
"unstorage": "^1.16.0",
|
"unstorage": "^1.16.0",
|
||||||
"valibot": "1.0.0-beta.10"
|
"valibot": "1.0.0-beta.10"
|
||||||
@@ -58,8 +58,9 @@
|
|||||||
"@playwright/test": "^1.53.1",
|
"@playwright/test": "^1.53.1",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
|
"eslint-plugin-solid": "^0.14.5",
|
||||||
"tinyglobby": "^0.2.14",
|
"tinyglobby": "^0.2.14",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"unocss": "^66.5.4",
|
"unocss": "^66.5.4",
|
||||||
"vite": "^7.1.9",
|
"vite": "^7.1.9",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/colo
|
|||||||
import { Router } from '@solidjs/router';
|
import { Router } from '@solidjs/router';
|
||||||
import { QueryClientProvider } from '@tanstack/solid-query';
|
import { QueryClientProvider } from '@tanstack/solid-query';
|
||||||
|
|
||||||
import { render, Suspense } from 'solid-js/web';
|
import { Suspense } from 'solid-js';
|
||||||
|
import { render } from 'solid-js/web';
|
||||||
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
||||||
import { ConfigProvider } from './modules/config/config.provider';
|
import { ConfigProvider } from './modules/config/config.provider';
|
||||||
import { DemoIndicator } from './modules/demo/demo.provider';
|
import { DemoIndicator } from './modules/demo/demo.provider';
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'documents.list.no-documents.title': 'Keine Dokumente',
|
'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-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.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.info': 'Info',
|
||||||
'documents.tabs.content': 'Inhalt',
|
'documents.tabs.content': 'Inhalt',
|
||||||
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Geben Sie "{{ text }}" ein zur Bestätigung',
|
'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 }}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -247,6 +247,11 @@ export const translations = {
|
|||||||
'documents.list.no-documents.title': 'No documents',
|
'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-documents.description': 'There are no documents in this organization yet. Start by uploading some documents.',
|
||||||
'documents.list.no-results': 'No documents found',
|
'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.info': 'Info',
|
||||||
'documents.tabs.content': 'Content',
|
'documents.tabs.content': 'Content',
|
||||||
@@ -642,7 +647,7 @@ export const translations = {
|
|||||||
|
|
||||||
// Demo
|
// Demo
|
||||||
|
|
||||||
'demo.popup.description': 'This is a demo environment, all data is save to your browser local storage.',
|
'demo.popup.description': 'This is a demo environment, all data is saved to your browser local storage.',
|
||||||
'demo.popup.discord': 'Join the {{ discordLink }} to get support, propose features or just chat.',
|
'demo.popup.discord': 'Join the {{ discordLink }} to get support, propose features or just chat.',
|
||||||
'demo.popup.discord-link-label': 'Discord server',
|
'demo.popup.discord-link-label': 'Discord server',
|
||||||
'demo.popup.reset': 'Reset demo data',
|
'demo.popup.reset': 'Reset demo data',
|
||||||
@@ -708,4 +713,6 @@ export const translations = {
|
|||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Type "{{ text }}" to confirm',
|
'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;
|
} as const;
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'documents.list.no-documents.title': 'Sin documentos',
|
'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-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.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.info': 'Información',
|
||||||
'documents.tabs.content': 'Contenido',
|
'documents.tabs.content': 'Contenido',
|
||||||
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Escriba "{{ text }}" para confirmar',
|
'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 }}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'documents.list.no-documents.title': 'Aucun document',
|
'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-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.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.info': 'Info',
|
||||||
'documents.tabs.content': 'Contenu',
|
'documents.tabs.content': 'Contenu',
|
||||||
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Saisissez "{{ text }}" pour confirmer',
|
'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 }}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
'documents.list.no-documents.title': 'Nessun documento',
|
'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-documents.description': 'Non ci sono ancora documenti in questa organizzazione. Inizia caricando dei documenti.',
|
||||||
'documents.list.no-results': 'Nessun documento trovato',
|
'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.info': 'Info',
|
||||||
'documents.tabs.content': 'Contenuto',
|
'documents.tabs.content': 'Contenuto',
|
||||||
@@ -710,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
|||||||
// Common / Shared
|
// Common / Shared
|
||||||
|
|
||||||
'common.confirm-modal.type-to-confirm': 'Digita "{{ text }}" per confermare',
|
'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 }}',
|
||||||
};
|
};
|
||||||
|
|||||||
720
apps/papra-client/src/locales/nl.dictionary.ts
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
import type { TranslationsDictionary } from '@/modules/i18n/locales.types';
|
||||||
|
|
||||||
|
export const translations: Partial<TranslationsDictionary> = {
|
||||||
|
// Authentication
|
||||||
|
|
||||||
|
'auth.request-password-reset.title': 'Wachtwoord opnieuw instellen',
|
||||||
|
'auth.request-password-reset.description': 'Voer uw e-mailadres in om uw wachtwoord opnieuw in te stellen.',
|
||||||
|
'auth.request-password-reset.requested': 'Als er een account bestaat voor dit e-mailadres, hebben we u een e-mail gestuurd om uw wachtwoord opnieuw in te stellen.',
|
||||||
|
'auth.request-password-reset.back-to-login': 'Terug naar inloggen',
|
||||||
|
'auth.request-password-reset.form.email.label': 'E-mail',
|
||||||
|
'auth.request-password-reset.form.email.placeholder': 'Voorbeeld: ada@papra.app',
|
||||||
|
'auth.request-password-reset.form.email.required': 'Voer uw e-mailadres in',
|
||||||
|
'auth.request-password-reset.form.email.invalid': 'Dit e-mailadres is ongeldig',
|
||||||
|
'auth.request-password-reset.form.submit': 'Wachtwoord opnieuw instellen',
|
||||||
|
|
||||||
|
'auth.reset-password.title': 'Wachtwoord opnieuw instellen',
|
||||||
|
'auth.reset-password.description': 'Voer uw nieuwe wachtwoord in om uw wachtwoord opnieuw in te stellen.',
|
||||||
|
'auth.reset-password.reset': 'Uw wachtwoord is opnieuw ingesteld.',
|
||||||
|
'auth.reset-password.back-to-login': 'Terug naar inloggen',
|
||||||
|
'auth.reset-password.form.new-password.label': 'Nieuw wachtwoord',
|
||||||
|
'auth.reset-password.form.new-password.placeholder': 'Voorbeeld: **********',
|
||||||
|
'auth.reset-password.form.new-password.required': 'Voer uw nieuwe wachtwoord in',
|
||||||
|
'auth.reset-password.form.new-password.min-length': 'Wachtwoord moet minimaal {{ minLength }} tekens bevatten',
|
||||||
|
'auth.reset-password.form.new-password.max-length': 'Wachtwoord mag maximaal {{ maxLength }} tekens bevatten',
|
||||||
|
'auth.reset-password.form.submit': 'Wachtwoord opnieuw instellen',
|
||||||
|
|
||||||
|
'auth.email-provider.open': 'Open {{ provider }}',
|
||||||
|
|
||||||
|
'auth.login.title': 'Inloggen bij Papra',
|
||||||
|
'auth.login.description': 'Voer uw e-mailadres in of gebruik sociale media om toegang te krijgen tot uw Papra-account.',
|
||||||
|
'auth.login.login-with-provider': 'Inloggen met {{ provider }}',
|
||||||
|
'auth.login.no-account': 'Nog geen account?',
|
||||||
|
'auth.login.register': 'Registreren',
|
||||||
|
'auth.login.form.email.label': 'E-mail',
|
||||||
|
'auth.login.form.email.placeholder': 'Voorbeeld: ada@papra.app',
|
||||||
|
'auth.login.form.email.required': 'Voer uw e-mailadres in',
|
||||||
|
'auth.login.form.email.invalid': 'Dit e-mailadres is ongeldig',
|
||||||
|
'auth.login.form.password.label': 'Wachtwoord',
|
||||||
|
'auth.login.form.password.placeholder': 'Voer een wachtwoord in',
|
||||||
|
'auth.login.form.password.required': 'Voer uw wachtwoord in',
|
||||||
|
'auth.login.form.remember-me.label': 'Onthoud mij',
|
||||||
|
'auth.login.form.forgot-password.label': 'Wachtwoord vergeten?',
|
||||||
|
'auth.login.form.submit': 'Inloggen',
|
||||||
|
|
||||||
|
'auth.register.title': 'Registreren bij Papra',
|
||||||
|
'auth.register.description': 'Maak een account aan om Papra te gebruiken.',
|
||||||
|
'auth.register.register-with-email': 'Registreren met e-mail',
|
||||||
|
'auth.register.register-with-provider': 'Registreren met {{ provider }}',
|
||||||
|
'auth.register.providers.google': 'Google',
|
||||||
|
'auth.register.providers.github': 'GitHub',
|
||||||
|
'auth.register.have-account': 'Heeft u al een account?',
|
||||||
|
'auth.register.login': 'Inloggen',
|
||||||
|
'auth.register.registration-disabled.title': 'Registratie is uitgeschakeld',
|
||||||
|
'auth.register.registration-disabled.description': 'Het aanmaken van nieuwe accounts is momenteel uitgeschakeld op deze Papra-instantie. Alleen gebruikers met bestaande accounts kunnen inloggen. Als u denkt dat dit een vergissing is, neem dan contact op met de beheerder van deze instantie.',
|
||||||
|
'auth.register.form.email.label': 'E-mail',
|
||||||
|
'auth.register.form.email.placeholder': 'Voorbeeld: ada@papra.app',
|
||||||
|
'auth.register.form.email.required': 'Voer uw e-mailadres in',
|
||||||
|
'auth.register.form.email.invalid': 'Dit e-mailadres is ongeldig',
|
||||||
|
'auth.register.form.password.label': 'Wachtwoord',
|
||||||
|
'auth.register.form.password.placeholder': 'Kies een wachtwoord',
|
||||||
|
'auth.register.form.password.required': 'Voer uw wachtwoord in',
|
||||||
|
'auth.register.form.password.min-length': 'Wachtwoord moet minimaal {{ minLength }} tekens bevatten',
|
||||||
|
'auth.register.form.password.max-length': 'Wachtwoord mag maximaal {{ maxLength }} tekens bevatten',
|
||||||
|
'auth.register.form.name.label': 'Naam',
|
||||||
|
'auth.register.form.name.placeholder': 'Voorbeeld: Ada Lovelace',
|
||||||
|
'auth.register.form.name.required': 'Voer uw naam in',
|
||||||
|
'auth.register.form.name.max-length': 'Naam mag maximaal {{ maxLength }} tekens bevatten',
|
||||||
|
'auth.register.form.submit': 'Registreren',
|
||||||
|
|
||||||
|
'auth.email-validation-required.title': 'Bevestig uw e-mail',
|
||||||
|
'auth.email-validation-required.description': 'We hebben een e-mail naar u gestuurd. Bevestig uw e-mailadres door op de link in de e-mail te klikken.',
|
||||||
|
|
||||||
|
'auth.email-verification.success.title': 'E-mail bevestigd',
|
||||||
|
'auth.email-verification.success.description': 'Uw e-mail is succesvol bevestigd. U kunt nu inloggen op uw account.',
|
||||||
|
'auth.email-verification.success.login': 'Ga naar inloggen',
|
||||||
|
'auth.email-verification.error.title': 'Bevestiging mislukt',
|
||||||
|
'auth.email-verification.error.description': 'De bevestigingslink is verlopen of ongeldig. Vraag een nieuwe e-mail aan door in te loggen.',
|
||||||
|
'auth.email-verification.error.back': 'Terug naar inloggen',
|
||||||
|
|
||||||
|
'auth.legal-links.description': 'Door door te gaan erkent u dat u de {{ terms }} en {{ privacy }} begrijpt en ermee instemt.',
|
||||||
|
'auth.legal-links.terms': 'Servicevoorwaarden',
|
||||||
|
'auth.legal-links.privacy': 'Privacybeleid',
|
||||||
|
|
||||||
|
'auth.no-auth-provider.title': 'Geen authenticatieprovider',
|
||||||
|
'auth.no-auth-provider.description': 'Er zijn geen authenticatieproviders ingeschakeld op deze Papra-instantie. Neem contact op met de beheerder om deze in te schakelen.',
|
||||||
|
|
||||||
|
// User settings
|
||||||
|
|
||||||
|
'user.settings.title': 'Gebruikersinstellingen',
|
||||||
|
'user.settings.description': 'Beheer hier uw accountinstellingen.',
|
||||||
|
|
||||||
|
'user.settings.email.title': 'E-mailadres',
|
||||||
|
'user.settings.email.description': 'Uw e-mailadres kan niet worden gewijzigd.',
|
||||||
|
'user.settings.email.label': 'E-mailadres',
|
||||||
|
|
||||||
|
'user.settings.name.title': 'Volledige naam',
|
||||||
|
'user.settings.name.description': 'Uw volledige naam wordt weergegeven aan andere organisatiedeelnemers.',
|
||||||
|
'user.settings.name.label': 'Volledige naam',
|
||||||
|
'user.settings.name.placeholder': 'Bijv. John Doe',
|
||||||
|
'user.settings.name.update': 'Naam bijwerken',
|
||||||
|
'user.settings.name.updated': 'Uw volledige naam is bijgewerkt',
|
||||||
|
|
||||||
|
'user.settings.logout.title': 'Uitloggen',
|
||||||
|
'user.settings.logout.description': 'Log uit van uw account. U kunt later weer inloggen.',
|
||||||
|
'user.settings.logout.button': 'Uitloggen',
|
||||||
|
|
||||||
|
// Organizations
|
||||||
|
|
||||||
|
'organizations.list.title': 'Uw organisaties',
|
||||||
|
'organizations.list.description': 'Organisaties zijn een manier om uw documenten te groeperen en toegang te beheren. U kunt meerdere organisaties aanmaken en uw teamleden uitnodigen om samen te werken.',
|
||||||
|
'organizations.list.create-new': 'Nieuwe organisatie aanmaken',
|
||||||
|
'organizations.list.back': 'Terug naar organisaties',
|
||||||
|
'organizations.list.deleted.title': 'Verwijderde organisaties',
|
||||||
|
'organizations.list.deleted.description': 'Verwijderde organisaties worden nog {{ days }} dagen bewaard voordat ze permanent worden verwijderd. U kunt ze in deze periode herstellen.',
|
||||||
|
'organizations.list.deleted.empty': 'Geen verwijderde organisaties',
|
||||||
|
'organizations.list.deleted.empty-description': 'Wanneer u een organisatie verwijdert, verschijnt deze hier gedurende {{ days }} dagen voordat deze permanent wordt verwijderd.',
|
||||||
|
'organizations.list.deleted.restore': 'Herstellen',
|
||||||
|
'organizations.list.deleted.restore-success': 'Organisatie succesvol hersteld',
|
||||||
|
'organizations.list.deleted.restore-confirm.title': 'Organisatie herstellen',
|
||||||
|
'organizations.list.deleted.restore-confirm.message': 'Weet u zeker dat u deze organisatie wilt herstellen? Deze wordt teruggezet naar uw lijst met actieve organisaties.',
|
||||||
|
'organizations.list.deleted.restore-confirm.confirm-button': 'Organisatie herstellen',
|
||||||
|
'organizations.list.deleted.deleted-at': 'Verwijderd {{ date }}',
|
||||||
|
'organizations.list.deleted.purge-at': 'Wordt permanent verwijderd op {{ date }}',
|
||||||
|
'organizations.list.deleted.days-remaining': '({{ daysUntilPurge, =1:{daysUntilPurge} dag, {daysUntilPurge} dagen }} resterend)',
|
||||||
|
|
||||||
|
'organizations.details.no-documents.title': 'Geen documenten',
|
||||||
|
'organizations.details.no-documents.description': 'Er staan nog geen documenten in deze organisatie. Begin met het uploaden van documenten.',
|
||||||
|
'organizations.details.upload-documents': 'Documenten uploaden',
|
||||||
|
'organizations.details.documents-count': 'documenten in totaal',
|
||||||
|
'organizations.details.total-size': 'totale grootte',
|
||||||
|
'organizations.details.latest-documents': 'Laatst geïmporteerde documenten',
|
||||||
|
|
||||||
|
'organizations.create.title': 'Maak een nieuwe organisatie',
|
||||||
|
'organizations.create.description': 'Uw documenten worden per organisatie gegroepeerd. U kunt meerdere organisaties aanmaken om uw documenten te scheiden, bijvoorbeeld privé en werk.',
|
||||||
|
'organizations.create.back': 'Terug',
|
||||||
|
'organizations.create.error.max-count-reached': 'U heeft het maximale aantal organisaties bereikt dat u kunt aanmaken. Neem contact op met support als u meer nodig heeft.',
|
||||||
|
'organizations.create.form.name.label': 'Organisatienaam',
|
||||||
|
'organizations.create.form.name.placeholder': 'Bijv. Acme Inc.',
|
||||||
|
'organizations.create.form.name.required': 'Voer een organisatienaam in',
|
||||||
|
'organizations.create.form.submit': 'Organisatie aanmaken',
|
||||||
|
'organizations.create.success': 'Organisatie succesvol aangemaakt',
|
||||||
|
|
||||||
|
'organizations.create-first.title': 'Maak uw organisatie',
|
||||||
|
'organizations.create-first.description': 'Uw documenten worden per organisatie gegroepeerd. U kunt meerdere organisaties aanmaken om uw documenten te scheiden, bijvoorbeeld privé en werk.',
|
||||||
|
'organizations.create-first.default-name': 'Mijn organisatie',
|
||||||
|
'organizations.create-first.user-name': '{{ name }}\'s organisatie',
|
||||||
|
|
||||||
|
'organization.settings.title': 'Organisatie-instellingen',
|
||||||
|
'organization.settings.page.title': 'Organisatie-instellingen',
|
||||||
|
'organization.settings.page.description': 'Beheer hier uw organisatie-instellingen.',
|
||||||
|
'organization.settings.name.title': 'Organisatienaam',
|
||||||
|
'organization.settings.name.update': 'Naam bijwerken',
|
||||||
|
'organization.settings.name.placeholder': 'Bijv. Acme Inc.',
|
||||||
|
'organization.settings.name.updated': 'Organisatienaam bijgewerkt',
|
||||||
|
'organization.settings.subscription.title': 'Abonnement',
|
||||||
|
'organization.settings.subscription.description': 'Beheer uw facturering, facturen en betaalmethoden.',
|
||||||
|
'organization.settings.subscription.manage': 'Abonnement beheren',
|
||||||
|
'organization.settings.subscription.error': 'Kan customer portal-URL niet ophalen',
|
||||||
|
'organization.settings.delete.title': 'Organisatie verwijderen',
|
||||||
|
'organization.settings.delete.description': 'Het verwijderen van deze organisatie zal alle bijbehorende gegevens permanent verwijderen.',
|
||||||
|
'organization.settings.delete.confirm.title': 'Organisatie verwijderen',
|
||||||
|
'organization.settings.delete.confirm.message': 'Weet u zeker dat u deze organisatie wilt verwijderen? De organisatie wordt gemarkeerd voor verwijdering en na {{ days }} dagen permanent verwijderd. Tijdens deze periode kunt u deze herstellen vanuit uw organisatieslijst. Alle documenten en gegevens worden na deze termijn permanent verwijderd.',
|
||||||
|
'organization.settings.delete.confirm.confirm-button': 'Organisatie verwijderen',
|
||||||
|
'organization.settings.delete.confirm.cancel-button': 'Annuleren',
|
||||||
|
'organization.settings.delete.success': 'Organisatie verwijderd',
|
||||||
|
'organization.settings.delete.only-owner': 'Alleen de eigenaar van de organisatie kan deze verwijderen.',
|
||||||
|
'organization.settings.delete.has-active-subscription': 'Kan organisatie met een actief abonnement niet verwijderen. Annuleer eerst uw abonnement hierboven.',
|
||||||
|
|
||||||
|
'organization.usage.page.title': 'Gebruik',
|
||||||
|
'organization.usage.page.description': 'Bekijk het huidige gebruik en de limieten van uw organisatie.',
|
||||||
|
'organization.usage.storage.title': 'Documentopslag',
|
||||||
|
'organization.usage.storage.description': 'Totale opslag die door uw documenten wordt gebruikt',
|
||||||
|
'organization.usage.intake-emails.title': 'Intake-e-mails',
|
||||||
|
'organization.usage.intake-emails.description': 'Aantal intake-e-mailadressen',
|
||||||
|
'organization.usage.members.title': 'Leden',
|
||||||
|
'organization.usage.members.description': 'Aantal leden in de organisatie',
|
||||||
|
'organization.usage.unlimited': 'Onbeperkt',
|
||||||
|
|
||||||
|
'organizations.members.title': 'Leden',
|
||||||
|
'organizations.members.description': 'Beheer de leden van uw organisatie',
|
||||||
|
'organizations.members.invite-member': 'Lid uitnodigen',
|
||||||
|
'organizations.members.invite-member-disabled-tooltip': 'Alleen beheerders of eigenaren kunnen leden uitnodigen voor de organisatie',
|
||||||
|
'organizations.members.remove-from-organization': 'Verwijderen uit organisatie',
|
||||||
|
'organizations.members.role': 'Rol',
|
||||||
|
'organizations.members.roles.owner': 'Eigenaar',
|
||||||
|
'organizations.members.roles.admin': 'Beheerder',
|
||||||
|
'organizations.members.roles.member': 'Lid',
|
||||||
|
'organizations.members.delete.confirm.title': 'Lid verwijderen',
|
||||||
|
'organizations.members.delete.confirm.message': 'Weet u zeker dat u dit lid uit de organisatie wilt verwijderen?',
|
||||||
|
'organizations.members.delete.confirm.confirm-button': 'Verwijderen',
|
||||||
|
'organizations.members.delete.confirm.cancel-button': 'Annuleren',
|
||||||
|
'organizations.members.delete.success': 'Lid verwijderd uit organisatie',
|
||||||
|
'organizations.members.update-role.success': 'Rol van lid bijgewerkt',
|
||||||
|
'organizations.members.table.headers.name': 'Naam',
|
||||||
|
'organizations.members.table.headers.email': 'E-mail',
|
||||||
|
'organizations.members.table.headers.role': 'Rol',
|
||||||
|
'organizations.members.table.headers.created': 'Aangemaakt',
|
||||||
|
'organizations.members.table.headers.actions': 'Acties',
|
||||||
|
|
||||||
|
'organizations.invite-member.title': 'Lid uitnodigen',
|
||||||
|
'organizations.invite-member.description': 'Nodig een lid uit voor uw organisatie',
|
||||||
|
'organizations.invite-member.form.email.label': 'E-mail',
|
||||||
|
'organizations.invite-member.form.email.placeholder': 'Voorbeeld: ada@papra.app',
|
||||||
|
'organizations.invite-member.form.email.required': 'Voer een geldig e-mailadres in',
|
||||||
|
'organizations.invite-member.form.role.label': 'Rol',
|
||||||
|
'organizations.invite-member.form.submit': 'Uitnodigen voor organisatie',
|
||||||
|
'organizations.invite-member.success.message': 'Lid uitgenodigd',
|
||||||
|
'organizations.invite-member.success.description': 'Het e-mailadres is uitgenodigd voor de organisatie.',
|
||||||
|
'organizations.invite-member.error.message': 'Uitnodiging mislukt',
|
||||||
|
|
||||||
|
'organizations.invitations.title': 'Uitnodigingen',
|
||||||
|
'organizations.invitations.description': 'Beheer uitnodigingen voor uw organisatie',
|
||||||
|
'organizations.invitations.list.cta': 'Lid uitnodigen',
|
||||||
|
'organizations.invitations.list.empty.title': 'Geen openstaande uitnodigingen',
|
||||||
|
'organizations.invitations.list.empty.description': 'U bent nog voor geen organisaties uitgenodigd.',
|
||||||
|
'organizations.invitations.status.pending': 'In afwachting',
|
||||||
|
'organizations.invitations.status.accepted': 'Geaccepteerd',
|
||||||
|
'organizations.invitations.status.rejected': 'Geweigerd',
|
||||||
|
'organizations.invitations.status.expired': 'Verlopen',
|
||||||
|
'organizations.invitations.status.cancelled': 'Geannuleerd',
|
||||||
|
'organizations.invitations.resend': 'Uitnodiging opnieuw verzenden',
|
||||||
|
'organizations.invitations.cancel.title': 'Uitnodiging annuleren',
|
||||||
|
'organizations.invitations.cancel.description': 'Weet u zeker dat u deze uitnodiging wilt annuleren?',
|
||||||
|
'organizations.invitations.cancel.confirm': 'Uitnodiging annuleren',
|
||||||
|
'organizations.invitations.cancel.cancel': 'Annuleren',
|
||||||
|
'organizations.invitations.resend.title': 'Uitnodiging opnieuw verzenden',
|
||||||
|
'organizations.invitations.resend.description': 'Weet u zeker dat u deze uitnodiging opnieuw wilt verzenden? Er wordt dan een nieuwe e-mail naar de ontvanger gestuurd.',
|
||||||
|
'organizations.invitations.resend.confirm': 'Opnieuw verzenden',
|
||||||
|
'organizations.invitations.resend.cancel': 'Annuleren',
|
||||||
|
|
||||||
|
'invitations.list.title': 'Uitnodigingen',
|
||||||
|
'invitations.list.description': 'Beheer uw organisatie-uitnodigingen',
|
||||||
|
'invitations.list.empty.title': 'Geen openstaande uitnodigingen',
|
||||||
|
'invitations.list.empty.description': 'U bent nog door geen organisaties uitgenodigd.',
|
||||||
|
'invitations.list.headers.organization': 'Organisatie',
|
||||||
|
'invitations.list.headers.status': 'Status',
|
||||||
|
'invitations.list.headers.created': 'Aangemaakt',
|
||||||
|
'invitations.list.headers.actions': 'Acties',
|
||||||
|
'invitations.list.actions.accept': 'Accepteer',
|
||||||
|
'invitations.list.actions.reject': 'Weiger',
|
||||||
|
'invitations.list.actions.accept.success.message': 'Uitnodiging geaccepteerd',
|
||||||
|
'invitations.list.actions.accept.success.description': 'De uitnodiging is geaccepteerd.',
|
||||||
|
'invitations.list.actions.reject.success.message': 'Uitnodiging geweigerd',
|
||||||
|
'invitations.list.actions.reject.success.description': 'De uitnodiging is geweigerd.',
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
|
||||||
|
'documents.list.title': 'Documenten',
|
||||||
|
'documents.list.no-documents.title': 'Geen documenten',
|
||||||
|
'documents.list.no-documents.description': 'Er staan nog geen documenten in deze organisatie. Begin met het uploaden van documenten.',
|
||||||
|
'documents.list.no-results': 'Geen documenten gevonden',
|
||||||
|
'documents.list.table.headers.file-name': 'Bestandsnaam',
|
||||||
|
'documents.list.table.headers.created': 'Aangemaakt op',
|
||||||
|
'documents.list.table.headers.deleted': 'Verwijderd op',
|
||||||
|
'documents.list.table.headers.actions': 'Acties',
|
||||||
|
'documents.list.table.headers.tags': 'Tags',
|
||||||
|
|
||||||
|
'documents.tabs.info': 'Info',
|
||||||
|
'documents.tabs.content': 'Inhoud',
|
||||||
|
'documents.tabs.activity': 'Activiteit',
|
||||||
|
'documents.deleted.message': 'Dit document is verwijderd en wordt permanent verwijderd over {{ days }} dagen.',
|
||||||
|
'documents.actions.download': 'Downloaden',
|
||||||
|
'documents.actions.open-in-new-tab': 'Openen in nieuw tabblad',
|
||||||
|
'documents.actions.restore': 'Herstellen',
|
||||||
|
'documents.actions.delete': 'Verwijderen',
|
||||||
|
'documents.actions.edit': 'Bewerken',
|
||||||
|
'documents.actions.cancel': 'Annuleren',
|
||||||
|
'documents.actions.save': 'Opslaan',
|
||||||
|
'documents.actions.saving': 'Bezig met opslaan...',
|
||||||
|
'documents.content.alert': 'De inhoud van het document wordt automatisch geëxtraheerd bij het uploaden. Deze wordt alleen gebruikt voor zoeken en indexering.',
|
||||||
|
'documents.content.empty-placeholder': 'Dit document heeft geen geëxtraheerde inhoud; u kunt deze hier handmatig instellen.',
|
||||||
|
'documents.info.id': 'ID',
|
||||||
|
'documents.info.name': 'Naam',
|
||||||
|
'documents.info.type': 'Type',
|
||||||
|
'documents.info.size': 'Grootte',
|
||||||
|
'documents.info.created-at': 'Aangemaakt op',
|
||||||
|
'documents.info.updated-at': 'Bijgewerkt op',
|
||||||
|
'documents.info.never': 'Nooit',
|
||||||
|
|
||||||
|
'documents.rename.title': 'Document hernoemen',
|
||||||
|
'documents.rename.form.name.label': 'Naam',
|
||||||
|
'documents.rename.form.name.placeholder': 'Voorbeeld: Factuur 2024',
|
||||||
|
'documents.rename.form.name.required': 'Voer een naam voor het document in',
|
||||||
|
'documents.rename.form.name.max-length': 'De naam mag minder dan 255 tekens bevatten',
|
||||||
|
'documents.rename.form.submit': 'Document hernoemen',
|
||||||
|
'documents.rename.success': 'Document succesvol hernoemd',
|
||||||
|
'documents.rename.cancel': 'Annuleren',
|
||||||
|
|
||||||
|
'import-documents.title.error': '{{ count }} documenten mislukt',
|
||||||
|
'import-documents.title.success': '{{ count }} documenten geïmporteerd',
|
||||||
|
'import-documents.title.pending': '{{ count }} / {{ total }} documenten geïmporteerd',
|
||||||
|
'import-documents.title.none': 'Documenten importeren',
|
||||||
|
'import-documents.no-import-in-progress': 'Geen documentimport in uitvoering',
|
||||||
|
|
||||||
|
'documents.deleted.title': 'Verwijderde documenten',
|
||||||
|
'documents.deleted.empty.title': 'Geen verwijderde documenten',
|
||||||
|
'documents.deleted.empty.description': 'U heeft geen verwijderde documenten. Verwijderde documenten worden gedurende {{ days }} dagen naar de prullenbak verplaatst.',
|
||||||
|
'documents.deleted.retention-notice': 'Alle verwijderde documenten worden gedurende {{ days }} dagen in de prullenbak bewaard. Na deze periode worden ze permanent verwijderd en kunt u ze niet meer herstellen.',
|
||||||
|
'documents.deleted.deleted-at': 'Verwijderd',
|
||||||
|
'documents.deleted.restoring': 'Bezig met herstellen...',
|
||||||
|
'documents.deleted.deleting': 'Bezig met verwijderen...',
|
||||||
|
|
||||||
|
'documents.preview.unknown-file-type': 'Geen voorbeeld beschikbaar voor dit bestandstype',
|
||||||
|
'documents.preview.binary-file': 'Dit lijkt een binaire bestandsinhoud te zijn en kan niet als tekst worden weergegeven',
|
||||||
|
|
||||||
|
'trash.delete-all.button': 'Alles verwijderen',
|
||||||
|
'trash.delete-all.confirm.title': 'Alle documenten permanent verwijderen?',
|
||||||
|
'trash.delete-all.confirm.description': 'Weet u zeker dat u alle documenten uit de prullenbak permanent wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
|
||||||
|
'trash.delete-all.confirm.label': 'Verwijderen',
|
||||||
|
'trash.delete-all.confirm.cancel': 'Annuleren',
|
||||||
|
'trash.delete.button': 'Verwijderen',
|
||||||
|
'trash.delete.confirm.title': 'Document permanent verwijderen?',
|
||||||
|
'trash.delete.confirm.description': 'Weet u zeker dat u dit document permanent uit de prullenbak wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
|
||||||
|
'trash.delete.confirm.label': 'Verwijderen',
|
||||||
|
'trash.delete.confirm.cancel': 'Annuleren',
|
||||||
|
'trash.deleted.success.title': 'Document verwijderd',
|
||||||
|
'trash.deleted.success.description': 'Het document is permanent verwijderd.',
|
||||||
|
|
||||||
|
'activity.document.created': 'Het document is aangemaakt',
|
||||||
|
'activity.document.updated.single': 'Het {{ field }} is bijgewerkt',
|
||||||
|
'activity.document.updated.multiple': 'De {{ fields }} zijn bijgewerkt',
|
||||||
|
'activity.document.updated': 'Het document is bijgewerkt',
|
||||||
|
'activity.document.deleted': 'Het document is verwijderd',
|
||||||
|
'activity.document.restored': 'Het document is hersteld',
|
||||||
|
'activity.document.tagged': 'Label {{ tag }} is toegevoegd',
|
||||||
|
'activity.document.untagged': 'Label {{ tag }} is verwijderd',
|
||||||
|
|
||||||
|
'activity.document.user.name': 'door {{ name }}',
|
||||||
|
|
||||||
|
'activity.load-more': 'Meer laden',
|
||||||
|
'activity.no-more-activities': 'Geen verdere activiteiten voor dit document',
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
|
||||||
|
'tags.no-tags.title': 'Nog geen labels',
|
||||||
|
'tags.no-tags.description': 'Deze organisatie heeft nog geen labels. Labels worden gebruikt om documenten te categoriseren. U kunt labels toevoegen aan uw documenten om ze eenvoudiger te vinden en te organiseren.',
|
||||||
|
'tags.no-tags.create-tag': 'Label aanmaken',
|
||||||
|
|
||||||
|
'tags.title': 'Documentlabels',
|
||||||
|
'tags.description': 'Labels worden gebruikt om documenten te categoriseren. U kunt labels toevoegen aan uw documenten om ze eenvoudiger te vinden en te organiseren.',
|
||||||
|
'tags.create': 'Label aanmaken',
|
||||||
|
'tags.update': 'Label bijwerken',
|
||||||
|
'tags.delete': 'Label verwijderen',
|
||||||
|
'tags.delete.confirm.title': 'Label verwijderen',
|
||||||
|
'tags.delete.confirm.message': 'Weet u zeker dat u dit label wilt verwijderen? Het verwijderen van een label verwijdert het van alle documenten.',
|
||||||
|
'tags.delete.confirm.confirm-button': 'Verwijderen',
|
||||||
|
'tags.delete.confirm.cancel-button': 'Annuleren',
|
||||||
|
'tags.delete.success': 'Label succesvol verwijderd',
|
||||||
|
'tags.create.success': 'Label "{{ name }}" succesvol aangemaakt.',
|
||||||
|
'tags.update.success': 'Label "{{ name }}" succesvol bijgewerkt.',
|
||||||
|
'tags.form.name.label': 'Naam',
|
||||||
|
'tags.form.name.placeholder': 'Bijv. Contracten',
|
||||||
|
'tags.form.name.required': 'Voer een labelnaam in',
|
||||||
|
'tags.form.name.max-length': 'Labelnaam moet minder dan 64 tekens bevatten',
|
||||||
|
'tags.form.color.label': 'Kleur',
|
||||||
|
'tags.form.color.required': 'Voer een kleur in',
|
||||||
|
'tags.form.color.invalid': 'De hex-kleur heeft een onjuist formaat.',
|
||||||
|
'tags.form.description.label': 'Beschrijving',
|
||||||
|
'tags.form.description.optional': '(optioneel)',
|
||||||
|
'tags.form.description.placeholder': 'Bijv. Alle door het bedrijf ondertekende contracten',
|
||||||
|
'tags.form.description.max-length': 'Beschrijving moet minder dan 256 tekens bevatten',
|
||||||
|
'tags.form.no-description': 'Geen beschrijving',
|
||||||
|
'tags.table.headers.tag': 'Label',
|
||||||
|
'tags.table.headers.description': 'Beschrijving',
|
||||||
|
'tags.table.headers.documents': 'Documenten',
|
||||||
|
'tags.table.headers.created': 'Aangemaakt',
|
||||||
|
'tags.table.headers.actions': 'Acties',
|
||||||
|
|
||||||
|
// Tagging rules
|
||||||
|
|
||||||
|
'tagging-rules.field.name': 'documentnaam',
|
||||||
|
'tagging-rules.field.content': 'documentinhoud',
|
||||||
|
'tagging-rules.operator.equals': 'is gelijk aan',
|
||||||
|
'tagging-rules.operator.not-equals': 'is niet gelijk aan',
|
||||||
|
'tagging-rules.operator.contains': 'bevat',
|
||||||
|
'tagging-rules.operator.not-contains': 'bevat niet',
|
||||||
|
'tagging-rules.operator.starts-with': 'begint met',
|
||||||
|
'tagging-rules.operator.ends-with': 'eindigt met',
|
||||||
|
'tagging-rules.list.title': 'Labelregels',
|
||||||
|
'tagging-rules.list.description': 'Beheer de labelregels van uw organisatie om documenten automatisch te labelen op basis van door u gedefinieerde voorwaarden.',
|
||||||
|
'tagging-rules.list.demo-warning': 'Opmerking: Dit is een demoomgeving (zonder server); labelregels worden niet toegepast op nieuw toegevoegde documenten.',
|
||||||
|
'tagging-rules.list.no-tagging-rules.title': 'Geen labelregels',
|
||||||
|
'tagging-rules.list.no-tagging-rules.description': 'Maak een labelregel om automatisch labels toe te passen op documenten die voldoen aan uw voorwaarden.',
|
||||||
|
'tagging-rules.list.no-tagging-rules.create-tagging-rule': 'Labelregel aanmaken',
|
||||||
|
'tagging-rules.list.card.no-conditions': 'Geen voorwaarden',
|
||||||
|
'tagging-rules.list.card.one-condition': '1 voorwaarde',
|
||||||
|
'tagging-rules.list.card.conditions': '{{ count }} voorwaarden',
|
||||||
|
'tagging-rules.list.card.delete': 'Regel verwijderen',
|
||||||
|
'tagging-rules.list.card.edit': 'Regel bewerken',
|
||||||
|
'tagging-rules.create.title': 'Labelregel aanmaken',
|
||||||
|
'tagging-rules.create.success': 'Labelregel succesvol aangemaakt',
|
||||||
|
'tagging-rules.create.error': 'Kan labelregel niet aanmaken',
|
||||||
|
'tagging-rules.create.submit': 'Regel aanmaken',
|
||||||
|
'tagging-rules.form.name.label': 'Naam',
|
||||||
|
'tagging-rules.form.name.placeholder': 'Voorbeeld: Label facturen',
|
||||||
|
'tagging-rules.form.name.min-length': 'Voer een naam voor de regel in',
|
||||||
|
'tagging-rules.form.name.max-length': 'De naam mag minder dan 64 tekens bevatten',
|
||||||
|
'tagging-rules.form.description.label': 'Beschrijving',
|
||||||
|
'tagging-rules.form.description.placeholder': 'Bijv. Label documenten met \'factuur\' in de naam',
|
||||||
|
'tagging-rules.form.description.max-length': 'De beschrijving mag minder dan 256 tekens bevatten',
|
||||||
|
'tagging-rules.form.conditions.label': 'Voorwaarden',
|
||||||
|
'tagging-rules.form.conditions.description': 'Definieer de voorwaarden waaraan moet worden voldaan zodat de regel wordt toegepast. Geen voorwaarden betekent dat de regel op alle documenten wordt toegepast',
|
||||||
|
'tagging-rules.form.conditions.add-condition': 'Voorwaarde toevoegen',
|
||||||
|
'tagging-rules.form.conditions.connector.when': 'Wanneer',
|
||||||
|
'tagging-rules.form.conditions.connector.and': 'en',
|
||||||
|
'tagging-rules.form.conditions.connector.or': 'of',
|
||||||
|
'tagging-rules.condition-match-mode.all': 'Alle voorwaarden moeten overeenkomen',
|
||||||
|
'tagging-rules.condition-match-mode.any': 'Een van de voorwaarden moet overeenkomen',
|
||||||
|
'tagging-rules.form.conditions.no-conditions.title': 'Geen voorwaarden',
|
||||||
|
'tagging-rules.form.conditions.no-conditions.description': 'U heeft geen voorwaarden toegevoegd aan deze regel. Deze regel zal zijn labels op alle documenten toepassen.',
|
||||||
|
'tagging-rules.form.conditions.no-conditions.confirm': 'Regel toepassen zonder voorwaarden',
|
||||||
|
'tagging-rules.form.conditions.no-conditions.cancel': 'Annuleren',
|
||||||
|
'tagging-rules.form.conditions.value.placeholder': 'Voorbeeld: factuur',
|
||||||
|
'tagging-rules.form.conditions.value.min-length': 'Voer een waarde in voor de voorwaarde',
|
||||||
|
'tagging-rules.form.tags.label': 'Labels',
|
||||||
|
'tagging-rules.form.tags.description': 'Selecteer de labels die moeten worden toegepast op documenten die aan de voorwaarden voldoen',
|
||||||
|
'tagging-rules.form.tags.min-length': 'Minstens één label is vereist om toe te passen',
|
||||||
|
'tagging-rules.form.tags.add-tag': 'Label aanmaken',
|
||||||
|
'tagging-rules.form.submit': 'Regel aanmaken',
|
||||||
|
'tagging-rules.update.title': 'Labelregel bijwerken',
|
||||||
|
'tagging-rules.update.error': 'Kan labelregel niet bijwerken',
|
||||||
|
'tagging-rules.update.submit': 'Regel bijwerken',
|
||||||
|
'tagging-rules.update.cancel': 'Annuleren',
|
||||||
|
'tagging-rules.apply.button': 'Toepassen op bestaande documenten',
|
||||||
|
'tagging-rules.apply.confirm.title': 'Regel toepassen op bestaande documenten?',
|
||||||
|
'tagging-rules.apply.confirm.description': 'Dit controleert alle bestaande documenten in uw organisatie en past labels toe waar voorwaarden overeenkomen. De verwerking gebeurt op de achtergrond.',
|
||||||
|
'tagging-rules.apply.confirm.button': 'Regel toepassen',
|
||||||
|
'tagging-rules.apply.success': 'Het toepassen van de regel is op de achtergrond gestart',
|
||||||
|
'tagging-rules.apply.error': 'Kan het toepassen van de regel niet starten',
|
||||||
|
'tagging-rules.apply.processing': 'Starten...',
|
||||||
|
|
||||||
|
// Intake emails
|
||||||
|
|
||||||
|
'intake-emails.title': 'Intake-e-mails',
|
||||||
|
'intake-emails.description': 'Intake-e-mailadressen worden gebruikt om e-mails automatisch in Papra te importeren. Stuur e-mails door naar het intake-adres en de bijlagen worden toegevoegd aan de documenten van uw organisatie.',
|
||||||
|
'intake-emails.disabled.title': 'Intake-e-mails zijn uitgeschakeld',
|
||||||
|
'intake-emails.disabled.description': 'Intake-e-mails zijn uitgeschakeld op deze instantie. Neem contact op met uw beheerder om ze in te schakelen. Zie de {{ documentation }} voor meer informatie.',
|
||||||
|
'intake-emails.disabled.documentation': 'documentatie',
|
||||||
|
'intake-emails.info': 'Alleen ingeschakelde intake-e-mails van toegestane bronnen worden verwerkt. U kunt een intake-e-mail op elk moment in- of uitschakelen.',
|
||||||
|
'intake-emails.empty.title': 'Geen intake-e-mails',
|
||||||
|
'intake-emails.empty.description': 'Genereer een intake-adres om eenvoudig e-mailbijlagen te importeren.',
|
||||||
|
'intake-emails.empty.generate': 'Intake-e-mail genereren',
|
||||||
|
'intake-emails.count': '{{ count }} intake-e-mail{{ plural }} voor deze organisatie',
|
||||||
|
'intake-emails.new': 'Nieuwe intake-e-mail',
|
||||||
|
'intake-emails.disabled-label': '(Uitgeschakeld)',
|
||||||
|
'intake-emails.no-origins': 'Geen toegestane bronnen',
|
||||||
|
'intake-emails.allowed-origins': 'Toegestaan vanaf {{ count }} adres{{ plural }}',
|
||||||
|
'intake-emails.actions.enable': 'Inschakelen',
|
||||||
|
'intake-emails.actions.disable': 'Uitschakelen',
|
||||||
|
'intake-emails.actions.manage-origins': 'Bronadressen beheren',
|
||||||
|
'intake-emails.actions.delete': 'Verwijderen',
|
||||||
|
'intake-emails.delete.confirm.title': 'Intake-e-mail verwijderen?',
|
||||||
|
'intake-emails.delete.confirm.message': 'Weet u zeker dat u deze intake-e-mail wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
|
||||||
|
'intake-emails.delete.confirm.confirm-button': 'Intake-e-mail verwijderen',
|
||||||
|
'intake-emails.delete.confirm.cancel-button': 'Annuleren',
|
||||||
|
'intake-emails.delete.success': 'Intake-e-mail verwijderd',
|
||||||
|
'intake-emails.create.success': 'Intake-e-mail aangemaakt',
|
||||||
|
'intake-emails.update.success.enabled': 'Intake-e-mail ingeschakeld',
|
||||||
|
'intake-emails.update.success.disabled': 'Intake-e-mail uitgeschakeld',
|
||||||
|
'intake-emails.allowed-origins.title': 'Toegestane bronnen',
|
||||||
|
'intake-emails.allowed-origins.description': 'Alleen e-mails verzonden naar {{ email }} vanaf deze bronnen worden verwerkt. Als er geen bronnen zijn opgegeven, worden alle e-mails genegeerd.',
|
||||||
|
'intake-emails.allowed-origins.add.label': 'Toegestaan origineel e-mailadres toevoegen',
|
||||||
|
'intake-emails.allowed-origins.add.placeholder': 'Bijv. ada@papra.app',
|
||||||
|
'intake-emails.allowed-origins.add.button': 'Toevoegen',
|
||||||
|
'intake-emails.allowed-origins.add.error.exists': 'Dit e-mailadres staat al in de toegestane bronnen voor deze intake-e-mail',
|
||||||
|
|
||||||
|
// API keys
|
||||||
|
|
||||||
|
'api-keys.permissions.select-all': 'Alles selecteren',
|
||||||
|
'api-keys.permissions.deselect-all': 'Alles deselecteren',
|
||||||
|
'api-keys.permissions.organizations.title': 'Organisaties',
|
||||||
|
'api-keys.permissions.organizations.organizations:create': 'Organisaties aanmaken',
|
||||||
|
'api-keys.permissions.organizations.organizations:read': 'Organisaties lezen',
|
||||||
|
'api-keys.permissions.organizations.organizations:update': 'Organisaties bijwerken',
|
||||||
|
'api-keys.permissions.organizations.organizations:delete': 'Organisaties verwijderen',
|
||||||
|
'api-keys.permissions.documents.title': 'Documenten',
|
||||||
|
'api-keys.permissions.documents.documents:create': 'Documenten aanmaken',
|
||||||
|
'api-keys.permissions.documents.documents:read': 'Documenten lezen',
|
||||||
|
'api-keys.permissions.documents.documents:update': 'Documenten bijwerken',
|
||||||
|
'api-keys.permissions.documents.documents:delete': 'Documenten verwijderen',
|
||||||
|
'api-keys.permissions.tags.title': 'Labels',
|
||||||
|
'api-keys.permissions.tags.tags:create': 'Labels aanmaken',
|
||||||
|
'api-keys.permissions.tags.tags:read': 'Labels lezen',
|
||||||
|
'api-keys.permissions.tags.tags:update': 'Labels bijwerken',
|
||||||
|
'api-keys.permissions.tags.tags:delete': 'Labels verwijderen',
|
||||||
|
'api-keys.create.title': 'API-sleutel aanmaken',
|
||||||
|
'api-keys.create.description': 'Maak een nieuwe API-sleutel om de Papra API te gebruiken.',
|
||||||
|
'api-keys.create.success': 'De API-sleutel is succesvol aangemaakt.',
|
||||||
|
'api-keys.create.back': 'Terug naar API-sleutels',
|
||||||
|
'api-keys.create.form.name.label': 'Naam',
|
||||||
|
'api-keys.create.form.name.placeholder': 'Bijv. Mijn API-sleutel',
|
||||||
|
'api-keys.create.form.name.required': 'Voer een naam in voor de API-sleutel',
|
||||||
|
'api-keys.create.form.permissions.label': 'Rechten',
|
||||||
|
'api-keys.create.form.permissions.required': 'Selecteer minimaal één machtiging',
|
||||||
|
'api-keys.create.form.submit': 'API-sleutel aanmaken',
|
||||||
|
'api-keys.create.created.title': 'API-sleutel aangemaakt',
|
||||||
|
'api-keys.create.created.description': 'De API-sleutel is succesvol aangemaakt. Sla deze op een veilige plek op, want deze wordt niet opnieuw weergegeven.',
|
||||||
|
'api-keys.list.title': 'API-sleutels',
|
||||||
|
'api-keys.list.description': 'Beheer hier uw API-sleutels.',
|
||||||
|
'api-keys.list.create': 'API-sleutel aanmaken',
|
||||||
|
'api-keys.list.empty.title': 'Geen API-sleutels',
|
||||||
|
'api-keys.list.empty.description': 'Maak een API-sleutel om de Papra API te gebruiken.',
|
||||||
|
'api-keys.list.card.last-used': 'Laatst gebruikt',
|
||||||
|
'api-keys.list.card.never': 'Nooit',
|
||||||
|
'api-keys.list.card.created': 'Aangemaakt',
|
||||||
|
'api-keys.delete.success': 'De API-sleutel is succesvol verwijderd',
|
||||||
|
'api-keys.delete.confirm.title': 'API-sleutel verwijderen',
|
||||||
|
'api-keys.delete.confirm.message': 'Weet u zeker dat u deze API-sleutel wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.',
|
||||||
|
'api-keys.delete.confirm.confirm-button': 'Verwijderen',
|
||||||
|
'api-keys.delete.confirm.cancel-button': 'Annuleren',
|
||||||
|
|
||||||
|
// Webhooks
|
||||||
|
|
||||||
|
'webhooks.list.title': 'Webhooks',
|
||||||
|
'webhooks.list.description': 'Beheer webhooks voor uw organisatie',
|
||||||
|
'webhooks.list.empty.title': 'Geen webhooks',
|
||||||
|
'webhooks.list.empty.description': 'Maak uw eerste webhook om evenementen te ontvangen',
|
||||||
|
'webhooks.list.create': 'Webhook aanmaken',
|
||||||
|
'webhooks.list.card.last-triggered': 'Laatst geactiveerd',
|
||||||
|
'webhooks.list.card.never': 'Nooit',
|
||||||
|
'webhooks.list.card.created': 'Aangemaakt',
|
||||||
|
'webhooks.create.title': 'Webhook aanmaken',
|
||||||
|
'webhooks.create.description': 'Maak een nieuwe webhook om evenementen te ontvangen',
|
||||||
|
'webhooks.create.success': 'Webhook succesvol aangemaakt',
|
||||||
|
'webhooks.create.back': 'Terug',
|
||||||
|
'webhooks.create.form.submit': 'Webhook aanmaken',
|
||||||
|
'webhooks.create.form.name.label': 'Webhooknaam',
|
||||||
|
'webhooks.create.form.name.placeholder': 'Voer een webhooknaam in',
|
||||||
|
'webhooks.create.form.name.required': 'Naam is verplicht',
|
||||||
|
'webhooks.create.form.url.label': 'Webhook-URL',
|
||||||
|
'webhooks.create.form.url.placeholder': 'Voer een webhook-URL in',
|
||||||
|
'webhooks.create.form.url.required': 'URL is verplicht',
|
||||||
|
'webhooks.create.form.url.invalid': 'URL is ongeldig',
|
||||||
|
'webhooks.create.form.secret.label': 'Secret',
|
||||||
|
'webhooks.create.form.secret.placeholder': 'Voer webhook-secret in',
|
||||||
|
'webhooks.create.form.events.label': 'Evenementen',
|
||||||
|
'webhooks.create.form.events.required': 'Minimaal één evenement is verplicht',
|
||||||
|
'webhooks.update.title': 'Webhook bewerken',
|
||||||
|
'webhooks.update.description': 'Werk uw webhookgegevens bij',
|
||||||
|
'webhooks.update.success': 'Webhook succesvol bijgewerkt',
|
||||||
|
'webhooks.update.submit': 'Webhook bijwerken',
|
||||||
|
'webhooks.update.cancel': 'Annuleren',
|
||||||
|
'webhooks.update.form.secret.placeholder': 'Voer nieuw secret in',
|
||||||
|
'webhooks.update.form.secret.placeholder-redacted': '[Geredigeerd secret]',
|
||||||
|
'webhooks.update.form.rotate-secret.button': 'Secret roteren',
|
||||||
|
'webhooks.delete.success': 'Webhook succesvol verwijderd',
|
||||||
|
'webhooks.delete.confirm.title': 'Webhook verwijderen',
|
||||||
|
'webhooks.delete.confirm.message': 'Weet u zeker dat u deze webhook wilt verwijderen?',
|
||||||
|
'webhooks.delete.confirm.confirm-button': 'Verwijderen',
|
||||||
|
'webhooks.delete.confirm.cancel-button': 'Annuleren',
|
||||||
|
|
||||||
|
'webhooks.events.documents.title': 'Documentevenementen',
|
||||||
|
'webhooks.events.documents.document:created.description': 'Document aangemaakt',
|
||||||
|
'webhooks.events.documents.document:deleted.description': 'Document verwijderd',
|
||||||
|
'webhooks.events.documents.document:updated.description': 'Document bijgewerkt',
|
||||||
|
'webhooks.events.documents.document:tag:added.description': 'Er is een label toegevoegd aan een document',
|
||||||
|
'webhooks.events.documents.document:tag:removed.description': 'Er is een label verwijderd van een document',
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
|
||||||
|
'layout.menu.home': 'Start',
|
||||||
|
'layout.menu.documents': 'Documenten',
|
||||||
|
'layout.menu.tags': 'Labels',
|
||||||
|
'layout.menu.tagging-rules': 'Labelregels',
|
||||||
|
'layout.menu.deleted-documents': 'Verwijderde documenten',
|
||||||
|
'layout.menu.organization-settings': 'Instellingen',
|
||||||
|
'layout.menu.api-keys': 'API-sleutels',
|
||||||
|
'layout.menu.settings': 'Instellingen',
|
||||||
|
'layout.menu.account': 'Account',
|
||||||
|
'layout.menu.general-settings': 'Algemene instellingen',
|
||||||
|
'layout.menu.usage': 'Gebruik',
|
||||||
|
'layout.menu.intake-emails': 'Intake-e-mails',
|
||||||
|
'layout.menu.webhooks': 'Webhooks',
|
||||||
|
'layout.menu.members': 'Leden',
|
||||||
|
'layout.menu.invitations': 'Uitnodigingen',
|
||||||
|
|
||||||
|
'layout.upgrade-cta.title': 'Meer ruimte nodig?',
|
||||||
|
'layout.upgrade-cta.description': 'Krijg 10x meer opslag + team samenwerking',
|
||||||
|
'layout.upgrade-cta.button': 'Nu upgraden',
|
||||||
|
|
||||||
|
'layout.theme.light': 'Licht thema',
|
||||||
|
'layout.theme.dark': 'Donker thema',
|
||||||
|
'layout.theme.system': 'Systeemstandaard',
|
||||||
|
|
||||||
|
'layout.search.placeholder': 'Zoeken...',
|
||||||
|
'layout.menu.import-document': 'Een document importeren',
|
||||||
|
|
||||||
|
'user-menu.account-settings': 'Accountinstellingen',
|
||||||
|
'user-menu.api-keys': 'API-sleutels',
|
||||||
|
'user-menu.invitations': 'Uitnodigingen',
|
||||||
|
'user-menu.language': 'Taal',
|
||||||
|
'user-menu.logout': 'Uitloggen',
|
||||||
|
|
||||||
|
// Command palette
|
||||||
|
|
||||||
|
'command-palette.search.placeholder': 'Zoek opdrachten of documenten',
|
||||||
|
'command-palette.no-results': 'Geen resultaten gevonden',
|
||||||
|
'command-palette.sections.documents': 'Documenten',
|
||||||
|
'command-palette.sections.theme': 'Thema',
|
||||||
|
|
||||||
|
// API errors
|
||||||
|
|
||||||
|
'api-errors.document.already_exists': 'Het document bestaat al',
|
||||||
|
'api-errors.document.size_too_large': 'Het bestand is te groot',
|
||||||
|
'api-errors.intake-emails.already_exists': 'Er bestaat al een intake-e-mail met dit adres.',
|
||||||
|
'api-errors.intake_email.limit_reached': 'Het maximum aantal intake-e-mails voor deze organisatie is bereikt. Upgrade uw plan om meer intake-e-mails aan te maken.',
|
||||||
|
'api-errors.user.max_organization_count_reached': 'U heeft het maximale aantal organisaties bereikt dat u kunt aanmaken. Neem contact op met support als u meer nodig heeft.',
|
||||||
|
'api-errors.default': 'Er is een fout opgetreden bij het verwerken van uw verzoek.',
|
||||||
|
'api-errors.organization.invitation_already_exists': 'Er bestaat al een uitnodiging voor dit e-mailadres in deze organisatie.',
|
||||||
|
'api-errors.user.already_in_organization': 'Deze gebruiker zit al in deze organisatie.',
|
||||||
|
'api-errors.user.organization_invitation_limit_reached': 'Het maximale aantal uitnodigingen voor vandaag is bereikt. Probeer het morgen opnieuw.',
|
||||||
|
'api-errors.demo.not_available': 'Deze functie is niet beschikbaar in de demo',
|
||||||
|
'api-errors.tags.already_exists': 'Er bestaat al een label met deze naam voor deze organisatie',
|
||||||
|
'api-errors.internal.error': 'Er is een fout opgetreden bij het verwerken van uw verzoek. Probeer het later opnieuw.',
|
||||||
|
'api-errors.auth.invalid_origin': 'Ongeldige applicatiebron. Als u Papra zelf host, zorg ervoor dat uw APP_BASE_URL-omgeving variabele overeenkomt met uw huidige URL. Zie https://docs.papra.app/resources/troubleshooting/#invalid-application-origin voor meer informatie.',
|
||||||
|
'api-errors.organization.max_members_count_reached': 'Het maximale aantal leden en openstaande uitnodigingen voor deze organisatie is bereikt. Upgrade uw plan om meer leden toe te voegen.',
|
||||||
|
'api-errors.organization.has_active_subscription': 'Kan de organisatie niet verwijderen vanwege een actief abonnement. Annuleer eerst uw abonnement via de knop Abonnement beheren.',
|
||||||
|
// Better auth api errors
|
||||||
|
'api-errors.USER_NOT_FOUND': 'Gebruiker niet gevonden',
|
||||||
|
'api-errors.FAILED_TO_CREATE_USER': 'Kan gebruiker niet aanmaken',
|
||||||
|
'api-errors.FAILED_TO_CREATE_SESSION': 'Kan sessie niet aanmaken',
|
||||||
|
'api-errors.FAILED_TO_UPDATE_USER': 'Kan gebruiker niet bijwerken',
|
||||||
|
'api-errors.FAILED_TO_GET_SESSION': 'Kan sessie niet ophalen',
|
||||||
|
'api-errors.INVALID_PASSWORD': 'Ongeldig wachtwoord',
|
||||||
|
'api-errors.INVALID_EMAIL': 'Ongeldig e-mailadres',
|
||||||
|
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Het e-mailadres of wachtwoord is onjuist, of het account bestaat niet.',
|
||||||
|
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social account is al gekoppeld',
|
||||||
|
'api-errors.PROVIDER_NOT_FOUND': 'Provider niet gevonden',
|
||||||
|
'api-errors.INVALID_TOKEN': 'Ongeldige token',
|
||||||
|
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID-token wordt niet ondersteund',
|
||||||
|
'api-errors.FAILED_TO_GET_USER_INFO': 'Kan gebruikersinformatie niet ophalen',
|
||||||
|
'api-errors.USER_EMAIL_NOT_FOUND': 'E-mail van gebruiker niet gevonden',
|
||||||
|
'api-errors.EMAIL_NOT_VERIFIED': 'E-mail niet geverifieerd',
|
||||||
|
'api-errors.PASSWORD_TOO_SHORT': 'Wachtwoord te kort',
|
||||||
|
'api-errors.PASSWORD_TOO_LONG': 'Wachtwoord te lang',
|
||||||
|
'api-errors.USER_ALREADY_EXISTS': 'Er bestaat al een gebruiker met dit e-mailadres',
|
||||||
|
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'E-mail kan niet worden bijgewerkt',
|
||||||
|
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Credentie-account niet gevonden',
|
||||||
|
'api-errors.SESSION_EXPIRED': 'Sessie verlopen',
|
||||||
|
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Kan laatste account niet loskoppelen',
|
||||||
|
'api-errors.ACCOUNT_NOT_FOUND': 'Account niet gevonden',
|
||||||
|
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Gebruiker heeft al een wachtwoord',
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
|
||||||
|
'not-found.title': '404 - Niet gevonden',
|
||||||
|
'not-found.description': 'Sorry, de door u gezochte pagina lijkt niet te bestaan. Controleer de URL en probeer het opnieuw.',
|
||||||
|
'not-found.back-to-home': 'Ga terug naar start',
|
||||||
|
|
||||||
|
// Demo
|
||||||
|
|
||||||
|
'demo.popup.description': 'Dit is een demoomgeving; alle gegevens worden opgeslagen in uw browserlokale opslag.',
|
||||||
|
'demo.popup.discord': 'Sluit u aan bij de {{ discordLink }} voor ondersteuning, het voorstellen van functies of gewoon om te chatten.',
|
||||||
|
'demo.popup.discord-link-label': 'Discord-server',
|
||||||
|
'demo.popup.reset': 'Demo-gegevens opnieuw instellen',
|
||||||
|
'demo.popup.hide': 'Verbergen',
|
||||||
|
|
||||||
|
// Color picker
|
||||||
|
|
||||||
|
'color-picker.hue': 'Kleurtoon',
|
||||||
|
'color-picker.saturation': 'Verzadiging',
|
||||||
|
'color-picker.lightness': 'Helderheid',
|
||||||
|
'color-picker.select-color': 'Kleur selecteren',
|
||||||
|
'color-picker.select-a-color': 'Selecteer een kleur',
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
|
||||||
|
'subscriptions.checkout-success.title': 'Betaling geslaagd!',
|
||||||
|
'subscriptions.checkout-success.description': 'Uw abonnement is succesvol geactiveerd.',
|
||||||
|
'subscriptions.checkout-success.thank-you': 'Dank u voor het upgraden naar Papra Plus. U heeft nu toegang tot alle premiumfuncties.',
|
||||||
|
'subscriptions.checkout-success.go-to-organizations': 'Ga naar organisaties',
|
||||||
|
'subscriptions.checkout-success.redirecting': 'Omleiden over {{ count }} seconde{{ plural }}...',
|
||||||
|
|
||||||
|
'subscriptions.checkout-cancel.title': 'Betaling geannuleerd',
|
||||||
|
'subscriptions.checkout-cancel.description': 'Uw abonnementsupgrade is geannuleerd.',
|
||||||
|
'subscriptions.checkout-cancel.no-charges': 'Er zijn geen kosten in rekening gebracht. Probeer het opnieuw wanneer u er klaar voor bent.',
|
||||||
|
'subscriptions.checkout-cancel.back-to-organizations': 'Terug naar organisaties',
|
||||||
|
'subscriptions.checkout-cancel.need-help': 'Heeft u hulp nodig?',
|
||||||
|
'subscriptions.checkout-cancel.contact-support': 'Contact opnemen met support',
|
||||||
|
|
||||||
|
'subscriptions.upgrade-dialog.title': 'Deze organisatie upgraden',
|
||||||
|
'subscriptions.upgrade-dialog.description': 'Ontgrendel krachtige functies voor uw organisatie',
|
||||||
|
'subscriptions.upgrade-dialog.contact-us': 'Neem contact op',
|
||||||
|
'subscriptions.upgrade-dialog.enterprise-plans': 'als u aangepaste enterprise-plannen nodig heeft.',
|
||||||
|
'subscriptions.upgrade-dialog.current-plan': 'Huidig plan',
|
||||||
|
'subscriptions.upgrade-dialog.recommended': 'Aanbevolen',
|
||||||
|
'subscriptions.upgrade-dialog.per-month': '/maand',
|
||||||
|
'subscriptions.upgrade-dialog.billed-annually': '€{{ price }} jaarlijks in rekening gebracht',
|
||||||
|
'subscriptions.upgrade-dialog.upgrade-now': 'Nu upgraden',
|
||||||
|
'subscriptions.upgrade-dialog.promo-banner.title': 'Tijdelijk aanbod',
|
||||||
|
'subscriptions.upgrade-dialog.promo-banner.description': 'Krijg {{ percent }}% korting op alle plannen voor altijd per organisatie als vroege gebruiker! Aanbod verloopt over {{ days, >1:{days} days, =1:1 day, less than 1 day }}.',
|
||||||
|
|
||||||
|
'subscriptions.plan.free.name': 'Gratis plan',
|
||||||
|
'subscriptions.plan.plus.name': 'Plus',
|
||||||
|
'subscriptions.plan.pro.name': 'Pro',
|
||||||
|
|
||||||
|
'subscriptions.features.storage-size': 'Documentopslaggrootte',
|
||||||
|
'subscriptions.features.members': 'Organisatieleden',
|
||||||
|
'subscriptions.features.members-count': '{{ count }} leden',
|
||||||
|
'subscriptions.features.email-intakes': 'E-mailintakes',
|
||||||
|
'subscriptions.features.email-intakes-count-singular': '{{ count }} adres',
|
||||||
|
'subscriptions.features.email-intakes-count-plural': '{{ count }} adressen',
|
||||||
|
'subscriptions.features.max-upload-size': 'Maximale uploadbestandsgrootte',
|
||||||
|
'subscriptions.features.support': 'Ondersteuning',
|
||||||
|
'subscriptions.features.support-community': 'Community-ondersteuning',
|
||||||
|
'subscriptions.features.support-email': 'E-mailondersteuning',
|
||||||
|
'subscriptions.features.support-priority': 'Prioriteitsondersteuning',
|
||||||
|
|
||||||
|
'subscriptions.billing-interval.monthly': 'Maandelijks',
|
||||||
|
'subscriptions.billing-interval.annual': 'Jaarlijks',
|
||||||
|
|
||||||
|
'subscriptions.usage-warning.message': 'U heeft {{ percent }}% van uw documentopslag gebruikt. Overweeg uw plan te upgraden voor meer ruimte.',
|
||||||
|
'subscriptions.usage-warning.upgrade-button': 'Plan upgraden',
|
||||||
|
|
||||||
|
// Common / Shared
|
||||||
|
|
||||||
|
'common.confirm-modal.type-to-confirm': 'Typ "{{ text }}" om te bevestigen',
|
||||||
|
'common.tables.rows-per-page': 'Rijen per pagina',
|
||||||
|
'common.tables.pagination-info': 'Pagina {{ currentPage }} van {{ totalPages }}',
|
||||||
|
};
|
||||||