178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Alert, Pressable, Text, TextInput, View } from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import * as Linking from 'expo-linking';
|
|
import { Stack } from 'expo-router';
|
|
import * as WebBrowser from 'expo-web-browser';
|
|
import { useAuthActions } from '@convex-dev/auth/react';
|
|
import { useConvexAuth, useQuery } from 'convex/react';
|
|
|
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
|
|
|
WebBrowser.maybeCompleteAuthSession();
|
|
|
|
const Stat = ({ label, value }: { label: string; value: number }) => (
|
|
<View className='border-border bg-card flex-1 rounded-lg border p-4'>
|
|
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
|
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
|
|
</View>
|
|
);
|
|
|
|
const Index = () => {
|
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
|
const { signIn, signOut } = useAuthActions();
|
|
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
|
const spoons =
|
|
useQuery(api.spoons.listMine, isAuthenticated ? {} : 'skip') ?? [];
|
|
const syncRuns =
|
|
useQuery(
|
|
api.syncRuns.listRecent,
|
|
isAuthenticated ? { limit: 5 } : 'skip',
|
|
) ?? [];
|
|
const threads =
|
|
useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ??
|
|
[];
|
|
const [email, setEmail] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const redirectTo = useMemo(() => Linking.createURL(''), []);
|
|
|
|
const handlePasswordSignIn = async () => {
|
|
setSubmitting(true);
|
|
try {
|
|
await signIn('password', { email, password, flow: 'signIn' });
|
|
} catch (error) {
|
|
console.error(error);
|
|
Alert.alert('Sign in failed', 'Check your email and password.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleAuthentikSignIn = async () => {
|
|
setSubmitting(true);
|
|
try {
|
|
const result = await signIn('authentik', { redirectTo });
|
|
if (!result.redirect) return;
|
|
const authResult = await WebBrowser.openAuthSessionAsync(
|
|
result.redirect.toString(),
|
|
redirectTo,
|
|
);
|
|
if (authResult.type !== 'success') return;
|
|
const parsed = Linking.parse(authResult.url);
|
|
const code = parsed.queryParams?.code;
|
|
if (typeof code !== 'string') {
|
|
Alert.alert('Sign in failed', 'Authentik did not return a code.');
|
|
return;
|
|
}
|
|
await signIn('authentik', { code });
|
|
} catch (error) {
|
|
console.error(error);
|
|
Alert.alert('Sign in failed', 'Could not complete Authentik sign in.');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView className='bg-background flex-1'>
|
|
<Stack.Screen options={{ title: 'Spoon' }} />
|
|
<View className='flex-1 gap-5 p-6'>
|
|
<View>
|
|
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
|
|
<Text className='text-muted-foreground mt-2 text-base leading-6'>
|
|
Fork freely. Stay close to upstream.
|
|
</Text>
|
|
</View>
|
|
|
|
{isLoading ? (
|
|
<Text className='text-muted-foreground'>Loading...</Text>
|
|
) : isAuthenticated ? (
|
|
<View className='gap-5'>
|
|
<View>
|
|
<Text className='text-foreground text-xl font-semibold'>
|
|
Welcome{user?.name ? `, ${user.name}` : ''}
|
|
</Text>
|
|
<Text className='text-muted-foreground mt-1'>
|
|
Monitor your managed forks from anywhere.
|
|
</Text>
|
|
</View>
|
|
<View className='flex-row gap-3'>
|
|
<Stat label='Spoons' value={spoons.length} />
|
|
<Stat label='Checks' value={syncRuns.length} />
|
|
<Stat label='Threads' value={threads.length} />
|
|
</View>
|
|
<View className='border-border bg-card rounded-lg border p-4'>
|
|
<Text className='text-foreground font-semibold'>
|
|
Recent Spoons
|
|
</Text>
|
|
{spoons.length ? (
|
|
spoons.slice(0, 4).map((spoon) => (
|
|
<Text key={spoon._id} className='text-muted-foreground mt-3'>
|
|
{spoon.name} - {spoon.status.replaceAll('_', ' ')}
|
|
</Text>
|
|
))
|
|
) : (
|
|
<Text className='text-muted-foreground mt-3'>
|
|
Create your first Spoon from the web dashboard.
|
|
</Text>
|
|
)}
|
|
</View>
|
|
<Pressable
|
|
className='bg-primary items-center rounded-md p-3'
|
|
onPress={() => void signOut()}
|
|
>
|
|
<Text className='text-primary-foreground font-semibold'>
|
|
Sign out
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
<View className='gap-4'>
|
|
<TextInput
|
|
className='border-input text-foreground rounded-md border px-3 py-3'
|
|
autoCapitalize='none'
|
|
keyboardType='email-address'
|
|
placeholder='Email'
|
|
placeholderTextColor='#64748b'
|
|
value={email}
|
|
onChangeText={setEmail}
|
|
/>
|
|
<TextInput
|
|
className='border-input text-foreground rounded-md border px-3 py-3'
|
|
secureTextEntry
|
|
placeholder='Password'
|
|
placeholderTextColor='#64748b'
|
|
value={password}
|
|
onChangeText={setPassword}
|
|
/>
|
|
<Pressable
|
|
className='bg-primary items-center rounded-md p-3 disabled:opacity-60'
|
|
disabled={submitting}
|
|
onPress={() => void handlePasswordSignIn()}
|
|
>
|
|
<Text className='text-primary-foreground font-semibold'>
|
|
Sign in with password
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
className='border-border items-center rounded-md border p-3 disabled:opacity-60'
|
|
disabled={submitting}
|
|
onPress={() => void handleAuthentikSignIn()}
|
|
>
|
|
<Text className='text-foreground font-semibold'>
|
|
Continue with Authentik
|
|
</Text>
|
|
</Pressable>
|
|
<Text className='text-muted-foreground text-sm'>
|
|
Register the native redirect URI based on spoon:// in Authentik.
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
};
|
|
|
|
export default Index;
|