Skip to content

Commit 5aec9a4

Browse files
committed
Update storage upload and scroll view with download.
1 parent bb45455 commit 5aec9a4

File tree

13 files changed

+125
-232
lines changed

13 files changed

+125
-232
lines changed

README.md

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,25 @@
1-
# Expo Router Example
1+
# React Native File Upload with Expo to Supabase Storage
22

3-
Use [`expo-router`](https://expo.github.io/router) to build native navigation using files in the `app/` directory.
3+
This app is using the [`expo-router`](https://expo.github.io/router) and [Supabase](https://supabase.io) to upload files to Supabase Storage.
44

5-
## 🚀 How to use
5+
Routes are protected by [Supabase Auth](https://supabase.io/docs/guides/auth).
66

7-
```sh
8-
npx create-expo-app -e with-router
9-
```
7+
## Configuration
8+
9+
1. Make sure to include your own Supabase keys inside the `config/initSupabase.ts` file.
10+
2. Also create a new bucket `files` inside Supabase Storage, and use the `create-policy.sql` to create a policy that allows users to upload files only to their own folder inside the bucket.
11+
12+
## Running this example
1013

11-
## 📝 Notes
14+
To run the provided example, you can use:
15+
16+
```bash
17+
npm expo start
18+
```
1219

13-
- [Expo Router: Docs](https://expo.github.io/router)
14-
- [Expo Router: Repo](https://github.com/expo/router)
20+
## Preview
21+
<div style="display: flex; flex-direction: 'row';">
22+
<img src="./screenshots/1.png" width=30%>
23+
<img src="./screenshots/2.png" width=30%>
24+
<img src="./screenshots/3.png" width=30%>
25+
</div>

app/(auth)/_layout.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { Stack } from 'expo-router';
2+
import { useAuth } from '../../provider/AuthProvider';
3+
import React from 'react';
4+
import { TouchableOpacity } from 'react-native';
5+
import { Ionicons } from '@expo/vector-icons';
26

7+
// Simple stack layout within the authenticated area
38
const StackLayout = () => {
9+
const { signOut } = useAuth();
10+
411
return (
512
<Stack
613
screenOptions={{
@@ -13,6 +20,11 @@ const StackLayout = () => {
1320
name="list"
1421
options={{
1522
headerTitle: 'My Files',
23+
headerRight: () => (
24+
<TouchableOpacity onPress={signOut}>
25+
<Ionicons name="log-out-outline" size={30} color={'#fff'} />
26+
</TouchableOpacity>
27+
),
1628
}}></Stack.Screen>
1729
</Stack>
1830
);

app/(auth)/list.tsx

Lines changed: 28 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,29 @@
1-
import { View, Text, StyleSheet, TouchableOpacity, Image, FlatList } from 'react-native';
1+
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
22
import React, { useEffect, useState } from 'react';
33
import { Ionicons } from '@expo/vector-icons';
44
import * as ImagePicker from 'expo-image-picker';
55
import { useAuth } from '../../provider/AuthProvider';
6-
import Uppy from '@uppy/core';
7-
import Tus from '@uppy/tus';
8-
import { TusFileReader } from '../../helper/tusFileReader';
96
import * as FileSystem from 'expo-file-system';
107
import { decode } from 'base64-arraybuffer';
11-
12-
const SUPABASE_STORAGE_URL = 'https://kolrncrjvromhaivenfp.supabase.co/storage/v1/';
8+
import { supabase } from '../../config/initSupabase';
9+
import { FileObject } from '@supabase/storage-js';
10+
import ImageItem from '../../components/ImageItem';
1311

1412
const list = () => {
15-
const { session } = useAuth();
16-
const [uppy, setUppy] = useState<Uppy>(null);
17-
const [progress, setProgress] = useState(0);
18-
const [uploading, setUploading] = useState(false);
19-
const [files, setFiles] = useState([]);
13+
const { user } = useAuth();
14+
const [files, setFiles] = useState<FileObject[]>([]);
2015

2116
useEffect(() => {
22-
if (!session) return;
23-
24-
const uppyInstance = new Uppy({ debug: true, autoProceed: false })
25-
.use(Tus, {
26-
endpoint: `${SUPABASE_STORAGE_URL}object/files/${session.user.id}`,
27-
// fileReader: new TusFileReader(), <- this is not working
28-
// retryDelays: [0, 3000, 5000, 10000, 20000],
29-
// uploadDataDuringCreation: true,
30-
headers: {
31-
authorization: `Bearer ${session.access_token}`,
32-
'x-upsert': 'true',
33-
},
34-
chunkSize: 6 * 1024 * 1024,
35-
allowedMetaFields: ['bucketName', 'objectName', 'contentType', 'cacheControl'],
36-
})
17+
if (!user) return;
3718

38-
.on('file-added', (file) => {
39-
file.meta = {
40-
...file.meta,
41-
bucketName: `files`,
42-
objectName: `${session.user.id}/${file.name}`,
43-
contentType: file.type,
44-
};
45-
console.log('file-added:', file);
46-
})
47-
.on('upload-progress', (file, progress) => {
48-
console.log('upload-progress:', progress);
19+
// Load user images
20+
loadImages();
21+
}, [user]);
4922

50-
setProgress(progress.bytesUploaded / progress.bytesTotal);
51-
})
52-
.on('complete', (result) => {
53-
console.log('complete:', result);
54-
setFiles([]);
55-
setUploading(false);
56-
})
57-
.on('progress', (progress) => {
58-
console.log('progress:', progress);
59-
})
60-
.on('error', (error) => {
61-
console.log('error:', JSON.stringify(error));
62-
});
63-
64-
setUppy(uppyInstance);
65-
}, [session]);
23+
const loadImages = async () => {
24+
const { data } = await supabase.storage.from('files').list(user.id);
25+
setFiles(data);
26+
};
6627

6728
const onSelectImage = async () => {
6829
const options: ImagePicker.ImagePickerOptions = {
@@ -74,84 +35,29 @@ const list = () => {
7435

7536
// Save image if not cancelled
7637
if (!result.canceled) {
77-
// UPPY APPROACH
7838
const img = result.assets[0];
79-
const fetchResponse = await fetch(img.uri);
80-
const blob = await fetchResponse.blob();
81-
console.log('🚀 ~ file: list.tsx:83 ~ onSelectImage ~ blob:', blob);
39+
const base64 = await FileSystem.readAsStringAsync(img.uri, { encoding: 'base64' });
40+
const filePath = `${user.id}/${new Date().getTime()}.${img.type === 'image' ? 'png' : 'mp4'}`;
8241
const contentType = img.type === 'image' ? 'image/png' : 'video/mp4';
83-
const fileName = `${new Date().getTime()}.${img.type === 'image' ? 'png' : 'mp4'}`;
84-
console.log('🚀 ~ file: list.tsx:87 ~ onSelectImage ~ fileName:', fileName);
85-
86-
// Add file to uppy with blob data
87-
uppy.addFile({
88-
id: fileName,
89-
name: fileName,
90-
type: blob.type,
91-
data: blob,
92-
size: blob.size,
93-
});
94-
95-
// Add image to local files array
96-
setFiles((old) => [...old, { uri: img.uri, fileName, contentType }]);
97-
98-
// SUPABASE STANDARD APPROACH
99-
// THIS WORKS
100-
// const img = result.assets[0];
101-
// const base64 = await FileSystem.readAsStringAsync(img.uri, { encoding: 'base64' });
102-
// const filePath = `${user.id}/${new Date().getTime()}.${img.type === 'image' ? 'png' : 'mp4'}`;
103-
// const contentType = img.type === 'image' ? 'image/png' : 'video/mp4';
104-
// let { error, data } = await supabase.storage.from('files').upload(filePath, decode(base64), { contentType });
105-
// console.log('error', error);
106-
// console.log('data', data);
42+
await supabase.storage.from('files').upload(filePath, decode(base64), { contentType });
43+
loadImages();
10744
}
10845
};
10946

110-
const onUpload = async () => {
111-
uppy.upload();
112-
setUploading(true);
113-
};
114-
115-
const onPause = async () => {
116-
uppy.pauseAll();
117-
setUploading(false);
118-
};
119-
120-
const onResume = async () => {
121-
uppy.resumeAll();
122-
setUploading(true);
123-
};
124-
125-
// Render image list item
126-
const renderItem = ({ item }: { item: any }) => {
127-
return (
128-
<View style={{ flexDirection: 'row', margin: 1, alignItems: 'center', gap: 5 }}>
129-
<Image style={{ width: 80, height: 80 }} source={{ uri: item.uri }} />
130-
<Text style={{ flex: 1, color: '#fff' }}>{item.fileName}</Text>
131-
</View>
132-
);
47+
const onRemoveImage = async (item: FileObject, listIndex: number) => {
48+
supabase.storage.from('files').remove([`${user.id}/${item.name}`]);
49+
const newFiles = [...files];
50+
newFiles.splice(listIndex, 1);
51+
setFiles(newFiles);
13352
};
13453

13554
return (
13655
<View style={styles.container}>
137-
{/* Progress bar */}
138-
{uploading && <View style={{ width: `${progress}%`, height: 4, backgroundColor: '#fff', marginBottom: 10, borderRadius: 2 }} />}
139-
140-
{/* Row with 3 buttons to start, pause and resume uploads */}
141-
<View style={{ flexDirection: 'row', justifyContent: 'space-evenly' }}>
142-
<TouchableOpacity onPress={onUpload} style={styles.button}>
143-
<Text style={{ color: '#fff' }}>Upload</Text>
144-
</TouchableOpacity>
145-
<TouchableOpacity onPress={onPause} style={styles.button}>
146-
<Text style={{ color: '#fff' }}>Pause</Text>
147-
</TouchableOpacity>
148-
<TouchableOpacity onPress={onResume} style={styles.button}>
149-
<Text style={{ color: '#fff' }}>Resume</Text>
150-
</TouchableOpacity>
151-
</View>
152-
153-
{/* List of images to upload */}
154-
<FlatList data={files} renderItem={renderItem} style={{ marginTop: 50 }} />
56+
<ScrollView>
57+
{files.map((item, index) => (
58+
<ImageItem key={item.id} item={item} userId={user.id} onRemoveImage={() => onRemoveImage(item, index)} />
59+
))}
60+
</ScrollView>
15561

15662
{/* FAB to add images */}
15763
<TouchableOpacity onPress={onSelectImage} style={styles.fab}>
@@ -179,12 +85,6 @@ const styles = StyleSheet.create({
17985
backgroundColor: '#2b825b',
18086
borderRadius: 100,
18187
},
182-
button: {
183-
borderWidth: 2,
184-
borderColor: '#2b825b',
185-
padding: 12,
186-
borderRadius: 4,
187-
},
18888
});
18989

19090
export default list;

app/_layout.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Slot, useRouter, useSegments } from 'expo-router';
22
import { useEffect } from 'react';
33
import { AuthProvider, useAuth } from '../provider/AuthProvider';
44

5+
// Makes sure the user is authenticated before accessing protected pages
56
const InitialLayout = () => {
67
const { session, initialized } = useAuth();
78
const segments = useSegments();
@@ -10,24 +11,22 @@ const InitialLayout = () => {
1011
useEffect(() => {
1112
if (!initialized) return;
1213

14+
// Check if the path/url is in the (auth) group
1315
const inAuthGroup = segments[0] === '(auth)';
1416

15-
// console.log('CHANGED: ', session);
16-
1717
if (session && !inAuthGroup) {
18-
console.log('REDIRECTING TO LIST');
19-
18+
// Redirect authenticated users to the list page
2019
router.replace('/list');
2120
} else if (!session) {
22-
console.log('REDIRECTING TO LOGIN');
23-
21+
// Redirect unauthenticated users to the login page
2422
router.replace('/');
2523
}
2624
}, [session, initialized]);
2725

2826
return <Slot />;
2927
};
3028

29+
// Wrap the app with the AuthProvider
3130
const RootLayout = () => {
3231
return (
3332
<AuthProvider>

app/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import Spinner from 'react-native-loading-spinner-overlay';
55
import { supabase } from '../config/initSupabase';
66

77
const Login = () => {
8-
const [email, setEmail] = useState('[email protected]');
9-
const [password, setPassword] = useState('123456');
8+
const [email, setEmail] = useState('');
9+
const [password, setPassword] = useState('');
1010
const [loading, setLoading] = useState(false);
1111

12+
// Sign in with email and password
1213
const onSignInPress = async () => {
1314
setLoading(true);
1415

@@ -21,6 +22,7 @@ const Login = () => {
2122
setLoading(false);
2223
};
2324

25+
// Create a new user
2426
const onSignUpPress = async () => {
2527
setLoading(true);
2628
const { error } = await supabase.auth.signUp({

components/ImageItem.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { FileObject } from '@supabase/storage-js';
2+
import { Image, View, Text, TouchableOpacity } from 'react-native';
3+
import { supabase } from '../config/initSupabase';
4+
import { useState } from 'react';
5+
import { Ionicons } from '@expo/vector-icons';
6+
7+
// Image item component that displays the image from Supabase Storage and a delte button
8+
const ImageItem = ({ item, userId, onRemoveImage }: { item: FileObject; userId: string; onRemoveImage: () => void }) => {
9+
const [image, setImage] = useState<string>('');
10+
11+
supabase.storage
12+
.from('files')
13+
.download(`${userId}/${item.name}`)
14+
.then(({ data }) => {
15+
const fr = new FileReader();
16+
fr.readAsDataURL(data);
17+
fr.onload = () => {
18+
setImage(fr.result as string);
19+
};
20+
});
21+
22+
return (
23+
<View style={{ flexDirection: 'row', margin: 1, alignItems: 'center', gap: 5 }}>
24+
{image ? <Image style={{ width: 80, height: 80 }} source={{ uri: image }} /> : <View style={{ width: 80, height: 80, backgroundColor: '#1A1A1A' }} />}
25+
<Text style={{ flex: 1, color: '#fff' }}>{item.name}</Text>
26+
{/* Delete image button */}
27+
<TouchableOpacity onPress={onRemoveImage}>
28+
<Ionicons name="trash-outline" size={20} color={'#fff'} />
29+
</TouchableOpacity>
30+
</View>
31+
);
32+
};
33+
34+
export default ImageItem;

config/initSupabase.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import 'react-native-url-polyfill/auto';
33

44
import { createClient } from '@supabase/supabase-js';
55

6+
// Use a custom secure storage solution for the Supabase client to store the JWT
67
const ExpoSecureStoreAdapter = {
78
getItem: (key: string) => {
89
return SecureStore.getItemAsync(key);
@@ -15,13 +16,10 @@ const ExpoSecureStoreAdapter = {
1516
},
1617
};
1718

18-
export const supabase = createClient(
19-
'https://kolrncrjvromhaivenfp.supabase.co',
20-
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtvbHJuY3JqdnJvbWhhaXZlbmZwIiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODg0Nzc1MTAsImV4cCI6MjAwNDA1MzUxMH0.QlBUWRQByC9NibQFivFDSeT2FPkq5r_Owyj0MQSQbLQ',
21-
{
22-
auth: {
23-
storage: ExpoSecureStoreAdapter as any,
24-
detectSessionInUrl: false,
25-
},
26-
}
27-
);
19+
// Initialize the Supabase client
20+
export const supabase = createClient('YOUR-SUPABASE-URL', 'YOUR-SUPABASE-ANON-KEY', {
21+
auth: {
22+
storage: ExpoSecureStoreAdapter as any,
23+
detectSessionInUrl: false,
24+
},
25+
});

create-policy.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE POLICY "Enable storage access for users based on user_id" ON "storage"."objects"
2+
AS PERMISSIVE FOR ALL
3+
TO public
4+
USING (bucket_id = 'files' AND auth.uid()::text = (storage.foldername(name))[1])
5+
WITH CHECK (bucket_id = 'files' AND auth.uid()::text = (storage.foldername(name))[1])

0 commit comments

Comments
 (0)