Initial commit for project Spoon!
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
|
||||
"40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
|
||||
export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
...config,
|
||||
name: 'Spoon',
|
||||
slug: 'spoon',
|
||||
scheme: 'spoon',
|
||||
version: '0.1.0',
|
||||
orientation: 'portrait',
|
||||
icon: './assets/icon-light.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
updates: {
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
newArchEnabled: true,
|
||||
assetBundlePatterns: ['**/*'],
|
||||
ios: {
|
||||
bundleIdentifier: 'org.gbrown.spoon',
|
||||
supportsTablet: true,
|
||||
icon: {
|
||||
light: './assets/icon-light.png',
|
||||
dark: './assets/icon-dark.png',
|
||||
},
|
||||
},
|
||||
android: {
|
||||
package: 'org.gbrown.spoon',
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/icon-light.png',
|
||||
backgroundColor: '#0f766e',
|
||||
},
|
||||
edgeToEdgeEnabled: true,
|
||||
},
|
||||
// extra: {
|
||||
// eas: {
|
||||
// projectId: "your-eas-project-id",
|
||||
// },
|
||||
// },
|
||||
experiments: {
|
||||
tsconfigPaths: true,
|
||||
typedRoutes: true,
|
||||
reactCanary: true,
|
||||
reactCompiler: true,
|
||||
},
|
||||
plugins: [
|
||||
'expo-router',
|
||||
'expo-secure-store',
|
||||
'expo-web-browser',
|
||||
[
|
||||
'expo-splash-screen',
|
||||
{
|
||||
backgroundColor: '#f8fafc',
|
||||
image: './assets/icon-light.png',
|
||||
dark: {
|
||||
backgroundColor: '#111827',
|
||||
image: './assets/icon-dark.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 4.1.2",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
"node": "22.12.0",
|
||||
"bun": "1.3.10",
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"extends": "base",
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"extends": "base",
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"extends": "base"
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
|
||||
import { baseConfig } from '@spoon/eslint-config/base';
|
||||
import { reactConfig } from '@spoon/eslint-config/react';
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: ['.expo/**', 'expo-plugins/**'],
|
||||
},
|
||||
baseConfig,
|
||||
reactConfig,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
import 'expo-router/entry';
|
||||
@@ -0,0 +1,15 @@
|
||||
const path = require('node:path');
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { FileStore } = require('metro-cache');
|
||||
const { withNativewind } = require('nativewind/metro');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
config.cacheStores = [
|
||||
new FileStore({
|
||||
root: path.join(__dirname, 'node_modules', '.cache', 'metro'),
|
||||
}),
|
||||
];
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
module.exports = withNativewind(config);
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="react-native-css/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@spoon/expo",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .cache .expo .turbo android ios node_modules",
|
||||
"dev": "bun with-env expo start",
|
||||
"dev:tunnel": "bun with-env expo start --tunnel",
|
||||
"dev:android": "bun with-env expo start --android",
|
||||
"dev:ios": "bun with-env expo start --ios",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/auth": "catalog:convex",
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@legendapp/list": "^2.0.19",
|
||||
"@react-navigation/bottom-tabs": "^7.15.8",
|
||||
"@react-navigation/elements": "^2.9.13",
|
||||
"@react-navigation/native": "^7.2.1",
|
||||
"@sentry/react-native": "^7.13.0",
|
||||
"@spoon/backend": "workspace:*",
|
||||
"convex": "catalog:convex",
|
||||
"expo": "~54.0.33",
|
||||
"expo-apple-authentication": "~8.0.8",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-dev-client": "~6.0.20",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-router": "~6.0.23",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-splash-screen": "~31.0.13",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-symbols": "~1.0.8",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"nativewind": "5.0.0-preview.2",
|
||||
"react": "catalog:react19",
|
||||
"react-dom": "catalog:react19",
|
||||
"react-native": "~0.81.6",
|
||||
"react-native-css": "3.0.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.7",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.2",
|
||||
"react-native-worklets": "~0.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spoon/eslint-config": "workspace:*",
|
||||
"@spoon/prettier-config": "workspace:*",
|
||||
"@spoon/tailwind-config": "workspace:*",
|
||||
"@spoon/tsconfig": "workspace:*",
|
||||
"@types/react": "catalog:react19",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@spoon/prettier-config"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@spoon/tailwind-config/postcss-config');
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
@@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'nativewind/theme';
|
||||
@import '@spoon/tailwind-config/theme';
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -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());
|
||||
@@ -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),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": ["@spoon/tsconfig/base.json"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-native",
|
||||
"checkJs": false,
|
||||
"moduleSuffixes": [".ios", ".android", ".native", ""],
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"*.ts",
|
||||
"*.js",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://v2-8-20.turborepo.dev/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"persistent": true,
|
||||
"interactive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user