Initial commit for project Spoon!
Build and Push Next App / quality (push) Failing after 45s
Build and Push Next App / build-next (push) Has been skipped

This commit is contained in:
Gabriel Brown
2026-06-21 17:52:02 -05:00
commit cf7ff2ee4e
268 changed files with 32981 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import { useColorScheme } from 'react-native';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { ConvexAuthProvider } from '@convex-dev/auth/react';
import { convex } from '~/utils/convex';
import { secureTokenStorage } from '~/utils/session-store';
import '../styles.css';
const RootLayout = () => {
const colorScheme = useColorScheme();
return (
<ConvexAuthProvider client={convex} storage={secureTokenStorage}>
<Stack
screenOptions={{
headerStyle: {
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
},
headerTintColor: colorScheme === 'dark' ? '#f8fafc' : '#111827',
contentStyle: {
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
},
}}
/>
<StatusBar style='auto' />
</ConvexAuthProvider>
);
};
export default RootLayout;
+179
View File
@@ -0,0 +1,179 @@
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 agentRequests =
useQuery(
api.agentRequests.listRecent,
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='Updates' value={syncRuns.length} />
<Stat label='Agents' value={agentRequests.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;
+21
View File
@@ -0,0 +1,21 @@
import { Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Stack, useLocalSearchParams } from 'expo-router';
const Post = () => {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<SafeAreaView className='bg-background flex-1'>
<Stack.Screen options={{ title: 'Post' }} />
<View className='flex-1 p-4'>
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
<Text className='text-muted-foreground mt-2'>
Implement your post detail screen here using Convex queries.
</Text>
</View>
</SafeAreaView>
);
};
export default Post;
+1
View File
@@ -0,0 +1 @@
declare module '*.css';
+3
View File
@@ -0,0 +1,3 @@
@import 'tailwindcss';
@import 'nativewind/theme';
@import '@spoon/tailwind-config/theme';
+26
View File
@@ -0,0 +1,26 @@
import Constants from 'expo-constants';
/**
* Extend this function when going to production by
* setting the baseUrl to your production API URL.
*/
export const getBaseUrl = () => {
/**
* Gets the IP address of your host-machine. If it cannot automatically find it,
* you'll have to manually set it. NOTE: Port 3000 should work for most but confirm
* you don't have anything else running on it, or you'd have to change it.
*
* **NOTE**: This is only for development. In production, you'll want to set the
* baseUrl to your production API URL.
*/
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(':')[0];
if (!localhost) {
// return "https://turbo.t3.gg";
throw new Error(
'Failed to get localhost. Please point to your production server.',
);
}
return `http://${localhost}:3000`;
};
+26
View File
@@ -0,0 +1,26 @@
import Constants from 'expo-constants';
import { ConvexReactClient } from 'convex/react';
const getConvexUrl = (): string => {
// Allow override via Expo extra config (set in app.config.ts for production)
const fromConfig = Constants.expoConfig?.extra?.convexUrl as
| string
| undefined;
if (fromConfig) return fromConfig;
// Fall back to deriving from the dev server host (same pattern as getBaseUrl)
const debuggerHost = Constants.expoConfig?.hostUri;
const localhost = debuggerHost?.split(':')[0];
if (!localhost) {
throw new Error(
'Could not determine Convex URL. Set extra.convexUrl in app.config.ts for production.',
);
}
// Point at the self-hosted Convex backend on the local network
// Update this port if your Convex backend runs on a different port
return `http://${localhost}:3210`;
};
export const convex = new ConvexReactClient(getConvexUrl());
+7
View File
@@ -0,0 +1,7 @@
import * as SecureStore from 'expo-secure-store';
export const secureTokenStorage = {
getItem: (key: string) => SecureStore.getItemAsync(key),
setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
removeItem: (key: string) => SecureStore.deleteItemAsync(key),
};