Compare commits
95 Commits
@papra/app
...
@papra/api
| 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 | ||
|
|
3903eed170 | ||
|
|
c70d7e419a | ||
|
|
2240f58f04 | ||
|
|
79e9bb1b61 | ||
|
|
6e18162435 | ||
|
|
16ae4617df | ||
|
|
1c46071e00 | ||
|
|
377c11c185 | ||
|
|
28c3c15cef | ||
|
|
0391a3bcd5 | ||
|
|
2c75eec862 | ||
|
|
ccf7602f19 | ||
|
|
b8a515a313 | ||
|
|
0aad88471b | ||
|
|
efd2ae1c73 | ||
|
|
e9a719d06a | ||
|
|
68714267ad | ||
|
|
75a13da526 | ||
|
|
59d5819018 | ||
|
|
a857370343 | ||
|
|
f4740ba59a | ||
|
|
b0abf7f78a | ||
|
|
182ccbb30b | ||
|
|
75340f0ce7 | ||
|
|
1228486f28 | ||
|
|
655a1c5475 | ||
|
|
d1797eb9be | ||
|
|
bd3e321eb7 | ||
|
|
be25de7721 | ||
|
|
e85403f9a1 | ||
|
|
7de5d0956b | ||
|
|
b1a88230cd | ||
|
|
55bb29582e | ||
|
|
d9263dc703 | ||
|
|
c3ffa8387e | ||
|
|
d40514c043 | ||
|
|
d7df2f095b | ||
|
|
afdcc1c5ba | ||
|
|
92daaa35bb | ||
|
|
e4295e14ab | ||
|
|
ae37d1db36 | ||
|
|
a7464f8b89 | ||
|
|
2dd9ca9835 | ||
|
|
54cc14052c | ||
|
|
f930e46dde | ||
|
|
df75e5accb | ||
|
|
f66a9f5d1b | ||
|
|
c5b337f3bb | ||
|
|
bb1ba3e15e | ||
|
|
ce839c4127 | ||
|
|
8aabd28168 | ||
|
|
1a7a14b3ed | ||
|
|
17cebde051 |
4
.github/workflows/ci.yaml
vendored
@@ -18,8 +18,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
|
||||
10
.github/workflows/release.yml
vendored
@@ -11,6 +11,7 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository == 'papra-hq/papra'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -24,8 +25,12 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
# Ensure npm 11.5.1 or later is installed
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i
|
||||
@@ -41,7 +46,6 @@ jobs:
|
||||
title: "chore(release): update versions"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Trigger Docker build
|
||||
if: steps.changesets.outputs.published == 'true' && contains(fromJson(steps.changesets.outputs.publishedPackages).*.name, '@papra/docker')
|
||||
|
||||
12
CLAUDE.md
@@ -166,10 +166,20 @@ pnpm dev # localhost:4321
|
||||
|
||||
- Use **Vitest** for all testing
|
||||
- Test files: `*.test.ts` for unit tests, `*.int.test.ts` for integration tests
|
||||
- Use business-oriented test names (avoid `it('should return true')`)
|
||||
- Integration tests may use Testcontainers (Azurite, LocalStack)
|
||||
- All new features require test coverage
|
||||
|
||||
### Writing Good Test Names
|
||||
|
||||
Test names should explain the **why** (business logic, user scenario, or expected behavior), not the **how** (implementation details or return values).
|
||||
|
||||
**Key principles:**
|
||||
- **Describe blocks** should explain the business goal or rule being tested
|
||||
- **Test names** should explain the scenario, context, and reason for the behavior
|
||||
- Avoid implementation details like "returns X", "should be Y", "calls Z method"
|
||||
- Focus on user scenarios and business rules
|
||||
- Make tests readable as documentation - someone unfamiliar with the code should understand what's being tested and why
|
||||
|
||||
## Code Style
|
||||
|
||||
- **ESLint config**: `@antfu/eslint-config` (auto-fix on save recommended)
|
||||
|
||||
@@ -118,6 +118,7 @@ Papra would not have been possible without the following open-source projects:
|
||||
- **[HonoJS](https://hono.dev/)**: A small, fast, and lightweight web framework for building APIs.
|
||||
- **[Drizzle](https://orm.drizzle.team/)**: A simple and lightweight ORM for Node.js.
|
||||
- **[Better Auth](https://better-auth.com/)**: A simple and lightweight authentication library for Node.js.
|
||||
- **[CadenceMQ](https://github.com/papra-hq/cadence-mq)**: A self-hosted-friendly job queue for Node.js, made by Papra.
|
||||
- And other dependencies listed in the **[server package.json](./apps/papra-server/package.json)**
|
||||
- **Documentation**
|
||||
- **[Astro](https://astro.build)**: A great static site generator.
|
||||
@@ -128,7 +129,7 @@ Papra would not have been possible without the following open-source projects:
|
||||
- **[Github Actions](https://github.com/features/actions)**: For CI/CD.
|
||||
- **Infrastructure**
|
||||
- **[Cloudflare Pages](https://pages.cloudflare.com/)**: For static site hosting.
|
||||
- **[Render](https://render.com/)**: For backend hosting.
|
||||
- **[Fly.io](https://fly.io/)**: For backend hosting.
|
||||
- **[Turso](https://turso.tech/)**: For production database.
|
||||
|
||||
### Inspiration
|
||||
|
||||
1382
apps/docs/package-lock.json
generated
@@ -3,7 +3,6 @@
|
||||
"type": "module",
|
||||
"version": "0.6.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra documentation website",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -28,19 +27,20 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"yaml": "^2.8.0",
|
||||
"zod": "^3.25.67",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^3.13.0",
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.1.120",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@unocss/reset": "^0.64.0",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint": "catalog:",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"figue": "^3.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^15.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"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
|
||||
|
||||
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v22 and pnpm installed.
|
||||
Clone the [papra-hq/email-proxy](https://github.com/papra-hq/email-proxy) repository and deploy the worker using Wrangler cli. You will need to have Node.js v24 and pnpm installed.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
|
||||
102
apps/docs/src/content/docs/03-guides/06-tagging-rules.mdx
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Using Tagging Rules
|
||||
description: Learn how to automate document organization with tagging rules.
|
||||
slug: guides/tagging-rules
|
||||
---
|
||||
|
||||
## What are Tagging Rules?
|
||||
|
||||
Tagging rules allow you to automatically apply tags to documents based on specific conditions. This helps maintain consistent organization without manual effort, especially when dealing with large numbers of documents.
|
||||
|
||||
## How Tagging Rules Work
|
||||
|
||||
When a tagging rule is enabled, it automatically checks new documents as they're uploaded. If a document matches the rule's conditions, the specified tags are automatically applied.
|
||||
|
||||
### Rule Components
|
||||
|
||||
Each tagging rule consists of:
|
||||
|
||||
1. **Conditions**: Rules that determine which documents should be tagged
|
||||
- Field: The document property to check (e.g., name, content)
|
||||
- Operator: How to compare the field (e.g., contains, equals)
|
||||
- Value: The text to match against
|
||||
|
||||
2. **Actions**: The tags to apply when conditions are met
|
||||
|
||||
## Applying Rules to Existing Documents
|
||||
|
||||
### The "Run Now" Feature
|
||||
|
||||
When you create a new tagging rule, it only applies to documents uploaded *after* the rule is created. To apply the rule to documents that already exist in your organization, use the **"Apply to existing documents"** button.
|
||||
|
||||
This feature is particularly useful when:
|
||||
- You create a new rule and want to organize your existing documents
|
||||
- You modify a rule and want to reprocess documents
|
||||
- You're setting up your organization and want to retroactively organize imported documents
|
||||
|
||||
### How to Apply a Rule to Existing Documents
|
||||
|
||||
1. Navigate to your organization's Tagging Rules page
|
||||
2. Find the rule you want to apply
|
||||
3. Click the **"Apply to existing documents"** button
|
||||
4. Confirm the action in the dialog
|
||||
5. The task is queued and will be processed in the background
|
||||
|
||||
The system will:
|
||||
- Queue a background task to process all documents
|
||||
- Process documents in batches to avoid overloading the system
|
||||
- Check all existing documents in your organization
|
||||
- Apply tags where the rule's conditions match
|
||||
- Show you a success message once the task is queued
|
||||
|
||||
:::tip
|
||||
Applying a rule to existing documents runs as a background task, so you don't need to wait for it to complete. The processing happens asynchronously and efficiently handles large document collections by processing them in batches.
|
||||
:::
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Creating Effective Rules
|
||||
|
||||
1. **Be specific**: Use precise conditions to avoid over-tagging
|
||||
2. **Test first**: Create a rule and test it on a few documents before applying to all existing documents
|
||||
3. **Use multiple conditions**: Combine conditions for more accurate matching
|
||||
4. **Review regularly**: Periodically review your rules to ensure they're still relevant
|
||||
|
||||
### Example Rules
|
||||
|
||||
**Invoice Classification**
|
||||
- Condition: Document name contains "invoice"
|
||||
- Action: Apply "Invoice" tag
|
||||
|
||||
**Quarterly Reports**
|
||||
- Condition: Document name contains "Q1" or "Q2" or "Q3" or "Q4"
|
||||
- Action: Apply "Report" tag
|
||||
|
||||
## Using the API
|
||||
|
||||
You can also apply tagging rules programmatically using the API. The endpoint enqueues a background task and returns immediately:
|
||||
|
||||
```bash
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
https://api.papra.app/api/organizations/YOUR_ORG_ID/tagging-rules/RULE_ID/apply
|
||||
```
|
||||
|
||||
Response (HTTP 202 Accepted):
|
||||
```json
|
||||
{
|
||||
"taskId": "task_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Where:
|
||||
- `taskId`: The ID of the background task processing your request
|
||||
|
||||
:::note
|
||||
The API returns a task ID immediately. The actual processing happens in the background and may take some time depending on the number of documents. Task status retrieval will be available in a future release.
|
||||
:::
|
||||
|
||||
## Related Resources
|
||||
|
||||
- [API Endpoints Documentation](/resources/api-endpoints)
|
||||
- [CLI Documentation](/resources/cli)
|
||||
@@ -201,7 +201,10 @@ Search documents in the organization by name or content.
|
||||
- `pageIndex`: (optional, default: 0) The page index to start from.
|
||||
- `pageSize`: (optional, default: 100) The number of documents to return.
|
||||
- Response (JSON)
|
||||
- `documents`: The list of documents.
|
||||
- `searchResults`: The search results.
|
||||
- `documents`: The list of matching documents.
|
||||
- `id`: The document ID.
|
||||
- `name`: The document name.
|
||||
|
||||
### Get organization documents statistics
|
||||
|
||||
@@ -307,3 +310,13 @@ Remove a tag from a document.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response: empty (204 status code)
|
||||
|
||||
### Apply tagging rule to existing documents
|
||||
|
||||
**POST** `/api/organizations/:organizationId/tagging-rules/:taggingRuleId/apply`
|
||||
|
||||
Enqueue a background task to apply a tagging rule to all existing documents in the organization. This endpoint returns immediately with a task ID, and the processing happens asynchronously in the background. The task will check all documents and apply tags where the rule's conditions match.
|
||||
|
||||
- Required API key permissions: `tags:read` and `documents:update`
|
||||
- Response (JSON, HTTP 202)
|
||||
- `taskId`: The ID of the background task. You can use this to track the task's progress (task status retrieval coming in a future release).
|
||||
|
||||
@@ -40,6 +40,10 @@ export const sidebar = [
|
||||
label: 'Document Encryption',
|
||||
slug: 'guides/document-encryption',
|
||||
},
|
||||
{
|
||||
label: 'Tagging Rules',
|
||||
slug: 'guides/tagging-rules',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import type { ConfigDefinition } from 'figue';
|
||||
import { z } from 'astro:content';
|
||||
import { mapValues } from 'lodash-es';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { configDefinition } from '../../../papra-server/src/modules/config/config';
|
||||
|
||||
|
||||
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,
|
||||
},
|
||||
|
||||
solid: true,
|
||||
|
||||
ignores: [
|
||||
'public/manifest.json',
|
||||
],
|
||||
@@ -22,4 +24,10 @@ export default antfu({
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
},
|
||||
}, {
|
||||
files: ['src/locales/*.dictionary.ts'],
|
||||
rules: {
|
||||
// Sometimes for formatting amounts of dollar, we need "${{value}}" as value is interpolated later, it's not a template string here
|
||||
'no-template-curly-in-string': 'off',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,8 +65,19 @@
|
||||
</script>
|
||||
|
||||
<style>.sr-only {position: absolute;width: 1px;height: 1px;padding: 0;margin: -1px;overflow: hidden;clip: rect(0, 0, 0, 0);white-space: nowrap;border-width: 0;}</style>
|
||||
|
||||
<!-- Prevent flash of wrong theme on load -->
|
||||
<script>
|
||||
(function () {
|
||||
const stored = localStorage?.getItem('papra_color_mode') ?? 'dark';
|
||||
|
||||
if (stored === 'dark') {
|
||||
document.documentElement.setAttribute('data-kb-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="bg-background text-foreground">
|
||||
<h1 class="sr-only">Papra - Document archiving and sharing platform</h1>
|
||||
<p class="sr-only">Papra, the document archiving and sharing platform.</p>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"description": "Papra frontend client",
|
||||
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
@@ -12,7 +11,7 @@
|
||||
"url": "https://github.com/papra-hq/papra"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
@@ -29,30 +28,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@branchlet/core": "^1.0.0",
|
||||
"@corentinth/chisels": "^1.3.1",
|
||||
"@corentinth/chisels": "catalog:",
|
||||
"@kobalte/core": "^0.13.10",
|
||||
"@kobalte/utils": "^0.9.1",
|
||||
"@modular-forms/solid": "^0.25.1",
|
||||
"@pdfslick/solid": "^2.3.0",
|
||||
"@solid-primitives/storage": "^4.3.2",
|
||||
"@solidjs/router": "^0.14.10",
|
||||
"@tanstack/solid-query": "^5.81.2",
|
||||
"@tanstack/solid-query": "^5.90.3",
|
||||
"@tanstack/solid-table": "^8.21.3",
|
||||
"@unocss/reset": "^0.64.1",
|
||||
"better-auth": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk-solid": "^1.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"ofetch": "^1.4.1",
|
||||
"posthog-js": "^1.255.1",
|
||||
"p-limit": "^6.2.0",
|
||||
"posthog-js-lite": "^4.1.5",
|
||||
"radix3": "^1.1.2",
|
||||
"solid-js": "^1.9.7",
|
||||
"solid-js": "^1.9.9",
|
||||
"solid-sonner": "^0.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"ts-pattern": "^5.7.1",
|
||||
"unocss-preset-animations": "^1.2.1",
|
||||
"unocss-preset-animations": "^1.3.0",
|
||||
"unstorage": "^1.16.0",
|
||||
"valibot": "1.0.0-beta.10"
|
||||
},
|
||||
@@ -60,16 +56,15 @@
|
||||
"@antfu/eslint-config": "catalog:",
|
||||
"@iconify-json/tabler": "^1.2.19",
|
||||
"@playwright/test": "^1.53.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"jsdom": "^25.0.1",
|
||||
"eslint-plugin-solid": "^0.14.5",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tsx": "^4.20.3",
|
||||
"tsx": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"unocss": "0.65.0-beta.2",
|
||||
"vite": "^5.4.19",
|
||||
"vite-plugin-solid": "^2.11.7",
|
||||
"unocss": "^66.5.4",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-solid": "^2.11.9",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/* @refresh reload */
|
||||
|
||||
import type { ConfigColorMode } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { ColorModeProvider, createLocalStorageManager } from '@kobalte/core/color-mode';
|
||||
import { Router } from '@solidjs/router';
|
||||
import { QueryClientProvider } from '@tanstack/solid-query';
|
||||
|
||||
import { render, Suspense } from 'solid-js/web';
|
||||
import { Suspense } from 'solid-js';
|
||||
import { render } from 'solid-js/web';
|
||||
import { CommandPaletteProvider } from './modules/command-palette/command-palette.provider';
|
||||
import { ConfigProvider } from './modules/config/config.provider';
|
||||
import { DemoIndicator } from './modules/demo/demo.provider';
|
||||
@@ -17,7 +18,6 @@ import { IdentifyUser } from './modules/tracking/components/identify-user.compon
|
||||
import { PageViewTracker } from './modules/tracking/components/pageview-tracker.component';
|
||||
import { Toaster } from './modules/ui/components/sonner';
|
||||
import { routes } from './routes';
|
||||
import '@unocss/reset/tailwind.css';
|
||||
import 'virtual:uno.css';
|
||||
import './app.css';
|
||||
|
||||
@@ -28,17 +28,15 @@ render(
|
||||
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router
|
||||
children={routes}
|
||||
root={props => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense>
|
||||
<PageViewTracker />
|
||||
<IdentifyUser />
|
||||
|
||||
<Suspense>
|
||||
<I18nProvider>
|
||||
<ConfirmModalProvider>
|
||||
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
|
||||
<ColorModeProvider
|
||||
initialColorMode={initialColorMode}
|
||||
storageManager={localStorageManager}
|
||||
@@ -60,9 +58,9 @@ render(
|
||||
</ConfirmModalProvider>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
document.getElementById('root')!,
|
||||
|
||||
@@ -70,6 +70,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'auth.email-validation-required.title': 'E-Mail verifizieren',
|
||||
'auth.email-validation-required.description': 'Eine Verifizierungs-E-Mail wurde an Ihre E-Mail-Adresse gesendet. Bitte verifizieren Sie Ihre E-Mail-Adresse, indem Sie auf den Link in der E-Mail klicken.',
|
||||
|
||||
'auth.email-verification.success.title': 'E-Mail verifiziert',
|
||||
'auth.email-verification.success.description': 'Ihre E-Mail wurde erfolgreich verifiziert. Sie können sich jetzt in Ihr Konto einloggen.',
|
||||
'auth.email-verification.success.login': 'Zur Anmeldung',
|
||||
'auth.email-verification.error.title': 'Verifizierung fehlgeschlagen',
|
||||
'auth.email-verification.error.description': 'Der Verifizierungslink ist ungültig oder abgelaufen. Bitte fordern Sie eine neue Verifizierungs-E-Mail an, indem Sie sich anmelden.',
|
||||
'auth.email-verification.error.back': 'Zurück zur Anmeldung',
|
||||
|
||||
'auth.legal-links.description': 'Indem Sie fortfahren, bestätigen Sie, dass Sie die {{ terms }} und die {{ privacy }} verstanden haben und ihnen zustimmen.',
|
||||
'auth.legal-links.terms': 'Nutzungsbedingungen',
|
||||
'auth.legal-links.privacy': 'Datenschutzrichtlinie',
|
||||
@@ -157,6 +164,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'organization.settings.delete.confirm.cancel-button': 'Abbrechen',
|
||||
'organization.settings.delete.success': 'Organisation gelöscht',
|
||||
'organization.settings.delete.only-owner': 'Nur der Organisationsinhaber kann diese Organisation löschen.',
|
||||
'organization.settings.delete.has-active-subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden, bitte kündigen Sie zuerst Ihr Abonnement oben.',
|
||||
|
||||
'organization.usage.page.title': 'Nutzung',
|
||||
'organization.usage.page.description': 'Sehen Sie die aktuelle Nutzung und Limits Ihrer Organisation.',
|
||||
@@ -241,6 +249,11 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'documents.list.no-documents.title': 'Keine Dokumente',
|
||||
'documents.list.no-documents.description': 'Es sind noch keine Dokumente in dieser Organisation vorhanden. Beginnen Sie mit dem Hochladen von Dokumenten.',
|
||||
'documents.list.no-results': 'Keine Dokumente gefunden',
|
||||
'documents.list.table.headers.file-name': 'Dateiname',
|
||||
'documents.list.table.headers.created': 'Erstellt am',
|
||||
'documents.list.table.headers.deleted': 'Gelöscht am',
|
||||
'documents.list.table.headers.actions': 'Aktionen',
|
||||
'documents.list.table.headers.tags': 'Tags',
|
||||
|
||||
'documents.tabs.info': 'Info',
|
||||
'documents.tabs.content': 'Inhalt',
|
||||
@@ -386,8 +399,13 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.description.placeholder': 'Beispiel: Dokumente mit \'Rechnung\' im Namen taggen',
|
||||
'tagging-rules.form.description.max-length': 'Die Beschreibung muss weniger als 256 Zeichen lang sein',
|
||||
'tagging-rules.form.conditions.label': 'Bedingungen',
|
||||
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Alle Bedingungen müssen erfüllt sein, damit die Regel angewendet wird.',
|
||||
'tagging-rules.form.conditions.description': 'Definieren Sie die Bedingungen, die erfüllt sein müssen, damit die Regel angewendet wird. Keine Bedingungen bedeutet, dass die Regel auf alle Dokumente angewendet wird',
|
||||
'tagging-rules.form.conditions.add-condition': 'Bedingung hinzufügen',
|
||||
'tagging-rules.form.conditions.connector.when': 'Wenn',
|
||||
'tagging-rules.form.conditions.connector.and': 'und',
|
||||
'tagging-rules.form.conditions.connector.or': 'oder',
|
||||
'tagging-rules.condition-match-mode.all': 'Alle Bedingungen müssen erfüllt sein',
|
||||
'tagging-rules.condition-match-mode.any': 'Mindestens eine Bedingung muss erfüllt sein',
|
||||
'tagging-rules.form.conditions.no-conditions.title': 'Keine Bedingungen',
|
||||
'tagging-rules.form.conditions.no-conditions.description': 'Sie haben dieser Regel keine Bedingungen hinzugefügt. Diese Regel wendet ihre Tags auf alle Dokumente an.',
|
||||
'tagging-rules.form.conditions.no-conditions.confirm': 'Regel ohne Bedingungen anwenden',
|
||||
@@ -400,9 +418,16 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'tagging-rules.form.tags.add-tag': 'Tag erstellen',
|
||||
'tagging-rules.form.submit': 'Regel erstellen',
|
||||
'tagging-rules.update.title': 'Tagging-Regel aktualisieren',
|
||||
'tagging-rules.update.error': 'Tagging-Regel konnte nicht aktualisiert werden',
|
||||
'tagging-rules.update.error': 'Fehler beim Aktualisieren der Tagging-Regel',
|
||||
'tagging-rules.update.submit': 'Regel aktualisieren',
|
||||
'tagging-rules.update.cancel': 'Abbrechen',
|
||||
'tagging-rules.apply.button': 'Auf vorhandene Dokumente anwenden',
|
||||
'tagging-rules.apply.confirm.title': 'Regel auf vorhandene Dokumente anwenden?',
|
||||
'tagging-rules.apply.confirm.description': 'Dies überprüft alle vorhandenen Dokumente in Ihrer Organisation und wendet Tags an, wo Bedingungen übereinstimmen. Die Verarbeitung erfolgt im Hintergrund.',
|
||||
'tagging-rules.apply.confirm.button': 'Regel anwenden',
|
||||
'tagging-rules.apply.success': 'Regelanwendung im Hintergrund gestartet',
|
||||
'tagging-rules.apply.error': 'Fehler beim Starten der Regelanwendung',
|
||||
'tagging-rules.apply.processing': 'Wird gestartet...',
|
||||
|
||||
// Intake emails
|
||||
|
||||
@@ -551,7 +576,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
|
||||
'layout.upgrade-cta.title': 'Brauchen Sie mehr Platz?',
|
||||
'layout.upgrade-cta.description': '10x mehr Speicher + Team-Zusammenarbeit',
|
||||
'layout.upgrade-cta.button': 'Auf Plus upgraden',
|
||||
'layout.upgrade-cta.button': 'Jetzt upgraden',
|
||||
|
||||
'layout.theme.light': 'Heller Modus',
|
||||
'layout.theme.dark': 'Dunkler Modus',
|
||||
@@ -589,6 +614,32 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'api-errors.internal.error': 'Beim Verarbeiten Ihrer Anfrage ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
'api-errors.auth.invalid_origin': 'Ungültige Anwendungs-Ursprung. Wenn Sie Papra selbst hosten, stellen Sie sicher, dass Ihre APP_BASE_URL-Umgebungsvariable mit Ihrer aktuellen URL übereinstimmt. Weitere Details finden Sie unter https://docs.papra.app/resources/troubleshooting/#invalid-application-origin',
|
||||
'api-errors.organization.max_members_count_reached': 'Die maximale Anzahl an Mitgliedern und ausstehenden Einladungen für diese Organisation wurde erreicht. Bitte aktualisieren Sie Ihren Plan, um weitere Mitglieder hinzuzufügen.',
|
||||
'api-errors.organization.has_active_subscription': 'Organisation mit aktivem Abonnement kann nicht gelöscht werden. Bitte kündigen Sie zuerst Ihr Abonnement über die Schaltfläche Abonnement verwalten oben.',
|
||||
// Better auth api errors
|
||||
'api-errors.USER_NOT_FOUND': 'Benutzer nicht gefunden',
|
||||
'api-errors.FAILED_TO_CREATE_USER': 'Fehler beim Erstellen des Benutzers',
|
||||
'api-errors.FAILED_TO_CREATE_SESSION': 'Fehler beim Erstellen der Sitzung',
|
||||
'api-errors.FAILED_TO_UPDATE_USER': 'Fehler beim Aktualisieren des Benutzers',
|
||||
'api-errors.FAILED_TO_GET_SESSION': 'Fehler beim Abrufen der Sitzung',
|
||||
'api-errors.INVALID_PASSWORD': 'Ungültiges Passwort',
|
||||
'api-errors.INVALID_EMAIL': 'Ungültige E-Mail',
|
||||
'api-errors.INVALID_EMAIL_OR_PASSWORD': 'Die E-Mail oder das Passwort ist falsch, oder das Konto existiert nicht.',
|
||||
'api-errors.SOCIAL_ACCOUNT_ALREADY_LINKED': 'Social-Media-Konto bereits verknüpft',
|
||||
'api-errors.PROVIDER_NOT_FOUND': 'Anbieter nicht gefunden',
|
||||
'api-errors.INVALID_TOKEN': 'Ungültiger Token',
|
||||
'api-errors.ID_TOKEN_NOT_SUPPORTED': 'ID-Token wird nicht unterstützt',
|
||||
'api-errors.FAILED_TO_GET_USER_INFO': 'Fehler beim Abrufen der Benutzerinformationen',
|
||||
'api-errors.USER_EMAIL_NOT_FOUND': 'Benutzer-E-Mail nicht gefunden',
|
||||
'api-errors.EMAIL_NOT_VERIFIED': 'E-Mail nicht verifiziert',
|
||||
'api-errors.PASSWORD_TOO_SHORT': 'Passwort zu kurz',
|
||||
'api-errors.PASSWORD_TOO_LONG': 'Passwort zu lang',
|
||||
'api-errors.USER_ALREADY_EXISTS': 'Ein Benutzer mit dieser E-Mail existiert bereits',
|
||||
'api-errors.EMAIL_CAN_NOT_BE_UPDATED': 'E-Mail kann nicht aktualisiert werden',
|
||||
'api-errors.CREDENTIAL_ACCOUNT_NOT_FOUND': 'Anmeldekonto nicht gefunden',
|
||||
'api-errors.SESSION_EXPIRED': 'Sitzung abgelaufen',
|
||||
'api-errors.FAILED_TO_UNLINK_LAST_ACCOUNT': 'Fehler beim Trennen des letzten Kontos',
|
||||
'api-errors.ACCOUNT_NOT_FOUND': 'Konto nicht gefunden',
|
||||
'api-errors.USER_ALREADY_HAS_PASSWORD': 'Benutzer hat bereits ein Passwort',
|
||||
|
||||
// Not found
|
||||
|
||||
@@ -627,17 +678,21 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.checkout-cancel.need-help': 'Benötigen Sie Hilfe?',
|
||||
'subscriptions.checkout-cancel.contact-support': 'Support kontaktieren',
|
||||
|
||||
'subscriptions.upgrade-dialog.title': 'Auf Plus upgraden',
|
||||
'subscriptions.upgrade-dialog.title': 'Diese Organisation upgraden',
|
||||
'subscriptions.upgrade-dialog.description': 'Schalten Sie leistungsstarke Funktionen für Ihre Organisation frei',
|
||||
'subscriptions.upgrade-dialog.contact-us': 'Kontaktieren Sie uns',
|
||||
'subscriptions.upgrade-dialog.enterprise-plans': 'wenn Sie benutzerdefinierte Enterprise-Pläne benötigen.',
|
||||
'subscriptions.upgrade-dialog.current-plan': 'Aktueller Plan',
|
||||
'subscriptions.upgrade-dialog.recommended': 'Empfohlen',
|
||||
'subscriptions.upgrade-dialog.per-month': '/Monat',
|
||||
'subscriptions.upgrade-dialog.billed-annually': '${{ price }} jährlich abgerechnet',
|
||||
'subscriptions.upgrade-dialog.upgrade-now': 'Jetzt upgraden',
|
||||
'subscriptions.upgrade-dialog.promo-banner.title': 'Zeitlich begrenztes Angebot',
|
||||
'subscriptions.upgrade-dialog.promo-banner.description': 'Erhalten Sie {{ percent }}% Rabatt pro Organisation auf alle Tarife für immer als Early Adopter! Angebot läuft ab in {{ days, >1:{days} Tagen, =1:1 Tag, weniger als 1 Tag }}.',
|
||||
|
||||
'subscriptions.plan.free.name': 'Kostenloser Plan',
|
||||
'subscriptions.plan.plus.name': 'Plus',
|
||||
'subscriptions.plan.pro.name': 'Pro',
|
||||
|
||||
'subscriptions.features.storage-size': 'Dokumentenspeichergröße',
|
||||
'subscriptions.features.members': 'Organisationsmitglieder',
|
||||
@@ -649,6 +704,7 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
'subscriptions.features.support': 'Support',
|
||||
'subscriptions.features.support-community': 'Community-Support',
|
||||
'subscriptions.features.support-email': 'E-Mail-Support',
|
||||
'subscriptions.features.support-priority': 'Prioritäts-Support',
|
||||
|
||||
'subscriptions.billing-interval.monthly': 'Monatlich',
|
||||
'subscriptions.billing-interval.annual': 'Jährlich',
|
||||
@@ -659,4 +715,6 @@ export const translations: Partial<TranslationsDictionary> = {
|
||||
// Common / Shared
|
||||
|
||||
'common.confirm-modal.type-to-confirm': 'Geben Sie "{{ text }}" ein zur Bestätigung',
|
||||
'common.tables.rows-per-page': 'Zeilen pro Seite',
|
||||
'common.tables.pagination-info': 'Seite {{ currentPage }} von {{ totalPages }}',
|
||||
};
|
||||
|
||||