Merge pull request from nativeFuncs
Native funcs
This commit is contained in:
16
FastNotes/.gitignore
vendored
16
FastNotes/.gitignore
vendored
@@ -30,14 +30,20 @@ yarn-error.*
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
# local env files
|
|
||||||
.env*.local
|
|
||||||
.env.local
|
|
||||||
.env
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# generated native folders
|
# generated native folders
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
|
||||||
|
# Supabase
|
||||||
|
.branches
|
||||||
|
.temp
|
||||||
|
config.toml
|
||||||
|
|
||||||
|
# dotenvx
|
||||||
|
.env.keys
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
.env
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ export default ({ config }) => ({
|
|||||||
...config.extra,
|
...config.extra,
|
||||||
supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL,
|
supabaseUrl: process.env.EXPO_PUBLIC_SUPABASE_URL,
|
||||||
supabaseKey: process.env.EXPO_PUBLIC_SUPABASE_KEY,
|
supabaseKey: process.env.EXPO_PUBLIC_SUPABASE_KEY,
|
||||||
|
easProjectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,15 +9,20 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.fastnotes.app"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"package": "com.fastnotes.app",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
|
"permissions": [
|
||||||
|
"POST_NOTIFICATIONS"
|
||||||
|
],
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false
|
"predictiveBackGestureEnabled": false
|
||||||
},
|
},
|
||||||
@@ -27,6 +32,14 @@
|
|||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "Allow FastNotes to access your photo library so you can attach images to notes.",
|
||||||
|
"cameraPermission": "Allow FastNotes to use your camera so you can take photos for notes."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-notifications",
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import AuthProvider from '@/providers/auth-provider'
|
|||||||
import { NotesProvider } from "@/src/notes/NotesContext"
|
import { NotesProvider } from "@/src/notes/NotesContext"
|
||||||
import { AppThemeProvider, useAppTheme } from '@/src/theme/AppThemeProvider'
|
import { AppThemeProvider, useAppTheme } from '@/src/theme/AppThemeProvider'
|
||||||
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
|
||||||
|
import Constants from "expo-constants"
|
||||||
import { Stack } from 'expo-router'
|
import { Stack } from 'expo-router'
|
||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
|
import { ComponentType, PropsWithChildren, useEffect, useState } from "react"
|
||||||
|
import { Platform } from "react-native"
|
||||||
import 'react-native-reanimated'
|
import 'react-native-reanimated'
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
import { SafeAreaProvider } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
@@ -71,9 +74,11 @@ function ThemedRootLayout() {
|
|||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<ThemeProvider value={navigationTheme}>
|
<ThemeProvider value={navigationTheme}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<NotesProvider>
|
<NotificationProviderGate>
|
||||||
<RootNavigator />
|
<NotesProvider>
|
||||||
</NotesProvider>
|
<RootNavigator />
|
||||||
|
</NotesProvider>
|
||||||
|
</NotificationProviderGate>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
style={colorScheme === "dark" ? "light" : "dark"}
|
style={colorScheme === "dark" ? "light" : "dark"}
|
||||||
@@ -84,6 +89,44 @@ function ThemedRootLayout() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NotificationProviderGate({ children }: PropsWithChildren) {
|
||||||
|
const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient"
|
||||||
|
const [provider, setProvider] = useState<ComponentType<PropsWithChildren> | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAndroidExpoGo) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true
|
||||||
|
|
||||||
|
const loadProvider = async () => {
|
||||||
|
try {
|
||||||
|
const module = await import("@/src/notifications/PushNotificationsProvider")
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setProvider(() => module.default)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load push notifications provider:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadProvider()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [isAndroidExpoGo])
|
||||||
|
|
||||||
|
if (isAndroidExpoGo || !provider) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
const PushNotificationsProvider = provider
|
||||||
|
return <PushNotificationsProvider>{children}</PushNotificationsProvider>
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<AppThemeProvider>
|
<AppThemeProvider>
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"
|
import {
|
||||||
|
Alert,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from "react-native"
|
||||||
import { router, useLocalSearchParams } from "expo-router"
|
import { router, useLocalSearchParams } from "expo-router"
|
||||||
|
import { BlurView } from "expo-blur"
|
||||||
import { useHeaderHeight } from "@react-navigation/elements"
|
import { useHeaderHeight } from "@react-navigation/elements"
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||||
|
|
||||||
|
import NoteImagePanel from "@/components/note-image-panel"
|
||||||
|
import UploadProgressBar from "@/components/upload-progress-bar"
|
||||||
|
import { useAuthContext } from "@/hooks/use-auth-context"
|
||||||
|
import { NoteImageChange, useNotes } from "@/src/notes/NotesContext"
|
||||||
|
import { StagedNoteImage, validateStagedNoteImage } from "@/src/notes/image-utils"
|
||||||
|
import { pickImageFromCamera, pickImageFromLibrary } from "@/src/notes/native-image-picker"
|
||||||
|
import { detailScreenStyles as styles } from "@/src/styles/app-styles"
|
||||||
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
||||||
|
|
||||||
import { useAuthContext } from "@/hooks/use-auth-context"
|
export default function DetailScreen() {
|
||||||
import { useNotes } from "@/src/notes/NotesContext"
|
const { id } = useLocalSearchParams<{
|
||||||
|
|
||||||
|
|
||||||
export default function DetailScreen()
|
|
||||||
{
|
|
||||||
const { id } = useLocalSearchParams<
|
|
||||||
{
|
|
||||||
id?: string
|
id?: string
|
||||||
}>()
|
}>()
|
||||||
const { claims } = useAuthContext()
|
const { claims } = useAuthContext()
|
||||||
@@ -21,8 +33,11 @@ export default function DetailScreen()
|
|||||||
const canEdit = note?.createdBy === claims?.sub
|
const canEdit = note?.createdBy === claims?.sub
|
||||||
const [title, setTitle] = useState(note?.title ?? "")
|
const [title, setTitle] = useState(note?.title ?? "")
|
||||||
const [content, setContent] = useState(note?.content ?? "")
|
const [content, setContent] = useState(note?.content ?? "")
|
||||||
|
const [stagedImage, setStagedImage] = useState<StagedNoteImage | null>(null)
|
||||||
|
const [imageChange, setImageChange] = useState<NoteImageChange>({ type: "keep" })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||||
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
|
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
@@ -42,7 +57,48 @@ export default function DetailScreen()
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitle(note?.title ?? "")
|
setTitle(note?.title ?? "")
|
||||||
setContent(note?.content ?? "")
|
setContent(note?.content ?? "")
|
||||||
}, [note?.content, note?.title])
|
setStagedImage(null)
|
||||||
|
setImageChange({ type: "keep" })
|
||||||
|
}, [note?.content, note?.id, note?.title])
|
||||||
|
|
||||||
|
const attachFromCamera = async () => {
|
||||||
|
try {
|
||||||
|
const image = await pickImageFromCamera()
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
validateStagedNoteImage(image)
|
||||||
|
setStagedImage(image)
|
||||||
|
setImageChange({ type: "replace", image })
|
||||||
|
setLocalErrorMessage(null)
|
||||||
|
setStatusMessage(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setLocalErrorMessage(error instanceof Error ? error.message : "The camera could not be opened.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachFromGallery = async () => {
|
||||||
|
try {
|
||||||
|
const image = await pickImageFromLibrary()
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
validateStagedNoteImage(image)
|
||||||
|
setStagedImage(image)
|
||||||
|
setImageChange({ type: "replace", image })
|
||||||
|
setLocalErrorMessage(null)
|
||||||
|
setStatusMessage(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setLocalErrorMessage(error instanceof Error ? error.message : "The gallery could not be opened.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
setStagedImage(null)
|
||||||
|
setStatusMessage(null)
|
||||||
|
setLocalErrorMessage(null)
|
||||||
|
setImageChange(note?.imageUrl ? { type: "remove" } : { type: "keep" })
|
||||||
|
}
|
||||||
|
|
||||||
const onSave = async () => {
|
const onSave = async () => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@@ -56,20 +112,27 @@ export default function DetailScreen()
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
setUploadProgress(null)
|
||||||
setLocalErrorMessage(null)
|
setLocalErrorMessage(null)
|
||||||
setStatusMessage(null)
|
setStatusMessage(null)
|
||||||
|
|
||||||
const wasSaved = await updateNote(id, title, content)
|
const wasSaved = await updateNote(id, title, content, imageChange, {
|
||||||
|
onImageUploadProgress: (progress) => {
|
||||||
|
setUploadProgress(progress.progress)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
|
setUploadProgress(null)
|
||||||
|
|
||||||
if (wasSaved) {
|
if (wasSaved) {
|
||||||
|
setStagedImage(null)
|
||||||
|
setImageChange({ type: "keep" })
|
||||||
setStatusMessage("Note updated.")
|
setStatusMessage("Note updated.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDelete = () => {
|
const confirmDelete = () => {
|
||||||
// Require explicit confirmation before deleting the note.
|
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Delete note",
|
"Delete note",
|
||||||
"Are you sure you want to delete this note?",
|
"Are you sure you want to delete this note?",
|
||||||
@@ -117,7 +180,17 @@ export default function DetailScreen()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return(
|
const currentImageUrl = imageChange.type === "remove" ? null : note.imageUrl
|
||||||
|
const currentImageMimeType = imageChange.type === "remove" ? null : note.imageMimeType
|
||||||
|
const currentImageSizeBytes = imageChange.type === "remove" ? null : note.imageSizeBytes
|
||||||
|
const isUploading = uploadProgress !== null
|
||||||
|
const imageActionsDisabled = isSaving || isUploading
|
||||||
|
const saveDisabled = isSaving
|
||||||
|
const deleteDisabled = isDeleting || isUploading
|
||||||
|
const primaryButtonStyle = saveDisabled ? styles.disabledButton : styles.enabledButtonShadow
|
||||||
|
const deleteButtonStyle = deleteDisabled ? styles.disabledButton : styles.enabledButtonShadow
|
||||||
|
|
||||||
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
keyboardVerticalOffset={headerHeight}
|
keyboardVerticalOffset={headerHeight}
|
||||||
@@ -131,7 +204,10 @@ export default function DetailScreen()
|
|||||||
<TextInput
|
<TextInput
|
||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
onChangeText={setTitle}
|
onChangeText={setTitle}
|
||||||
style={[styles.titleInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]}
|
style={[
|
||||||
|
styles.titleInput,
|
||||||
|
{ color: palette.text, borderColor: palette.border, backgroundColor: palette.input },
|
||||||
|
]}
|
||||||
value={title}
|
value={title}
|
||||||
placeholderTextColor={palette.mutedText}
|
placeholderTextColor={palette.mutedText}
|
||||||
/>
|
/>
|
||||||
@@ -143,11 +219,40 @@ export default function DetailScreen()
|
|||||||
editable={canEdit}
|
editable={canEdit}
|
||||||
multiline
|
multiline
|
||||||
onChangeText={setContent}
|
onChangeText={setContent}
|
||||||
style={[styles.contentInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]}
|
style={[
|
||||||
|
styles.contentInput,
|
||||||
|
{ color: palette.text, borderColor: palette.border, backgroundColor: palette.input },
|
||||||
|
]}
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
value={content}
|
value={content}
|
||||||
placeholderTextColor={palette.mutedText}
|
placeholderTextColor={palette.mutedText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NoteImagePanel
|
||||||
|
canEdit={Boolean(canEdit)}
|
||||||
|
isBusy={imageActionsDisabled}
|
||||||
|
currentImageUrl={currentImageUrl}
|
||||||
|
currentImageMimeType={currentImageMimeType}
|
||||||
|
currentImageSizeBytes={currentImageSizeBytes}
|
||||||
|
stagedImage={stagedImage}
|
||||||
|
helperText={
|
||||||
|
canEdit
|
||||||
|
? "Replacing or removing the image only takes effect after you save changes."
|
||||||
|
: "This image is attached to the note and stored in Supabase."
|
||||||
|
}
|
||||||
|
palette={palette}
|
||||||
|
primaryTextColor={colorScheme === "dark" ? "#000" : "#fff"}
|
||||||
|
onTakePhoto={() => {
|
||||||
|
void attachFromCamera()
|
||||||
|
}}
|
||||||
|
onChooseFromLibrary={() => {
|
||||||
|
void attachFromGallery()
|
||||||
|
}}
|
||||||
|
onRemoveImage={clearImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{uploadProgress !== null ? <UploadProgressBar progress={uploadProgress} palette={palette} /> : null}
|
||||||
|
|
||||||
{!canEdit ? (
|
{!canEdit ? (
|
||||||
<Text style={[styles.readOnlyText, { color: palette.mutedText }]}>
|
<Text style={[styles.readOnlyText, { color: palette.mutedText }]}>
|
||||||
Only the creator of this note can update or delete it.
|
Only the creator of this note can update or delete it.
|
||||||
@@ -158,95 +263,37 @@ export default function DetailScreen()
|
|||||||
{statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null}
|
{statusMessage ? <Text style={styles.successText}>{statusMessage}</Text> : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<View style={[styles.actions, { bottom: insets.bottom + 16 }]}>
|
<View style={[styles.actions, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
<Pressable disabled={isSaving} onPress={onSave} style={[styles.primaryButton, { backgroundColor: palette.accent }]}>
|
<BlurView
|
||||||
<Text style={[styles.primaryButtonText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
|
intensity={22}
|
||||||
{isSaving ? "Saving..." : "Save changes"}
|
tint={colorScheme}
|
||||||
</Text>
|
style={styles.actionsBlur}
|
||||||
</Pressable>
|
/>
|
||||||
<Pressable disabled={isDeleting} onPress={confirmDelete} style={[styles.deleteButton, { backgroundColor: palette.destructive }]}>
|
<View style={[styles.actionsContent, { borderColor: palette.border }]}>
|
||||||
<Text style={styles.deleteButtonText}>
|
<Pressable
|
||||||
{isDeleting ? "Deleting..." : "Delete note"}
|
disabled={saveDisabled}
|
||||||
</Text>
|
onPress={() => {
|
||||||
</Pressable>
|
void onSave()
|
||||||
|
}}
|
||||||
|
style={[styles.primaryButton, primaryButtonStyle, { backgroundColor: palette.accent }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.primaryButtonText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
|
||||||
|
{isSaving ? "Saving..." : "Save changes"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
disabled={deleteDisabled}
|
||||||
|
onPress={confirmDelete}
|
||||||
|
style={[styles.deleteButton, deleteButtonStyle, { backgroundColor: palette.destructive }]}
|
||||||
|
>
|
||||||
|
<Text style={styles.deleteButtonText}>
|
||||||
|
{isDeleting ? "Deleting..." : "Delete note"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const styles = StyleSheet.create(
|
|
||||||
{
|
|
||||||
keyboardAvoider: { flex: 1 },
|
|
||||||
container: { flex: 1 },
|
|
||||||
formContent: { padding: 16, gap: 12 },
|
|
||||||
title: { fontSize: 22, fontWeight:"700" },
|
|
||||||
content: { fontSize: 16 },
|
|
||||||
titleInput: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
signature: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: "#666",
|
|
||||||
},
|
|
||||||
contentInput: {
|
|
||||||
minHeight: 200,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: "#c62828",
|
|
||||||
},
|
|
||||||
successText: {
|
|
||||||
color: "#2e7d32",
|
|
||||||
},
|
|
||||||
readOnlyText: {
|
|
||||||
color: "#666",
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
position: "absolute",
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
primaryButton: {
|
|
||||||
flex: 1,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: "center",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.18,
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowRadius: 16,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
primaryButtonText: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
deleteButton: {
|
|
||||||
flex: 1,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: "center",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.18,
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowRadius: 16,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
deleteButtonText: {
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Appearance, FlatList, Pressable, StyleSheet, Text, View } from "react-native"
|
import { FlatList, Pressable, Text, View } from "react-native"
|
||||||
import { router } from "expo-router"
|
import { router } from "expo-router"
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||||
|
import { Image } from "expo-image"
|
||||||
import { useAuthContext } from "@/hooks/use-auth-context"
|
import { useAuthContext } from "@/hooks/use-auth-context"
|
||||||
import { useNotes } from "@/src/notes/NotesContext"
|
import { useNotes } from "@/src/notes/NotesContext"
|
||||||
import SignOutButton from '@/components/social-auth-buttons/sign-out-button'
|
import SignOutButton from '@/components/social-auth-buttons/sign-out-button'
|
||||||
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
||||||
|
import { homeScreenStyles as styles } from "@/src/styles/app-styles"
|
||||||
|
|
||||||
|
|
||||||
type TabKey = "my-notes" | "work-notes"
|
type TabKey = "my-notes" | "work-notes"
|
||||||
@@ -109,12 +111,23 @@ export default function HomeScreen()
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={[styles.noteTitle, { color: palette.text }]}>{item.title}</Text>
|
<View style={styles.noteCardRow}>
|
||||||
<Text numberOfLines={2} style={[styles.notePreview, { color: palette.mutedText }]}>{item.content}</Text>
|
<View style={styles.noteBody}>
|
||||||
<Text style={[styles.noteMeta, { color: palette.mutedText }]}>Created by {item.creatorLabel}</Text>
|
<Text style={[styles.noteTitle, { color: palette.text }]}>{item.title}</Text>
|
||||||
<Text style={[styles.noteMeta, { color: palette.mutedText }]}>
|
<Text numberOfLines={2} style={[styles.notePreview, { color: palette.mutedText }]}>
|
||||||
Last changed {formatTimestamp(item.lastChangedAt)}
|
{item.content}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text style={[styles.noteMeta, { color: palette.mutedText }]}>Created by {item.creatorLabel}</Text>
|
||||||
|
<Text style={[styles.noteMeta, { color: palette.mutedText }]}>
|
||||||
|
Last changed {formatTimestamp(item.lastChangedAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.imageUrl ? (
|
||||||
|
<View style={styles.noteThumbnailFrame}>
|
||||||
|
<Image source={{ uri: item.imageUrl }} style={styles.noteThumbnail} contentFit="contain" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -130,70 +143,3 @@ export default function HomeScreen()
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: { flex: 1 },
|
|
||||||
topBar: {
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 8,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
},
|
|
||||||
screenTitle: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
tabBar: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 8,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 8,
|
|
||||||
paddingTop: 12
|
|
||||||
},
|
|
||||||
tabButton: {
|
|
||||||
flex: 1,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingVertical: 10,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
tabButtonActive: {
|
|
||||||
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000":"#8a8888",
|
|
||||||
},
|
|
||||||
tabButtonText: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: "600",
|
|
||||||
},
|
|
||||||
tabButtonTextActive: {
|
|
||||||
color: "#fff",
|
|
||||||
},
|
|
||||||
list: { padding: 16, gap: 12, paddingTop: 8 },
|
|
||||||
noteItem: { padding: 16, borderWidth: 1, borderRadius:12, gap: 8 },
|
|
||||||
noteTitle: { fontSize: 16, fontWeight: "600" },
|
|
||||||
notePreview: { fontSize: 14 },
|
|
||||||
noteMeta: { fontSize: 12 },
|
|
||||||
emptyText: {
|
|
||||||
textAlign: "center",
|
|
||||||
paddingVertical: 32,
|
|
||||||
color: "#666",
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: "#c62828",
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 8,
|
|
||||||
},
|
|
||||||
fab:
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
width: 56, height: 56, borderRadius: 28,
|
|
||||||
alignItems: "center", justifyContent: "center",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.18,
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowRadius: 16,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700"}
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, Stack } from 'expo-router'
|
import { Link, Stack } from 'expo-router'
|
||||||
import { Appearance, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
|
import { Pressable, Text, TextInput, View } from 'react-native'
|
||||||
import { supabase } from '@/libs/supabase'
|
import { supabase } from '@/libs/supabase'
|
||||||
import { useAppTheme } from '@/src/theme/AppThemeProvider'
|
import { useAppTheme } from '@/src/theme/AppThemeProvider'
|
||||||
|
import { loginScreenStyles as styles } from '@/src/styles/app-styles'
|
||||||
|
|
||||||
export default function LoginScreen() {
|
export default function LoginScreen() {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -87,56 +88,3 @@ export default function LoginScreen() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 24,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: '#c62828',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
loginButton: {
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: Appearance.getColorScheme() === "light" ? '#000000':'#696969',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
loginButtonPressed: {
|
|
||||||
opacity: 0.85,
|
|
||||||
},
|
|
||||||
loginButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
loginButtonText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
alignSelf: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
color: '#0b57d0',
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import
|
import { useRef, useState } from "react"
|
||||||
{
|
import {
|
||||||
StyleSheet, Text, View, KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform, Pressable, TextInput, ScrollView
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
} from "react-native"
|
} from "react-native"
|
||||||
import { router, } from "expo-router"
|
import { router } from "expo-router"
|
||||||
import { useNotes } from "@/src/notes/NotesContext"
|
import { BlurView } from "expo-blur"
|
||||||
import { useState, useRef } from "react"
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
|
||||||
import { useHeaderHeight } from "@react-navigation/elements"
|
import { useHeaderHeight } from "@react-navigation/elements"
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||||
|
|
||||||
|
import NoteImagePanel from "@/components/note-image-panel"
|
||||||
|
import UploadProgressBar from "@/components/upload-progress-bar"
|
||||||
|
import { useNotes } from "@/src/notes/NotesContext"
|
||||||
|
import { StagedNoteImage, validateStagedNoteImage } from "@/src/notes/image-utils"
|
||||||
|
import { newNoteScreenStyles as styles } from "@/src/styles/app-styles"
|
||||||
|
import { pickImageFromCamera, pickImageFromLibrary } from "@/src/notes/native-image-picker"
|
||||||
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
||||||
|
|
||||||
|
export default function NewNoteScreen() {
|
||||||
|
|
||||||
export default function NewNoteScreen()
|
|
||||||
{
|
|
||||||
const { addNote, errorMessage } = useNotes()
|
const { addNote, errorMessage } = useNotes()
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const [content, setContent] = useState("")
|
const [content, setContent] = useState("")
|
||||||
|
const [stagedImage, setStagedImage] = useState<StagedNoteImage | null>(null)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||||
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
|
const [localErrorMessage, setLocalErrorMessage] = useState<string | null>(null)
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const headerHeight = useHeaderHeight()
|
const headerHeight = useHeaderHeight()
|
||||||
@@ -25,114 +35,163 @@ export default function NewNoteScreen()
|
|||||||
const [contentHeight, setContentHeight] = useState(160)
|
const [contentHeight, setContentHeight] = useState(160)
|
||||||
const scrollRef = useRef<ScrollView>(null)
|
const scrollRef = useRef<ScrollView>(null)
|
||||||
|
|
||||||
const onSave = async () =>
|
const attachFromCamera = async () => {
|
||||||
{
|
try {
|
||||||
if(!title.trim() || !content.trim()) {
|
const image = await pickImageFromCamera()
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
validateStagedNoteImage(image)
|
||||||
|
setStagedImage(image)
|
||||||
|
setLocalErrorMessage(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setLocalErrorMessage(error instanceof Error ? error.message : "The camera could not be opened.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachFromGallery = async () => {
|
||||||
|
try {
|
||||||
|
const image = await pickImageFromLibrary()
|
||||||
|
|
||||||
|
if (image) {
|
||||||
|
validateStagedNoteImage(image)
|
||||||
|
setStagedImage(image)
|
||||||
|
setLocalErrorMessage(null)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setLocalErrorMessage(error instanceof Error ? error.message : "The gallery could not be opened.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSave = async () => {
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
setLocalErrorMessage("Title and content are required.")
|
setLocalErrorMessage("Title and content are required.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
setUploadProgress(null)
|
||||||
setLocalErrorMessage(null)
|
setLocalErrorMessage(null)
|
||||||
|
|
||||||
const wasSaved = await addNote(title, content)
|
const wasSaved = await addNote(title, content, stagedImage, {
|
||||||
|
onImageUploadProgress: (progress) => {
|
||||||
|
setUploadProgress(progress.progress)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
|
setUploadProgress(null)
|
||||||
|
|
||||||
if (wasSaved) {
|
if (wasSaved) {
|
||||||
router.back()
|
if (router.canGoBack()) {
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.replace("/index")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveDisabled = isSaving
|
||||||
|
const imageActionsDisabled = isSaving
|
||||||
|
const saveButtonStyle = saveDisabled ? styles.disabledButton : styles.enabledButtonShadow
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.keyboardAvoider}
|
style={styles.keyboardAvoider}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
keyboardVerticalOffset={headerHeight}>
|
keyboardVerticalOffset={headerHeight}
|
||||||
<View style={[styles.container, { backgroundColor: palette.background }]}>
|
>
|
||||||
<ScrollView ref={scrollRef} contentContainerStyle={[styles.formContent, { paddingBottom: insets.bottom + 112 }]}
|
<View style={[styles.container, { backgroundColor: palette.background }]}>
|
||||||
keyboardShouldPersistTaps="handled">
|
<ScrollView
|
||||||
<TextInput value={title} onChangeText={setTitle}
|
ref={scrollRef}
|
||||||
placeholder="Give it a title..." style={[styles.titleInput, { color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]}
|
contentContainerStyle={[styles.formContent, { paddingBottom: insets.bottom + 112 }]}
|
||||||
placeholderTextColor={palette.mutedText}
|
keyboardShouldPersistTaps="handled"
|
||||||
returnKeyType="next"/>
|
>
|
||||||
|
<TextInput
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholder="Give it a title..."
|
||||||
|
style={[
|
||||||
|
styles.titleInput,
|
||||||
|
{ color: palette.text, borderColor: palette.border, backgroundColor: palette.input },
|
||||||
|
]}
|
||||||
|
placeholderTextColor={palette.mutedText}
|
||||||
|
returnKeyType="next"
|
||||||
|
/>
|
||||||
|
|
||||||
<TextInput value={content} onChangeText={setContent} placeholder="Write your note..."
|
<TextInput
|
||||||
style={[styles.contentInput, { minHeight: Math.max(200, contentHeight), color: palette.text, borderColor: palette.border, backgroundColor: palette.input }]} multiline
|
value={content}
|
||||||
placeholderTextColor={palette.mutedText}
|
onChangeText={setContent}
|
||||||
textAlignVertical="top"
|
placeholder="Write your note..."
|
||||||
onContentSizeChange={(e) => {setContentHeight(e.nativeEvent.contentSize.height)
|
style={[
|
||||||
scrollRef.current?.scrollToEnd({ animated: true })
|
styles.contentInput,
|
||||||
}}/>
|
{
|
||||||
|
minHeight: Math.max(200, contentHeight),
|
||||||
|
color: palette.text,
|
||||||
|
borderColor: palette.border,
|
||||||
|
backgroundColor: palette.input,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
multiline
|
||||||
|
placeholderTextColor={palette.mutedText}
|
||||||
|
textAlignVertical="top"
|
||||||
|
onContentSizeChange={(e) => {
|
||||||
|
setContentHeight(e.nativeEvent.contentSize.height)
|
||||||
|
scrollRef.current?.scrollToEnd({ animated: true })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{localErrorMessage ? (
|
<NoteImagePanel
|
||||||
<Text style={styles.errorText}>{localErrorMessage}</Text>
|
canEdit
|
||||||
) : null}
|
isBusy={imageActionsDisabled}
|
||||||
|
stagedImage={stagedImage}
|
||||||
|
helperText="Images upload when you save the note. Allowed formats: PNG, JPG, WEBP. Max 15 MB after compression."
|
||||||
|
palette={palette}
|
||||||
|
primaryTextColor={colorScheme === "dark" ? "#000" : "#fff"}
|
||||||
|
onTakePhoto={() => {
|
||||||
|
void attachFromCamera()
|
||||||
|
}}
|
||||||
|
onChooseFromLibrary={() => {
|
||||||
|
void attachFromGallery()
|
||||||
|
}}
|
||||||
|
onRemoveImage={() => {
|
||||||
|
setStagedImage(null)
|
||||||
|
setLocalErrorMessage(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{!localErrorMessage && errorMessage ? (
|
{uploadProgress !== null ? <UploadProgressBar progress={uploadProgress} palette={palette} /> : null}
|
||||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
</ScrollView>
|
{localErrorMessage ? (
|
||||||
<View style={[styles.actions, { bottom: insets.bottom + 16 }]}>
|
<Text style={styles.errorText}>{localErrorMessage}</Text>
|
||||||
<Pressable disabled={isSaving} onPress={onSave}
|
) : null}
|
||||||
style={[styles.saveButton, { backgroundColor: palette.accent }]}>
|
|
||||||
<Text style={[styles.saveFloatingText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
|
{!localErrorMessage && errorMessage ? (
|
||||||
{isSaving ? "Saving..." : "Save note"}
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||||
</Text>
|
) : null}
|
||||||
</Pressable>
|
</ScrollView>
|
||||||
|
<View style={[styles.actions, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
|
<BlurView
|
||||||
|
intensity={22}
|
||||||
|
tint={colorScheme}
|
||||||
|
style={styles.actionsBlur}
|
||||||
|
/>
|
||||||
|
<View style={[styles.actionsContent, { borderColor: palette.border }]}>
|
||||||
|
<Pressable
|
||||||
|
disabled={saveDisabled}
|
||||||
|
onPress={() => {
|
||||||
|
void onSave()
|
||||||
|
}}
|
||||||
|
style={[styles.saveButton, saveButtonStyle, { backgroundColor: palette.accent }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.saveFloatingText, { color: colorScheme === "dark" ? "#000" : "#fff" }]}>
|
||||||
|
{isSaving ? "Saving..." : "Save note"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</KeyboardAvoidingView>
|
||||||
</KeyboardAvoidingView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create(
|
|
||||||
{
|
|
||||||
keyboardAvoider: { flex: 1 },
|
|
||||||
container: { flex: 1 },
|
|
||||||
formContent: { padding: 16, gap: 12 },
|
|
||||||
titleInput:
|
|
||||||
{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: "700",
|
|
||||||
},
|
|
||||||
contentInput:
|
|
||||||
{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
actions: {
|
|
||||||
position: "absolute",
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
saveButton:
|
|
||||||
{
|
|
||||||
flex: 1,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: "center",
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOpacity: 0.18,
|
|
||||||
shadowOffset: { width: 0, height: 8 },
|
|
||||||
shadowRadius: 16,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
saveFloatingText:
|
|
||||||
{
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: "700"
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: "#c62828"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { supabase } from "@/libs/supabase"
|
import { supabase } from "@/libs/supabase"
|
||||||
import { Link, Stack } from "expo-router"
|
import { Link, Stack } from "expo-router"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
|
import { Pressable, Text, TextInput, View } from 'react-native'
|
||||||
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
import { useAppTheme } from "@/src/theme/AppThemeProvider"
|
||||||
|
import { signupScreenStyles as styles } from "@/src/styles/app-styles"
|
||||||
|
|
||||||
export default function SignupScreen(){
|
export default function SignupScreen(){
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@@ -115,61 +116,3 @@ export default function SignupScreen(){
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 24,
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: '#c62828',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
successText: {
|
|
||||||
color: '#2e7d32',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#111',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
actionButtonPressed: {
|
|
||||||
opacity: 0.85,
|
|
||||||
},
|
|
||||||
actionButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
actionButtonText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
link: {
|
|
||||||
alignSelf: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
color: '#0b57d0',
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
173
FastNotes/components/note-image-panel.tsx
Normal file
173
FastNotes/components/note-image-panel.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Image } from "expo-image"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Modal, Pressable, Text, useWindowDimensions, View } from "react-native"
|
||||||
|
|
||||||
|
import { formatBytes, StagedNoteImage } from "@/src/notes/image-utils"
|
||||||
|
import { noteImagePanelStyles as styles } from "@/src/styles/app-styles"
|
||||||
|
|
||||||
|
type Palette = {
|
||||||
|
surface: string
|
||||||
|
elevated: string
|
||||||
|
text: string
|
||||||
|
mutedText: string
|
||||||
|
border: string
|
||||||
|
accent: string
|
||||||
|
destructive: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoteImagePanelProps = {
|
||||||
|
canEdit: boolean
|
||||||
|
isBusy?: boolean
|
||||||
|
currentImageUrl?: string | null
|
||||||
|
currentImageMimeType?: string | null
|
||||||
|
currentImageSizeBytes?: number | null
|
||||||
|
stagedImage?: StagedNoteImage | null
|
||||||
|
helperText?: string | null
|
||||||
|
palette: Palette
|
||||||
|
primaryTextColor: string
|
||||||
|
onTakePhoto?: () => void
|
||||||
|
onChooseFromLibrary?: () => void
|
||||||
|
onRemoveImage?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NoteImagePanel({
|
||||||
|
canEdit,
|
||||||
|
isBusy = false,
|
||||||
|
currentImageUrl,
|
||||||
|
currentImageMimeType,
|
||||||
|
currentImageSizeBytes,
|
||||||
|
stagedImage,
|
||||||
|
helperText,
|
||||||
|
palette,
|
||||||
|
primaryTextColor,
|
||||||
|
onTakePhoto,
|
||||||
|
onChooseFromLibrary,
|
||||||
|
onRemoveImage,
|
||||||
|
}: NoteImagePanelProps) {
|
||||||
|
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false)
|
||||||
|
const { width } = useWindowDimensions()
|
||||||
|
const previewUri = stagedImage?.uri ?? currentImageUrl ?? null
|
||||||
|
const mimeType = stagedImage?.mimeType ?? currentImageMimeType ?? null
|
||||||
|
const sizeBytes = stagedImage?.fileSize ?? currentImageSizeBytes ?? null
|
||||||
|
const useStackedLayout = width < 680
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
{previewUri ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.previewCard,
|
||||||
|
styles.previewLayout,
|
||||||
|
useStackedLayout ? styles.previewLayoutStacked : null,
|
||||||
|
{ borderColor: palette.border, backgroundColor: palette.elevated },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.previewDetails}>
|
||||||
|
<Text style={[styles.previewMetaLabel, { color: palette.text }]}>
|
||||||
|
{stagedImage ? "Staged image" : "Saved image"}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.previewMeta, { color: palette.mutedText }]}>
|
||||||
|
{(mimeType ?? "Unknown type").toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.previewMeta, { color: palette.mutedText }]}>
|
||||||
|
{formatBytes(sizeBytes)}
|
||||||
|
</Text>
|
||||||
|
{!stagedImage && currentImageUrl ? (
|
||||||
|
<Text selectable numberOfLines={3} style={[styles.urlText, { color: palette.mutedText }]}>
|
||||||
|
{currentImageUrl}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
disabled={!previewUri}
|
||||||
|
onPress={() => {
|
||||||
|
if (previewUri) {
|
||||||
|
setIsFullscreenOpen(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={styles.previewFrame}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
source={{ uri: previewUri }}
|
||||||
|
style={styles.previewImage}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.emptyText, { color: palette.mutedText }]}>
|
||||||
|
No image attached.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{helperText ? <Text style={[styles.helperText, { color: palette.mutedText }]}>{helperText}</Text> : null}
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
<Pressable
|
||||||
|
disabled={isBusy}
|
||||||
|
onPress={onTakePhoto}
|
||||||
|
style={[styles.actionButton, styles.enabledButtonShadow, isBusy ? styles.disabledButton : null, { backgroundColor: palette.accent }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.actionButtonText, { color: primaryTextColor }]}>Take photo</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
disabled={isBusy}
|
||||||
|
onPress={onChooseFromLibrary}
|
||||||
|
style={[
|
||||||
|
styles.secondaryButton,
|
||||||
|
isBusy ? styles.disabledButton : null,
|
||||||
|
{ borderColor: palette.border, backgroundColor: palette.elevated },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.secondaryButtonText, { color: palette.text }]}>Choose from gallery</Text>
|
||||||
|
</Pressable>
|
||||||
|
{previewUri ? (
|
||||||
|
<Pressable
|
||||||
|
disabled={isBusy}
|
||||||
|
onPress={onRemoveImage}
|
||||||
|
style={[
|
||||||
|
styles.secondaryButton,
|
||||||
|
isBusy ? styles.disabledButton : null,
|
||||||
|
{ borderColor: palette.destructive, backgroundColor: palette.surface },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.removeButtonText, { color: palette.destructive }]}>Remove image</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={isFullscreenOpen}
|
||||||
|
animationType="fade"
|
||||||
|
transparent
|
||||||
|
onRequestClose={() => {
|
||||||
|
setIsFullscreenOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.fullscreenOverlay}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.fullscreenBackdrop}
|
||||||
|
onPress={() => {
|
||||||
|
setIsFullscreenOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View style={[styles.fullscreenCard, { backgroundColor: palette.surface, borderColor: palette.border }]}>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => {
|
||||||
|
setIsFullscreenOpen(false)
|
||||||
|
}}
|
||||||
|
style={[styles.closeButton, { borderColor: palette.border, backgroundColor: palette.elevated }]}
|
||||||
|
>
|
||||||
|
<Text style={[styles.closeButtonText, { color: palette.text }]}>Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
{previewUri ? (
|
||||||
|
<Image source={{ uri: previewUri }} style={styles.fullscreenImage} contentFit="contain" />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { PropsWithChildren, ReactElement } from 'react';
|
import type { PropsWithChildren, ReactElement } from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
|
||||||
import Animated, {
|
import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
useAnimatedRef,
|
useAnimatedRef,
|
||||||
@@ -10,6 +9,7 @@ import Animated, {
|
|||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||||
|
import { parallaxScrollViewStyles as styles } from '@/src/styles/app-styles';
|
||||||
|
|
||||||
const HEADER_HEIGHT = 250;
|
const HEADER_HEIGHT = 250;
|
||||||
|
|
||||||
@@ -61,19 +61,3 @@ export default function ParallaxScrollView({
|
|||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
height: HEADER_HEIGHT,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
padding: 32,
|
|
||||||
gap: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { supabase } from '@/libs/supabase'
|
import { supabase } from '@/libs/supabase'
|
||||||
import { router } from 'expo-router'
|
import { router } from 'expo-router'
|
||||||
|
import Constants from "expo-constants"
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Pressable, StyleSheet, Text } from 'react-native'
|
import { Platform, Pressable, Text } from 'react-native'
|
||||||
import { useAppTheme } from '@/src/theme/AppThemeProvider'
|
import { useAppTheme } from '@/src/theme/AppThemeProvider'
|
||||||
|
import { signOutButtonStyles as styles } from '@/src/styles/app-styles'
|
||||||
|
|
||||||
async function onSignOutButtonPress() {
|
async function onSignOutButtonPress() {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
const isAndroidExpoGo = Platform.OS === "android" && Constants.executionEnvironment === "storeClient"
|
||||||
|
|
||||||
|
if (user?.id && !isAndroidExpoGo) {
|
||||||
|
const { unregisterPushNotifications } = await import('@/src/notifications/push-notifications')
|
||||||
|
const removed = await unregisterPushNotifications(user.id)
|
||||||
|
|
||||||
|
if (!removed) {
|
||||||
|
console.error('Failed to unregister push notifications before sign out.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { error } = await supabase.auth.signOut()
|
const { error } = await supabase.auth.signOut()
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -27,16 +44,3 @@ export default function SignOutButton() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
button: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 999,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StyleSheet, Text, type TextProps } from 'react-native'
|
import { Text, type TextProps } from 'react-native'
|
||||||
|
|
||||||
import { useThemeColor } from '@/hooks/use-theme-color'
|
import { useThemeColor } from '@/hooks/use-theme-color'
|
||||||
|
import { themedTextStyles as styles } from '@/src/styles/app-styles'
|
||||||
|
|
||||||
export type ThemedTextProps = TextProps & {
|
export type ThemedTextProps = TextProps & {
|
||||||
lightColor?: string
|
lightColor?: string
|
||||||
@@ -32,29 +33,3 @@ export function ThemedText({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { PropsWithChildren, useState } from 'react';
|
import { PropsWithChildren, useState } from 'react';
|
||||||
import { StyleSheet, TouchableOpacity } from 'react-native';
|
import { TouchableOpacity } from 'react-native';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/themed-text';
|
import { ThemedText } from '@/components/themed-text';
|
||||||
import { ThemedView } from '@/components/themed-view';
|
import { ThemedView } from '@/components/themed-view';
|
||||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||||
import { Colors } from '@/constants/theme';
|
import { Colors } from '@/constants/theme';
|
||||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
import { collapsibleStyles as styles } from '@/src/styles/app-styles';
|
||||||
|
|
||||||
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -31,15 +32,3 @@ export function Collapsible({ children, title }: PropsWithChildren & { title: st
|
|||||||
</ThemedView>
|
</ThemedView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
heading: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 24,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
36
FastNotes/components/upload-progress-bar.tsx
Normal file
36
FastNotes/components/upload-progress-bar.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Text, View } from "react-native"
|
||||||
|
import { uploadProgressBarStyles as styles } from "@/src/styles/app-styles"
|
||||||
|
|
||||||
|
type Palette = {
|
||||||
|
accent: string
|
||||||
|
border: string
|
||||||
|
elevated: string
|
||||||
|
mutedText: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadProgressBarProps = {
|
||||||
|
progress: number
|
||||||
|
label?: string
|
||||||
|
palette: Palette
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadProgressBar({
|
||||||
|
progress,
|
||||||
|
label = "Uploading image...",
|
||||||
|
palette,
|
||||||
|
}: UploadProgressBarProps) {
|
||||||
|
const clampedProgress = Math.max(0, Math.min(100, progress))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.label, { color: palette.text }]}>{label}</Text>
|
||||||
|
<Text style={[styles.percentage, { color: palette.mutedText }]}>{clampedProgress}%</Text>
|
||||||
|
</View>
|
||||||
|
<View style={[styles.track, { borderColor: palette.border, backgroundColor: palette.elevated }]}>
|
||||||
|
<View style={[styles.fill, { width: `${clampedProgress}%`, backgroundColor: palette.accent }]} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,25 +1,9 @@
|
|||||||
import '@react-native-async-storage/async-storage'
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
import { createClient } from '@supabase/supabase-js'
|
import { createClient } from '@supabase/supabase-js'
|
||||||
import Constants from "expo-constants"
|
import Constants from "expo-constants"
|
||||||
import { deleteItemAsync, getItemAsync, setItemAsync } from 'expo-secure-store'
|
|
||||||
import { Platform } from 'react-native'
|
import { Platform } from 'react-native'
|
||||||
import 'react-native-url-polyfill/auto'
|
import 'react-native-url-polyfill/auto'
|
||||||
|
|
||||||
const ExpoSecureStoreAdapter = {
|
|
||||||
getItem: (key: string) => {
|
|
||||||
return getItemAsync(key)
|
|
||||||
},
|
|
||||||
setItem: (key: string, value: string) => {
|
|
||||||
if (value.length > 2048) {
|
|
||||||
console.warn('Value being stored in SecureStore is larger than 2048 bytes and it may not be stored successfully. In a future SDK version, this call may throw an error.')
|
|
||||||
}
|
|
||||||
return setItemAsync(key, value)
|
|
||||||
},
|
|
||||||
removeItem: (key: string) => {
|
|
||||||
return deleteItemAsync(key)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const extra = (Constants.expoConfig?.extra ?? Constants.expoConfig?.extra) as {
|
const extra = (Constants.expoConfig?.extra ?? Constants.expoConfig?.extra) as {
|
||||||
supabaseUrl?: string
|
supabaseUrl?: string
|
||||||
supabaseKey?: string
|
supabaseKey?: string
|
||||||
@@ -35,7 +19,7 @@ if(!supabaseUrl || !supabaseAnonKey){
|
|||||||
const storage = (
|
const storage = (
|
||||||
Platform.OS === "web"
|
Platform.OS === "web"
|
||||||
? window.localStorage
|
? window.localStorage
|
||||||
: ExpoSecureStoreAdapter
|
: AsyncStorage
|
||||||
)
|
)
|
||||||
|
|
||||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
|||||||
281
FastNotes/package-lock.json
generated
281
FastNotes/package-lock.json
generated
@@ -16,11 +16,15 @@
|
|||||||
"@supabase/supabase-js": "^2.98.0",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"async-storage": "^0.1.0",
|
"async-storage": "^0.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
|
"expo-blur": "~15.0.8",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
|
"expo-image-manipulator": "^55.0.10",
|
||||||
|
"expo-image-picker": "^55.0.12",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-notifications": "^55.0.12",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
@@ -2128,9 +2132,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/image-utils": {
|
"node_modules/@expo/image-utils": {
|
||||||
"version": "0.8.8",
|
"version": "0.8.12",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.12.tgz",
|
||||||
"integrity": "sha512-HHHaG4J4nKjTtVa1GG9PCh763xlETScfEyNxxOvfTRr8IKPJckjTyqSLEtdJoFNJ1vqiABEjW7tqGhqGibZLeA==",
|
"integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/spawn-async": "^1.7.2",
|
"@expo/spawn-async": "^1.7.2",
|
||||||
@@ -2139,10 +2143,7 @@
|
|||||||
"jimp-compact": "0.16.1",
|
"jimp-compact": "0.16.1",
|
||||||
"parse-png": "^2.1.0",
|
"parse-png": "^2.1.0",
|
||||||
"resolve-from": "^5.0.0",
|
"resolve-from": "^5.0.0",
|
||||||
"resolve-global": "^1.0.0",
|
"semver": "^7.6.0"
|
||||||
"semver": "^7.6.0",
|
|
||||||
"temp-dir": "~2.0.0",
|
|
||||||
"unique-string": "~2.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/image-utils/node_modules/semver": {
|
"node_modules/@expo/image-utils/node_modules/semver": {
|
||||||
@@ -2158,24 +2159,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/json-file": {
|
"node_modules/@expo/json-file": {
|
||||||
"version": "10.0.8",
|
"version": "10.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.12.tgz",
|
||||||
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==",
|
"integrity": "sha512-inbDycp1rMAelAofg7h/mMzIe+Owx6F7pur3XdQ3EPTy00tme+4P6FWgHKUcjN8dBSrnbRNpSyh5/shzHyVCyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "~7.10.4",
|
"@babel/code-frame": "^7.20.0",
|
||||||
"json5": "^2.2.3"
|
"json5": "^2.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/json-file/node_modules/@babel/code-frame": {
|
|
||||||
"version": "7.10.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
|
||||||
"integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/highlight": "^7.10.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@expo/metro": {
|
"node_modules/@expo/metro": {
|
||||||
"version": "54.2.0",
|
"version": "54.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz",
|
||||||
@@ -2365,6 +2357,25 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@expo/require-utils": {
|
||||||
|
"version": "55.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.2.tgz",
|
||||||
|
"integrity": "sha512-dV5oCShQ1umKBKagMMT4B/N+SREsQe3lU4Zgmko5AO0rxKV0tynZT6xXs+e2JxuqT4Rz997atg7pki0BnZb4uw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/code-frame": "^7.20.0",
|
||||||
|
"@babel/core": "^7.25.2",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.24.8"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5.0.0 || ^5.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@expo/schema-utils": {
|
"node_modules/@expo/schema-utils": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz",
|
||||||
@@ -4814,6 +4825,12 @@
|
|||||||
"@babel/core": "^7.0.0"
|
"@babel/core": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/badgin": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -5462,15 +5479,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/crypto-random-string": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/css-in-js-utils": {
|
"node_modules/css-in-js-utils": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||||
@@ -6489,6 +6497,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-application": {
|
||||||
|
"version": "55.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-55.0.9.tgz",
|
||||||
|
"integrity": "sha512-jXTaLKdW4cvGSUjF2UQed9ao4P/7TsEo/To7TjxM+jNa74xCSUCBSTxdQftm6hZWRzXG8KT7rSoQDEL51neh1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-asset": {
|
"node_modules/expo-asset": {
|
||||||
"version": "12.0.12",
|
"version": "12.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz",
|
||||||
@@ -6504,6 +6521,17 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-blur": {
|
||||||
|
"version": "15.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-blur/-/expo-blur-15.0.8.tgz",
|
||||||
|
"integrity": "sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.13",
|
"version": "18.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||||
@@ -6568,6 +6596,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-image-loader": {
|
||||||
|
"version": "55.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-55.0.0.tgz",
|
||||||
|
"integrity": "sha512-NOjp56wDrfuA5aiNAybBIjqIn1IxKeGJ8CECWZncQ/GzjZfyTYAHTCyeApYkdKkMBLHINzI4BbTGSlbCa0fXXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-image-manipulator": {
|
||||||
|
"version": "55.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-manipulator/-/expo-image-manipulator-55.0.10.tgz",
|
||||||
|
"integrity": "sha512-eEiHSznWa0i5I7iNFDRuHz663XiS26s8SEFigGbsvkFDibGI9x391Qb76DPSGtnqNkJa39etuFw42lbErHphHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-image-loader": "~55.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-image-picker": {
|
||||||
|
"version": "55.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-55.0.12.tgz",
|
||||||
|
"integrity": "sha512-ky8nzXTd5eLUDct5daAHng0xrWYRJyXfLCRmEdE9v/IUywYCnFU7aCnQ7PTQJvzGSzhePJJmP/POvTkVP//+qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-image-loader": "~55.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-keep-awake": {
|
"node_modules/expo-keep-awake": {
|
||||||
"version": "15.0.8",
|
"version": "15.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz",
|
||||||
@@ -6621,6 +6682,121 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-notifications": {
|
||||||
|
"version": "55.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-55.0.12.tgz",
|
||||||
|
"integrity": "sha512-AUAH1ipq7yChZqwp9P/gfmXNoaleKWvEhnIB6/dhtWtTnZZ5VDHdxqzQIbTemYQyIK6kpUc4JZpR9eU3d59K3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/image-utils": "^0.8.12",
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"badgin": "^1.1.5",
|
||||||
|
"expo-application": "~55.0.9",
|
||||||
|
"expo-constants": "~55.0.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/@expo/config": {
|
||||||
|
"version": "55.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.8.tgz",
|
||||||
|
"integrity": "sha512-D7RYYHfErCgEllGxNwdYdkgzLna7zkzUECBV3snbUpf7RvIpB5l1LpCgzuVoc5KVew5h7N1Tn4LnT/tBSUZsQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/config-plugins": "~55.0.6",
|
||||||
|
"@expo/config-types": "^55.0.5",
|
||||||
|
"@expo/json-file": "^10.0.12",
|
||||||
|
"@expo/require-utils": "^55.0.2",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"getenv": "^2.0.0",
|
||||||
|
"glob": "^13.0.0",
|
||||||
|
"resolve-from": "^5.0.0",
|
||||||
|
"resolve-workspace-root": "^2.0.0",
|
||||||
|
"semver": "^7.6.0",
|
||||||
|
"slugify": "^1.3.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/@expo/config-plugins": {
|
||||||
|
"version": "55.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.6.tgz",
|
||||||
|
"integrity": "sha512-cIox6FjZlFaaX40rbQ3DvP9e87S5X85H9uw+BAxJE5timkMhuByy3GAlOsj1h96EyzSiol7Q6YIGgY1Jiz4M+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/config-types": "^55.0.5",
|
||||||
|
"@expo/json-file": "~10.0.12",
|
||||||
|
"@expo/plist": "^0.5.2",
|
||||||
|
"@expo/sdk-runtime-versions": "^1.0.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"debug": "^4.3.5",
|
||||||
|
"getenv": "^2.0.0",
|
||||||
|
"glob": "^13.0.0",
|
||||||
|
"resolve-from": "^5.0.0",
|
||||||
|
"semver": "^7.5.4",
|
||||||
|
"slugify": "^1.6.6",
|
||||||
|
"xcode": "^3.0.1",
|
||||||
|
"xml2js": "0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/@expo/config-types": {
|
||||||
|
"version": "55.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz",
|
||||||
|
"integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/@expo/env": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chalk": "^4.0.0",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"getenv": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/@expo/plist": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-o4xdVdBpe4aTl3sPMZ2u3fJH4iG1I768EIRk1xRZP+GaFI93MaR3JvoFibYqxeTmLQ1p1kNEVqylfUjezxx45g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.8",
|
||||||
|
"base64-js": "^1.5.1",
|
||||||
|
"xmlbuilder": "^15.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/expo-constants": {
|
||||||
|
"version": "55.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-55.0.7.tgz",
|
||||||
|
"integrity": "sha512-kdcO4TsQRRqt0USvjaY5vgQMO9H52K3kBZ/ejC7F6rz70mv08GoowrZ1CYOr5O4JpPDRlIpQfZJUucaS/c+KWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/config": "~55.0.8",
|
||||||
|
"@expo/env": "~2.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*",
|
||||||
|
"react-native": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/expo-notifications/node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-router": {
|
"node_modules/expo-router": {
|
||||||
"version": "6.0.23",
|
"version": "6.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/expo-router/-/expo-router-6.0.23.tgz",
|
||||||
@@ -7475,18 +7651,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/global-dirs": {
|
|
||||||
"version": "0.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
|
|
||||||
"integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ini": "^1.3.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/globals": {
|
"node_modules/globals": {
|
||||||
"version": "14.0.0",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||||
@@ -11140,18 +11304,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/resolve-global": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"global-dirs": "^0.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -12102,15 +12254,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/temp-dir": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/terminal-link": {
|
"node_modules/terminal-link": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
|
||||||
@@ -12455,7 +12598,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -12565,18 +12708,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unique-string": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"crypto-random-string": "^2.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -19,11 +19,15 @@
|
|||||||
"@supabase/supabase-js": "^2.98.0",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"async-storage": "^0.1.0",
|
"async-storage": "^0.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
|
"expo-blur": "~15.0.8",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
"expo-haptics": "~15.0.8",
|
"expo-haptics": "~15.0.8",
|
||||||
"expo-image": "~3.0.11",
|
"expo-image": "~3.0.11",
|
||||||
|
"expo-image-manipulator": "^55.0.10",
|
||||||
|
"expo-image-picker": "^55.0.12",
|
||||||
"expo-linking": "~8.0.11",
|
"expo-linking": "~8.0.11",
|
||||||
|
"expo-notifications": "^55.0.12",
|
||||||
"expo-router": "~6.0.23",
|
"expo-router": "~6.0.23",
|
||||||
"expo-secure-store": "~15.0.8",
|
"expo-secure-store": "~15.0.8",
|
||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AuthContext } from '@/hooks/use-auth-context'
|
import { AuthContext } from '@/hooks/use-auth-context'
|
||||||
import { hasSecureRefreshToken, supabase } from '@/libs/supabase'
|
import { supabase } from '@/libs/supabase'
|
||||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||||
|
|
||||||
const buildClaims = (user?: { id: string; email?: string | null } | null) =>
|
const buildClaims = (user?: { id: string; email?: string | null } | null) =>
|
||||||
@@ -26,10 +26,6 @@ export default function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
console.error('Error hydrating session:', error)
|
console.error('Error hydrating session:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session && await hasSecureRefreshToken()) {
|
|
||||||
console.warn('Found an encrypted Supabase refresh token backup, but fallback session recovery is not implemented.')
|
|
||||||
}
|
|
||||||
|
|
||||||
setClaims(buildClaims(session?.user))
|
setClaims(buildClaims(session?.user))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from "react"
|
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
import { useAuthContext } from "@/hooks/use-auth-context"
|
import { useAuthContext } from "@/hooks/use-auth-context"
|
||||||
import { supabase } from "@/libs/supabase"
|
import { supabase } from "@/libs/supabase"
|
||||||
|
import { deleteNoteImage, NoteImageUploadProgress, uploadNoteImage } from "@/src/notes/note-image-storage"
|
||||||
|
import { StagedNoteImage } from "@/src/notes/image-utils"
|
||||||
|
|
||||||
type NoteRow = {
|
type NoteRow = {
|
||||||
id: number
|
id: number
|
||||||
@@ -9,6 +12,10 @@ type NoteRow = {
|
|||||||
content: string
|
content: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string | null
|
updated_at?: string | null
|
||||||
|
image_url?: string | null
|
||||||
|
image_path?: string | null
|
||||||
|
image_mime_type?: string | null
|
||||||
|
image_size_bytes?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileRow = {
|
type ProfileRow = {
|
||||||
@@ -26,20 +33,53 @@ export type Note = {
|
|||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
creatorLabel: string
|
creatorLabel: string
|
||||||
|
imageUrl: string | null
|
||||||
|
imagePath: string | null
|
||||||
|
imageMimeType: string | null
|
||||||
|
imageSizeBytes: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NoteImageChange =
|
||||||
|
| { type: "keep" }
|
||||||
|
| { type: "remove" }
|
||||||
|
| { type: "replace"; image: StagedNoteImage }
|
||||||
|
|
||||||
type NotesContextValue = {
|
type NotesContextValue = {
|
||||||
notes: Note[]
|
notes: Note[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
errorMessage: string | null
|
errorMessage: string | null
|
||||||
refreshNotes: () => Promise<void>
|
refreshNotes: () => Promise<void>
|
||||||
addNote: (title: string, content: string) => Promise<boolean>
|
addNote: (
|
||||||
updateNote: (noteId: string, title: string, content: string) => Promise<boolean>
|
title: string,
|
||||||
|
content: string,
|
||||||
|
image?: StagedNoteImage | null,
|
||||||
|
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||||
|
) => Promise<boolean>
|
||||||
|
updateNote: (
|
||||||
|
noteId: string,
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
imageChange?: NoteImageChange,
|
||||||
|
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||||
|
) => Promise<boolean>
|
||||||
deleteNote: (noteId: string) => Promise<boolean>
|
deleteNote: (noteId: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotesContext = createContext<NotesContextValue | undefined>(undefined)
|
const NotesContext = createContext<NotesContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
function normalizeImageSizeBytes(value: number | string | null | undefined) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function NotesProvider({ children }: { children: React.ReactNode }) {
|
export function NotesProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { claims, isLoggedIn, profile } = useAuthContext()
|
const { claims, isLoggedIn, profile } = useAuthContext()
|
||||||
const [notes, setNotes] = useState<Note[]>([])
|
const [notes, setNotes] = useState<Note[]>([])
|
||||||
@@ -81,19 +121,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapNote = (row: NoteRow, labels: Record<string, string>): Note => ({
|
const loadNotes = useCallback(async () => {
|
||||||
id: String(row.id),
|
|
||||||
createdBy: row.created_by,
|
|
||||||
createdAt: row.created_at,
|
|
||||||
lastChangedAt: row.updated_at || row.created_at,
|
|
||||||
title: row.title,
|
|
||||||
content: row.content,
|
|
||||||
creatorLabel:
|
|
||||||
labels[row.created_by] ||
|
|
||||||
(row.created_by === userId ? creatorLabel : "Unknown user"),
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadNotes = async () => {
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
setNotes([])
|
setNotes([])
|
||||||
setErrorMessage(null)
|
setErrorMessage(null)
|
||||||
@@ -106,7 +134,9 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from("Notes")
|
.from("Notes")
|
||||||
.select("id, created_by, title, content, created_at, updated_at")
|
.select(
|
||||||
|
"id, created_by, title, content, created_at, updated_at, image_url, image_path, image_mime_type, image_size_bytes"
|
||||||
|
)
|
||||||
.order("updated_at", { ascending: false, nullsFirst: false })
|
.order("updated_at", { ascending: false, nullsFirst: false })
|
||||||
.order("created_at", { ascending: false })
|
.order("created_at", { ascending: false })
|
||||||
|
|
||||||
@@ -118,12 +148,27 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rows = (data ?? []) as NoteRow[]
|
const rows = (data ?? []) as NoteRow[]
|
||||||
// Resolve public creator labels separately so note reads do not depend on a view schema.
|
|
||||||
const labels = await buildCreatorLabels(rows)
|
const labels = await buildCreatorLabels(rows)
|
||||||
|
|
||||||
setNotes(rows.map((row) => mapNote(row, labels)))
|
setNotes(
|
||||||
|
rows.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
createdBy: row.created_by,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
lastChangedAt: row.updated_at || row.created_at,
|
||||||
|
title: row.title,
|
||||||
|
content: row.content,
|
||||||
|
creatorLabel:
|
||||||
|
labels[row.created_by] ||
|
||||||
|
(row.created_by === userId ? creatorLabel : "Unknown user"),
|
||||||
|
imageUrl: row.image_url ?? null,
|
||||||
|
imagePath: row.image_path ?? null,
|
||||||
|
imageMimeType: row.image_mime_type ?? null,
|
||||||
|
imageSizeBytes: normalizeImageSizeBytes(row.image_size_bytes),
|
||||||
|
}))
|
||||||
|
)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}, [creatorLabel, isLoggedIn, userId])
|
||||||
|
|
||||||
const refreshNotes = async () => {
|
const refreshNotes = async () => {
|
||||||
await loadNotes()
|
await loadNotes()
|
||||||
@@ -138,14 +183,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadNotes()
|
void loadNotes()
|
||||||
}, [creatorLabel, isLoggedIn, userId])
|
}, [creatorLabel, isLoggedIn, loadNotes, userId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn || !userId) {
|
if (!isLoggedIn || !userId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for remote changes so other users' edits appear without a manual refresh.
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
void loadNotes()
|
void loadNotes()
|
||||||
}, 30000)
|
}, 30000)
|
||||||
@@ -153,9 +197,14 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(intervalId)
|
clearInterval(intervalId)
|
||||||
}
|
}
|
||||||
}, [creatorLabel, isLoggedIn, userId])
|
}, [creatorLabel, isLoggedIn, loadNotes, userId])
|
||||||
|
|
||||||
const addNote = async (title: string, content: string) => {
|
const addNote = async (
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
image?: StagedNoteImage | null,
|
||||||
|
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||||
|
) => {
|
||||||
const trimmedTitle = title.trim()
|
const trimmedTitle = title.trim()
|
||||||
const trimmedContent = content.trim()
|
const trimmedContent = content.trim()
|
||||||
|
|
||||||
@@ -170,16 +219,47 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setErrorMessage(null)
|
setErrorMessage(null)
|
||||||
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("Notes")
|
|
||||||
.insert({
|
|
||||||
title: trimmedTitle,
|
|
||||||
content: trimmedContent,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (error) {
|
let uploadedImage:
|
||||||
setErrorMessage(error.message)
|
| {
|
||||||
|
path: string
|
||||||
|
publicUrl: string
|
||||||
|
mimeType: string
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
| null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (image) {
|
||||||
|
uploadedImage = await uploadNoteImage(userId, image, {
|
||||||
|
onProgress: options?.onImageUploadProgress,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("Notes")
|
||||||
|
.insert({
|
||||||
|
title: trimmedTitle,
|
||||||
|
content: trimmedContent,
|
||||||
|
image_url: uploadedImage?.publicUrl ?? null,
|
||||||
|
image_path: uploadedImage?.path ?? null,
|
||||||
|
image_mime_type: uploadedImage?.mimeType ?? null,
|
||||||
|
image_size_bytes: uploadedImage?.sizeBytes ?? null,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (uploadedImage?.path) {
|
||||||
|
try {
|
||||||
|
await deleteNoteImage(uploadedImage.path)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error("Failed to roll back uploaded note image:", cleanupError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Failed to save note.")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +267,13 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNote = async (noteId: string, title: string, content: string) => {
|
const updateNote = async (
|
||||||
|
noteId: string,
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
imageChange: NoteImageChange = { type: "keep" },
|
||||||
|
options?: { onImageUploadProgress?: (progress: NoteImageUploadProgress) => void }
|
||||||
|
) => {
|
||||||
const trimmedTitle = title.trim()
|
const trimmedTitle = title.trim()
|
||||||
const trimmedContent = content.trim()
|
const trimmedContent = content.trim()
|
||||||
|
|
||||||
@@ -201,44 +287,109 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingNote = notes.find((note) => note.id === noteId)
|
||||||
|
|
||||||
|
if (!existingNote) {
|
||||||
|
setErrorMessage("This note could not be found.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
setErrorMessage(null)
|
setErrorMessage(null)
|
||||||
|
|
||||||
const { data, error } = await supabase
|
let uploadedImage:
|
||||||
.from("Notes")
|
| {
|
||||||
.update({
|
path: string
|
||||||
title: trimmedTitle,
|
publicUrl: string
|
||||||
content: trimmedContent,
|
mimeType: string
|
||||||
updated_at: new Date().toISOString(),
|
sizeBytes: number
|
||||||
})
|
}
|
||||||
.eq("id", Number(noteId))
|
| null = null
|
||||||
.eq("created_by", userId)
|
|
||||||
.select("id, title, content, updated_at")
|
|
||||||
.maybeSingle()
|
|
||||||
|
|
||||||
if (error) {
|
const updates: Record<string, string | number | null> = {
|
||||||
setErrorMessage(error.message)
|
title: trimmedTitle,
|
||||||
return false
|
content: trimmedContent,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
try {
|
||||||
setErrorMessage("Update failed. You can only edit notes that you created.")
|
if (imageChange.type === "replace") {
|
||||||
return false
|
uploadedImage = await uploadNoteImage(userId, imageChange.image, {
|
||||||
}
|
onProgress: options?.onImageUploadProgress,
|
||||||
|
})
|
||||||
|
updates.image_url = uploadedImage.publicUrl
|
||||||
|
updates.image_path = uploadedImage.path
|
||||||
|
updates.image_mime_type = uploadedImage.mimeType
|
||||||
|
updates.image_size_bytes = uploadedImage.sizeBytes
|
||||||
|
} else if (imageChange.type === "remove") {
|
||||||
|
updates.image_url = null
|
||||||
|
updates.image_path = null
|
||||||
|
updates.image_mime_type = null
|
||||||
|
updates.image_size_bytes = null
|
||||||
|
}
|
||||||
|
|
||||||
// Update the edited note locally so the new timestamp is visible immediately.
|
const { data, error } = await supabase
|
||||||
setNotes((prev) =>
|
.from("Notes")
|
||||||
prev.map((note) =>
|
.update(updates)
|
||||||
note.id === noteId
|
.eq("id", Number(noteId))
|
||||||
? {
|
.eq("created_by", userId)
|
||||||
...note,
|
.select(
|
||||||
title: data.title ?? trimmedTitle,
|
"id, title, content, updated_at, image_url, image_path, image_mime_type, image_size_bytes"
|
||||||
content: data.content ?? trimmedContent,
|
)
|
||||||
lastChangedAt: data.updated_at ?? new Date().toISOString(),
|
.maybeSingle()
|
||||||
}
|
|
||||||
: note
|
if (error) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("Update failed. You can only edit notes that you created.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageChange.type === "replace" && existingNote.imagePath) {
|
||||||
|
try {
|
||||||
|
await deleteNoteImage(existingNote.imagePath)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error("Failed to remove replaced note image:", cleanupError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageChange.type === "remove" && existingNote.imagePath) {
|
||||||
|
try {
|
||||||
|
await deleteNoteImage(existingNote.imagePath)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error("Failed to remove deleted note image:", cleanupError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotes((prev) =>
|
||||||
|
prev.map((note) =>
|
||||||
|
note.id === noteId
|
||||||
|
? {
|
||||||
|
...note,
|
||||||
|
title: data.title ?? trimmedTitle,
|
||||||
|
content: data.content ?? trimmedContent,
|
||||||
|
lastChangedAt: data.updated_at ?? updates.updated_at ?? new Date().toISOString(),
|
||||||
|
imageUrl: data.image_url ?? null,
|
||||||
|
imagePath: data.image_path ?? null,
|
||||||
|
imageMimeType: data.image_mime_type ?? null,
|
||||||
|
imageSizeBytes: normalizeImageSizeBytes(data.image_size_bytes),
|
||||||
|
}
|
||||||
|
: note
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
return true
|
||||||
return true
|
} catch (error) {
|
||||||
|
if (uploadedImage?.path) {
|
||||||
|
try {
|
||||||
|
await deleteNoteImage(uploadedImage.path)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error("Failed to roll back uploaded replacement image:", cleanupError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : "Failed to update note.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteNote = async (noteId: string) => {
|
const deleteNote = async (noteId: string) => {
|
||||||
@@ -247,6 +398,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingNote = notes.find((note) => note.id === noteId)
|
||||||
|
|
||||||
setErrorMessage(null)
|
setErrorMessage(null)
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
@@ -260,6 +413,15 @@ export function NotesProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingNote?.imagePath) {
|
||||||
|
try {
|
||||||
|
await deleteNoteImage(existingNote.imagePath)
|
||||||
|
} catch (cleanupError) {
|
||||||
|
console.error("Failed to remove note image during deletion:", cleanupError)
|
||||||
|
setErrorMessage("The note was deleted, but its stored image could not be cleaned up.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setNotes((prev) => prev.filter((note) => note.id !== noteId))
|
setNotes((prev) => prev.filter((note) => note.id !== noteId))
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
199
FastNotes/src/notes/image-utils.ts
Normal file
199
FastNotes/src/notes/image-utils.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import * as ImageManipulator from "expo-image-manipulator"
|
||||||
|
|
||||||
|
export const NOTE_IMAGE_BUCKET = "note-images"
|
||||||
|
export const MAX_NOTE_IMAGE_BYTES = 15 * 1024 * 1024
|
||||||
|
export const SUPPORTED_NOTE_IMAGE_FORMATS = "PNG, JPG/JPEG, WEBP"
|
||||||
|
|
||||||
|
const JPEG_MIME = "image/jpeg"
|
||||||
|
const PNG_MIME = "image/png"
|
||||||
|
const WEBP_MIME = "image/webp"
|
||||||
|
|
||||||
|
export type SupportedMimeType =
|
||||||
|
| typeof JPEG_MIME
|
||||||
|
| typeof PNG_MIME
|
||||||
|
| typeof WEBP_MIME
|
||||||
|
|
||||||
|
export type StagedNoteImage = {
|
||||||
|
uri: string
|
||||||
|
fileName: string
|
||||||
|
mimeType: string | null
|
||||||
|
fileSize: number | null
|
||||||
|
width?: number | null
|
||||||
|
height?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadedNoteImage = {
|
||||||
|
path: string
|
||||||
|
publicUrl: string
|
||||||
|
mimeType: SupportedMimeType
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreparedNoteImage = {
|
||||||
|
uri: string
|
||||||
|
mimeType: SupportedMimeType
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFileExtension(fileName: string | null | undefined) {
|
||||||
|
const extension = fileName?.split(".").pop()?.toLowerCase()
|
||||||
|
|
||||||
|
if (!extension) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === "jpg") {
|
||||||
|
return "jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
return extension
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMimeType(
|
||||||
|
mimeType: string | null | undefined,
|
||||||
|
fileName?: string | null
|
||||||
|
): SupportedMimeType | null {
|
||||||
|
if (mimeType === JPEG_MIME || mimeType === PNG_MIME || mimeType === WEBP_MIME) {
|
||||||
|
return mimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === "image/jpg") {
|
||||||
|
return JPEG_MIME
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = normalizeFileExtension(fileName)
|
||||||
|
|
||||||
|
if (extension === "jpeg") {
|
||||||
|
return JPEG_MIME
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === "png") {
|
||||||
|
return PNG_MIME
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extension === "webp") {
|
||||||
|
return WEBP_MIME
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileExtension(mimeType: SupportedMimeType) {
|
||||||
|
if (mimeType === PNG_MIME) {
|
||||||
|
return "png"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === WEBP_MIME) {
|
||||||
|
return "webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSaveFormat(mimeType: SupportedMimeType) {
|
||||||
|
if (mimeType === PNG_MIME) {
|
||||||
|
return ImageManipulator.SaveFormat.PNG
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === WEBP_MIME) {
|
||||||
|
return ImageManipulator.SaveFormat.WEBP
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageManipulator.SaveFormat.JPEG
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number | null | undefined) {
|
||||||
|
if (!bytes || Number.isNaN(bytes) || bytes <= 0) {
|
||||||
|
return "Unknown size"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateStagedNoteImage(image: StagedNoteImage) {
|
||||||
|
const normalizedMimeType = normalizeMimeType(image.mimeType, image.fileName)
|
||||||
|
|
||||||
|
if (!normalizedMimeType) {
|
||||||
|
throw new Error(`Unsupported image format. Allowed formats: ${SUPPORTED_NOTE_IMAGE_FORMATS}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedMimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFileSize(uri: string) {
|
||||||
|
const response = await fetch(uri)
|
||||||
|
const blob = await response.blob()
|
||||||
|
return blob.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readUriAsArrayBuffer(uri: string) {
|
||||||
|
const response = await fetch(uri)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("The selected image could not be read.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.arrayBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActions(width: number, height: number) {
|
||||||
|
const largestSide = Math.max(width, height)
|
||||||
|
|
||||||
|
if (largestSide <= 1600) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = 1600 / largestSide
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
resize: {
|
||||||
|
width: Math.max(1, Math.round(width * scale)),
|
||||||
|
height: Math.max(1, Math.round(height * scale)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareNoteImage(image: StagedNoteImage): Promise<PreparedNoteImage> {
|
||||||
|
const normalizedMimeType = validateStagedNoteImage(image)
|
||||||
|
const targetMimeType = normalizedMimeType === PNG_MIME ? PNG_MIME : JPEG_MIME
|
||||||
|
const saveFormat = getSaveFormat(targetMimeType)
|
||||||
|
const actions =
|
||||||
|
image.width && image.height
|
||||||
|
? buildActions(image.width, image.height)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const compressions = targetMimeType === PNG_MIME ? [1] : [0.82, 0.7, 0.56, 0.42, 0.32]
|
||||||
|
|
||||||
|
for (const compression of compressions) {
|
||||||
|
const result = await ImageManipulator.manipulateAsync(image.uri, actions, {
|
||||||
|
compress: compression,
|
||||||
|
format: saveFormat,
|
||||||
|
})
|
||||||
|
const sizeBytes = await getFileSize(result.uri)
|
||||||
|
|
||||||
|
if (sizeBytes <= MAX_NOTE_IMAGE_BYTES) {
|
||||||
|
return {
|
||||||
|
uri: result.uri,
|
||||||
|
mimeType: targetMimeType,
|
||||||
|
sizeBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Image too large. The selected image is still larger than 15 MB after compression.")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStoragePath(userId: string, mimeType: SupportedMimeType) {
|
||||||
|
const id =
|
||||||
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
|
||||||
|
return `${userId}/${id}.${getFileExtension(mimeType)}`
|
||||||
|
}
|
||||||
79
FastNotes/src/notes/native-image-picker.ts
Normal file
79
FastNotes/src/notes/native-image-picker.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as ImagePicker from "expo-image-picker"
|
||||||
|
import { Platform } from "react-native"
|
||||||
|
|
||||||
|
import { StagedNoteImage } from "@/src/notes/image-utils"
|
||||||
|
|
||||||
|
function buildStagedImage(asset: ImagePicker.ImagePickerAsset): StagedNoteImage {
|
||||||
|
return {
|
||||||
|
uri: asset.uri,
|
||||||
|
fileName: asset.fileName ?? `note-image-${Date.now()}.jpg`,
|
||||||
|
mimeType: asset.mimeType ?? null,
|
||||||
|
fileSize: asset.fileSize ?? null,
|
||||||
|
width: asset.width,
|
||||||
|
height: asset.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsupportedPlatformError(action: "camera" | "gallery") {
|
||||||
|
return Platform.OS === "web"
|
||||||
|
? `Native ${action} support is only enabled on iOS and Android in this app.`
|
||||||
|
: `This device cannot open the ${action}.`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickImageFromLibrary() {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
throw new Error(unsupportedPlatformError("gallery"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await ImagePicker.getMediaLibraryPermissionsAsync()
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
const requested = await ImagePicker.requestMediaLibraryPermissionsAsync()
|
||||||
|
|
||||||
|
if (!requested.granted) {
|
||||||
|
throw new Error("Photo library access is required to choose an image.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
allowsEditing: false,
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
quality: 1,
|
||||||
|
selectionLimit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStagedImage(result.assets[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickImageFromCamera() {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
throw new Error(unsupportedPlatformError("camera"))
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await ImagePicker.getCameraPermissionsAsync()
|
||||||
|
|
||||||
|
if (!permission.granted) {
|
||||||
|
const requested = await ImagePicker.requestCameraPermissionsAsync()
|
||||||
|
|
||||||
|
if (!requested.granted) {
|
||||||
|
throw new Error("Camera access is required to take a photo.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
|
allowsEditing: false,
|
||||||
|
cameraType: ImagePicker.CameraType.back,
|
||||||
|
mediaTypes: ["images"],
|
||||||
|
quality: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets?.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildStagedImage(result.assets[0])
|
||||||
|
}
|
||||||
153
FastNotes/src/notes/note-image-storage.ts
Normal file
153
FastNotes/src/notes/note-image-storage.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { supabase, supabaseAnonKey, supabaseUrl } from "@/libs/supabase"
|
||||||
|
import {
|
||||||
|
createStoragePath,
|
||||||
|
NOTE_IMAGE_BUCKET,
|
||||||
|
prepareNoteImage,
|
||||||
|
StagedNoteImage,
|
||||||
|
UploadedNoteImage,
|
||||||
|
} from "@/src/notes/image-utils"
|
||||||
|
|
||||||
|
export type NoteImageUploadProgress = {
|
||||||
|
loaded: number
|
||||||
|
total: number
|
||||||
|
progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type UploadNoteImageOptions = {
|
||||||
|
onProgress?: (progress: NoteImageUploadProgress) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUploadFailureMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (
|
||||||
|
error.message.startsWith("Unsupported image format.") ||
|
||||||
|
error.message.startsWith("Image too large.")
|
||||||
|
) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message.trim()) {
|
||||||
|
return `Image upload failed. ${error.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Image upload failed. Check your connection and try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadWithProgress(
|
||||||
|
path: string,
|
||||||
|
fileUri: string,
|
||||||
|
mimeType: string,
|
||||||
|
onProgress?: (progress: NoteImageUploadProgress) => void
|
||||||
|
) {
|
||||||
|
if (!supabaseUrl || !supabaseAnonKey) {
|
||||||
|
throw new Error("Image upload failed. Storage configuration is missing.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const anonKey = supabaseAnonKey
|
||||||
|
const storageBaseUrl = supabaseUrl
|
||||||
|
const session = await supabase.auth.getSession()
|
||||||
|
const accessToken = session.data.session?.access_token
|
||||||
|
const uploadUrl = `${storageBaseUrl}/storage/v1/object/${NOTE_IMAGE_BUCKET}/${path}`
|
||||||
|
|
||||||
|
const response = await fetch(fileUri)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("The selected image could not be read.")
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBlob = await response.blob()
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
|
||||||
|
xhr.open("POST", uploadUrl)
|
||||||
|
xhr.setRequestHeader("apikey", anonKey)
|
||||||
|
xhr.setRequestHeader("Authorization", `Bearer ${accessToken ?? anonKey}`)
|
||||||
|
xhr.setRequestHeader("x-upsert", "false")
|
||||||
|
xhr.setRequestHeader("cache-control", "3600")
|
||||||
|
xhr.setRequestHeader("content-type", mimeType)
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable || !event.total) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
loaded: event.loaded,
|
||||||
|
total: event.total,
|
||||||
|
progress: Math.min(100, Math.round((event.loaded / event.total) * 100)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
onProgress?.({
|
||||||
|
loaded: fileBlob.size,
|
||||||
|
total: fileBlob.size,
|
||||||
|
progress: 100,
|
||||||
|
})
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(xhr.responseText) as { message?: string; error?: string }
|
||||||
|
reject(new Error(parsed.message || parsed.error || "Upload failed."))
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Upload failed."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
reject(new Error("Check your connection and try again."))
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onabort = () => {
|
||||||
|
reject(new Error("Upload cancelled."))
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.({
|
||||||
|
loaded: 0,
|
||||||
|
total: fileBlob.size,
|
||||||
|
progress: 0,
|
||||||
|
})
|
||||||
|
xhr.send(fileBlob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadNoteImage(
|
||||||
|
userId: string,
|
||||||
|
image: StagedNoteImage,
|
||||||
|
options: UploadNoteImageOptions = {}
|
||||||
|
): Promise<UploadedNoteImage> {
|
||||||
|
const preparedImage = await prepareNoteImage(image)
|
||||||
|
const path = createStoragePath(userId, preparedImage.mimeType)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadWithProgress(path, preparedImage.uri, preparedImage.mimeType, options.onProgress)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(normalizeUploadFailureMessage(error))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = supabase.storage
|
||||||
|
.from(NOTE_IMAGE_BUCKET)
|
||||||
|
.getPublicUrl(path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
publicUrl: data.publicUrl,
|
||||||
|
mimeType: preparedImage.mimeType,
|
||||||
|
sizeBytes: preparedImage.sizeBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNoteImage(path: string) {
|
||||||
|
const { error } = await supabase.storage
|
||||||
|
.from(NOTE_IMAGE_BUCKET)
|
||||||
|
.remove([path])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
105
FastNotes/src/notifications/PushNotificationsProvider.tsx
Normal file
105
FastNotes/src/notifications/PushNotificationsProvider.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { PropsWithChildren, useEffect } from "react"
|
||||||
|
import * as Notifications from "expo-notifications"
|
||||||
|
|
||||||
|
import { useAuthContext } from "@/hooks/use-auth-context"
|
||||||
|
import { supabase } from "@/libs/supabase"
|
||||||
|
import { registerPushNotifications } from "@/src/notifications/push-notifications"
|
||||||
|
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldShowBanner: true,
|
||||||
|
shouldShowList: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCreatorEmail(userId: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("email")
|
||||||
|
.eq("id", userId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to load note creator email:", error.message)
|
||||||
|
return "unknown user"
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof data?.email === "string" && data.email.trim() ? data.email : "unknown user"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PushNotificationsProvider({ children }: PropsWithChildren) {
|
||||||
|
const { claims, isLoading, isLoggedIn } = useAuthContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading || !isLoggedIn || !claims?.sub) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false
|
||||||
|
let teardownRealtimeFallback: (() => void) | undefined
|
||||||
|
|
||||||
|
const register = async () => {
|
||||||
|
const result = await registerPushNotifications(claims.sub as string)
|
||||||
|
|
||||||
|
if (isCancelled || result.status === "registered" || result.status === "denied" || result.status === "unsupported") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "local-only" || result.status === "missing-project-id" || result.status === "error") {
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`notes-local-notifications-${claims.sub}`)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{
|
||||||
|
event: "INSERT",
|
||||||
|
schema: "public",
|
||||||
|
table: "Notes",
|
||||||
|
},
|
||||||
|
async (payload) => {
|
||||||
|
const note = payload.new as { id?: string | number; title?: string; created_by?: string }
|
||||||
|
|
||||||
|
if (!note?.created_by || note.created_by === claims.sub) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorEmail = await loadCreatorEmail(note.created_by)
|
||||||
|
|
||||||
|
void Notifications.scheduleNotificationAsync({
|
||||||
|
content: {
|
||||||
|
title: "FastNotes",
|
||||||
|
body: `New note: "${note.title ?? "Untitled"}" by ${creatorEmail}`,
|
||||||
|
data: {
|
||||||
|
type: "new-note",
|
||||||
|
noteId: note.id ? String(note.id) : "",
|
||||||
|
title: note.title ?? "Untitled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
trigger: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
teardownRealtimeFallback = () => {
|
||||||
|
void supabase.removeChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status === "missing-project-id" || result.status === "error") {
|
||||||
|
console.error("Push notification setup failed:", result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void register()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
teardownRealtimeFallback?.()
|
||||||
|
}
|
||||||
|
}, [claims?.sub, isLoading, isLoggedIn])
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
134
FastNotes/src/notifications/push-notifications.ts
Normal file
134
FastNotes/src/notifications/push-notifications.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage"
|
||||||
|
import Constants from "expo-constants"
|
||||||
|
import * as Notifications from "expo-notifications"
|
||||||
|
import { Platform } from "react-native"
|
||||||
|
|
||||||
|
import { supabase } from "@/libs/supabase"
|
||||||
|
|
||||||
|
const INSTALLATION_ID_STORAGE_KEY = "fastnotes.push.installation-id"
|
||||||
|
const PUSH_TOKEN_TABLE = "user_push_tokens"
|
||||||
|
|
||||||
|
type PushRegistrationResult =
|
||||||
|
| { status: "unsupported" | "denied" | "registered" | "local-only" }
|
||||||
|
| { status: "missing-project-id" | "error"; message: string }
|
||||||
|
|
||||||
|
function getEasProjectId() {
|
||||||
|
return (
|
||||||
|
Constants.easConfig?.projectId ??
|
||||||
|
Constants.expoConfig?.extra?.easProjectId ??
|
||||||
|
Constants.expoConfig?.extra?.eas?.projectId ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInstallationId() {
|
||||||
|
const existingInstallationId = await AsyncStorage.getItem(INSTALLATION_ID_STORAGE_KEY)
|
||||||
|
|
||||||
|
if (existingInstallationId) {
|
||||||
|
return existingInstallationId
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextInstallationId = `${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
|
||||||
|
await AsyncStorage.setItem(INSTALLATION_ID_STORAGE_KEY, nextInstallationId)
|
||||||
|
return nextInstallationId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAndroidChannel() {
|
||||||
|
if (Platform.OS !== "android") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await Notifications.setNotificationChannelAsync("default", {
|
||||||
|
name: "default",
|
||||||
|
importance: Notifications.AndroidImportance.MAX,
|
||||||
|
vibrationPattern: [0, 250, 250, 250],
|
||||||
|
lightColor: "#1f6feb",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPhysicalDevice() {
|
||||||
|
return Constants.isDevice ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerPushNotifications(userId: string): Promise<PushRegistrationResult> {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
return { status: "unsupported" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureAndroidChannel()
|
||||||
|
|
||||||
|
const existingPermissions = await Notifications.getPermissionsAsync()
|
||||||
|
let finalStatus = existingPermissions.status
|
||||||
|
|
||||||
|
if (finalStatus !== "granted") {
|
||||||
|
const requestedPermissions = await Notifications.requestPermissionsAsync()
|
||||||
|
finalStatus = requestedPermissions.status
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalStatus !== "granted") {
|
||||||
|
return { status: "denied" }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPhysicalDevice()) {
|
||||||
|
return { status: "local-only" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = getEasProjectId()
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return {
|
||||||
|
status: "missing-project-id",
|
||||||
|
message: "Missing Expo EAS project ID. Set EXPO_PUBLIC_EAS_PROJECT_ID before building the app.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expoPushToken = await Notifications.getExpoPushTokenAsync({ projectId })
|
||||||
|
const installationId = await getInstallationId()
|
||||||
|
|
||||||
|
const { error } = await supabase.from(PUSH_TOKEN_TABLE).upsert(
|
||||||
|
{
|
||||||
|
installation_id: installationId,
|
||||||
|
user_id: userId,
|
||||||
|
push_token: expoPushToken.data,
|
||||||
|
platform: Platform.OS,
|
||||||
|
is_active: true,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onConflict: "installation_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { status: "error", message: error.message }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "registered" }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: error instanceof Error ? error.message : "Push notification registration failed.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterPushNotifications(userId?: string) {
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const installationId = await getInstallationId()
|
||||||
|
let query = supabase.from(PUSH_TOKEN_TABLE).delete().eq("installation_id", installationId)
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
query = query.eq("user_id", userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await query
|
||||||
|
return !error
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
568
FastNotes/src/styles/app-styles.ts
Normal file
568
FastNotes/src/styles/app-styles.ts
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
import { Appearance, StyleSheet } from "react-native"
|
||||||
|
|
||||||
|
export const uploadProgressBarStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
percentage: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
height: 10,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
fill: {
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const parallaxScrollViewStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
height: 250,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 32,
|
||||||
|
gap: 16,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const themedTextStyles = 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 const loginScreenStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#c62828",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
loginButton: {
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#696969",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
loginButtonPressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
loginButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
loginButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
alignSelf: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: "#0b57d0",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const signupScreenStyles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#c62828",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
successText: {
|
||||||
|
color: "#2e7d32",
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: "#111",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
actionButtonPressed: {
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
actionButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
alignSelf: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
linkText: {
|
||||||
|
color: "#0b57d0",
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const signOutButtonStyles = StyleSheet.create({
|
||||||
|
button: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const collapsibleStyles = StyleSheet.create({
|
||||||
|
heading: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const homeScreenStyles = StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
topBar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
screenTitle: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
tabBar: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
tabButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingVertical: 10,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
tabButtonActive: {
|
||||||
|
backgroundColor: Appearance.getColorScheme() === "light" ? "#000000" : "#8a8888",
|
||||||
|
},
|
||||||
|
tabButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
tabButtonTextActive: {
|
||||||
|
color: "#fff",
|
||||||
|
},
|
||||||
|
list: { padding: 16, gap: 12, paddingTop: 8 },
|
||||||
|
noteItem: { padding: 16, borderWidth: 1, borderRadius: 12, gap: 8 },
|
||||||
|
noteCardRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
noteBody: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
noteTitle: { fontSize: 16, fontWeight: "600" },
|
||||||
|
noteThumbnailFrame: {
|
||||||
|
width: 110,
|
||||||
|
height: 110,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
noteThumbnail: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
notePreview: { fontSize: 14 },
|
||||||
|
noteMeta: { fontSize: 12 },
|
||||||
|
emptyText: {
|
||||||
|
textAlign: "center",
|
||||||
|
paddingVertical: 32,
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#c62828",
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
},
|
||||||
|
fab: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
fabText: { fontSize: 28, lineHeight: 28, fontWeight: "700" },
|
||||||
|
})
|
||||||
|
|
||||||
|
export const noteImagePanelStyles = StyleSheet.create({
|
||||||
|
section: {
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
previewCard: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
previewLayout: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
alignItems: "stretch",
|
||||||
|
},
|
||||||
|
previewLayoutStacked: {
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
previewDetails: {
|
||||||
|
flex: 0.95,
|
||||||
|
gap: 6,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
previewFrame: {
|
||||||
|
flex: 1.7,
|
||||||
|
minHeight: 220,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
previewImage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
previewMetaLabel: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
previewMeta: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
urlText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
helperText: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
enabledButtonShadow: {
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.14,
|
||||||
|
shadowOffset: { width: 0, height: 6 },
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.45,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingVertical: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
secondaryButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
removeButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
fullscreenOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.82)",
|
||||||
|
},
|
||||||
|
fullscreenBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
fullscreenCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 900,
|
||||||
|
height: "82%",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 12,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 999,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
closeButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
fullscreenImage: {
|
||||||
|
flex: 1,
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: "#d7d7d7",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const newNoteScreenStyles = StyleSheet.create({
|
||||||
|
keyboardAvoider: { flex: 1 },
|
||||||
|
container: { flex: 1 },
|
||||||
|
formContent: { padding: 16, gap: 12 },
|
||||||
|
titleInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
contentInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
actionsBlur: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
actionsContent: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
enabledButtonShadow: {
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.45,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
saveFloatingText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#c62828",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const detailScreenStyles = StyleSheet.create({
|
||||||
|
keyboardAvoider: { flex: 1 },
|
||||||
|
container: { flex: 1 },
|
||||||
|
formContent: { padding: 16, gap: 12 },
|
||||||
|
title: { fontSize: 22, fontWeight: "700" },
|
||||||
|
content: { fontSize: 16 },
|
||||||
|
titleInput: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
signature: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
contentInput: {
|
||||||
|
minHeight: 200,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: "#c62828",
|
||||||
|
},
|
||||||
|
successText: {
|
||||||
|
color: "#2e7d32",
|
||||||
|
},
|
||||||
|
readOnlyText: {
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
actionsBlur: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
actionsContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
paddingTop: 10,
|
||||||
|
},
|
||||||
|
primaryButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
enabledButtonShadow: {
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.18,
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowRadius: 16,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
primaryButtonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
disabledButton: {
|
||||||
|
opacity: 0.45,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
elevation: 0,
|
||||||
|
},
|
||||||
|
deleteButtonText: {
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -14,8 +14,8 @@ const lightPalette = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const darkPalette = {
|
const darkPalette = {
|
||||||
background: "#000000",
|
background: "#191919",
|
||||||
surface: "#0b0b0b",
|
surface: "#343232",
|
||||||
elevated: "#111111",
|
elevated: "#111111",
|
||||||
text: "#ffffff",
|
text: "#ffffff",
|
||||||
mutedText: "#b8b8b8",
|
mutedText: "#b8b8b8",
|
||||||
|
|||||||
388
FastNotes/supabase/config.toml
Normal file
388
FastNotes/supabase/config.toml
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
# For detailed configuration reference documentation, visit:
|
||||||
|
# https://supabase.com/docs/guides/local-development/cli/config
|
||||||
|
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||||
|
# working directory name when running `supabase init`.
|
||||||
|
project_id = "FastNotes"
|
||||||
|
|
||||||
|
[api]
|
||||||
|
enabled = true
|
||||||
|
# Port to use for the API URL.
|
||||||
|
port = 54321
|
||||||
|
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||||
|
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||||
|
schemas = ["public", "graphql_public"]
|
||||||
|
# Extra schemas to add to the search_path of every request.
|
||||||
|
extra_search_path = ["public", "extensions"]
|
||||||
|
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||||
|
# for accidental or malicious requests.
|
||||||
|
max_rows = 1000
|
||||||
|
|
||||||
|
[api.tls]
|
||||||
|
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||||
|
enabled = false
|
||||||
|
# Paths to self-signed certificate pair.
|
||||||
|
# cert_path = "../certs/my-cert.pem"
|
||||||
|
# key_path = "../certs/my-key.pem"
|
||||||
|
|
||||||
|
[db]
|
||||||
|
# Port to use for the local database URL.
|
||||||
|
port = 54322
|
||||||
|
# Port used by db diff command to initialize the shadow database.
|
||||||
|
shadow_port = 54320
|
||||||
|
# Maximum amount of time to wait for health check when starting the local database.
|
||||||
|
health_timeout = "2m"
|
||||||
|
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||||
|
# server_version;` on the remote database to check.
|
||||||
|
major_version = 17
|
||||||
|
|
||||||
|
[db.pooler]
|
||||||
|
enabled = false
|
||||||
|
# Port to use for the local connection pooler.
|
||||||
|
port = 54329
|
||||||
|
# Specifies when a server connection can be reused by other clients.
|
||||||
|
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||||
|
pool_mode = "transaction"
|
||||||
|
# How many server connections to allow per user/database pair.
|
||||||
|
default_pool_size = 20
|
||||||
|
# Maximum number of client connections allowed.
|
||||||
|
max_client_conn = 100
|
||||||
|
|
||||||
|
# [db.vault]
|
||||||
|
# secret_key = "env(SECRET_VALUE)"
|
||||||
|
|
||||||
|
[db.migrations]
|
||||||
|
# If disabled, migrations will be skipped during a db push or reset.
|
||||||
|
enabled = true
|
||||||
|
# Specifies an ordered list of schema files that describe your database.
|
||||||
|
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||||
|
schema_paths = []
|
||||||
|
|
||||||
|
[db.seed]
|
||||||
|
# If enabled, seeds the database after migrations during a db reset.
|
||||||
|
enabled = true
|
||||||
|
# Specifies an ordered list of seed files to load during db reset.
|
||||||
|
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||||
|
sql_paths = ["./seed.sql"]
|
||||||
|
|
||||||
|
[db.network_restrictions]
|
||||||
|
# Enable management of network restrictions.
|
||||||
|
enabled = false
|
||||||
|
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||||
|
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||||
|
allowed_cidrs = ["0.0.0.0/0"]
|
||||||
|
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||||
|
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||||
|
allowed_cidrs_v6 = ["::/0"]
|
||||||
|
|
||||||
|
# Uncomment to reject non-secure connections to the database.
|
||||||
|
# [db.ssl_enforcement]
|
||||||
|
# enabled = true
|
||||||
|
|
||||||
|
[realtime]
|
||||||
|
enabled = true
|
||||||
|
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||||
|
# ip_version = "IPv6"
|
||||||
|
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||||
|
# max_header_length = 4096
|
||||||
|
|
||||||
|
[studio]
|
||||||
|
enabled = true
|
||||||
|
# Port to use for Supabase Studio.
|
||||||
|
port = 54323
|
||||||
|
# External URL of the API server that frontend connects to.
|
||||||
|
api_url = "http://127.0.0.1"
|
||||||
|
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||||
|
openai_api_key = "env(OPENAI_API_KEY)"
|
||||||
|
|
||||||
|
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||||
|
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||||
|
[inbucket]
|
||||||
|
enabled = true
|
||||||
|
# Port to use for the email testing server web interface.
|
||||||
|
port = 54324
|
||||||
|
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||||
|
# smtp_port = 54325
|
||||||
|
# pop3_port = 54326
|
||||||
|
# admin_email = "admin@email.com"
|
||||||
|
# sender_name = "Admin"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
enabled = true
|
||||||
|
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||||
|
file_size_limit = "50MiB"
|
||||||
|
|
||||||
|
# Uncomment to configure local storage buckets
|
||||||
|
# [storage.buckets.images]
|
||||||
|
# public = false
|
||||||
|
# file_size_limit = "50MiB"
|
||||||
|
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||||
|
# objects_path = "./images"
|
||||||
|
|
||||||
|
# Allow connections via S3 compatible clients
|
||||||
|
[storage.s3_protocol]
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Image transformation API is available to Supabase Pro plan.
|
||||||
|
# [storage.image_transformation]
|
||||||
|
# enabled = true
|
||||||
|
|
||||||
|
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||||
|
# This feature is only available on the hosted platform.
|
||||||
|
[storage.analytics]
|
||||||
|
enabled = false
|
||||||
|
max_namespaces = 5
|
||||||
|
max_tables = 10
|
||||||
|
max_catalogs = 2
|
||||||
|
|
||||||
|
# Analytics Buckets is available to Supabase Pro plan.
|
||||||
|
# [storage.analytics.buckets.my-warehouse]
|
||||||
|
|
||||||
|
# Store vector embeddings in S3 for large and durable datasets
|
||||||
|
# This feature is only available on the hosted platform.
|
||||||
|
[storage.vector]
|
||||||
|
enabled = false
|
||||||
|
max_buckets = 10
|
||||||
|
max_indexes = 5
|
||||||
|
|
||||||
|
# Vector Buckets is available to Supabase Pro plan.
|
||||||
|
# [storage.vector.buckets.documents-openai]
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
enabled = true
|
||||||
|
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||||
|
# in emails.
|
||||||
|
site_url = "http://127.0.0.1:3000"
|
||||||
|
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||||
|
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||||
|
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||||
|
jwt_expiry = 3600
|
||||||
|
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
|
||||||
|
# jwt_issuer = ""
|
||||||
|
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||||
|
# signing_keys_path = "./signing_keys.json"
|
||||||
|
# If disabled, the refresh token will never expire.
|
||||||
|
enable_refresh_token_rotation = true
|
||||||
|
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||||
|
# Requires enable_refresh_token_rotation = true.
|
||||||
|
refresh_token_reuse_interval = 10
|
||||||
|
# Allow/disallow new user signups to your project.
|
||||||
|
enable_signup = true
|
||||||
|
# Allow/disallow anonymous sign-ins to your project.
|
||||||
|
enable_anonymous_sign_ins = false
|
||||||
|
# Allow/disallow testing manual linking of accounts
|
||||||
|
enable_manual_linking = false
|
||||||
|
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||||
|
minimum_password_length = 6
|
||||||
|
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||||
|
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||||
|
password_requirements = ""
|
||||||
|
|
||||||
|
[auth.rate_limit]
|
||||||
|
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||||
|
email_sent = 2
|
||||||
|
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||||
|
sms_sent = 30
|
||||||
|
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||||
|
anonymous_users = 30
|
||||||
|
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||||
|
token_refresh = 150
|
||||||
|
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||||
|
sign_in_sign_ups = 30
|
||||||
|
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||||
|
token_verifications = 30
|
||||||
|
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||||
|
web3 = 30
|
||||||
|
|
||||||
|
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||||
|
# [auth.captcha]
|
||||||
|
# enabled = true
|
||||||
|
# provider = "hcaptcha"
|
||||||
|
# secret = ""
|
||||||
|
|
||||||
|
[auth.email]
|
||||||
|
# Allow/disallow new user signups via email to your project.
|
||||||
|
enable_signup = true
|
||||||
|
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||||
|
# addresses. If disabled, only the new email is required to confirm.
|
||||||
|
double_confirm_changes = true
|
||||||
|
# If enabled, users need to confirm their email address before signing in.
|
||||||
|
enable_confirmations = false
|
||||||
|
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||||
|
secure_password_change = false
|
||||||
|
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||||
|
max_frequency = "1s"
|
||||||
|
# Number of characters used in the email OTP.
|
||||||
|
otp_length = 6
|
||||||
|
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||||
|
otp_expiry = 3600
|
||||||
|
|
||||||
|
# Use a production-ready SMTP server
|
||||||
|
# [auth.email.smtp]
|
||||||
|
# enabled = true
|
||||||
|
# host = "smtp.sendgrid.net"
|
||||||
|
# port = 587
|
||||||
|
# user = "apikey"
|
||||||
|
# pass = "env(SENDGRID_API_KEY)"
|
||||||
|
# admin_email = "admin@email.com"
|
||||||
|
# sender_name = "Admin"
|
||||||
|
|
||||||
|
# Uncomment to customize email template
|
||||||
|
# [auth.email.template.invite]
|
||||||
|
# subject = "You have been invited"
|
||||||
|
# content_path = "./supabase/templates/invite.html"
|
||||||
|
|
||||||
|
# Uncomment to customize notification email template
|
||||||
|
# [auth.email.notification.password_changed]
|
||||||
|
# enabled = true
|
||||||
|
# subject = "Your password has been changed"
|
||||||
|
# content_path = "./templates/password_changed_notification.html"
|
||||||
|
|
||||||
|
[auth.sms]
|
||||||
|
# Allow/disallow new user signups via SMS to your project.
|
||||||
|
enable_signup = false
|
||||||
|
# If enabled, users need to confirm their phone number before signing in.
|
||||||
|
enable_confirmations = false
|
||||||
|
# Template for sending OTP to users
|
||||||
|
template = "Your code is {{ .Code }}"
|
||||||
|
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||||
|
max_frequency = "5s"
|
||||||
|
|
||||||
|
# Use pre-defined map of phone number to OTP for testing.
|
||||||
|
# [auth.sms.test_otp]
|
||||||
|
# 4152127777 = "123456"
|
||||||
|
|
||||||
|
# Configure logged in session timeouts.
|
||||||
|
# [auth.sessions]
|
||||||
|
# Force log out after the specified duration.
|
||||||
|
# timebox = "24h"
|
||||||
|
# Force log out if the user has been inactive longer than the specified duration.
|
||||||
|
# inactivity_timeout = "8h"
|
||||||
|
|
||||||
|
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||||
|
# [auth.hook.before_user_created]
|
||||||
|
# enabled = true
|
||||||
|
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||||
|
|
||||||
|
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||||
|
# [auth.hook.custom_access_token]
|
||||||
|
# enabled = true
|
||||||
|
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||||
|
|
||||||
|
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||||
|
[auth.sms.twilio]
|
||||||
|
enabled = false
|
||||||
|
account_sid = ""
|
||||||
|
message_service_sid = ""
|
||||||
|
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||||
|
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||||
|
|
||||||
|
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||||
|
[auth.mfa]
|
||||||
|
# Control how many MFA factors can be enrolled at once per user.
|
||||||
|
max_enrolled_factors = 10
|
||||||
|
|
||||||
|
# Control MFA via App Authenticator (TOTP)
|
||||||
|
[auth.mfa.totp]
|
||||||
|
enroll_enabled = false
|
||||||
|
verify_enabled = false
|
||||||
|
|
||||||
|
# Configure MFA via Phone Messaging
|
||||||
|
[auth.mfa.phone]
|
||||||
|
enroll_enabled = false
|
||||||
|
verify_enabled = false
|
||||||
|
otp_length = 6
|
||||||
|
template = "Your code is {{ .Code }}"
|
||||||
|
max_frequency = "5s"
|
||||||
|
|
||||||
|
# Configure MFA via WebAuthn
|
||||||
|
# [auth.mfa.web_authn]
|
||||||
|
# enroll_enabled = true
|
||||||
|
# verify_enabled = true
|
||||||
|
|
||||||
|
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||||
|
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||||
|
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
||||||
|
[auth.external.apple]
|
||||||
|
enabled = false
|
||||||
|
client_id = ""
|
||||||
|
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||||
|
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||||
|
# Overrides the default auth redirectUrl.
|
||||||
|
redirect_uri = ""
|
||||||
|
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||||
|
# or any other third-party OIDC providers.
|
||||||
|
url = ""
|
||||||
|
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||||
|
skip_nonce_check = false
|
||||||
|
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||||
|
email_optional = false
|
||||||
|
|
||||||
|
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||||
|
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||||
|
[auth.web3.solana]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.firebase]
|
||||||
|
enabled = false
|
||||||
|
# project_id = "my-firebase-project"
|
||||||
|
|
||||||
|
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.auth0]
|
||||||
|
enabled = false
|
||||||
|
# tenant = "my-auth0-tenant"
|
||||||
|
# tenant_region = "us"
|
||||||
|
|
||||||
|
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.aws_cognito]
|
||||||
|
enabled = false
|
||||||
|
# user_pool_id = "my-user-pool-id"
|
||||||
|
# user_pool_region = "us-east-1"
|
||||||
|
|
||||||
|
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||||
|
[auth.third_party.clerk]
|
||||||
|
enabled = false
|
||||||
|
# Obtain from https://clerk.com/setup/supabase
|
||||||
|
# domain = "example.clerk.accounts.dev"
|
||||||
|
|
||||||
|
# OAuth server configuration
|
||||||
|
[auth.oauth_server]
|
||||||
|
# Enable OAuth server functionality
|
||||||
|
enabled = false
|
||||||
|
# Path for OAuth consent flow UI
|
||||||
|
authorization_url_path = "/oauth/consent"
|
||||||
|
# Allow dynamic client registration
|
||||||
|
allow_dynamic_registration = false
|
||||||
|
|
||||||
|
[edge_runtime]
|
||||||
|
enabled = true
|
||||||
|
# Supported request policies: `oneshot`, `per_worker`.
|
||||||
|
# `per_worker` (default) — enables hot reload during local development.
|
||||||
|
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||||
|
policy = "per_worker"
|
||||||
|
# Port to attach the Chrome inspector for debugging edge functions.
|
||||||
|
inspector_port = 8083
|
||||||
|
# The Deno major version to use.
|
||||||
|
deno_version = 2
|
||||||
|
|
||||||
|
# [edge_runtime.secrets]
|
||||||
|
# secret_key = "env(SECRET_VALUE)"
|
||||||
|
|
||||||
|
[analytics]
|
||||||
|
enabled = true
|
||||||
|
port = 54327
|
||||||
|
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||||
|
backend = "postgres"
|
||||||
|
|
||||||
|
# Experimental features may be deprecated any time
|
||||||
|
[experimental]
|
||||||
|
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||||
|
orioledb_version = ""
|
||||||
|
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||||
|
s3_host = "env(S3_HOST)"
|
||||||
|
# Configures S3 bucket region, eg. us-east-1
|
||||||
|
s3_region = "env(S3_REGION)"
|
||||||
|
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||||
|
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||||
|
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||||
|
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||||
164
FastNotes/supabase/functions/push/index.ts
Normal file
164
FastNotes/supabase/functions/push/index.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { createClient } from "npm:@supabase/supabase-js@2"
|
||||||
|
|
||||||
|
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""
|
||||||
|
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
|
||||||
|
const EXPO_ACCESS_TOKEN = Deno.env.get("EXPO_ACCESS_TOKEN") ?? ""
|
||||||
|
|
||||||
|
type NoteRecord = {
|
||||||
|
id: number | string
|
||||||
|
created_by: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseWebhookPayload = {
|
||||||
|
type: "INSERT" | "UPDATE" | "DELETE"
|
||||||
|
table: string
|
||||||
|
schema: string
|
||||||
|
record?: NoteRecord | null
|
||||||
|
old_record?: NoteRecord | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpoPushMessage = {
|
||||||
|
to: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
sound: "default"
|
||||||
|
data: {
|
||||||
|
type: "new-note"
|
||||||
|
noteId: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: Record<string, unknown>) {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkMessages<T>(items: T[], size: number) {
|
||||||
|
const chunks: T[][] = []
|
||||||
|
|
||||||
|
for (let index = 0; index < items.length; index += size) {
|
||||||
|
chunks.push(items.slice(index, index + size))
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCreatorEmail(userId: string) {
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("email")
|
||||||
|
.eq("id", userId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (profile?.email) {
|
||||||
|
return profile.email as string
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.auth.admin.getUserById(userId)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to load note creator:", error.message)
|
||||||
|
return "unknown user"
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.user.email ?? "unknown user"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecipientTokens(userId: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("user_push_tokens")
|
||||||
|
.select("push_token")
|
||||||
|
.neq("user_id", userId)
|
||||||
|
.eq("is_active", true)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set((data ?? []).map((row) => row.push_token as string).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EXPO_ACCESS_TOKEN) {
|
||||||
|
headers.Authorization = `Bearer ${EXPO_ACCESS_TOKEN}`
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chunk of chunkMessages(messages, 100)) {
|
||||||
|
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(chunk),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.text()
|
||||||
|
throw new Error(`Expo push request failed with ${response.status}: ${errorBody}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return jsonResponse(405, { error: "Method not allowed" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||||
|
return jsonResponse(500, { error: "Missing Supabase environment variables." })
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: DatabaseWebhookPayload
|
||||||
|
|
||||||
|
try {
|
||||||
|
payload = await request.json()
|
||||||
|
} catch {
|
||||||
|
return jsonResponse(400, { error: "Invalid JSON payload." })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type !== "INSERT" || payload.table !== "Notes" || !payload.record) {
|
||||||
|
return jsonResponse(200, { ignored: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const note = payload.record
|
||||||
|
const [creatorEmail, recipientTokens] = await Promise.all([
|
||||||
|
loadCreatorEmail(note.created_by),
|
||||||
|
loadRecipientTokens(note.created_by),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (recipientTokens.length === 0) {
|
||||||
|
return jsonResponse(200, { sent: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `New note: "${note.title}" by ${creatorEmail}`
|
||||||
|
const messages: ExpoPushMessage[] = recipientTokens.map((token) => ({
|
||||||
|
to: token,
|
||||||
|
title: "FastNotes",
|
||||||
|
body,
|
||||||
|
sound: "default",
|
||||||
|
data: {
|
||||||
|
type: "new-note",
|
||||||
|
noteId: String(note.id),
|
||||||
|
title: note.title,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
await sendExpoPushNotifications(messages)
|
||||||
|
|
||||||
|
return jsonResponse(200, { sent: messages.length })
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unexpected error."
|
||||||
|
console.error("Push notification webhook failed:", message)
|
||||||
|
return jsonResponse(500, { error: message })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Image } from 'expo-image'
|
|
||||||
import { StyleSheet } from 'react-native'
|
|
||||||
|
|
||||||
import { HelloWave } from '@/components/hello-wave'
|
|
||||||
import ParallaxScrollView from '@/components/parallax-scroll-view'
|
|
||||||
import { ThemedText } from '@/components/themed-text'
|
|
||||||
import { ThemedView } from '@/components/themed-view'
|
|
||||||
import SignOutButton from '@/components/social-auth-buttons/sign-out-button'
|
|
||||||
import { useAuthContext } from '@/hooks/use-auth-context'
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
const { profile } = useAuthContext()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
|
|
||||||
headerImage={
|
|
||||||
<Image
|
|
||||||
source={require('@/assets/images/partial-react-logo.png')}
|
|
||||||
style={styles.reactLogo}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ThemedView style={styles.titleContainer}>
|
|
||||||
<ThemedText type="title">Welcome!</ThemedText>
|
|
||||||
<HelloWave />
|
|
||||||
</ThemedView>
|
|
||||||
<ThemedView style={styles.stepContainer}>
|
|
||||||
<ThemedText type="subtitle">Username</ThemedText>
|
|
||||||
<ThemedText>{profile?.username}</ThemedText>
|
|
||||||
<ThemedText type="subtitle">Full name</ThemedText>
|
|
||||||
<ThemedText>{profile?.full_name}</ThemedText>
|
|
||||||
</ThemedView>
|
|
||||||
<SignOutButton />
|
|
||||||
</ParallaxScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
titleContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
stepContainer: {
|
|
||||||
gap: 8,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
reactLogo: {
|
|
||||||
height: 178,
|
|
||||||
width: 290,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
position: 'absolute',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user