Delete mobile
Browse files- mobile/.env.example +0 -10
- mobile/app.json +0 -60
- mobile/app/(auth)/_layout.tsx +0 -12
- mobile/app/(auth)/forgot-password.tsx +0 -94
- mobile/app/(auth)/login.tsx +0 -207
- mobile/app/(auth)/register.tsx +0 -176
- mobile/app/(tabs)/_layout.tsx +0 -60
- mobile/app/(tabs)/albums.tsx +0 -182
- mobile/app/(tabs)/gallery.tsx +0 -270
- mobile/app/(tabs)/settings.tsx +0 -258
- mobile/app/_layout.tsx +0 -60
- mobile/hooks/useAlbums.ts +0 -49
- mobile/hooks/usePhotos.ts +0 -70
- mobile/hooks/useSync.ts +0 -26
- mobile/package.json +0 -47
- mobile/services/api.ts +0 -164
- mobile/services/backgroundSync.ts +0 -73
- mobile/services/hashUtil.ts +0 -38
- mobile/services/supabase.ts +0 -16
- mobile/services/syncEngine.ts +0 -157
- mobile/store/authStore.ts +0 -84
- mobile/store/syncStore.ts +0 -92
- mobile/tsconfig.json +0 -9
mobile/.env.example
DELETED
|
@@ -1,10 +0,0 @@
|
|
| 1 |
-
# Mobile App Environment Variables
|
| 2 |
-
# Copy this file to .env and fill in your values
|
| 3 |
-
|
| 4 |
-
# KeyStone Backend API URL
|
| 5 |
-
EXPO_PUBLIC_API_URL=https://dpv007-keystone.hf.space
|
| 6 |
-
|
| 7 |
-
# Supabase
|
| 8 |
-
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImtmZmFuaGtiZGJhYnhhbndubW1lIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODQyMTExNTYsImV4cCI6MjA5OTc4NzE1Nn0.W4LgjVQekUxlkz2ZPTg4BpckCBp_Z7QEKXN-XOH1CvE
|
| 9 |
-
EXPO_PUBLIC_SUPABASE_URL=https://kffanhkbdbabxanwnmme.supabase.co
|
| 10 |
-
EXPO_PUBLIC_SUPABASE_KEY=sb_publishable_pTshogCZaZas2Im0POcPDw_fkMdTqSe
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app.json
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"expo": {
|
| 3 |
-
"name": "KeyStone",
|
| 4 |
-
"slug": "keystone",
|
| 5 |
-
"version": "1.0.0",
|
| 6 |
-
"orientation": "portrait",
|
| 7 |
-
"icon": "./assets/icon.png",
|
| 8 |
-
"scheme": "keystone",
|
| 9 |
-
"userInterfaceStyle": "automatic",
|
| 10 |
-
"splash": {
|
| 11 |
-
"image": "./assets/splash.png",
|
| 12 |
-
"resizeMode": "contain",
|
| 13 |
-
"backgroundColor": "#0f0f23"
|
| 14 |
-
},
|
| 15 |
-
"assetBundlePatterns": ["**/*"],
|
| 16 |
-
"ios": {
|
| 17 |
-
"supportsTablet": true,
|
| 18 |
-
"bundleIdentifier": "com.dpv007.keystone",
|
| 19 |
-
"infoPlist": {
|
| 20 |
-
"NSPhotoLibraryUsageDescription": "KeyStone needs access to your photo library to back up your photos.",
|
| 21 |
-
"NSPhotoLibraryAddUsageDescription": "KeyStone needs access to save photos.",
|
| 22 |
-
"UIBackgroundModes": ["fetch", "processing"]
|
| 23 |
-
}
|
| 24 |
-
},
|
| 25 |
-
"android": {
|
| 26 |
-
"adaptiveIcon": {
|
| 27 |
-
"foregroundImage": "./assets/adaptive-icon.png",
|
| 28 |
-
"backgroundColor": "#0f0f23"
|
| 29 |
-
},
|
| 30 |
-
"package": "com.dpv007.keystone",
|
| 31 |
-
"permissions": [
|
| 32 |
-
"android.permission.READ_MEDIA_IMAGES",
|
| 33 |
-
"android.permission.READ_MEDIA_VIDEO",
|
| 34 |
-
"android.permission.READ_EXTERNAL_STORAGE",
|
| 35 |
-
"android.permission.ACCESS_NETWORK_STATE",
|
| 36 |
-
"android.permission.RECEIVE_BOOT_COMPLETED",
|
| 37 |
-
"android.permission.FOREGROUND_SERVICE"
|
| 38 |
-
]
|
| 39 |
-
},
|
| 40 |
-
"web": {
|
| 41 |
-
"bundler": "metro",
|
| 42 |
-
"output": "static",
|
| 43 |
-
"favicon": "./assets/favicon.png"
|
| 44 |
-
},
|
| 45 |
-
"plugins": [
|
| 46 |
-
"expo-router",
|
| 47 |
-
[
|
| 48 |
-
"expo-media-library",
|
| 49 |
-
{
|
| 50 |
-
"photosPermission": "Allow KeyStone to access your photos.",
|
| 51 |
-
"savePhotosPermission": "Allow KeyStone to save photos.",
|
| 52 |
-
"isAccessMediaLocationEnabled": true
|
| 53 |
-
}
|
| 54 |
-
]
|
| 55 |
-
],
|
| 56 |
-
"experiments": {
|
| 57 |
-
"typedRoutes": true
|
| 58 |
-
}
|
| 59 |
-
}
|
| 60 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(auth)/_layout.tsx
DELETED
|
@@ -1,12 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Auth Layout
|
| 3 |
-
* Wraps all auth screens with a shared gradient background.
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import { Stack } from 'expo-router';
|
| 7 |
-
|
| 8 |
-
export default function AuthLayout() {
|
| 9 |
-
return (
|
| 10 |
-
<Stack screenOptions={{ headerShown: false }} />
|
| 11 |
-
);
|
| 12 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(auth)/forgot-password.tsx
DELETED
|
@@ -1,94 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Forgot Password Screen
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import {
|
| 6 |
-
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
| 7 |
-
KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator,
|
| 8 |
-
} from 'react-native';
|
| 9 |
-
import { useState } from 'react';
|
| 10 |
-
import { Link } from 'expo-router';
|
| 11 |
-
import { LinearGradient } from 'expo-linear-gradient';
|
| 12 |
-
import { useAuthStore } from '../../store/authStore';
|
| 13 |
-
|
| 14 |
-
export default function ForgotPasswordScreen() {
|
| 15 |
-
const [email, setEmail] = useState('');
|
| 16 |
-
const [error, setError] = useState('');
|
| 17 |
-
const [sent, setSent] = useState(false);
|
| 18 |
-
const { resetPassword, isLoading } = useAuthStore();
|
| 19 |
-
|
| 20 |
-
const handleReset = async () => {
|
| 21 |
-
setError('');
|
| 22 |
-
if (!email) { setError('Please enter your email address.'); return; }
|
| 23 |
-
try {
|
| 24 |
-
await resetPassword(email);
|
| 25 |
-
setSent(true);
|
| 26 |
-
} catch (err: any) {
|
| 27 |
-
setError(err.message || 'Failed to send reset email.');
|
| 28 |
-
}
|
| 29 |
-
};
|
| 30 |
-
|
| 31 |
-
return (
|
| 32 |
-
<LinearGradient colors={['#0f0f23', '#1a0a2e', '#16213e']} style={styles.gradient}>
|
| 33 |
-
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.flex}>
|
| 34 |
-
<ScrollView contentContainerStyle={styles.scroll}>
|
| 35 |
-
<View style={styles.container}>
|
| 36 |
-
<View style={styles.card}>
|
| 37 |
-
<Text style={styles.cardTitle}>{sent ? '📨 Email sent!' : '🔑 Reset password'}</Text>
|
| 38 |
-
<Text style={styles.cardSubtitle}>
|
| 39 |
-
{sent
|
| 40 |
-
? `We sent a password reset link to ${email}.`
|
| 41 |
-
: "Enter your email and we'll send you a reset link."}
|
| 42 |
-
</Text>
|
| 43 |
-
|
| 44 |
-
{!sent && (
|
| 45 |
-
<>
|
| 46 |
-
{error ? <View style={styles.errorBox}><Text style={styles.errorText}>{error}</Text></View> : null}
|
| 47 |
-
<View style={styles.inputGroup}>
|
| 48 |
-
<Text style={styles.label}>Email</Text>
|
| 49 |
-
<TextInput
|
| 50 |
-
style={styles.input}
|
| 51 |
-
placeholder="you@example.com"
|
| 52 |
-
placeholderTextColor="#4a4a6a"
|
| 53 |
-
value={email}
|
| 54 |
-
onChangeText={setEmail}
|
| 55 |
-
autoCapitalize="none"
|
| 56 |
-
keyboardType="email-address"
|
| 57 |
-
/>
|
| 58 |
-
</View>
|
| 59 |
-
<TouchableOpacity style={[styles.btn, isLoading && styles.btnDisabled]} onPress={handleReset} disabled={isLoading} activeOpacity={0.85}>
|
| 60 |
-
<LinearGradient colors={['#7c3aed', '#4f46e5']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.btnGradient}>
|
| 61 |
-
{isLoading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>Send Reset Link</Text>}
|
| 62 |
-
</LinearGradient>
|
| 63 |
-
</TouchableOpacity>
|
| 64 |
-
</>
|
| 65 |
-
)}
|
| 66 |
-
|
| 67 |
-
<Link href="/(auth)/login" style={styles.backLink}>← Back to Sign In</Link>
|
| 68 |
-
</View>
|
| 69 |
-
</View>
|
| 70 |
-
</ScrollView>
|
| 71 |
-
</KeyboardAvoidingView>
|
| 72 |
-
</LinearGradient>
|
| 73 |
-
);
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
const styles = StyleSheet.create({
|
| 77 |
-
gradient: { flex: 1 },
|
| 78 |
-
flex: { flex: 1 },
|
| 79 |
-
scroll: { flexGrow: 1, justifyContent: 'center' },
|
| 80 |
-
container: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20 },
|
| 81 |
-
card: { width: '100%', maxWidth: 420, backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', borderRadius: 24, padding: 28 },
|
| 82 |
-
cardTitle: { fontSize: 22, fontWeight: '700', color: '#fff', marginBottom: 8 },
|
| 83 |
-
cardSubtitle: { fontSize: 14, color: '#8888aa', marginBottom: 24, lineHeight: 20 },
|
| 84 |
-
errorBox: { backgroundColor: 'rgba(239,68,68,0.15)', borderWidth: 1, borderColor: 'rgba(239,68,68,0.3)', borderRadius: 10, padding: 12, marginBottom: 16 },
|
| 85 |
-
errorText: { color: '#fca5a5', fontSize: 13 },
|
| 86 |
-
inputGroup: { marginBottom: 16 },
|
| 87 |
-
label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 },
|
| 88 |
-
input: { backgroundColor: 'rgba(255,255,255,0.06)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, color: '#fff', fontSize: 15 },
|
| 89 |
-
btn: { borderRadius: 14, overflow: 'hidden', marginBottom: 20 },
|
| 90 |
-
btnDisabled: { opacity: 0.6 },
|
| 91 |
-
btnGradient: { paddingVertical: 15, alignItems: 'center', borderRadius: 14 },
|
| 92 |
-
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
| 93 |
-
backLink: { color: '#7c3aed', fontSize: 14, textAlign: 'center', marginTop: 8 },
|
| 94 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(auth)/login.tsx
DELETED
|
@@ -1,207 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Login Screen
|
| 3 |
-
* Works on Android, iOS, and Web (React Native Web).
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import {
|
| 7 |
-
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
| 8 |
-
KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator,
|
| 9 |
-
Alert, Dimensions,
|
| 10 |
-
} from 'react-native';
|
| 11 |
-
import { useState } from 'react';
|
| 12 |
-
import { Link, useRouter } from 'expo-router';
|
| 13 |
-
import { LinearGradient } from 'expo-linear-gradient';
|
| 14 |
-
import { useAuthStore } from '../../store/authStore';
|
| 15 |
-
|
| 16 |
-
const { width } = Dimensions.get('window');
|
| 17 |
-
const isWeb = Platform.OS === 'web';
|
| 18 |
-
|
| 19 |
-
export default function LoginScreen() {
|
| 20 |
-
const [email, setEmail] = useState('');
|
| 21 |
-
const [password, setPassword] = useState('');
|
| 22 |
-
const [error, setError] = useState('');
|
| 23 |
-
const { signIn, isLoading } = useAuthStore();
|
| 24 |
-
const router = useRouter();
|
| 25 |
-
|
| 26 |
-
const handleLogin = async () => {
|
| 27 |
-
setError('');
|
| 28 |
-
if (!email || !password) {
|
| 29 |
-
setError('Please fill in all fields.');
|
| 30 |
-
return;
|
| 31 |
-
}
|
| 32 |
-
try {
|
| 33 |
-
await signIn(email, password);
|
| 34 |
-
router.replace('/(tabs)/gallery');
|
| 35 |
-
} catch (err: any) {
|
| 36 |
-
setError(err.message || 'Login failed. Please try again.');
|
| 37 |
-
}
|
| 38 |
-
};
|
| 39 |
-
|
| 40 |
-
return (
|
| 41 |
-
<LinearGradient colors={['#0f0f23', '#1a0a2e', '#16213e']} style={styles.gradient}>
|
| 42 |
-
<KeyboardAvoidingView
|
| 43 |
-
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
| 44 |
-
style={styles.flex}
|
| 45 |
-
>
|
| 46 |
-
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
| 47 |
-
<View style={styles.container}>
|
| 48 |
-
|
| 49 |
-
{/* Logo / Brand */}
|
| 50 |
-
<View style={styles.logoArea}>
|
| 51 |
-
<View style={styles.logoIcon}>
|
| 52 |
-
<Text style={styles.logoEmoji}>🔷</Text>
|
| 53 |
-
</View>
|
| 54 |
-
<Text style={styles.brandName}>KeyStone</Text>
|
| 55 |
-
<Text style={styles.brandTagline}>Your private photo cloud</Text>
|
| 56 |
-
</View>
|
| 57 |
-
|
| 58 |
-
{/* Card */}
|
| 59 |
-
<View style={styles.card}>
|
| 60 |
-
<Text style={styles.cardTitle}>Welcome back</Text>
|
| 61 |
-
<Text style={styles.cardSubtitle}>Sign in to your account</Text>
|
| 62 |
-
|
| 63 |
-
{error ? (
|
| 64 |
-
<View style={styles.errorBox}>
|
| 65 |
-
<Text style={styles.errorText}>{error}</Text>
|
| 66 |
-
</View>
|
| 67 |
-
) : null}
|
| 68 |
-
|
| 69 |
-
<View style={styles.inputGroup}>
|
| 70 |
-
<Text style={styles.label}>Email</Text>
|
| 71 |
-
<TextInput
|
| 72 |
-
style={styles.input}
|
| 73 |
-
placeholder="you@example.com"
|
| 74 |
-
placeholderTextColor="#4a4a6a"
|
| 75 |
-
value={email}
|
| 76 |
-
onChangeText={setEmail}
|
| 77 |
-
autoCapitalize="none"
|
| 78 |
-
keyboardType="email-address"
|
| 79 |
-
autoComplete="email"
|
| 80 |
-
/>
|
| 81 |
-
</View>
|
| 82 |
-
|
| 83 |
-
<View style={styles.inputGroup}>
|
| 84 |
-
<View style={styles.labelRow}>
|
| 85 |
-
<Text style={styles.label}>Password</Text>
|
| 86 |
-
<Link href="/(auth)/forgot-password" style={styles.forgotLink}>
|
| 87 |
-
Forgot password?
|
| 88 |
-
</Link>
|
| 89 |
-
</View>
|
| 90 |
-
<TextInput
|
| 91 |
-
style={styles.input}
|
| 92 |
-
placeholder="••••••••"
|
| 93 |
-
placeholderTextColor="#4a4a6a"
|
| 94 |
-
value={password}
|
| 95 |
-
onChangeText={setPassword}
|
| 96 |
-
secureTextEntry
|
| 97 |
-
autoComplete="current-password"
|
| 98 |
-
/>
|
| 99 |
-
</View>
|
| 100 |
-
|
| 101 |
-
<TouchableOpacity
|
| 102 |
-
style={[styles.btn, isLoading && styles.btnDisabled]}
|
| 103 |
-
onPress={handleLogin}
|
| 104 |
-
disabled={isLoading}
|
| 105 |
-
activeOpacity={0.85}
|
| 106 |
-
>
|
| 107 |
-
<LinearGradient
|
| 108 |
-
colors={['#7c3aed', '#4f46e5']}
|
| 109 |
-
start={{ x: 0, y: 0 }}
|
| 110 |
-
end={{ x: 1, y: 0 }}
|
| 111 |
-
style={styles.btnGradient}
|
| 112 |
-
>
|
| 113 |
-
{isLoading ? (
|
| 114 |
-
<ActivityIndicator color="#fff" />
|
| 115 |
-
) : (
|
| 116 |
-
<Text style={styles.btnText}>Sign In</Text>
|
| 117 |
-
)}
|
| 118 |
-
</LinearGradient>
|
| 119 |
-
</TouchableOpacity>
|
| 120 |
-
|
| 121 |
-
<View style={styles.footerRow}>
|
| 122 |
-
<Text style={styles.footerText}>Don't have an account? </Text>
|
| 123 |
-
<Link href="/(auth)/register" style={styles.footerLink}>
|
| 124 |
-
Sign up
|
| 125 |
-
</Link>
|
| 126 |
-
</View>
|
| 127 |
-
</View>
|
| 128 |
-
|
| 129 |
-
</View>
|
| 130 |
-
</ScrollView>
|
| 131 |
-
</KeyboardAvoidingView>
|
| 132 |
-
</LinearGradient>
|
| 133 |
-
);
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
const styles = StyleSheet.create({
|
| 137 |
-
gradient: { flex: 1 },
|
| 138 |
-
flex: { flex: 1 },
|
| 139 |
-
scroll: { flexGrow: 1, justifyContent: 'center' },
|
| 140 |
-
container: {
|
| 141 |
-
flex: 1,
|
| 142 |
-
alignItems: 'center',
|
| 143 |
-
justifyContent: 'center',
|
| 144 |
-
paddingHorizontal: 20,
|
| 145 |
-
paddingVertical: 40,
|
| 146 |
-
},
|
| 147 |
-
logoArea: { alignItems: 'center', marginBottom: 40 },
|
| 148 |
-
logoIcon: {
|
| 149 |
-
width: 72, height: 72,
|
| 150 |
-
borderRadius: 20,
|
| 151 |
-
backgroundColor: 'rgba(124,58,237,0.2)',
|
| 152 |
-
borderWidth: 1,
|
| 153 |
-
borderColor: 'rgba(124,58,237,0.5)',
|
| 154 |
-
alignItems: 'center',
|
| 155 |
-
justifyContent: 'center',
|
| 156 |
-
marginBottom: 12,
|
| 157 |
-
},
|
| 158 |
-
logoEmoji: { fontSize: 36 },
|
| 159 |
-
brandName: { fontSize: 32, fontWeight: '800', color: '#fff', letterSpacing: -0.5 },
|
| 160 |
-
brandTagline: { fontSize: 14, color: '#8888aa', marginTop: 4 },
|
| 161 |
-
|
| 162 |
-
card: {
|
| 163 |
-
width: '100%',
|
| 164 |
-
maxWidth: 420,
|
| 165 |
-
backgroundColor: 'rgba(255,255,255,0.04)',
|
| 166 |
-
borderWidth: 1,
|
| 167 |
-
borderColor: 'rgba(255,255,255,0.08)',
|
| 168 |
-
borderRadius: 24,
|
| 169 |
-
padding: 28,
|
| 170 |
-
},
|
| 171 |
-
cardTitle: { fontSize: 24, fontWeight: '700', color: '#fff', marginBottom: 4 },
|
| 172 |
-
cardSubtitle: { fontSize: 14, color: '#8888aa', marginBottom: 24 },
|
| 173 |
-
|
| 174 |
-
errorBox: {
|
| 175 |
-
backgroundColor: 'rgba(239,68,68,0.15)',
|
| 176 |
-
borderWidth: 1,
|
| 177 |
-
borderColor: 'rgba(239,68,68,0.3)',
|
| 178 |
-
borderRadius: 10,
|
| 179 |
-
padding: 12,
|
| 180 |
-
marginBottom: 16,
|
| 181 |
-
},
|
| 182 |
-
errorText: { color: '#fca5a5', fontSize: 13 },
|
| 183 |
-
|
| 184 |
-
inputGroup: { marginBottom: 16 },
|
| 185 |
-
labelRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
|
| 186 |
-
label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 },
|
| 187 |
-
forgotLink: { fontSize: 12, color: '#7c3aed' },
|
| 188 |
-
input: {
|
| 189 |
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
| 190 |
-
borderWidth: 1,
|
| 191 |
-
borderColor: 'rgba(255,255,255,0.1)',
|
| 192 |
-
borderRadius: 12,
|
| 193 |
-
paddingHorizontal: 16,
|
| 194 |
-
paddingVertical: 13,
|
| 195 |
-
color: '#fff',
|
| 196 |
-
fontSize: 15,
|
| 197 |
-
},
|
| 198 |
-
|
| 199 |
-
btn: { borderRadius: 14, overflow: 'hidden', marginTop: 8 },
|
| 200 |
-
btnDisabled: { opacity: 0.6 },
|
| 201 |
-
btnGradient: { paddingVertical: 15, alignItems: 'center' },
|
| 202 |
-
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
| 203 |
-
|
| 204 |
-
footerRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
| 205 |
-
footerText: { color: '#8888aa', fontSize: 13 },
|
| 206 |
-
footerLink: { color: '#7c3aed', fontWeight: '600', fontSize: 13 },
|
| 207 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(auth)/register.tsx
DELETED
|
@@ -1,176 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Register Screen
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import {
|
| 6 |
-
View, Text, TextInput, TouchableOpacity, StyleSheet,
|
| 7 |
-
KeyboardAvoidingView, Platform, ScrollView, ActivityIndicator,
|
| 8 |
-
} from 'react-native';
|
| 9 |
-
import { useState } from 'react';
|
| 10 |
-
import { Link, useRouter } from 'expo-router';
|
| 11 |
-
import { LinearGradient } from 'expo-linear-gradient';
|
| 12 |
-
import { useAuthStore } from '../../store/authStore';
|
| 13 |
-
|
| 14 |
-
export default function RegisterScreen() {
|
| 15 |
-
const [email, setEmail] = useState('');
|
| 16 |
-
const [password, setPassword] = useState('');
|
| 17 |
-
const [confirm, setConfirm] = useState('');
|
| 18 |
-
const [error, setError] = useState('');
|
| 19 |
-
const [success, setSuccess] = useState(false);
|
| 20 |
-
const { signUp, isLoading } = useAuthStore();
|
| 21 |
-
const router = useRouter();
|
| 22 |
-
|
| 23 |
-
const handleRegister = async () => {
|
| 24 |
-
setError('');
|
| 25 |
-
if (!email || !password || !confirm) {
|
| 26 |
-
setError('Please fill in all fields.');
|
| 27 |
-
return;
|
| 28 |
-
}
|
| 29 |
-
if (password.length < 8) {
|
| 30 |
-
setError('Password must be at least 8 characters.');
|
| 31 |
-
return;
|
| 32 |
-
}
|
| 33 |
-
if (password !== confirm) {
|
| 34 |
-
setError('Passwords do not match.');
|
| 35 |
-
return;
|
| 36 |
-
}
|
| 37 |
-
try {
|
| 38 |
-
await signUp(email, password);
|
| 39 |
-
setSuccess(true);
|
| 40 |
-
} catch (err: any) {
|
| 41 |
-
setError(err.message || 'Registration failed. Please try again.');
|
| 42 |
-
}
|
| 43 |
-
};
|
| 44 |
-
|
| 45 |
-
if (success) {
|
| 46 |
-
return (
|
| 47 |
-
<LinearGradient colors={['#0f0f23', '#1a0a2e', '#16213e']} style={styles.gradient}>
|
| 48 |
-
<View style={styles.successContainer}>
|
| 49 |
-
<Text style={styles.successEmoji}>📬</Text>
|
| 50 |
-
<Text style={styles.successTitle}>Check your email!</Text>
|
| 51 |
-
<Text style={styles.successText}>
|
| 52 |
-
We've sent a confirmation link to {email}. Click it to activate your account.
|
| 53 |
-
</Text>
|
| 54 |
-
<TouchableOpacity onPress={() => router.replace('/(auth)/login')}>
|
| 55 |
-
<LinearGradient colors={['#7c3aed', '#4f46e5']} style={styles.btnGradient}>
|
| 56 |
-
<Text style={styles.btnText}>Back to Sign In</Text>
|
| 57 |
-
</LinearGradient>
|
| 58 |
-
</TouchableOpacity>
|
| 59 |
-
</View>
|
| 60 |
-
</LinearGradient>
|
| 61 |
-
);
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
return (
|
| 65 |
-
<LinearGradient colors={['#0f0f23', '#1a0a2e', '#16213e']} style={styles.gradient}>
|
| 66 |
-
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.flex}>
|
| 67 |
-
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
|
| 68 |
-
<View style={styles.container}>
|
| 69 |
-
<View style={styles.logoArea}>
|
| 70 |
-
<View style={styles.logoIcon}>
|
| 71 |
-
<Text style={styles.logoEmoji}>🔷</Text>
|
| 72 |
-
</View>
|
| 73 |
-
<Text style={styles.brandName}>KeyStone</Text>
|
| 74 |
-
<Text style={styles.brandTagline}>Create your private cloud</Text>
|
| 75 |
-
</View>
|
| 76 |
-
|
| 77 |
-
<View style={styles.card}>
|
| 78 |
-
<Text style={styles.cardTitle}>Create account</Text>
|
| 79 |
-
<Text style={styles.cardSubtitle}>Start backing up your photos today</Text>
|
| 80 |
-
|
| 81 |
-
{error ? (
|
| 82 |
-
<View style={styles.errorBox}>
|
| 83 |
-
<Text style={styles.errorText}>{error}</Text>
|
| 84 |
-
</View>
|
| 85 |
-
) : null}
|
| 86 |
-
|
| 87 |
-
<View style={styles.inputGroup}>
|
| 88 |
-
<Text style={styles.label}>Email</Text>
|
| 89 |
-
<TextInput
|
| 90 |
-
style={styles.input}
|
| 91 |
-
placeholder="you@example.com"
|
| 92 |
-
placeholderTextColor="#4a4a6a"
|
| 93 |
-
value={email}
|
| 94 |
-
onChangeText={setEmail}
|
| 95 |
-
autoCapitalize="none"
|
| 96 |
-
keyboardType="email-address"
|
| 97 |
-
/>
|
| 98 |
-
</View>
|
| 99 |
-
|
| 100 |
-
<View style={styles.inputGroup}>
|
| 101 |
-
<Text style={styles.label}>Password</Text>
|
| 102 |
-
<TextInput
|
| 103 |
-
style={styles.input}
|
| 104 |
-
placeholder="Min. 8 characters"
|
| 105 |
-
placeholderTextColor="#4a4a6a"
|
| 106 |
-
value={password}
|
| 107 |
-
onChangeText={setPassword}
|
| 108 |
-
secureTextEntry
|
| 109 |
-
/>
|
| 110 |
-
</View>
|
| 111 |
-
|
| 112 |
-
<View style={styles.inputGroup}>
|
| 113 |
-
<Text style={styles.label}>Confirm Password</Text>
|
| 114 |
-
<TextInput
|
| 115 |
-
style={styles.input}
|
| 116 |
-
placeholder="Repeat password"
|
| 117 |
-
placeholderTextColor="#4a4a6a"
|
| 118 |
-
value={confirm}
|
| 119 |
-
onChangeText={setConfirm}
|
| 120 |
-
secureTextEntry
|
| 121 |
-
/>
|
| 122 |
-
</View>
|
| 123 |
-
|
| 124 |
-
<TouchableOpacity
|
| 125 |
-
style={[styles.btn, isLoading && styles.btnDisabled]}
|
| 126 |
-
onPress={handleRegister}
|
| 127 |
-
disabled={isLoading}
|
| 128 |
-
activeOpacity={0.85}
|
| 129 |
-
>
|
| 130 |
-
<LinearGradient colors={['#7c3aed', '#4f46e5']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.btnGradient}>
|
| 131 |
-
{isLoading ? <ActivityIndicator color="#fff" /> : <Text style={styles.btnText}>Create Account</Text>}
|
| 132 |
-
</LinearGradient>
|
| 133 |
-
</TouchableOpacity>
|
| 134 |
-
|
| 135 |
-
<View style={styles.footerRow}>
|
| 136 |
-
<Text style={styles.footerText}>Already have an account? </Text>
|
| 137 |
-
<Link href="/(auth)/login" style={styles.footerLink}>Sign in</Link>
|
| 138 |
-
</View>
|
| 139 |
-
</View>
|
| 140 |
-
</View>
|
| 141 |
-
</ScrollView>
|
| 142 |
-
</KeyboardAvoidingView>
|
| 143 |
-
</LinearGradient>
|
| 144 |
-
);
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
const styles = StyleSheet.create({
|
| 148 |
-
gradient: { flex: 1 },
|
| 149 |
-
flex: { flex: 1 },
|
| 150 |
-
scroll: { flexGrow: 1, justifyContent: 'center' },
|
| 151 |
-
container: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 20, paddingVertical: 40 },
|
| 152 |
-
logoArea: { alignItems: 'center', marginBottom: 32 },
|
| 153 |
-
logoIcon: { width: 64, height: 64, borderRadius: 18, backgroundColor: 'rgba(124,58,237,0.2)', borderWidth: 1, borderColor: 'rgba(124,58,237,0.5)', alignItems: 'center', justifyContent: 'center', marginBottom: 10 },
|
| 154 |
-
logoEmoji: { fontSize: 30 },
|
| 155 |
-
brandName: { fontSize: 28, fontWeight: '800', color: '#fff' },
|
| 156 |
-
brandTagline: { fontSize: 13, color: '#8888aa', marginTop: 4 },
|
| 157 |
-
card: { width: '100%', maxWidth: 420, backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.08)', borderRadius: 24, padding: 28 },
|
| 158 |
-
cardTitle: { fontSize: 22, fontWeight: '700', color: '#fff', marginBottom: 4 },
|
| 159 |
-
cardSubtitle: { fontSize: 13, color: '#8888aa', marginBottom: 20 },
|
| 160 |
-
errorBox: { backgroundColor: 'rgba(239,68,68,0.15)', borderWidth: 1, borderColor: 'rgba(239,68,68,0.3)', borderRadius: 10, padding: 12, marginBottom: 16 },
|
| 161 |
-
errorText: { color: '#fca5a5', fontSize: 13 },
|
| 162 |
-
inputGroup: { marginBottom: 14 },
|
| 163 |
-
label: { fontSize: 13, fontWeight: '600', color: '#c4c4d4', marginBottom: 8 },
|
| 164 |
-
input: { backgroundColor: 'rgba(255,255,255,0.06)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13, color: '#fff', fontSize: 15 },
|
| 165 |
-
btn: { borderRadius: 14, overflow: 'hidden', marginTop: 8 },
|
| 166 |
-
btnDisabled: { opacity: 0.6 },
|
| 167 |
-
btnGradient: { paddingVertical: 15, alignItems: 'center', borderRadius: 14 },
|
| 168 |
-
btnText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
| 169 |
-
footerRow: { flexDirection: 'row', justifyContent: 'center', marginTop: 20 },
|
| 170 |
-
footerText: { color: '#8888aa', fontSize: 13 },
|
| 171 |
-
footerLink: { color: '#7c3aed', fontWeight: '600', fontSize: 13 },
|
| 172 |
-
successContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 },
|
| 173 |
-
successEmoji: { fontSize: 64, marginBottom: 20 },
|
| 174 |
-
successTitle: { fontSize: 26, fontWeight: '800', color: '#fff', marginBottom: 12 },
|
| 175 |
-
successText: { fontSize: 15, color: '#8888aa', textAlign: 'center', marginBottom: 32, lineHeight: 22 },
|
| 176 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/_layout.tsx
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Tabs Layout
|
| 3 |
-
* Bottom tab navigator for Gallery, Albums, and Settings.
|
| 4 |
-
* On web, renders as a sidebar navigation instead.
|
| 5 |
-
*/
|
| 6 |
-
|
| 7 |
-
import { Tabs } from 'expo-router';
|
| 8 |
-
import { Platform, View, StyleSheet } from 'react-native';
|
| 9 |
-
import { Ionicons } from '@expo/vector-icons';
|
| 10 |
-
|
| 11 |
-
const TAB_BAR_STYLE = {
|
| 12 |
-
backgroundColor: '#12122a',
|
| 13 |
-
borderTopColor: 'rgba(255,255,255,0.08)',
|
| 14 |
-
borderTopWidth: 1,
|
| 15 |
-
paddingBottom: Platform.OS === 'ios' ? 20 : 8,
|
| 16 |
-
paddingTop: 8,
|
| 17 |
-
height: Platform.OS === 'ios' ? 84 : 64,
|
| 18 |
-
};
|
| 19 |
-
|
| 20 |
-
export default function TabsLayout() {
|
| 21 |
-
return (
|
| 22 |
-
<Tabs
|
| 23 |
-
screenOptions={{
|
| 24 |
-
headerShown: false,
|
| 25 |
-
tabBarStyle: TAB_BAR_STYLE,
|
| 26 |
-
tabBarActiveTintColor: '#7c3aed',
|
| 27 |
-
tabBarInactiveTintColor: '#555577',
|
| 28 |
-
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: 2 },
|
| 29 |
-
}}
|
| 30 |
-
>
|
| 31 |
-
<Tabs.Screen
|
| 32 |
-
name="gallery"
|
| 33 |
-
options={{
|
| 34 |
-
title: 'Gallery',
|
| 35 |
-
tabBarIcon: ({ color, size }) => (
|
| 36 |
-
<Ionicons name="images-outline" size={size} color={color} />
|
| 37 |
-
),
|
| 38 |
-
}}
|
| 39 |
-
/>
|
| 40 |
-
<Tabs.Screen
|
| 41 |
-
name="albums"
|
| 42 |
-
options={{
|
| 43 |
-
title: 'Albums',
|
| 44 |
-
tabBarIcon: ({ color, size }) => (
|
| 45 |
-
<Ionicons name="albums-outline" size={size} color={color} />
|
| 46 |
-
),
|
| 47 |
-
}}
|
| 48 |
-
/>
|
| 49 |
-
<Tabs.Screen
|
| 50 |
-
name="settings"
|
| 51 |
-
options={{
|
| 52 |
-
title: 'Settings',
|
| 53 |
-
tabBarIcon: ({ color, size }) => (
|
| 54 |
-
<Ionicons name="settings-outline" size={size} color={color} />
|
| 55 |
-
),
|
| 56 |
-
}}
|
| 57 |
-
/>
|
| 58 |
-
</Tabs>
|
| 59 |
-
);
|
| 60 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/albums.tsx
DELETED
|
@@ -1,182 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Albums Screen
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import {
|
| 6 |
-
View, Text, StyleSheet, TouchableOpacity, FlatList,
|
| 7 |
-
Modal, TextInput, ActivityIndicator, Alert,
|
| 8 |
-
} from 'react-native';
|
| 9 |
-
import { useState } from 'react';
|
| 10 |
-
import { Image } from 'expo-image';
|
| 11 |
-
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 12 |
-
import { Ionicons } from '@expo/vector-icons';
|
| 13 |
-
import { LinearGradient } from 'expo-linear-gradient';
|
| 14 |
-
import { useAlbums, useCreateAlbum } from '../../hooks/useAlbums';
|
| 15 |
-
import { Album } from '../../services/api';
|
| 16 |
-
|
| 17 |
-
function AlbumCard({ album }: { album: Album }) {
|
| 18 |
-
return (
|
| 19 |
-
<TouchableOpacity style={styles.albumCard} activeOpacity={0.8}>
|
| 20 |
-
<View style={styles.albumCover}>
|
| 21 |
-
{album.cover_photo_id ? (
|
| 22 |
-
<Image
|
| 23 |
-
source={{ uri: `cover` }}
|
| 24 |
-
style={styles.albumImage}
|
| 25 |
-
contentFit="cover"
|
| 26 |
-
/>
|
| 27 |
-
) : (
|
| 28 |
-
<LinearGradient
|
| 29 |
-
colors={['#2d1b69', '#1e1458']}
|
| 30 |
-
style={styles.albumImagePlaceholder}
|
| 31 |
-
>
|
| 32 |
-
<Ionicons name="albums" size={32} color="rgba(124,58,237,0.6)" />
|
| 33 |
-
</LinearGradient>
|
| 34 |
-
)}
|
| 35 |
-
<View style={styles.albumOverlay}>
|
| 36 |
-
<Text style={styles.albumCount}>{album.photo_count}</Text>
|
| 37 |
-
</View>
|
| 38 |
-
</View>
|
| 39 |
-
<Text style={styles.albumName} numberOfLines={1}>{album.name}</Text>
|
| 40 |
-
{album.description ? (
|
| 41 |
-
<Text style={styles.albumDesc} numberOfLines={1}>{album.description}</Text>
|
| 42 |
-
) : null}
|
| 43 |
-
</TouchableOpacity>
|
| 44 |
-
);
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
function CreateAlbumModal({
|
| 48 |
-
visible,
|
| 49 |
-
onClose,
|
| 50 |
-
}: { visible: boolean; onClose: () => void }) {
|
| 51 |
-
const [name, setName] = useState('');
|
| 52 |
-
const [description, setDescription] = useState('');
|
| 53 |
-
const { mutate, isPending } = useCreateAlbum();
|
| 54 |
-
|
| 55 |
-
const handleCreate = () => {
|
| 56 |
-
if (!name.trim()) return;
|
| 57 |
-
mutate({ name: name.trim(), description: description.trim() || undefined }, {
|
| 58 |
-
onSuccess: () => { setName(''); setDescription(''); onClose(); },
|
| 59 |
-
});
|
| 60 |
-
};
|
| 61 |
-
|
| 62 |
-
return (
|
| 63 |
-
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
| 64 |
-
<View style={styles.modalOverlay}>
|
| 65 |
-
<View style={styles.modalCard}>
|
| 66 |
-
<Text style={styles.modalTitle}>New Album</Text>
|
| 67 |
-
<TextInput
|
| 68 |
-
style={styles.input}
|
| 69 |
-
placeholder="Album name"
|
| 70 |
-
placeholderTextColor="#4a4a6a"
|
| 71 |
-
value={name}
|
| 72 |
-
onChangeText={setName}
|
| 73 |
-
/>
|
| 74 |
-
<TextInput
|
| 75 |
-
style={[styles.input, styles.inputDesc]}
|
| 76 |
-
placeholder="Description (optional)"
|
| 77 |
-
placeholderTextColor="#4a4a6a"
|
| 78 |
-
value={description}
|
| 79 |
-
onChangeText={setDescription}
|
| 80 |
-
multiline
|
| 81 |
-
numberOfLines={3}
|
| 82 |
-
/>
|
| 83 |
-
<View style={styles.modalActions}>
|
| 84 |
-
<TouchableOpacity style={styles.cancelBtn} onPress={onClose}>
|
| 85 |
-
<Text style={styles.cancelBtnText}>Cancel</Text>
|
| 86 |
-
</TouchableOpacity>
|
| 87 |
-
<TouchableOpacity
|
| 88 |
-
style={[styles.createBtn, isPending && { opacity: 0.6 }]}
|
| 89 |
-
onPress={handleCreate}
|
| 90 |
-
disabled={isPending}
|
| 91 |
-
>
|
| 92 |
-
<LinearGradient colors={['#7c3aed', '#4f46e5']} style={styles.createBtnGradient}>
|
| 93 |
-
{isPending ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.createBtnText}>Create</Text>}
|
| 94 |
-
</LinearGradient>
|
| 95 |
-
</TouchableOpacity>
|
| 96 |
-
</View>
|
| 97 |
-
</View>
|
| 98 |
-
</View>
|
| 99 |
-
</Modal>
|
| 100 |
-
);
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
export default function AlbumsScreen() {
|
| 104 |
-
const [showCreate, setShowCreate] = useState(false);
|
| 105 |
-
const { data: albums = [], isLoading, refetch, isRefetching } = useAlbums();
|
| 106 |
-
|
| 107 |
-
return (
|
| 108 |
-
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
| 109 |
-
<View style={styles.container}>
|
| 110 |
-
|
| 111 |
-
<View style={styles.header}>
|
| 112 |
-
<Text style={styles.headerTitle}>Albums</Text>
|
| 113 |
-
<TouchableOpacity style={styles.addBtn} onPress={() => setShowCreate(true)}>
|
| 114 |
-
<Ionicons name="add" size={22} color="#fff" />
|
| 115 |
-
</TouchableOpacity>
|
| 116 |
-
</View>
|
| 117 |
-
|
| 118 |
-
{isLoading ? (
|
| 119 |
-
<View style={styles.centered}>
|
| 120 |
-
<ActivityIndicator size="large" color="#7c3aed" />
|
| 121 |
-
</View>
|
| 122 |
-
) : albums.length === 0 ? (
|
| 123 |
-
<View style={styles.centered}>
|
| 124 |
-
<Text style={styles.emptyEmoji}>🗂️</Text>
|
| 125 |
-
<Text style={styles.emptyTitle}>No albums yet</Text>
|
| 126 |
-
<Text style={styles.emptyText}>Organize your photos into albums.</Text>
|
| 127 |
-
<TouchableOpacity style={styles.emptyBtn} onPress={() => setShowCreate(true)}>
|
| 128 |
-
<Text style={styles.emptyBtnText}>Create Album</Text>
|
| 129 |
-
</TouchableOpacity>
|
| 130 |
-
</View>
|
| 131 |
-
) : (
|
| 132 |
-
<FlatList
|
| 133 |
-
data={albums}
|
| 134 |
-
renderItem={({ item }) => <AlbumCard album={item} />}
|
| 135 |
-
keyExtractor={(item) => item.id}
|
| 136 |
-
numColumns={2}
|
| 137 |
-
columnWrapperStyle={styles.row}
|
| 138 |
-
contentContainerStyle={styles.grid}
|
| 139 |
-
showsVerticalScrollIndicator={false}
|
| 140 |
-
/>
|
| 141 |
-
)}
|
| 142 |
-
|
| 143 |
-
<CreateAlbumModal visible={showCreate} onClose={() => setShowCreate(false)} />
|
| 144 |
-
</View>
|
| 145 |
-
</SafeAreaView>
|
| 146 |
-
);
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
const styles = StyleSheet.create({
|
| 150 |
-
safeArea: { flex: 1, backgroundColor: '#0f0f23' },
|
| 151 |
-
container: { flex: 1, backgroundColor: '#0f0f23' },
|
| 152 |
-
header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 14 },
|
| 153 |
-
headerTitle: { fontSize: 24, fontWeight: '800', color: '#fff' },
|
| 154 |
-
addBtn: { width: 36, height: 36, borderRadius: 10, backgroundColor: '#7c3aed', alignItems: 'center', justifyContent: 'center' },
|
| 155 |
-
centered: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 },
|
| 156 |
-
emptyEmoji: { fontSize: 56, marginBottom: 16 },
|
| 157 |
-
emptyTitle: { fontSize: 20, fontWeight: '700', color: '#fff', marginBottom: 8 },
|
| 158 |
-
emptyText: { fontSize: 14, color: '#8888aa', textAlign: 'center', marginBottom: 20 },
|
| 159 |
-
emptyBtn: { backgroundColor: '#7c3aed', paddingHorizontal: 24, paddingVertical: 12, borderRadius: 12 },
|
| 160 |
-
emptyBtnText: { color: '#fff', fontWeight: '700' },
|
| 161 |
-
grid: { paddingHorizontal: 12, paddingBottom: 20 },
|
| 162 |
-
row: { justifyContent: 'space-between', marginBottom: 16 },
|
| 163 |
-
albumCard: { width: '48%', backgroundColor: 'rgba(255,255,255,0.04)', borderRadius: 16, overflow: 'hidden', borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)' },
|
| 164 |
-
albumCover: { width: '100%', aspectRatio: 1, backgroundColor: '#1a1a2e', position: 'relative' },
|
| 165 |
-
albumImage: { width: '100%', height: '100%' },
|
| 166 |
-
albumImagePlaceholder: { width: '100%', height: '100%', alignItems: 'center', justifyContent: 'center' },
|
| 167 |
-
albumOverlay: { position: 'absolute', bottom: 6, right: 8 },
|
| 168 |
-
albumCount: { color: 'rgba(255,255,255,0.7)', fontSize: 11, fontWeight: '700' },
|
| 169 |
-
albumName: { color: '#fff', fontWeight: '700', fontSize: 14, padding: 10, paddingBottom: 2 },
|
| 170 |
-
albumDesc: { color: '#8888aa', fontSize: 12, paddingHorizontal: 10, paddingBottom: 10 },
|
| 171 |
-
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.7)', justifyContent: 'flex-end' },
|
| 172 |
-
modalCard: { backgroundColor: '#1a1a2e', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 40 },
|
| 173 |
-
modalTitle: { color: '#fff', fontSize: 20, fontWeight: '700', marginBottom: 20 },
|
| 174 |
-
input: { backgroundColor: 'rgba(255,255,255,0.06)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', borderRadius: 12, paddingHorizontal: 16, paddingVertical: 12, color: '#fff', fontSize: 15, marginBottom: 12 },
|
| 175 |
-
inputDesc: { height: 80, textAlignVertical: 'top' },
|
| 176 |
-
modalActions: { flexDirection: 'row', gap: 10, marginTop: 8 },
|
| 177 |
-
cancelBtn: { flex: 1, paddingVertical: 14, borderRadius: 12, borderWidth: 1, borderColor: 'rgba(255,255,255,0.1)', alignItems: 'center' },
|
| 178 |
-
cancelBtnText: { color: '#8888aa', fontWeight: '600' },
|
| 179 |
-
createBtn: { flex: 1, borderRadius: 12, overflow: 'hidden' },
|
| 180 |
-
createBtnGradient: { paddingVertical: 14, alignItems: 'center' },
|
| 181 |
-
createBtnText: { color: '#fff', fontWeight: '700' },
|
| 182 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/gallery.tsx
DELETED
|
@@ -1,270 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Gallery Screen
|
| 3 |
-
* Infinite-scroll photo grid with timeline grouping.
|
| 4 |
-
* Works on mobile (native FlashList) and web (CSS grid fallback).
|
| 5 |
-
*/
|
| 6 |
-
|
| 7 |
-
import {
|
| 8 |
-
View, Text, StyleSheet, TouchableOpacity, FlatList,
|
| 9 |
-
Dimensions, RefreshControl, Platform, TextInput, ActivityIndicator,
|
| 10 |
-
} from 'react-native';
|
| 11 |
-
import { useState, useCallback, useRef } from 'react';
|
| 12 |
-
import { Image } from 'expo-image';
|
| 13 |
-
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 14 |
-
import { Ionicons } from '@expo/vector-icons';
|
| 15 |
-
import { useInfinitePhotos, useSearchPhotos } from '../../hooks/usePhotos';
|
| 16 |
-
import { useSyncStore } from '../../store/syncStore';
|
| 17 |
-
import { runSync } from '../../services/syncEngine';
|
| 18 |
-
import { Photo } from '../../services/api';
|
| 19 |
-
|
| 20 |
-
const { width } = Dimensions.get('window');
|
| 21 |
-
const isWeb = Platform.OS === 'web';
|
| 22 |
-
|
| 23 |
-
// Responsive column count
|
| 24 |
-
const getNumColumns = () => {
|
| 25 |
-
if (isWeb) {
|
| 26 |
-
const w = typeof window !== 'undefined' ? window.innerWidth : 1024;
|
| 27 |
-
if (w > 1200) return 6;
|
| 28 |
-
if (w > 768) return 4;
|
| 29 |
-
return 3;
|
| 30 |
-
}
|
| 31 |
-
return width > 600 ? 4 : 3;
|
| 32 |
-
};
|
| 33 |
-
|
| 34 |
-
const NUM_COLS = getNumColumns();
|
| 35 |
-
const ITEM_SIZE = Math.floor((width - (NUM_COLS + 1) * 2) / NUM_COLS);
|
| 36 |
-
|
| 37 |
-
function PhotoItem({ photo, onPress }: { photo: Photo; onPress: () => void }) {
|
| 38 |
-
return (
|
| 39 |
-
<TouchableOpacity
|
| 40 |
-
onPress={onPress}
|
| 41 |
-
activeOpacity={0.85}
|
| 42 |
-
style={[styles.photoItem, { width: isWeb ? undefined : ITEM_SIZE, height: isWeb ? undefined : ITEM_SIZE }]}
|
| 43 |
-
>
|
| 44 |
-
<Image
|
| 45 |
-
source={{ uri: photo.thumbnail_path || photo.bucket_path }}
|
| 46 |
-
style={styles.photoImage}
|
| 47 |
-
contentFit="cover"
|
| 48 |
-
transition={200}
|
| 49 |
-
cachePolicy="memory-disk"
|
| 50 |
-
/>
|
| 51 |
-
{photo.is_favorite && (
|
| 52 |
-
<View style={styles.favBadge}>
|
| 53 |
-
<Ionicons name="heart" size={10} color="#fff" />
|
| 54 |
-
</View>
|
| 55 |
-
)}
|
| 56 |
-
</TouchableOpacity>
|
| 57 |
-
);
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
function SyncBanner() {
|
| 61 |
-
const { isRunning, uploaded, totalPhotos } = useSyncStore();
|
| 62 |
-
if (!isRunning) return null;
|
| 63 |
-
return (
|
| 64 |
-
<View style={styles.syncBanner}>
|
| 65 |
-
<ActivityIndicator size="small" color="#7c3aed" />
|
| 66 |
-
<Text style={styles.syncText}>
|
| 67 |
-
Syncing… {uploaded}/{totalPhotos} photos
|
| 68 |
-
</Text>
|
| 69 |
-
</View>
|
| 70 |
-
);
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
export default function GalleryScreen() {
|
| 74 |
-
const [searchQuery, setSearchQuery] = useState('');
|
| 75 |
-
const [isSearching, setIsSearching] = useState(false);
|
| 76 |
-
|
| 77 |
-
const {
|
| 78 |
-
data,
|
| 79 |
-
fetchNextPage,
|
| 80 |
-
hasNextPage,
|
| 81 |
-
isFetchingNextPage,
|
| 82 |
-
isLoading,
|
| 83 |
-
refetch,
|
| 84 |
-
isRefetching,
|
| 85 |
-
} = useInfinitePhotos();
|
| 86 |
-
|
| 87 |
-
const { data: searchData, isLoading: isSearchLoading } = useSearchPhotos(
|
| 88 |
-
isSearching ? searchQuery : '',
|
| 89 |
-
);
|
| 90 |
-
|
| 91 |
-
const photos: Photo[] = isSearching
|
| 92 |
-
? (searchData?.items || [])
|
| 93 |
-
: (data?.pages?.flatMap(p => p.items) || []);
|
| 94 |
-
|
| 95 |
-
const handleSyncNow = useCallback(async () => {
|
| 96 |
-
runSync();
|
| 97 |
-
}, []);
|
| 98 |
-
|
| 99 |
-
const renderItem = ({ item }: { item: Photo }) => (
|
| 100 |
-
<PhotoItem photo={item} onPress={() => {}} />
|
| 101 |
-
);
|
| 102 |
-
|
| 103 |
-
const renderFooter = () => {
|
| 104 |
-
if (!isFetchingNextPage) return null;
|
| 105 |
-
return (
|
| 106 |
-
<View style={styles.loaderFooter}>
|
| 107 |
-
<ActivityIndicator color="#7c3aed" />
|
| 108 |
-
</View>
|
| 109 |
-
);
|
| 110 |
-
};
|
| 111 |
-
|
| 112 |
-
return (
|
| 113 |
-
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
| 114 |
-
<View style={styles.container}>
|
| 115 |
-
|
| 116 |
-
{/* Header */}
|
| 117 |
-
<View style={styles.header}>
|
| 118 |
-
<View>
|
| 119 |
-
<Text style={styles.headerTitle}>KeyStone</Text>
|
| 120 |
-
<Text style={styles.headerSubtitle}>
|
| 121 |
-
{photos.length > 0 ? `${data?.pages?.[0]?.total ?? 0} photos` : 'Your private cloud'}
|
| 122 |
-
</Text>
|
| 123 |
-
</View>
|
| 124 |
-
<View style={styles.headerActions}>
|
| 125 |
-
<TouchableOpacity style={styles.iconBtn} onPress={handleSyncNow}>
|
| 126 |
-
<Ionicons name="cloud-upload-outline" size={22} color="#c4c4d4" />
|
| 127 |
-
</TouchableOpacity>
|
| 128 |
-
</View>
|
| 129 |
-
</View>
|
| 130 |
-
|
| 131 |
-
{/* Search Bar */}
|
| 132 |
-
<View style={styles.searchRow}>
|
| 133 |
-
<View style={styles.searchBar}>
|
| 134 |
-
<Ionicons name="search" size={16} color="#555577" style={{ marginRight: 8 }} />
|
| 135 |
-
<TextInput
|
| 136 |
-
style={styles.searchInput}
|
| 137 |
-
placeholder="Search photos…"
|
| 138 |
-
placeholderTextColor="#555577"
|
| 139 |
-
value={searchQuery}
|
| 140 |
-
onChangeText={(t) => { setSearchQuery(t); setIsSearching(t.length > 0); }}
|
| 141 |
-
returnKeyType="search"
|
| 142 |
-
/>
|
| 143 |
-
{searchQuery.length > 0 && (
|
| 144 |
-
<TouchableOpacity onPress={() => { setSearchQuery(''); setIsSearching(false); }}>
|
| 145 |
-
<Ionicons name="close-circle" size={16} color="#555577" />
|
| 146 |
-
</TouchableOpacity>
|
| 147 |
-
)}
|
| 148 |
-
</View>
|
| 149 |
-
</View>
|
| 150 |
-
|
| 151 |
-
{/* Sync Banner */}
|
| 152 |
-
<SyncBanner />
|
| 153 |
-
|
| 154 |
-
{/* Photo Grid */}
|
| 155 |
-
{isLoading ? (
|
| 156 |
-
<View style={styles.centered}>
|
| 157 |
-
<ActivityIndicator size="large" color="#7c3aed" />
|
| 158 |
-
<Text style={styles.loadingText}>Loading photos…</Text>
|
| 159 |
-
</View>
|
| 160 |
-
) : photos.length === 0 ? (
|
| 161 |
-
<View style={styles.centered}>
|
| 162 |
-
<Text style={styles.emptyEmoji}>📷</Text>
|
| 163 |
-
<Text style={styles.emptyTitle}>No photos yet</Text>
|
| 164 |
-
<Text style={styles.emptyText}>Tap the sync button to back up your camera roll.</Text>
|
| 165 |
-
<TouchableOpacity style={styles.emptyBtn} onPress={handleSyncNow}>
|
| 166 |
-
<Text style={styles.emptyBtnText}>Start Sync</Text>
|
| 167 |
-
</TouchableOpacity>
|
| 168 |
-
</View>
|
| 169 |
-
) : (
|
| 170 |
-
<FlatList
|
| 171 |
-
data={photos}
|
| 172 |
-
renderItem={renderItem}
|
| 173 |
-
keyExtractor={(item) => item.id}
|
| 174 |
-
numColumns={NUM_COLS}
|
| 175 |
-
key={`cols-${NUM_COLS}`}
|
| 176 |
-
contentContainerStyle={styles.grid}
|
| 177 |
-
columnWrapperStyle={styles.row}
|
| 178 |
-
onEndReached={() => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); }}
|
| 179 |
-
onEndReachedThreshold={0.5}
|
| 180 |
-
ListFooterComponent={renderFooter}
|
| 181 |
-
refreshControl={
|
| 182 |
-
<RefreshControl
|
| 183 |
-
refreshing={isRefetching}
|
| 184 |
-
onRefresh={refetch}
|
| 185 |
-
tintColor="#7c3aed"
|
| 186 |
-
/>
|
| 187 |
-
}
|
| 188 |
-
showsVerticalScrollIndicator={false}
|
| 189 |
-
/>
|
| 190 |
-
)}
|
| 191 |
-
</View>
|
| 192 |
-
</SafeAreaView>
|
| 193 |
-
);
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
const styles = StyleSheet.create({
|
| 197 |
-
safeArea: { flex: 1, backgroundColor: '#0f0f23' },
|
| 198 |
-
container: { flex: 1, backgroundColor: '#0f0f23' },
|
| 199 |
-
|
| 200 |
-
header: {
|
| 201 |
-
flexDirection: 'row',
|
| 202 |
-
justifyContent: 'space-between',
|
| 203 |
-
alignItems: 'center',
|
| 204 |
-
paddingHorizontal: 16,
|
| 205 |
-
paddingVertical: 12,
|
| 206 |
-
},
|
| 207 |
-
headerTitle: { fontSize: 24, fontWeight: '800', color: '#fff' },
|
| 208 |
-
headerSubtitle: { fontSize: 12, color: '#555577', marginTop: 2 },
|
| 209 |
-
headerActions: { flexDirection: 'row', gap: 8 },
|
| 210 |
-
iconBtn: {
|
| 211 |
-
width: 36, height: 36,
|
| 212 |
-
borderRadius: 10,
|
| 213 |
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
| 214 |
-
alignItems: 'center',
|
| 215 |
-
justifyContent: 'center',
|
| 216 |
-
},
|
| 217 |
-
|
| 218 |
-
searchRow: { paddingHorizontal: 16, paddingBottom: 12 },
|
| 219 |
-
searchBar: {
|
| 220 |
-
flexDirection: 'row',
|
| 221 |
-
alignItems: 'center',
|
| 222 |
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
| 223 |
-
borderWidth: 1,
|
| 224 |
-
borderColor: 'rgba(255,255,255,0.08)',
|
| 225 |
-
borderRadius: 12,
|
| 226 |
-
paddingHorizontal: 12,
|
| 227 |
-
paddingVertical: 10,
|
| 228 |
-
},
|
| 229 |
-
searchInput: { flex: 1, color: '#fff', fontSize: 14 },
|
| 230 |
-
|
| 231 |
-
syncBanner: {
|
| 232 |
-
flexDirection: 'row',
|
| 233 |
-
alignItems: 'center',
|
| 234 |
-
backgroundColor: 'rgba(124,58,237,0.12)',
|
| 235 |
-
borderBottomWidth: 1,
|
| 236 |
-
borderBottomColor: 'rgba(124,58,237,0.2)',
|
| 237 |
-
paddingHorizontal: 16,
|
| 238 |
-
paddingVertical: 10,
|
| 239 |
-
gap: 8,
|
| 240 |
-
},
|
| 241 |
-
syncText: { color: '#c4a8ff', fontSize: 13 },
|
| 242 |
-
|
| 243 |
-
grid: { paddingHorizontal: 2, paddingBottom: 20 },
|
| 244 |
-
row: { gap: 2, marginBottom: 2 },
|
| 245 |
-
photoItem: {
|
| 246 |
-
flex: isWeb ? 1 : undefined,
|
| 247 |
-
aspectRatio: 1,
|
| 248 |
-
backgroundColor: '#1a1a2e',
|
| 249 |
-
borderRadius: 4,
|
| 250 |
-
overflow: 'hidden',
|
| 251 |
-
},
|
| 252 |
-
photoImage: { width: '100%', height: '100%' },
|
| 253 |
-
favBadge: {
|
| 254 |
-
position: 'absolute',
|
| 255 |
-
top: 4, right: 4,
|
| 256 |
-
backgroundColor: 'rgba(239,68,68,0.85)',
|
| 257 |
-
borderRadius: 8,
|
| 258 |
-
padding: 3,
|
| 259 |
-
},
|
| 260 |
-
|
| 261 |
-
centered: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 },
|
| 262 |
-
loadingText: { color: '#8888aa', marginTop: 12, fontSize: 14 },
|
| 263 |
-
emptyEmoji: { fontSize: 64, marginBottom: 16 },
|
| 264 |
-
emptyTitle: { fontSize: 22, fontWeight: '700', color: '#fff', marginBottom: 8 },
|
| 265 |
-
emptyText: { fontSize: 14, color: '#8888aa', textAlign: 'center', lineHeight: 20, marginBottom: 24 },
|
| 266 |
-
emptyBtn: { backgroundColor: '#7c3aed', paddingHorizontal: 28, paddingVertical: 13, borderRadius: 14 },
|
| 267 |
-
emptyBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
| 268 |
-
|
| 269 |
-
loaderFooter: { paddingVertical: 20, alignItems: 'center' },
|
| 270 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/(tabs)/settings.tsx
DELETED
|
@@ -1,258 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Settings Screen
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import {
|
| 6 |
-
View, Text, StyleSheet, TouchableOpacity, Switch,
|
| 7 |
-
ScrollView, Alert, Platform,
|
| 8 |
-
} from 'react-native';
|
| 9 |
-
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 10 |
-
import { Ionicons } from '@expo/vector-icons';
|
| 11 |
-
import { useAuthStore } from '../../store/authStore';
|
| 12 |
-
import { useSyncStore } from '../../store/syncStore';
|
| 13 |
-
import { runSync, pauseSync, resumeSync } from '../../services/syncEngine';
|
| 14 |
-
import { healthCheck } from '../../services/api';
|
| 15 |
-
import { useState, useEffect } from 'react';
|
| 16 |
-
|
| 17 |
-
function SettingRow({
|
| 18 |
-
icon, label, sublabel, children, onPress, dangerous,
|
| 19 |
-
}: {
|
| 20 |
-
icon: string;
|
| 21 |
-
label: string;
|
| 22 |
-
sublabel?: string;
|
| 23 |
-
children?: React.ReactNode;
|
| 24 |
-
onPress?: () => void;
|
| 25 |
-
dangerous?: boolean;
|
| 26 |
-
}) {
|
| 27 |
-
return (
|
| 28 |
-
<TouchableOpacity
|
| 29 |
-
style={styles.settingRow}
|
| 30 |
-
onPress={onPress}
|
| 31 |
-
disabled={!onPress}
|
| 32 |
-
activeOpacity={onPress ? 0.7 : 1}
|
| 33 |
-
>
|
| 34 |
-
<View style={[styles.settingIcon, dangerous && styles.settingIconDanger]}>
|
| 35 |
-
<Ionicons name={icon as any} size={18} color={dangerous ? '#ef4444' : '#7c3aed'} />
|
| 36 |
-
</View>
|
| 37 |
-
<View style={styles.settingInfo}>
|
| 38 |
-
<Text style={[styles.settingLabel, dangerous && { color: '#ef4444' }]}>{label}</Text>
|
| 39 |
-
{sublabel ? <Text style={styles.settingSubLabel}>{sublabel}</Text> : null}
|
| 40 |
-
</View>
|
| 41 |
-
{children}
|
| 42 |
-
{!children && onPress && (
|
| 43 |
-
<Ionicons name="chevron-forward" size={16} color="#555577" />
|
| 44 |
-
)}
|
| 45 |
-
</TouchableOpacity>
|
| 46 |
-
);
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
| 50 |
-
return (
|
| 51 |
-
<View style={styles.section}>
|
| 52 |
-
<Text style={styles.sectionTitle}>{title}</Text>
|
| 53 |
-
<View style={styles.sectionCard}>{children}</View>
|
| 54 |
-
</View>
|
| 55 |
-
);
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
export default function SettingsScreen() {
|
| 59 |
-
const { user, signOut } = useAuthStore();
|
| 60 |
-
const { settings, updateSettings, isRunning, isPaused, uploaded, skipped, failed, totalPhotos } = useSyncStore();
|
| 61 |
-
const [apiStatus, setApiStatus] = useState<'checking' | 'ok' | 'error'>('checking');
|
| 62 |
-
|
| 63 |
-
useEffect(() => {
|
| 64 |
-
healthCheck()
|
| 65 |
-
.then(() => setApiStatus('ok'))
|
| 66 |
-
.catch(() => setApiStatus('error'));
|
| 67 |
-
}, []);
|
| 68 |
-
|
| 69 |
-
const handleSignOut = () => {
|
| 70 |
-
if (Platform.OS === 'web') {
|
| 71 |
-
signOut();
|
| 72 |
-
} else {
|
| 73 |
-
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
| 74 |
-
{ text: 'Cancel', style: 'cancel' },
|
| 75 |
-
{ text: 'Sign Out', style: 'destructive', onPress: signOut },
|
| 76 |
-
]);
|
| 77 |
-
}
|
| 78 |
-
};
|
| 79 |
-
|
| 80 |
-
const handleSyncToggle = () => {
|
| 81 |
-
if (isRunning) {
|
| 82 |
-
pauseSync();
|
| 83 |
-
} else {
|
| 84 |
-
runSync();
|
| 85 |
-
}
|
| 86 |
-
};
|
| 87 |
-
|
| 88 |
-
return (
|
| 89 |
-
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
| 90 |
-
<ScrollView style={styles.container} showsVerticalScrollIndicator={false}>
|
| 91 |
-
|
| 92 |
-
{/* Header */}
|
| 93 |
-
<View style={styles.header}>
|
| 94 |
-
<Text style={styles.headerTitle}>Settings</Text>
|
| 95 |
-
</View>
|
| 96 |
-
|
| 97 |
-
{/* Profile */}
|
| 98 |
-
<View style={styles.profileCard}>
|
| 99 |
-
<View style={styles.avatar}>
|
| 100 |
-
<Text style={styles.avatarText}>
|
| 101 |
-
{user?.email?.[0]?.toUpperCase() ?? '?'}
|
| 102 |
-
</Text>
|
| 103 |
-
</View>
|
| 104 |
-
<View style={styles.profileInfo}>
|
| 105 |
-
<Text style={styles.profileEmail}>{user?.email ?? 'Unknown'}</Text>
|
| 106 |
-
<View style={styles.statusRow}>
|
| 107 |
-
<View style={[styles.statusDot, apiStatus === 'ok' && styles.statusDotGreen, apiStatus === 'error' && styles.statusDotRed]} />
|
| 108 |
-
<Text style={styles.statusText}>
|
| 109 |
-
API {apiStatus === 'checking' ? 'connecting…' : apiStatus === 'ok' ? 'connected' : 'unreachable'}
|
| 110 |
-
</Text>
|
| 111 |
-
</View>
|
| 112 |
-
</View>
|
| 113 |
-
</View>
|
| 114 |
-
|
| 115 |
-
{/* Sync Stats */}
|
| 116 |
-
{(uploaded > 0 || skipped > 0 || failed > 0) && (
|
| 117 |
-
<View style={styles.statsRow}>
|
| 118 |
-
<View style={styles.statItem}>
|
| 119 |
-
<Text style={styles.statNum}>{uploaded}</Text>
|
| 120 |
-
<Text style={styles.statLabel}>Uploaded</Text>
|
| 121 |
-
</View>
|
| 122 |
-
<View style={styles.statDivider} />
|
| 123 |
-
<View style={styles.statItem}>
|
| 124 |
-
<Text style={styles.statNum}>{skipped}</Text>
|
| 125 |
-
<Text style={styles.statLabel}>Skipped</Text>
|
| 126 |
-
</View>
|
| 127 |
-
<View style={styles.statDivider} />
|
| 128 |
-
<View style={styles.statItem}>
|
| 129 |
-
<Text style={[styles.statNum, failed > 0 && { color: '#ef4444' }]}>{failed}</Text>
|
| 130 |
-
<Text style={styles.statLabel}>Failed</Text>
|
| 131 |
-
</View>
|
| 132 |
-
</View>
|
| 133 |
-
)}
|
| 134 |
-
|
| 135 |
-
{/* Sync */}
|
| 136 |
-
<Section title="Sync">
|
| 137 |
-
<SettingRow icon="sync-outline" label="Auto Sync" sublabel="Automatically back up new photos">
|
| 138 |
-
<Switch
|
| 139 |
-
value={settings.autoSync}
|
| 140 |
-
onValueChange={(v) => updateSettings({ autoSync: v })}
|
| 141 |
-
trackColor={{ false: '#2a2a4a', true: '#7c3aed' }}
|
| 142 |
-
thumbColor="#fff"
|
| 143 |
-
/>
|
| 144 |
-
</SettingRow>
|
| 145 |
-
<View style={styles.divider} />
|
| 146 |
-
<SettingRow icon="wifi-outline" label="Wi-Fi Only" sublabel="Don't sync on cellular data">
|
| 147 |
-
<Switch
|
| 148 |
-
value={settings.wifiOnly}
|
| 149 |
-
onValueChange={(v) => updateSettings({ wifiOnly: v })}
|
| 150 |
-
trackColor={{ false: '#2a2a4a', true: '#7c3aed' }}
|
| 151 |
-
thumbColor="#fff"
|
| 152 |
-
/>
|
| 153 |
-
</SettingRow>
|
| 154 |
-
<View style={styles.divider} />
|
| 155 |
-
<SettingRow
|
| 156 |
-
icon={isRunning ? 'pause-circle-outline' : 'play-circle-outline'}
|
| 157 |
-
label={isRunning ? (isPaused ? 'Resume Sync' : 'Pause Sync') : 'Sync Now'}
|
| 158 |
-
sublabel={isRunning ? `${uploaded}/${totalPhotos} photos` : 'Manually start a sync'}
|
| 159 |
-
onPress={handleSyncToggle}
|
| 160 |
-
/>
|
| 161 |
-
</Section>
|
| 162 |
-
|
| 163 |
-
{/* Quality */}
|
| 164 |
-
<Section title="Quality">
|
| 165 |
-
{(['low', 'medium', 'high'] as const).map((q, i, arr) => (
|
| 166 |
-
<View key={q}>
|
| 167 |
-
<SettingRow
|
| 168 |
-
icon="image-outline"
|
| 169 |
-
label={q.charAt(0).toUpperCase() + q.slice(1)}
|
| 170 |
-
sublabel={q === 'low' ? 'Faster uploads, less storage' : q === 'high' ? 'Best quality, more storage' : 'Balanced'}
|
| 171 |
-
onPress={() => updateSettings({ thumbnailQuality: q })}
|
| 172 |
-
>
|
| 173 |
-
{settings.thumbnailQuality === q && (
|
| 174 |
-
<Ionicons name="checkmark-circle" size={20} color="#7c3aed" />
|
| 175 |
-
)}
|
| 176 |
-
</SettingRow>
|
| 177 |
-
{i < arr.length - 1 && <View style={styles.divider} />}
|
| 178 |
-
</View>
|
| 179 |
-
))}
|
| 180 |
-
</Section>
|
| 181 |
-
|
| 182 |
-
{/* About */}
|
| 183 |
-
<Section title="About">
|
| 184 |
-
<SettingRow icon="information-circle-outline" label="Version" sublabel="1.0.0 – KeyStone Photo Cloud" />
|
| 185 |
-
<View style={styles.divider} />
|
| 186 |
-
<SettingRow icon="code-outline" label="API Endpoint" sublabel="dpv007-keystone.hf.space" />
|
| 187 |
-
</Section>
|
| 188 |
-
|
| 189 |
-
{/* Account */}
|
| 190 |
-
<Section title="Account">
|
| 191 |
-
<SettingRow
|
| 192 |
-
icon="log-out-outline"
|
| 193 |
-
label="Sign Out"
|
| 194 |
-
onPress={handleSignOut}
|
| 195 |
-
dangerous
|
| 196 |
-
/>
|
| 197 |
-
</Section>
|
| 198 |
-
|
| 199 |
-
<View style={{ height: 40 }} />
|
| 200 |
-
</ScrollView>
|
| 201 |
-
</SafeAreaView>
|
| 202 |
-
);
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
const styles = StyleSheet.create({
|
| 206 |
-
safeArea: { flex: 1, backgroundColor: '#0f0f23' },
|
| 207 |
-
container: { flex: 1, backgroundColor: '#0f0f23' },
|
| 208 |
-
header: { paddingHorizontal: 16, paddingVertical: 14 },
|
| 209 |
-
headerTitle: { fontSize: 24, fontWeight: '800', color: '#fff' },
|
| 210 |
-
|
| 211 |
-
profileCard: {
|
| 212 |
-
flexDirection: 'row',
|
| 213 |
-
alignItems: 'center',
|
| 214 |
-
marginHorizontal: 16,
|
| 215 |
-
marginBottom: 16,
|
| 216 |
-
backgroundColor: 'rgba(124,58,237,0.1)',
|
| 217 |
-
borderWidth: 1,
|
| 218 |
-
borderColor: 'rgba(124,58,237,0.2)',
|
| 219 |
-
borderRadius: 16,
|
| 220 |
-
padding: 16,
|
| 221 |
-
gap: 14,
|
| 222 |
-
},
|
| 223 |
-
avatar: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#7c3aed', alignItems: 'center', justifyContent: 'center' },
|
| 224 |
-
avatarText: { color: '#fff', fontWeight: '800', fontSize: 20 },
|
| 225 |
-
profileInfo: { flex: 1 },
|
| 226 |
-
profileEmail: { color: '#fff', fontWeight: '600', fontSize: 15 },
|
| 227 |
-
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 4 },
|
| 228 |
-
statusDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: '#555577' },
|
| 229 |
-
statusDotGreen: { backgroundColor: '#22c55e' },
|
| 230 |
-
statusDotRed: { backgroundColor: '#ef4444' },
|
| 231 |
-
statusText: { color: '#8888aa', fontSize: 12 },
|
| 232 |
-
|
| 233 |
-
statsRow: {
|
| 234 |
-
flexDirection: 'row',
|
| 235 |
-
marginHorizontal: 16,
|
| 236 |
-
marginBottom: 16,
|
| 237 |
-
backgroundColor: 'rgba(255,255,255,0.04)',
|
| 238 |
-
borderRadius: 14,
|
| 239 |
-
borderWidth: 1,
|
| 240 |
-
borderColor: 'rgba(255,255,255,0.06)',
|
| 241 |
-
padding: 14,
|
| 242 |
-
},
|
| 243 |
-
statItem: { flex: 1, alignItems: 'center' },
|
| 244 |
-
statNum: { color: '#7c3aed', fontSize: 22, fontWeight: '800' },
|
| 245 |
-
statLabel: { color: '#8888aa', fontSize: 11, marginTop: 2 },
|
| 246 |
-
statDivider: { width: 1, backgroundColor: 'rgba(255,255,255,0.06)' },
|
| 247 |
-
|
| 248 |
-
section: { marginBottom: 16 },
|
| 249 |
-
sectionTitle: { color: '#555577', fontSize: 11, fontWeight: '700', letterSpacing: 1, textTransform: 'uppercase', marginHorizontal: 16, marginBottom: 8 },
|
| 250 |
-
sectionCard: { marginHorizontal: 16, backgroundColor: 'rgba(255,255,255,0.04)', borderRadius: 16, borderWidth: 1, borderColor: 'rgba(255,255,255,0.06)', overflow: 'hidden' },
|
| 251 |
-
settingRow: { flexDirection: 'row', alignItems: 'center', padding: 14, gap: 12 },
|
| 252 |
-
settingIcon: { width: 34, height: 34, borderRadius: 9, backgroundColor: 'rgba(124,58,237,0.15)', alignItems: 'center', justifyContent: 'center' },
|
| 253 |
-
settingIconDanger: { backgroundColor: 'rgba(239,68,68,0.12)' },
|
| 254 |
-
settingInfo: { flex: 1 },
|
| 255 |
-
settingLabel: { color: '#e0e0f0', fontWeight: '600', fontSize: 14 },
|
| 256 |
-
settingSubLabel: { color: '#555577', fontSize: 12, marginTop: 2 },
|
| 257 |
-
divider: { height: 1, backgroundColor: 'rgba(255,255,255,0.04)', marginLeft: 60 },
|
| 258 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/app/_layout.tsx
DELETED
|
@@ -1,60 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Root Layout
|
| 3 |
-
* Sets up QueryClient, auth initialization, and route guarding.
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import { useEffect } from 'react';
|
| 7 |
-
import { Stack, useRouter, useSegments } from 'expo-router';
|
| 8 |
-
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
| 9 |
-
import { StatusBar } from 'expo-status-bar';
|
| 10 |
-
import { useAuthStore } from '../store/authStore';
|
| 11 |
-
import { registerBackgroundSync } from '../services/backgroundSync';
|
| 12 |
-
import { Platform } from 'react-native';
|
| 13 |
-
|
| 14 |
-
const queryClient = new QueryClient({
|
| 15 |
-
defaultOptions: {
|
| 16 |
-
queries: {
|
| 17 |
-
staleTime: 1000 * 60 * 2, // 2 minutes
|
| 18 |
-
retry: 2,
|
| 19 |
-
},
|
| 20 |
-
},
|
| 21 |
-
});
|
| 22 |
-
|
| 23 |
-
function AuthGuard({ children }: { children: React.ReactNode }) {
|
| 24 |
-
const { session, isInitialized, initialize } = useAuthStore();
|
| 25 |
-
const segments = useSegments();
|
| 26 |
-
const router = useRouter();
|
| 27 |
-
|
| 28 |
-
useEffect(() => {
|
| 29 |
-
initialize();
|
| 30 |
-
}, []);
|
| 31 |
-
|
| 32 |
-
useEffect(() => {
|
| 33 |
-
if (!isInitialized) return;
|
| 34 |
-
|
| 35 |
-
const inAuthGroup = segments[0] === '(auth)';
|
| 36 |
-
|
| 37 |
-
if (!session && !inAuthGroup) {
|
| 38 |
-
router.replace('/(auth)/login');
|
| 39 |
-
} else if (session && inAuthGroup) {
|
| 40 |
-
router.replace('/(tabs)/gallery');
|
| 41 |
-
// Register background sync on native
|
| 42 |
-
if (Platform.OS !== 'web') {
|
| 43 |
-
registerBackgroundSync();
|
| 44 |
-
}
|
| 45 |
-
}
|
| 46 |
-
}, [session, isInitialized, segments]);
|
| 47 |
-
|
| 48 |
-
return <>{children}</>;
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
export default function RootLayout() {
|
| 52 |
-
return (
|
| 53 |
-
<QueryClientProvider client={queryClient}>
|
| 54 |
-
<AuthGuard>
|
| 55 |
-
<StatusBar style="light" />
|
| 56 |
-
<Stack screenOptions={{ headerShown: false }} />
|
| 57 |
-
</AuthGuard>
|
| 58 |
-
</QueryClientProvider>
|
| 59 |
-
);
|
| 60 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/hooks/useAlbums.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Albums React Query Hooks
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
| 6 |
-
import { listAlbums, createAlbum, getAlbum, addPhotosToAlbum, Album } from '../services/api';
|
| 7 |
-
|
| 8 |
-
export const albumKeys = {
|
| 9 |
-
all: ['albums'] as const,
|
| 10 |
-
lists: () => [...albumKeys.all, 'list'] as const,
|
| 11 |
-
detail: (id: string) => [...albumKeys.all, id] as const,
|
| 12 |
-
};
|
| 13 |
-
|
| 14 |
-
export function useAlbums() {
|
| 15 |
-
return useQuery({
|
| 16 |
-
queryKey: albumKeys.lists(),
|
| 17 |
-
queryFn: listAlbums,
|
| 18 |
-
staleTime: 1000 * 60 * 2,
|
| 19 |
-
});
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
export function useAlbum(id: string) {
|
| 23 |
-
return useQuery({
|
| 24 |
-
queryKey: albumKeys.detail(id),
|
| 25 |
-
queryFn: () => getAlbum(id),
|
| 26 |
-
enabled: !!id,
|
| 27 |
-
});
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
export function useCreateAlbum() {
|
| 31 |
-
const qc = useQueryClient();
|
| 32 |
-
return useMutation({
|
| 33 |
-
mutationFn: (data: { name: string; description?: string }) => createAlbum(data),
|
| 34 |
-
onSuccess: () => {
|
| 35 |
-
qc.invalidateQueries({ queryKey: albumKeys.lists() });
|
| 36 |
-
},
|
| 37 |
-
});
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
export function useAddPhotosToAlbum() {
|
| 41 |
-
const qc = useQueryClient();
|
| 42 |
-
return useMutation({
|
| 43 |
-
mutationFn: ({ albumId, photoIds }: { albumId: string; photoIds: string[] }) =>
|
| 44 |
-
addPhotosToAlbum(albumId, photoIds),
|
| 45 |
-
onSuccess: (_, { albumId }) => {
|
| 46 |
-
qc.invalidateQueries({ queryKey: albumKeys.detail(albumId) });
|
| 47 |
-
},
|
| 48 |
-
});
|
| 49 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/hooks/usePhotos.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Photos React Query Hooks
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
| 6 |
-
import { listPhotos, getPhoto, deletePhoto, updatePhoto, searchPhotos, Photo } from '../services/api';
|
| 7 |
-
|
| 8 |
-
// ── Keys ───────────────────────────────────────────────────────────────────────
|
| 9 |
-
export const photoKeys = {
|
| 10 |
-
all: ['photos'] as const,
|
| 11 |
-
lists: () => [...photoKeys.all, 'list'] as const,
|
| 12 |
-
list: (filters?: object) => [...photoKeys.lists(), filters] as const,
|
| 13 |
-
detail: (id: string) => [...photoKeys.all, id] as const,
|
| 14 |
-
search: (q: string) => [...photoKeys.all, 'search', q] as const,
|
| 15 |
-
};
|
| 16 |
-
|
| 17 |
-
// ── Infinite Gallery ───────────────────────────────────────────────────────────
|
| 18 |
-
export function useInfinitePhotos(favoritesOnly = false) {
|
| 19 |
-
return useInfiniteQuery({
|
| 20 |
-
queryKey: photoKeys.list({ favoritesOnly }),
|
| 21 |
-
queryFn: ({ pageParam }) =>
|
| 22 |
-
listPhotos({ limit: 60, cursor: pageParam as string | undefined, favorites_only: favoritesOnly }),
|
| 23 |
-
initialPageParam: undefined as string | undefined,
|
| 24 |
-
getNextPageParam: (lastPage) =>
|
| 25 |
-
lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined,
|
| 26 |
-
staleTime: 1000 * 60,
|
| 27 |
-
});
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
// ── Single Photo ───────────────────────────────────────────────────────────────
|
| 31 |
-
export function usePhoto(id: string) {
|
| 32 |
-
return useQuery({
|
| 33 |
-
queryKey: photoKeys.detail(id),
|
| 34 |
-
queryFn: () => getPhoto(id),
|
| 35 |
-
enabled: !!id,
|
| 36 |
-
});
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
// ── Search ─────────────────────────────────────────────────────────────────────
|
| 40 |
-
export function useSearchPhotos(q: string) {
|
| 41 |
-
return useQuery({
|
| 42 |
-
queryKey: photoKeys.search(q),
|
| 43 |
-
queryFn: () => searchPhotos(q),
|
| 44 |
-
enabled: q.length > 0,
|
| 45 |
-
staleTime: 1000 * 30,
|
| 46 |
-
});
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
// ── Delete Photo ───────────────────────────────────────────────────────────────
|
| 50 |
-
export function useDeletePhoto() {
|
| 51 |
-
const qc = useQueryClient();
|
| 52 |
-
return useMutation({
|
| 53 |
-
mutationFn: (id: string) => deletePhoto(id),
|
| 54 |
-
onSuccess: () => {
|
| 55 |
-
qc.invalidateQueries({ queryKey: photoKeys.lists() });
|
| 56 |
-
},
|
| 57 |
-
});
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
// ── Toggle Favorite ────────────────────────────────────────────────────────────
|
| 61 |
-
export function useToggleFavorite() {
|
| 62 |
-
const qc = useQueryClient();
|
| 63 |
-
return useMutation({
|
| 64 |
-
mutationFn: ({ id, is_favorite }: { id: string; is_favorite: boolean }) =>
|
| 65 |
-
updatePhoto(id, { is_favorite }),
|
| 66 |
-
onSuccess: () => {
|
| 67 |
-
qc.invalidateQueries({ queryKey: photoKeys.lists() });
|
| 68 |
-
},
|
| 69 |
-
});
|
| 70 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/hooks/useSync.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Sync Hook
|
| 3 |
-
*/
|
| 4 |
-
|
| 5 |
-
import { useMutation } from '@tanstack/react-query';
|
| 6 |
-
import { runSync, pauseSync, resumeSync } from '../services/syncEngine';
|
| 7 |
-
import { useSyncStore } from '../store/syncStore';
|
| 8 |
-
|
| 9 |
-
export function useSync() {
|
| 10 |
-
const { isRunning, isPaused, uploaded, skipped, failed, totalPhotos } = useSyncStore();
|
| 11 |
-
|
| 12 |
-
const startMutation = useMutation({ mutationFn: runSync });
|
| 13 |
-
|
| 14 |
-
return {
|
| 15 |
-
isRunning,
|
| 16 |
-
isPaused,
|
| 17 |
-
uploaded,
|
| 18 |
-
skipped,
|
| 19 |
-
failed,
|
| 20 |
-
totalPhotos,
|
| 21 |
-
startSync: startMutation.mutate,
|
| 22 |
-
pauseSync,
|
| 23 |
-
resumeSync,
|
| 24 |
-
isStarting: startMutation.isPending,
|
| 25 |
-
};
|
| 26 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/package.json
DELETED
|
@@ -1,47 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"name": "keystone",
|
| 3 |
-
"version": "1.0.0",
|
| 4 |
-
"main": "expo-router/entry",
|
| 5 |
-
"scripts": {
|
| 6 |
-
"start": "expo start",
|
| 7 |
-
"android": "expo start --android",
|
| 8 |
-
"ios": "expo start --ios",
|
| 9 |
-
"web": "expo start --web",
|
| 10 |
-
"lint": "expo lint"
|
| 11 |
-
},
|
| 12 |
-
"dependencies": {
|
| 13 |
-
"@expo/vector-icons": "^14.0.2",
|
| 14 |
-
"@react-native-async-storage/async-storage": "^1.23.1",
|
| 15 |
-
"@supabase/supabase-js": "^2.47.10",
|
| 16 |
-
"@tanstack/react-query": "^5.62.3",
|
| 17 |
-
"expo": "~52.0.20",
|
| 18 |
-
"expo-asset": "~10.0.0",
|
| 19 |
-
"expo-background-fetch": "~12.0.1",
|
| 20 |
-
"expo-battery": "^57.0.1",
|
| 21 |
-
"expo-blur": "~14.0.1",
|
| 22 |
-
"expo-constants": "~17.0.3",
|
| 23 |
-
"expo-crypto": "~14.0.1",
|
| 24 |
-
"expo-file-system": "~18.0.6",
|
| 25 |
-
"expo-image": "~2.0.3",
|
| 26 |
-
"expo-linear-gradient": "~14.0.1",
|
| 27 |
-
"expo-media-library": "~16.0.5",
|
| 28 |
-
"expo-network": "~7.0.1",
|
| 29 |
-
"expo-router": "~4.0.14",
|
| 30 |
-
"expo-splash-screen": "~0.29.18",
|
| 31 |
-
"expo-status-bar": "~2.0.0",
|
| 32 |
-
"expo-task-manager": "~12.0.3",
|
| 33 |
-
"react": "18.3.1",
|
| 34 |
-
"react-native": "0.76.5",
|
| 35 |
-
"react-native-gesture-handler": "~2.20.2",
|
| 36 |
-
"react-native-reanimated": "~3.16.1",
|
| 37 |
-
"react-native-safe-area-context": "4.12.0",
|
| 38 |
-
"react-native-screens": "~4.4.0",
|
| 39 |
-
"react-native-web": "~0.19.13",
|
| 40 |
-
"zustand": "^5.0.2"
|
| 41 |
-
},
|
| 42 |
-
"devDependencies": {
|
| 43 |
-
"@babel/core": "^7.25.2",
|
| 44 |
-
"@types/react": "~18.3.12",
|
| 45 |
-
"typescript": "^5.3.3"
|
| 46 |
-
}
|
| 47 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/services/api.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – API Client
|
| 3 |
-
* Thin wrapper around fetch that attaches the Supabase JWT automatically.
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import { supabase, API_URL } from './supabase';
|
| 7 |
-
|
| 8 |
-
class ApiError extends Error {
|
| 9 |
-
constructor(public status: number, message: string) {
|
| 10 |
-
super(message);
|
| 11 |
-
this.name = 'ApiError';
|
| 12 |
-
}
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
async function getAuthHeader(): Promise<Record<string, string>> {
|
| 16 |
-
const { data: { session } } = await supabase.auth.getSession();
|
| 17 |
-
if (!session?.access_token) {
|
| 18 |
-
throw new ApiError(401, 'Not authenticated');
|
| 19 |
-
}
|
| 20 |
-
return { Authorization: `Bearer ${session.access_token}` };
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
async function request<T>(
|
| 24 |
-
path: string,
|
| 25 |
-
options: RequestInit = {},
|
| 26 |
-
): Promise<T> {
|
| 27 |
-
const authHeader = await getAuthHeader();
|
| 28 |
-
const url = `${API_URL}${path}`;
|
| 29 |
-
|
| 30 |
-
const response = await fetch(url, {
|
| 31 |
-
...options,
|
| 32 |
-
headers: {
|
| 33 |
-
'Content-Type': 'application/json',
|
| 34 |
-
...authHeader,
|
| 35 |
-
...options.headers,
|
| 36 |
-
},
|
| 37 |
-
});
|
| 38 |
-
|
| 39 |
-
if (!response.ok) {
|
| 40 |
-
const body = await response.json().catch(() => ({}));
|
| 41 |
-
throw new ApiError(response.status, body.detail || `HTTP ${response.status}`);
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
if (response.status === 204) return undefined as T;
|
| 45 |
-
return response.json();
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
// ── Auth ──────────────────────────────────────────────────────────────────────
|
| 49 |
-
export const verifyToken = () => request('/auth/verify', { method: 'POST' });
|
| 50 |
-
|
| 51 |
-
// ── Sync ──────────────────────────────────────────────────────────────────────
|
| 52 |
-
export const syncStart = (data: { device_name: string; platform: string; device_token?: string }) =>
|
| 53 |
-
request('/sync/start', { method: 'POST', body: JSON.stringify(data) });
|
| 54 |
-
|
| 55 |
-
export const syncCheck = (hashes: string[]) =>
|
| 56 |
-
request<{ missing_hashes: string[]; existing_count: number; missing_count: number }>(
|
| 57 |
-
'/sync/check',
|
| 58 |
-
{ method: 'POST', body: JSON.stringify({ hashes }) },
|
| 59 |
-
);
|
| 60 |
-
|
| 61 |
-
// ── Photos ────────────────────────────────────────────────────────────────────
|
| 62 |
-
export const listPhotos = (params?: { limit?: number; cursor?: string; favorites_only?: boolean }) => {
|
| 63 |
-
const qs = new URLSearchParams();
|
| 64 |
-
if (params?.limit) qs.set('limit', String(params.limit));
|
| 65 |
-
if (params?.cursor) qs.set('cursor', params.cursor);
|
| 66 |
-
if (params?.favorites_only) qs.set('favorites_only', 'true');
|
| 67 |
-
return request<{ items: Photo[]; total: number; next_cursor: string | null; has_more: boolean }>(
|
| 68 |
-
`/photos?${qs.toString()}`,
|
| 69 |
-
);
|
| 70 |
-
};
|
| 71 |
-
|
| 72 |
-
export const getPhoto = (id: string) => request<Photo>(`/photos/${id}`);
|
| 73 |
-
|
| 74 |
-
export const deletePhoto = (id: string) =>
|
| 75 |
-
request(`/photos/${id}`, { method: 'DELETE' });
|
| 76 |
-
|
| 77 |
-
export const updatePhoto = (id: string, data: Partial<Photo>) =>
|
| 78 |
-
request<Photo>(`/photos/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
|
| 79 |
-
|
| 80 |
-
// ── Upload ────────────────────────────────────────────────────────────────────
|
| 81 |
-
export const uploadPhoto = async (
|
| 82 |
-
fileUri: string,
|
| 83 |
-
filename: string,
|
| 84 |
-
mimeType: string,
|
| 85 |
-
sha256: string,
|
| 86 |
-
takenAt?: string,
|
| 87 |
-
) => {
|
| 88 |
-
const authHeader = await getAuthHeader();
|
| 89 |
-
const formData = new FormData();
|
| 90 |
-
formData.append('file', { uri: fileUri, name: filename, type: mimeType } as any);
|
| 91 |
-
formData.append('sha256', sha256);
|
| 92 |
-
if (takenAt) formData.append('taken_at', takenAt);
|
| 93 |
-
|
| 94 |
-
const response = await fetch(`${API_URL}/upload`, {
|
| 95 |
-
method: 'POST',
|
| 96 |
-
headers: authHeader,
|
| 97 |
-
body: formData,
|
| 98 |
-
});
|
| 99 |
-
|
| 100 |
-
if (!response.ok) {
|
| 101 |
-
const body = await response.json().catch(() => ({}));
|
| 102 |
-
throw new ApiError(response.status, body.detail || 'Upload failed');
|
| 103 |
-
}
|
| 104 |
-
return response.json();
|
| 105 |
-
};
|
| 106 |
-
|
| 107 |
-
// ── Albums ────────────────────────────────────────────────────────────────────
|
| 108 |
-
export const listAlbums = () =>
|
| 109 |
-
request<Album[]>('/albums');
|
| 110 |
-
|
| 111 |
-
export const createAlbum = (data: { name: string; description?: string }) =>
|
| 112 |
-
request<Album>('/albums', { method: 'POST', body: JSON.stringify(data) });
|
| 113 |
-
|
| 114 |
-
export const getAlbum = (id: string) =>
|
| 115 |
-
request<Album>(`/albums/${id}`);
|
| 116 |
-
|
| 117 |
-
export const addPhotosToAlbum = (albumId: string, photoIds: string[]) =>
|
| 118 |
-
request(`/albums/${albumId}/photos`, {
|
| 119 |
-
method: 'POST',
|
| 120 |
-
body: JSON.stringify({ photo_ids: photoIds }),
|
| 121 |
-
});
|
| 122 |
-
|
| 123 |
-
// ── Search ────────────────────────────────────────────────────────────────────
|
| 124 |
-
export const searchPhotos = (q: string, limit = 50) =>
|
| 125 |
-
request<{ items: Photo[]; total: number; has_more: boolean }>(
|
| 126 |
-
`/search?q=${encodeURIComponent(q)}&limit=${limit}`,
|
| 127 |
-
);
|
| 128 |
-
|
| 129 |
-
// ── Health ────────────────────────────────────────────────────────────────────
|
| 130 |
-
export const healthCheck = () =>
|
| 131 |
-
fetch(`${API_URL}/health`).then(r => r.json());
|
| 132 |
-
|
| 133 |
-
// ── Types ─────────────────────────────────────────────────────────────────────
|
| 134 |
-
export interface Photo {
|
| 135 |
-
id: string;
|
| 136 |
-
user_id: string;
|
| 137 |
-
sha256: string;
|
| 138 |
-
filename: string;
|
| 139 |
-
mime_type: string;
|
| 140 |
-
bucket_path: string;
|
| 141 |
-
thumbnail_path: string | null;
|
| 142 |
-
preview_path: string | null;
|
| 143 |
-
width: number | null;
|
| 144 |
-
height: number | null;
|
| 145 |
-
size: number;
|
| 146 |
-
taken_at: string | null;
|
| 147 |
-
created_at: string;
|
| 148 |
-
uploaded_at: string;
|
| 149 |
-
deleted: boolean;
|
| 150 |
-
is_favorite: boolean;
|
| 151 |
-
ai_description: string | null;
|
| 152 |
-
ai_tags: string | null;
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
export interface Album {
|
| 156 |
-
id: string;
|
| 157 |
-
user_id: string;
|
| 158 |
-
name: string;
|
| 159 |
-
description: string | null;
|
| 160 |
-
cover_photo_id: string | null;
|
| 161 |
-
created_at: string;
|
| 162 |
-
updated_at: string;
|
| 163 |
-
photo_count: number;
|
| 164 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/services/backgroundSync.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Background Sync
|
| 3 |
-
* Registers an Expo Background Fetch task that runs the sync engine
|
| 4 |
-
* periodically even when the app is in the background.
|
| 5 |
-
*/
|
| 6 |
-
|
| 7 |
-
import * as BackgroundFetch from 'expo-background-fetch';
|
| 8 |
-
import * as TaskManager from 'expo-task-manager';
|
| 9 |
-
import * as Battery from 'expo-battery';
|
| 10 |
-
|
| 11 |
-
import { runSync } from './syncEngine';
|
| 12 |
-
import { useSyncStore } from '../store/syncStore';
|
| 13 |
-
|
| 14 |
-
export const BACKGROUND_SYNC_TASK = 'KEYSTONE_BACKGROUND_SYNC';
|
| 15 |
-
|
| 16 |
-
// ── Task Definition ───────────────────────────────────────────────────────────
|
| 17 |
-
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
|
| 18 |
-
console.log('[BackgroundSync] Task fired');
|
| 19 |
-
const { settings } = useSyncStore.getState();
|
| 20 |
-
|
| 21 |
-
if (!settings.autoSync) {
|
| 22 |
-
return BackgroundFetch.BackgroundFetchResult.NoData;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
try {
|
| 26 |
-
// Skip if battery is critically low (< 15%)
|
| 27 |
-
try {
|
| 28 |
-
const batteryLevel = await Battery.getBatteryLevelAsync();
|
| 29 |
-
const batteryState = await Battery.getBatteryStateAsync();
|
| 30 |
-
const isCharging = batteryState === Battery.BatteryState.CHARGING ||
|
| 31 |
-
batteryState === Battery.BatteryState.FULL;
|
| 32 |
-
|
| 33 |
-
if (batteryLevel < 0.15 && !isCharging) {
|
| 34 |
-
console.log('[BackgroundSync] Battery too low – skipping');
|
| 35 |
-
return BackgroundFetch.BackgroundFetchResult.NoData;
|
| 36 |
-
}
|
| 37 |
-
} catch {
|
| 38 |
-
// expo-battery may not be available on all platforms
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
await runSync();
|
| 42 |
-
return BackgroundFetch.BackgroundFetchResult.NewData;
|
| 43 |
-
} catch (err) {
|
| 44 |
-
console.error('[BackgroundSync] Error:', err);
|
| 45 |
-
return BackgroundFetch.BackgroundFetchResult.Failed;
|
| 46 |
-
}
|
| 47 |
-
});
|
| 48 |
-
|
| 49 |
-
// ── Registration ──────────────────────────────────────────────────────────────
|
| 50 |
-
export async function registerBackgroundSync(): Promise<void> {
|
| 51 |
-
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_SYNC_TASK);
|
| 52 |
-
if (isRegistered) return;
|
| 53 |
-
|
| 54 |
-
try {
|
| 55 |
-
await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
|
| 56 |
-
minimumInterval: 15 * 60, // 15 minutes minimum (iOS may enforce longer)
|
| 57 |
-
stopOnTerminate: false,
|
| 58 |
-
startOnBoot: true,
|
| 59 |
-
});
|
| 60 |
-
console.log('[BackgroundSync] Registered successfully');
|
| 61 |
-
} catch (err) {
|
| 62 |
-
console.warn('[BackgroundSync] Registration failed:', err);
|
| 63 |
-
}
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
export async function unregisterBackgroundSync(): Promise<void> {
|
| 67 |
-
try {
|
| 68 |
-
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_SYNC_TASK);
|
| 69 |
-
console.log('[BackgroundSync] Unregistered');
|
| 70 |
-
} catch {
|
| 71 |
-
// Task may not be registered
|
| 72 |
-
}
|
| 73 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/services/hashUtil.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Hash Utility
|
| 3 |
-
* Computes SHA256 of a local file using expo-crypto.
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import * as Crypto from 'expo-crypto';
|
| 7 |
-
import * as FileSystem from 'expo-file-system';
|
| 8 |
-
|
| 9 |
-
/**
|
| 10 |
-
* Compute the SHA256 hash of a file at the given URI.
|
| 11 |
-
* Reads the file as base64, then hashes it.
|
| 12 |
-
*/
|
| 13 |
-
export async function computeFileSHA256(uri: string): Promise<string> {
|
| 14 |
-
// Read file as base64
|
| 15 |
-
const base64 = await FileSystem.readAsStringAsync(uri, {
|
| 16 |
-
encoding: FileSystem.EncodingType.Base64,
|
| 17 |
-
});
|
| 18 |
-
|
| 19 |
-
// Convert base64 to binary string and hash
|
| 20 |
-
const digest = await Crypto.digestStringAsync(
|
| 21 |
-
Crypto.CryptoDigestAlgorithm.SHA256,
|
| 22 |
-
base64,
|
| 23 |
-
{ encoding: Crypto.CryptoEncoding.HEX },
|
| 24 |
-
);
|
| 25 |
-
|
| 26 |
-
return digest;
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
/**
|
| 30 |
-
* Compute SHA256 from a base64-encoded string directly.
|
| 31 |
-
*/
|
| 32 |
-
export async function computeBase64SHA256(base64: string): Promise<string> {
|
| 33 |
-
return Crypto.digestStringAsync(
|
| 34 |
-
Crypto.CryptoDigestAlgorithm.SHA256,
|
| 35 |
-
base64,
|
| 36 |
-
{ encoding: Crypto.CryptoEncoding.HEX },
|
| 37 |
-
);
|
| 38 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/services/supabase.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
import { createClient } from '@supabase/supabase-js';
|
| 2 |
-
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 3 |
-
|
| 4 |
-
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!;
|
| 5 |
-
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY!;
|
| 6 |
-
|
| 7 |
-
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
| 8 |
-
auth: {
|
| 9 |
-
storage: AsyncStorage,
|
| 10 |
-
autoRefreshToken: true,
|
| 11 |
-
persistSession: true,
|
| 12 |
-
detectSessionInUrl: false,
|
| 13 |
-
},
|
| 14 |
-
});
|
| 15 |
-
|
| 16 |
-
export const API_URL = process.env.EXPO_PUBLIC_API_URL || 'https://dpv007-keystone.hf.space';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/services/syncEngine.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Sync Engine
|
| 3 |
-
* Core upload loop:
|
| 4 |
-
* 1. Request media library permission
|
| 5 |
-
* 2. Fetch all photos from camera roll
|
| 6 |
-
* 3. Compute SHA256 for each
|
| 7 |
-
* 4. POST /sync/check to get only missing hashes
|
| 8 |
-
* 5. Upload missing photos one by one
|
| 9 |
-
*/
|
| 10 |
-
|
| 11 |
-
import * as MediaLibrary from 'expo-media-library';
|
| 12 |
-
import * as Network from 'expo-network';
|
| 13 |
-
import { Platform } from 'react-native';
|
| 14 |
-
|
| 15 |
-
import { syncStart, syncCheck, uploadPhoto } from './api';
|
| 16 |
-
import { computeFileSHA256 } from './hashUtil';
|
| 17 |
-
import { useSyncStore } from '../store/syncStore';
|
| 18 |
-
import { useAuthStore } from '../store/authStore';
|
| 19 |
-
|
| 20 |
-
const BATCH_SIZE = 50; // hashes sent per /sync/check call
|
| 21 |
-
|
| 22 |
-
/**
|
| 23 |
-
* Request media library permissions.
|
| 24 |
-
* Returns true if granted.
|
| 25 |
-
*/
|
| 26 |
-
export async function requestMediaPermission(): Promise<boolean> {
|
| 27 |
-
const { status } = await MediaLibrary.requestPermissionsAsync();
|
| 28 |
-
return status === 'granted';
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
/**
|
| 32 |
-
* Main sync function. Call this to start a full sync session.
|
| 33 |
-
*/
|
| 34 |
-
export async function runSync(): Promise<void> {
|
| 35 |
-
const syncStore = useSyncStore.getState();
|
| 36 |
-
const { settings } = syncStore;
|
| 37 |
-
|
| 38 |
-
// Guard: already running
|
| 39 |
-
if (syncStore.isRunning) return;
|
| 40 |
-
|
| 41 |
-
// Guard: Wi-Fi only check
|
| 42 |
-
if (settings.wifiOnly) {
|
| 43 |
-
const netState = await Network.getNetworkStateAsync();
|
| 44 |
-
if (netState.type !== Network.NetworkStateType.WIFI) {
|
| 45 |
-
console.log('[Sync] Wi-Fi only mode – skipping sync (not on Wi-Fi)');
|
| 46 |
-
return;
|
| 47 |
-
}
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
// Guard: permissions
|
| 51 |
-
const hasPermission = await requestMediaPermission();
|
| 52 |
-
if (!hasPermission) {
|
| 53 |
-
console.warn('[Sync] Media library permission not granted');
|
| 54 |
-
return;
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
syncStore.setRunning(true);
|
| 58 |
-
syncStore.resetStats();
|
| 59 |
-
|
| 60 |
-
try {
|
| 61 |
-
// 1. Register device
|
| 62 |
-
const deviceName = `${Platform.OS}-device`;
|
| 63 |
-
const platform = Platform.OS; // android | ios | web
|
| 64 |
-
const { device_id } = await syncStart({ device_name: deviceName, platform });
|
| 65 |
-
syncStore.setDeviceId(device_id);
|
| 66 |
-
|
| 67 |
-
// 2. Fetch all media assets
|
| 68 |
-
let after: string | undefined;
|
| 69 |
-
let hasNextPage = true;
|
| 70 |
-
const allAssets: MediaLibrary.Asset[] = [];
|
| 71 |
-
|
| 72 |
-
while (hasNextPage) {
|
| 73 |
-
const page = await MediaLibrary.getAssetsAsync({
|
| 74 |
-
mediaType: [MediaLibrary.MediaType.photo],
|
| 75 |
-
first: 100,
|
| 76 |
-
after,
|
| 77 |
-
sortBy: MediaLibrary.SortBy.creationTime,
|
| 78 |
-
});
|
| 79 |
-
allAssets.push(...page.assets);
|
| 80 |
-
hasNextPage = page.hasNextPage;
|
| 81 |
-
after = page.endCursor;
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
syncStore.setTotalPhotos(allAssets.length);
|
| 85 |
-
console.log(`[Sync] Found ${allAssets.length} photos in camera roll`);
|
| 86 |
-
|
| 87 |
-
// 3. Process in batches for hash check
|
| 88 |
-
for (let i = 0; i < allAssets.length; i += BATCH_SIZE) {
|
| 89 |
-
if (syncStore.isPaused) {
|
| 90 |
-
console.log('[Sync] Paused – waiting...');
|
| 91 |
-
await new Promise(resolve => setTimeout(resolve, 2000));
|
| 92 |
-
i -= BATCH_SIZE; // retry this batch
|
| 93 |
-
continue;
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
const batch = allAssets.slice(i, i + BATCH_SIZE);
|
| 97 |
-
|
| 98 |
-
// Compute SHA256 for each asset in batch
|
| 99 |
-
const hashMap: Record<string, MediaLibrary.Asset> = {};
|
| 100 |
-
for (const asset of batch) {
|
| 101 |
-
try {
|
| 102 |
-
const info = await MediaLibrary.getAssetInfoAsync(asset);
|
| 103 |
-
const uri = info.localUri || asset.uri;
|
| 104 |
-
const hash = await computeFileSHA256(uri);
|
| 105 |
-
hashMap[hash] = asset;
|
| 106 |
-
} catch (err) {
|
| 107 |
-
console.warn('[Sync] Failed to hash asset:', asset.filename, err);
|
| 108 |
-
}
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
const batchHashes = Object.keys(hashMap);
|
| 112 |
-
if (batchHashes.length === 0) continue;
|
| 113 |
-
|
| 114 |
-
// 4. Check which are missing on server
|
| 115 |
-
const { missing_hashes, existing_count } = await syncCheck(batchHashes);
|
| 116 |
-
syncStore.incrementSkipped(); // approximate
|
| 117 |
-
|
| 118 |
-
console.log(`[Sync] Batch: ${existing_count} existing, ${missing_hashes.length} to upload`);
|
| 119 |
-
|
| 120 |
-
// 5. Upload missing
|
| 121 |
-
for (const hash of missing_hashes) {
|
| 122 |
-
if (syncStore.isPaused) break;
|
| 123 |
-
|
| 124 |
-
const asset = hashMap[hash];
|
| 125 |
-
if (!asset) continue;
|
| 126 |
-
|
| 127 |
-
try {
|
| 128 |
-
const info = await MediaLibrary.getAssetInfoAsync(asset);
|
| 129 |
-
const uri = info.localUri || asset.uri;
|
| 130 |
-
const mimeType = asset.mediaType === 'photo' ? 'image/jpeg' : 'image/jpeg';
|
| 131 |
-
const takenAt = new Date(asset.creationTime).toISOString();
|
| 132 |
-
|
| 133 |
-
await uploadPhoto(uri, asset.filename, mimeType, hash, takenAt);
|
| 134 |
-
syncStore.incrementUploaded();
|
| 135 |
-
console.log(`[Sync] Uploaded: ${asset.filename}`);
|
| 136 |
-
} catch (err: any) {
|
| 137 |
-
syncStore.incrementFailed();
|
| 138 |
-
console.error(`[Sync] Upload failed for ${asset.filename}:`, err.message);
|
| 139 |
-
}
|
| 140 |
-
}
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
console.log('[Sync] Complete ✓');
|
| 144 |
-
} catch (err) {
|
| 145 |
-
console.error('[Sync] Fatal error:', err);
|
| 146 |
-
} finally {
|
| 147 |
-
syncStore.setRunning(false);
|
| 148 |
-
}
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
export function pauseSync() {
|
| 152 |
-
useSyncStore.getState().setPaused(true);
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
export function resumeSync() {
|
| 156 |
-
useSyncStore.getState().setPaused(false);
|
| 157 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/store/authStore.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Auth Store (Zustand)
|
| 3 |
-
* Manages Supabase session, user profile, and auth state machine.
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import { create } from 'zustand';
|
| 7 |
-
import { Session, User } from '@supabase/supabase-js';
|
| 8 |
-
import { supabase } from '../services/supabase';
|
| 9 |
-
|
| 10 |
-
interface AuthState {
|
| 11 |
-
session: Session | null;
|
| 12 |
-
user: User | null;
|
| 13 |
-
isLoading: boolean;
|
| 14 |
-
isInitialized: boolean;
|
| 15 |
-
|
| 16 |
-
// Actions
|
| 17 |
-
initialize: () => Promise<void>;
|
| 18 |
-
signIn: (email: string, password: string) => Promise<void>;
|
| 19 |
-
signUp: (email: string, password: string) => Promise<void>;
|
| 20 |
-
signOut: () => Promise<void>;
|
| 21 |
-
resetPassword: (email: string) => Promise<void>;
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
export const useAuthStore = create<AuthState>((set) => ({
|
| 25 |
-
session: null,
|
| 26 |
-
user: null,
|
| 27 |
-
isLoading: false,
|
| 28 |
-
isInitialized: false,
|
| 29 |
-
|
| 30 |
-
initialize: async () => {
|
| 31 |
-
// Restore session from AsyncStorage
|
| 32 |
-
const { data: { session } } = await supabase.auth.getSession();
|
| 33 |
-
set({ session, user: session?.user ?? null, isInitialized: true });
|
| 34 |
-
|
| 35 |
-
// Listen for auth state changes
|
| 36 |
-
supabase.auth.onAuthStateChange((_event, session) => {
|
| 37 |
-
set({ session, user: session?.user ?? null });
|
| 38 |
-
});
|
| 39 |
-
},
|
| 40 |
-
|
| 41 |
-
signIn: async (email, password) => {
|
| 42 |
-
set({ isLoading: true });
|
| 43 |
-
try {
|
| 44 |
-
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
| 45 |
-
if (error) throw error;
|
| 46 |
-
set({ session: data.session, user: data.user });
|
| 47 |
-
} finally {
|
| 48 |
-
set({ isLoading: false });
|
| 49 |
-
}
|
| 50 |
-
},
|
| 51 |
-
|
| 52 |
-
signUp: async (email, password) => {
|
| 53 |
-
set({ isLoading: true });
|
| 54 |
-
try {
|
| 55 |
-
const { data, error } = await supabase.auth.signUp({ email, password });
|
| 56 |
-
if (error) throw error;
|
| 57 |
-
set({ session: data.session, user: data.user ?? null });
|
| 58 |
-
} finally {
|
| 59 |
-
set({ isLoading: false });
|
| 60 |
-
}
|
| 61 |
-
},
|
| 62 |
-
|
| 63 |
-
signOut: async () => {
|
| 64 |
-
set({ isLoading: true });
|
| 65 |
-
try {
|
| 66 |
-
await supabase.auth.signOut();
|
| 67 |
-
set({ session: null, user: null });
|
| 68 |
-
} finally {
|
| 69 |
-
set({ isLoading: false });
|
| 70 |
-
}
|
| 71 |
-
},
|
| 72 |
-
|
| 73 |
-
resetPassword: async (email) => {
|
| 74 |
-
set({ isLoading: true });
|
| 75 |
-
try {
|
| 76 |
-
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
| 77 |
-
redirectTo: 'keystone://reset-password',
|
| 78 |
-
});
|
| 79 |
-
if (error) throw error;
|
| 80 |
-
} finally {
|
| 81 |
-
set({ isLoading: false });
|
| 82 |
-
}
|
| 83 |
-
},
|
| 84 |
-
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/store/syncStore.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
* KeyStone – Sync Store (Zustand)
|
| 3 |
-
* Tracks upload queue, progress, and sync settings.
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
import { create } from 'zustand';
|
| 7 |
-
|
| 8 |
-
export type UploadItem = {
|
| 9 |
-
id: string; // local asset ID
|
| 10 |
-
uri: string;
|
| 11 |
-
filename: string;
|
| 12 |
-
sha256: string;
|
| 13 |
-
status: 'pending' | 'uploading' | 'done' | 'failed' | 'duplicate';
|
| 14 |
-
progress: number; // 0-100
|
| 15 |
-
error?: string;
|
| 16 |
-
};
|
| 17 |
-
|
| 18 |
-
interface SyncSettings {
|
| 19 |
-
autoSync: boolean;
|
| 20 |
-
wifiOnly: boolean;
|
| 21 |
-
thumbnailQuality: 'low' | 'medium' | 'high';
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
interface SyncState {
|
| 25 |
-
// Queue
|
| 26 |
-
queue: UploadItem[];
|
| 27 |
-
isRunning: boolean;
|
| 28 |
-
isPaused: boolean;
|
| 29 |
-
deviceId: string | null;
|
| 30 |
-
|
| 31 |
-
// Stats
|
| 32 |
-
uploaded: number;
|
| 33 |
-
skipped: number;
|
| 34 |
-
failed: number;
|
| 35 |
-
totalPhotos: number;
|
| 36 |
-
|
| 37 |
-
// Settings
|
| 38 |
-
settings: SyncSettings;
|
| 39 |
-
|
| 40 |
-
// Actions
|
| 41 |
-
setQueue: (items: UploadItem[]) => void;
|
| 42 |
-
updateItem: (id: string, updates: Partial<UploadItem>) => void;
|
| 43 |
-
setRunning: (running: boolean) => void;
|
| 44 |
-
setPaused: (paused: boolean) => void;
|
| 45 |
-
setDeviceId: (id: string) => void;
|
| 46 |
-
incrementUploaded: () => void;
|
| 47 |
-
incrementSkipped: () => void;
|
| 48 |
-
incrementFailed: () => void;
|
| 49 |
-
setTotalPhotos: (count: number) => void;
|
| 50 |
-
updateSettings: (settings: Partial<SyncSettings>) => void;
|
| 51 |
-
resetStats: () => void;
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
export const useSyncStore = create<SyncState>((set) => ({
|
| 55 |
-
queue: [],
|
| 56 |
-
isRunning: false,
|
| 57 |
-
isPaused: false,
|
| 58 |
-
deviceId: null,
|
| 59 |
-
uploaded: 0,
|
| 60 |
-
skipped: 0,
|
| 61 |
-
failed: 0,
|
| 62 |
-
totalPhotos: 0,
|
| 63 |
-
|
| 64 |
-
settings: {
|
| 65 |
-
autoSync: true,
|
| 66 |
-
wifiOnly: true,
|
| 67 |
-
thumbnailQuality: 'medium',
|
| 68 |
-
},
|
| 69 |
-
|
| 70 |
-
setQueue: (items) => set({ queue: items }),
|
| 71 |
-
|
| 72 |
-
updateItem: (id, updates) =>
|
| 73 |
-
set((state) => ({
|
| 74 |
-
queue: state.queue.map((item) =>
|
| 75 |
-
item.id === id ? { ...item, ...updates } : item,
|
| 76 |
-
),
|
| 77 |
-
})),
|
| 78 |
-
|
| 79 |
-
setRunning: (running) => set({ isRunning: running }),
|
| 80 |
-
setPaused: (paused) => set({ isPaused: paused }),
|
| 81 |
-
setDeviceId: (id) => set({ deviceId: id }),
|
| 82 |
-
|
| 83 |
-
incrementUploaded: () => set((s) => ({ uploaded: s.uploaded + 1 })),
|
| 84 |
-
incrementSkipped: () => set((s) => ({ skipped: s.skipped + 1 })),
|
| 85 |
-
incrementFailed: () => set((s) => ({ failed: s.failed + 1 })),
|
| 86 |
-
setTotalPhotos: (count) => set({ totalPhotos: count }),
|
| 87 |
-
|
| 88 |
-
updateSettings: (settings) =>
|
| 89 |
-
set((state) => ({ settings: { ...state.settings, ...settings } })),
|
| 90 |
-
|
| 91 |
-
resetStats: () => set({ uploaded: 0, skipped: 0, failed: 0, totalPhotos: 0 }),
|
| 92 |
-
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
mobile/tsconfig.json
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"extends": "expo/tsconfig.base",
|
| 3 |
-
"compilerOptions": {
|
| 4 |
-
"strict": true,
|
| 5 |
-
"paths": {
|
| 6 |
-
"@/*": ["./*"]
|
| 7 |
-
}
|
| 8 |
-
}
|
| 9 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|