dpv007 commited on
Commit
a9d060d
·
unverified ·
1 Parent(s): 7c159f4

Delete mobile

Browse files
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
- }