dpv007 commited on
Commit
3fdd49b
·
1 Parent(s): 9b8a4e6
keystone-app/App.tsx CHANGED
@@ -13,7 +13,7 @@ export default function App() {
13
  const styles = StyleSheet.create({
14
  container: {
15
  flex: 1,
16
- backgroundColor: '#fff',
17
  alignItems: 'center',
18
  justifyContent: 'center',
19
  },
 
13
  const styles = StyleSheet.create({
14
  container: {
15
  flex: 1,
16
+ backgroundColor: '#F6EAE1',
17
  alignItems: 'center',
18
  justifyContent: 'center',
19
  },
keystone-app/agent-creations/generated_13b76991.webp DELETED
Binary file (66.1 kB)
 
keystone-app/agent-creations/generated_14c7b769.webp DELETED
Binary file (32.8 kB)
 
keystone-app/agent-creations/generated_2f2c9fbb.webp DELETED
Binary file (90.9 kB)
 
keystone-app/agent-creations/generated_32ffb560.webp DELETED
Binary file (18.4 kB)
 
keystone-app/agent-creations/generated_4a0d43a3.webp DELETED
Binary file (58 kB)
 
keystone-app/agent-creations/generated_51e5ebf4.webp DELETED
Binary file (25.8 kB)
 
keystone-app/agent-creations/generated_63679208.webp DELETED
Binary file (14.7 kB)
 
keystone-app/agent-creations/generated_64a66005.webp DELETED
Binary file (60.2 kB)
 
keystone-app/agent-creations/generated_9c5738b9.webp DELETED
Binary file (31.6 kB)
 
keystone-app/agent-creations/generated_a2a6239c.webp DELETED
Binary file (47.9 kB)
 
keystone-app/agent-creations/generated_ac24836b.webp DELETED
Binary file (53.2 kB)
 
keystone-app/app.json CHANGED
@@ -23,7 +23,8 @@
23
  }
24
  ],
25
  "expo-router",
26
- "expo-font"
 
27
  ],
28
  "extra": {
29
  "eas": {
@@ -39,4 +40,3 @@
39
  }
40
  }
41
  }
42
-
 
23
  }
24
  ],
25
  "expo-router",
26
+ "expo-font",
27
+ "expo-video"
28
  ],
29
  "extra": {
30
  "eas": {
 
40
  }
41
  }
42
  }
 
keystone-app/app/(auth)/_layout.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
keystone-app/app/(auth)/forgot-password.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ });
keystone-app/app/(auth)/login.tsx ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ });
keystone-app/app/(auth)/register.tsx ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ });
keystone-app/app/(tabs)/index.tsx CHANGED
@@ -17,6 +17,7 @@ import {
17
  import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
18
  import * as MediaLibrary from 'expo-media-library';
19
  import { Image } from 'expo-image';
 
20
  import * as FileSystem from 'expo-file-system/legacy';
21
  import { Video, ResizeMode } from 'expo-av';
22
  import * as Sharing from 'expo-sharing';
@@ -97,10 +98,12 @@ const GalleryRow = React.memo(({
97
  activeOpacity={0.85}
98
  >
99
  <Image
100
- source={asset.isCloudOnly && isVideo && asset.cloudThumbUri ? asset.cloudThumbUri : asset.uri}
101
  style={styles.image}
102
  contentFit="cover"
103
  cachePolicy="memory-disk"
 
 
104
  />
105
  {isVideo && (
106
  <View style={styles.videoOverlay}>
@@ -470,8 +473,14 @@ export default function PhotosScreen() {
470
  const performAdvancedDelete = async (items: UnifiedAsset[], target: 'device' | 'cloud' | 'both') => {
471
  try {
472
  const token = await getToken();
473
- // 1. Delete from Device (Move to local bin)
474
  if (target === 'device' || target === 'both') {
 
 
 
 
 
 
475
  const trashed = await getTrashedAssets();
476
  const newTrashed = [...trashed, ...items];
477
  await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
@@ -801,36 +810,16 @@ export default function PhotosScreen() {
801
  renderImage={(props) => {
802
  const uri = props.source.uri;
803
  const asset = allAssets.find(a => a.uri === uri);
804
-
805
- if (asset?.mediaType === 'video') {
806
- return (
807
- <View
808
- style={{ width, height, paddingBottom: insets.bottom + 80, justifyContent: 'center' }}
809
- onStartShouldSetResponder={() => true}
810
- onResponderTerminationRequest={() => false}
811
- >
812
- <Video
813
- source={{ uri: asset.uri }}
814
- style={{ width: '100%', height: '100%' }}
815
- resizeMode={ResizeMode.CONTAIN}
816
- useNativeControls
817
- shouldPlay={allAssets[viewerIndex ?? 0]?.id === asset.id && isPlaying}
818
- isLooping={false}
819
- onPlaybackStatusUpdate={(status) => {
820
- if (status.isLoaded && status.didJustFinish) setIsPlaying(false);
821
- }}
822
- />
823
- </View>
824
- );
825
- }
826
 
827
  return (
828
- <Image
829
- source={uri}
830
- style={{ width: '100%', height: '100%' }}
831
- contentFit="contain"
832
- cachePolicy="memory-disk"
833
- />
 
834
  );
835
  }}
836
  />
@@ -936,8 +925,8 @@ export default function PhotosScreen() {
936
  }
937
 
938
  const styles = StyleSheet.create({
939
- container: { flex: 1, backgroundColor: '#fff' },
940
- center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, backgroundColor: '#fff' },
941
  webMessage: { fontSize: 20, fontWeight: '700', marginTop: 16, color: '#202124' },
942
  webSubMessage: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 6 },
943
  permissionTitle: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 20, marginBottom: 10, textAlign: 'center' },
@@ -947,7 +936,7 @@ const styles = StyleSheet.create({
947
 
948
  header: {
949
  flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
950
- paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#fff',
951
  },
952
  headerLogo: { fontSize: 22, fontWeight: '700', color: '#202124', letterSpacing: -0.5 },
953
  selectionCount: { fontSize: 18, fontWeight: '600', color: '#202124' },
@@ -966,7 +955,7 @@ const styles = StyleSheet.create({
966
  progressBarFill: { height: 3, backgroundColor: '#1a73e8', borderRadius: 2 },
967
 
968
  listContent: { paddingHorizontal: SPACING, paddingBottom: 16 },
969
- sectionHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 14, paddingHorizontal: 4, backgroundColor: '#fff' },
970
  sectionTitle: { fontSize: 15, fontWeight: '600', color: '#3c4043', flex: 1 },
971
  dayCheckBtn: { padding: 4 },
972
 
@@ -1007,7 +996,7 @@ const styles = StyleSheet.create({
1007
  viewerActionLabel: { color: '#fff', fontSize: 13, fontWeight: '500' },
1008
 
1009
  infoModalBg: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' },
1010
- infoSheet: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24 },
1011
  infoTitle: { fontSize: 20, fontWeight: '700', color: '#202124', marginBottom: 16 },
1012
  infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16, gap: 12 },
1013
  infoText: { fontSize: 15, color: '#3c4043', flex: 1 },
 
17
  import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
18
  import * as MediaLibrary from 'expo-media-library';
19
  import { Image } from 'expo-image';
20
+ import MediaViewer from '../../components/MediaViewer';
21
  import * as FileSystem from 'expo-file-system/legacy';
22
  import { Video, ResizeMode } from 'expo-av';
23
  import * as Sharing from 'expo-sharing';
 
98
  activeOpacity={0.85}
99
  >
100
  <Image
101
+ source={asset.isCloudOnly && asset.cloudThumbUri ? asset.cloudThumbUri : asset.uri}
102
  style={styles.image}
103
  contentFit="cover"
104
  cachePolicy="memory-disk"
105
+ transition={200}
106
+ backgroundColor="transparent"
107
  />
108
  {isVideo && (
109
  <View style={styles.videoOverlay}>
 
473
  const performAdvancedDelete = async (items: UnifiedAsset[], target: 'device' | 'cloud' | 'both') => {
474
  try {
475
  const token = await getToken();
476
+ // 1. Delete from Device (Move to local bin & delete physical file)
477
  if (target === 'device' || target === 'both') {
478
+ const localAssetsToDelete = items.filter(i => !i.isCloudOnly && i.originalId).map(i => i.originalId!);
479
+ if (localAssetsToDelete.length > 0) {
480
+ // Permanently delete from device's camera roll
481
+ await MediaLibrary.deleteAssetsAsync(localAssetsToDelete as any);
482
+ }
483
+
484
  const trashed = await getTrashedAssets();
485
  const newTrashed = [...trashed, ...items];
486
  await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
 
810
  renderImage={(props) => {
811
  const uri = props.source.uri;
812
  const asset = allAssets.find(a => a.uri === uri);
813
+ const shouldPlay = allAssets[viewerIndex ?? 0]?.uri === uri && isPlaying;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
 
815
  return (
816
+ <View
817
+ style={{ width, height, paddingBottom: insets.bottom + 80, justifyContent: 'center' }}
818
+ onStartShouldSetResponder={() => true}
819
+ onResponderTerminationRequest={() => false}
820
+ >
821
+ <MediaViewer asset={asset} shouldPlay={shouldPlay} />
822
+ </View>
823
  );
824
  }}
825
  />
 
925
  }
926
 
927
  const styles = StyleSheet.create({
928
+ container: { flex: 1, backgroundColor: '#F6EAE1' },
929
+ center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, backgroundColor: '#F6EAE1' },
930
  webMessage: { fontSize: 20, fontWeight: '700', marginTop: 16, color: '#202124' },
931
  webSubMessage: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 6 },
932
  permissionTitle: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 20, marginBottom: 10, textAlign: 'center' },
 
936
 
937
  header: {
938
  flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center',
939
+ paddingHorizontal: 16, paddingVertical: 10, backgroundColor: '#F6EAE1',
940
  },
941
  headerLogo: { fontSize: 22, fontWeight: '700', color: '#202124', letterSpacing: -0.5 },
942
  selectionCount: { fontSize: 18, fontWeight: '600', color: '#202124' },
 
955
  progressBarFill: { height: 3, backgroundColor: '#1a73e8', borderRadius: 2 },
956
 
957
  listContent: { paddingHorizontal: SPACING, paddingBottom: 16 },
958
+ sectionHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 14, paddingHorizontal: 4, backgroundColor: '#F6EAE1' },
959
  sectionTitle: { fontSize: 15, fontWeight: '600', color: '#3c4043', flex: 1 },
960
  dayCheckBtn: { padding: 4 },
961
 
 
996
  viewerActionLabel: { color: '#fff', fontSize: 13, fontWeight: '500' },
997
 
998
  infoModalBg: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' },
999
+ infoSheet: { backgroundColor: '#F6EAE1', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24 },
1000
  infoTitle: { fontSize: 20, fontWeight: '700', color: '#202124', marginBottom: 16 },
1001
  infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16, gap: 12 },
1002
  infoText: { fontSize: 15, color: '#3c4043', flex: 1 },
keystone-app/app/(tabs)/library.tsx CHANGED
@@ -15,6 +15,7 @@ import {
15
  import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
16
  import * as MediaLibrary from 'expo-media-library';
17
  import { Image } from 'expo-image';
 
18
  import { StatusBar } from 'expo-status-bar';
19
  import { MaterialIcons } from '@expo/vector-icons';
20
  import { format } from 'date-fns';
@@ -238,7 +239,7 @@ export default function LibraryScreen() {
238
 
239
  {/* Album detail modal */}
240
  <Modal visible={!!openAlbum} animationType="slide" onRequestClose={() => { setOpenAlbum(null); setAlbumAssets([]); }}>
241
- <SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }}>
242
  <View style={styles.modalHeader}>
243
  <TouchableOpacity onPress={() => { setOpenAlbum(null); setAlbumAssets([]); }} style={{ padding: 8 }}>
244
  <MaterialIcons name="arrow-back" size={24} color="#202124" />
@@ -270,7 +271,7 @@ export default function LibraryScreen() {
270
  style={{ flex: 1, margin: 2, aspectRatio: 1, borderRadius: 4, overflow: 'hidden', backgroundColor: '#e0e0e0' }}
271
  onPress={() => setViewerAsset(item)}
272
  >
273
- <Image source={{ uri: item.uri }} style={StyleSheet.absoluteFill} contentFit="cover" cachePolicy="memory-disk" />
274
  {item.mediaType === MediaLibrary.MediaType.video && (
275
  <View style={{ position: 'absolute', bottom: 4, left: 4 }}>
276
  <MaterialIcons name="play-circle-fill" size={18} color="rgba(255,255,255,0.9)" />
@@ -285,7 +286,7 @@ export default function LibraryScreen() {
285
 
286
  {/* Bin detail modal */}
287
  <Modal visible={viewingBin} animationType="slide" onRequestClose={() => setViewingBin(false)}>
288
- <SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }}>
289
  <View style={styles.modalHeader}>
290
  <TouchableOpacity onPress={() => setViewingBin(false)} style={{ padding: 8 }}>
291
  <MaterialIcons name="arrow-back" size={24} color="#202124" />
@@ -303,7 +304,7 @@ export default function LibraryScreen() {
303
  style={{ flex: 1, margin: 2, aspectRatio: 1, borderRadius: 4, overflow: 'hidden', backgroundColor: '#e0e0e0' }}
304
  onPress={() => { setViewerAsset(item); setIsViewerFromBin(true); }}
305
  >
306
- <Image source={{ uri: item.uri }} style={StyleSheet.absoluteFill} contentFit="cover" cachePolicy="memory-disk" />
307
  </TouchableOpacity>
308
  )}
309
  />
@@ -319,12 +320,7 @@ export default function LibraryScreen() {
319
  >
320
  <MaterialIcons name="close" size={26} color="#fff" />
321
  </TouchableOpacity>
322
- <Image
323
- source={{ uri: viewerAsset?.uri }}
324
- style={{ flex: 1 }}
325
- contentFit="contain"
326
- cachePolicy="memory-disk"
327
- />
328
  {viewerAsset && isViewerFromBin && (
329
  <View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, flexDirection: 'row', justifyContent: 'space-around', backgroundColor: 'rgba(0,0,0,0.8)', paddingVertical: 16, paddingBottom: insets.bottom + 16 }}>
330
  <TouchableOpacity onPress={() => setShowRestoreMenu(true)} style={{ alignItems: 'center' }}>
@@ -487,7 +483,7 @@ export default function LibraryScreen() {
487
  }
488
 
489
  const styles = StyleSheet.create({
490
- container: { flex: 1, backgroundColor: '#fff' },
491
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
492
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
493
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
@@ -519,7 +515,7 @@ const styles = StyleSheet.create({
519
  modalCount: { fontSize: 13, color: '#5f6368', marginRight: 8 },
520
 
521
  actionSheetBg: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' },
522
- actionSheet: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24, paddingBottom: 40 },
523
  actionSheetTitle: { fontSize: 20, fontWeight: '700', color: '#202124', marginBottom: 16 },
524
  actionSheetRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 20, gap: 12 },
525
  actionSheetText: { fontSize: 15, fontWeight: '600', color: '#202124' },
 
15
  import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
16
  import * as MediaLibrary from 'expo-media-library';
17
  import { Image } from 'expo-image';
18
+ import MediaViewer from '../../components/MediaViewer';
19
  import { StatusBar } from 'expo-status-bar';
20
  import { MaterialIcons } from '@expo/vector-icons';
21
  import { format } from 'date-fns';
 
239
 
240
  {/* Album detail modal */}
241
  <Modal visible={!!openAlbum} animationType="slide" onRequestClose={() => { setOpenAlbum(null); setAlbumAssets([]); }}>
242
+ <SafeAreaView style={{ flex: 1, backgroundColor: '#F6EAE1' }}>
243
  <View style={styles.modalHeader}>
244
  <TouchableOpacity onPress={() => { setOpenAlbum(null); setAlbumAssets([]); }} style={{ padding: 8 }}>
245
  <MaterialIcons name="arrow-back" size={24} color="#202124" />
 
271
  style={{ flex: 1, margin: 2, aspectRatio: 1, borderRadius: 4, overflow: 'hidden', backgroundColor: '#e0e0e0' }}
272
  onPress={() => setViewerAsset(item)}
273
  >
274
+ <Image source={{ uri: item.uri }} style={StyleSheet.absoluteFill} contentFit="cover" cachePolicy="memory-disk" transition={200} backgroundColor="transparent" />
275
  {item.mediaType === MediaLibrary.MediaType.video && (
276
  <View style={{ position: 'absolute', bottom: 4, left: 4 }}>
277
  <MaterialIcons name="play-circle-fill" size={18} color="rgba(255,255,255,0.9)" />
 
286
 
287
  {/* Bin detail modal */}
288
  <Modal visible={viewingBin} animationType="slide" onRequestClose={() => setViewingBin(false)}>
289
+ <SafeAreaView style={{ flex: 1, backgroundColor: '#F6EAE1' }}>
290
  <View style={styles.modalHeader}>
291
  <TouchableOpacity onPress={() => setViewingBin(false)} style={{ padding: 8 }}>
292
  <MaterialIcons name="arrow-back" size={24} color="#202124" />
 
304
  style={{ flex: 1, margin: 2, aspectRatio: 1, borderRadius: 4, overflow: 'hidden', backgroundColor: '#e0e0e0' }}
305
  onPress={() => { setViewerAsset(item); setIsViewerFromBin(true); }}
306
  >
307
+ <Image source={{ uri: item.isCloudOnly && item.cloudThumbUri ? item.cloudThumbUri : item.uri }} style={StyleSheet.absoluteFill} contentFit="cover" cachePolicy="memory-disk" transition={200} backgroundColor="transparent" />
308
  </TouchableOpacity>
309
  )}
310
  />
 
320
  >
321
  <MaterialIcons name="close" size={26} color="#fff" />
322
  </TouchableOpacity>
323
+ <MediaViewer asset={viewerAsset} />
 
 
 
 
 
324
  {viewerAsset && isViewerFromBin && (
325
  <View style={{ position: 'absolute', bottom: 0, left: 0, right: 0, flexDirection: 'row', justifyContent: 'space-around', backgroundColor: 'rgba(0,0,0,0.8)', paddingVertical: 16, paddingBottom: insets.bottom + 16 }}>
326
  <TouchableOpacity onPress={() => setShowRestoreMenu(true)} style={{ alignItems: 'center' }}>
 
483
  }
484
 
485
  const styles = StyleSheet.create({
486
+ container: { flex: 1, backgroundColor: '#F6EAE1' },
487
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
488
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
489
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
 
515
  modalCount: { fontSize: 13, color: '#5f6368', marginRight: 8 },
516
 
517
  actionSheetBg: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' },
518
+ actionSheet: { backgroundColor: '#F6EAE1', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24, paddingBottom: 40 },
519
  actionSheetTitle: { fontSize: 20, fontWeight: '700', color: '#202124', marginBottom: 16 },
520
  actionSheetRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 20, gap: 12 },
521
  actionSheetText: { fontSize: 15, fontWeight: '600', color: '#202124' },
keystone-app/app/(tabs)/memories.tsx CHANGED
@@ -12,8 +12,8 @@ import {
12
  Animated,
13
  } from 'react-native';
14
  import { SafeAreaView } from 'react-native-safe-area-context';
15
- import * as MediaLibrary from 'expo-media-library';
16
  import { Image } from 'expo-image';
 
17
  import { StatusBar } from 'expo-status-bar';
18
  import { MaterialIcons } from '@expo/vector-icons';
19
  import { LinearGradient } from 'expo-linear-gradient';
@@ -160,6 +160,8 @@ export default function MemoriesScreen() {
160
  style={StyleSheet.absoluteFill}
161
  contentFit="cover"
162
  cachePolicy="memory-disk"
 
 
163
  />
164
  {/* Gradient overlay */}
165
  <LinearGradient colors={['transparent', 'rgba(0,0,0,0.7)']} style={styles.memCardOverlay} />
@@ -197,7 +199,7 @@ export default function MemoriesScreen() {
197
  setViewerIndex(group.assets.indexOf(item));
198
  }}
199
  >
200
- <Image source={{ uri: item.uri }} style={StyleSheet.absoluteFill} contentFit="cover" cachePolicy="memory-disk" />
201
  </TouchableOpacity>
202
  )}
203
  />
@@ -224,13 +226,10 @@ export default function MemoriesScreen() {
224
  initialScrollIndex={viewerIndex}
225
  getItemLayout={(_, i) => ({ length: width, offset: width * i, index: i })}
226
  keyExtractor={a => a.id}
227
- renderItem={({ item }) => (
228
- <Image
229
- source={{ uri: item.uri }}
230
- style={{ width, height: '100%' }}
231
- contentFit="contain"
232
- cachePolicy="memory-disk"
233
- />
234
  )}
235
  />
236
  <View style={styles.viewerInfo}>
@@ -244,7 +243,7 @@ export default function MemoriesScreen() {
244
  }
245
 
246
  const styles = StyleSheet.create({
247
- container: { flex: 1, backgroundColor: '#fff' },
248
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
249
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
250
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
 
12
  Animated,
13
  } from 'react-native';
14
  import { SafeAreaView } from 'react-native-safe-area-context';
 
15
  import { Image } from 'expo-image';
16
+ import MediaViewer from '../../components/MediaViewer';
17
  import { StatusBar } from 'expo-status-bar';
18
  import { MaterialIcons } from '@expo/vector-icons';
19
  import { LinearGradient } from 'expo-linear-gradient';
 
160
  style={StyleSheet.absoluteFill}
161
  contentFit="cover"
162
  cachePolicy="memory-disk"
163
+ transition={200}
164
+ backgroundColor="transparent"
165
  />
166
  {/* Gradient overlay */}
167
  <LinearGradient colors={['transparent', 'rgba(0,0,0,0.7)']} style={styles.memCardOverlay} />
 
199
  setViewerIndex(group.assets.indexOf(item));
200
  }}
201
  >
202
+ <Image source={{ uri: item.uri }} style={StyleSheet.absoluteFill} contentFit="cover" cachePolicy="memory-disk" transition={200} backgroundColor="transparent" />
203
  </TouchableOpacity>
204
  )}
205
  />
 
226
  initialScrollIndex={viewerIndex}
227
  getItemLayout={(_, i) => ({ length: width, offset: width * i, index: i })}
228
  keyExtractor={a => a.id}
229
+ renderItem={({ item, index }) => (
230
+ <View style={{ width, height: '100%', justifyContent: 'center' }}>
231
+ <MediaViewer asset={item} shouldPlay={viewerIndex === index} />
232
+ </View>
 
 
 
233
  )}
234
  />
235
  <View style={styles.viewerInfo}>
 
243
  }
244
 
245
  const styles = StyleSheet.create({
246
+ container: { flex: 1, backgroundColor: '#F6EAE1' },
247
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
248
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
249
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
keystone-app/app/(tabs)/search.tsx CHANGED
@@ -13,8 +13,8 @@ import {
13
  ActivityIndicator,
14
  } from 'react-native';
15
  import { SafeAreaView } from 'react-native-safe-area-context';
16
- import * as MediaLibrary from 'expo-media-library';
17
  import { Image } from 'expo-image';
 
18
  import { StatusBar } from 'expo-status-bar';
19
  import { MaterialIcons } from '@expo/vector-icons';
20
  import { format, subDays, subMonths, subYears, startOfDay, endOfDay, startOfMonth, endOfMonth, startOfYear, endOfYear } from 'date-fns';
@@ -260,6 +260,8 @@ export default function SearchScreen() {
260
  style={StyleSheet.absoluteFill}
261
  contentFit="cover"
262
  cachePolicy="memory-disk"
 
 
263
  />
264
  {item.mediaType === MediaLibrary.MediaType.video && (
265
  <View style={styles.videoIcon}>
@@ -286,12 +288,7 @@ export default function SearchScreen() {
286
  >
287
  <MaterialIcons name="close" size={26} color="#fff" />
288
  </TouchableOpacity>
289
- <Image
290
- source={{ uri: viewerAsset?.uri }}
291
- style={{ flex: 1 }}
292
- contentFit="contain"
293
- cachePolicy="memory-disk"
294
- />
295
  {viewerAsset && (
296
  <View style={{ position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center' }}>
297
  <Text style={{ color: '#fff', fontSize: 13, backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16 }}>
@@ -306,7 +303,7 @@ export default function SearchScreen() {
306
  }
307
 
308
  const styles = StyleSheet.create({
309
- container: { flex: 1, backgroundColor: '#fff' },
310
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
311
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
312
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
 
13
  ActivityIndicator,
14
  } from 'react-native';
15
  import { SafeAreaView } from 'react-native-safe-area-context';
 
16
  import { Image } from 'expo-image';
17
+ import MediaViewer from '../../components/MediaViewer';
18
  import { StatusBar } from 'expo-status-bar';
19
  import { MaterialIcons } from '@expo/vector-icons';
20
  import { format, subDays, subMonths, subYears, startOfDay, endOfDay, startOfMonth, endOfMonth, startOfYear, endOfYear } from 'date-fns';
 
260
  style={StyleSheet.absoluteFill}
261
  contentFit="cover"
262
  cachePolicy="memory-disk"
263
+ transition={200}
264
+ backgroundColor="transparent"
265
  />
266
  {item.mediaType === MediaLibrary.MediaType.video && (
267
  <View style={styles.videoIcon}>
 
288
  >
289
  <MaterialIcons name="close" size={26} color="#fff" />
290
  </TouchableOpacity>
291
+ <MediaViewer asset={viewerAsset} />
 
 
 
 
 
292
  {viewerAsset && (
293
  <View style={{ position: 'absolute', bottom: 40, left: 0, right: 0, alignItems: 'center' }}>
294
  <Text style={{ color: '#fff', fontSize: 13, backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16 }}>
 
303
  }
304
 
305
  const styles = StyleSheet.create({
306
+ container: { flex: 1, backgroundColor: '#F6EAE1' },
307
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
308
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
309
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
keystone-app/app/(tabs)/settings.tsx CHANGED
@@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
4
  import { StatusBar } from 'expo-status-bar';
5
  import { MaterialIcons } from '@expo/vector-icons';
6
  import AsyncStorage from '@react-native-async-storage/async-storage';
7
- import { BACKGROUND_SYNC_ENABLED_KEY } from '../utils/syncManager';
8
  import { useAuth } from '../../components/AuthContext';
9
 
10
  export const SYNC_ON_CELLULAR_KEY = '@setting_sync_cellular';
@@ -14,6 +14,7 @@ export default function SettingsScreen() {
14
  const [syncOnCellular, setSyncOnCellular] = useState(false);
15
  const [bgSync, setBgSync] = useState(true); // Default to true if not set
16
  const [isLoading, setIsLoading] = useState(true);
 
17
 
18
  useEffect(() => {
19
  loadSettings();
@@ -34,6 +35,19 @@ export default function SettingsScreen() {
34
  } else {
35
  await AsyncStorage.setItem(BACKGROUND_SYNC_ENABLED_KEY, 'true');
36
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  } catch (e) {
38
  console.error('Failed to load settings', e);
39
  } finally {
@@ -144,6 +158,29 @@ export default function SettingsScreen() {
144
  </View>
145
  )}
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  {!isLoading && (
148
  <View style={[styles.settingsList, { marginTop: 24 }]}>
149
  <TouchableOpacity style={styles.settingItem} onPress={handleLogout}>
@@ -164,12 +201,12 @@ export default function SettingsScreen() {
164
  }
165
 
166
  const styles = StyleSheet.create({
167
- container: { flex: 1, backgroundColor: '#f8f9fa' },
168
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
169
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
170
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
171
 
172
- header: { paddingHorizontal: 20, paddingTop: 8, paddingBottom: 16, backgroundColor: '#fff', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
173
  headerTitle: { fontSize: 28, fontWeight: '800', color: '#202124', letterSpacing: -0.5 },
174
 
175
  settingsList: { marginTop: 24, marginHorizontal: 16, backgroundColor: '#fff', borderRadius: 16, overflow: 'hidden' },
 
4
  import { StatusBar } from 'expo-status-bar';
5
  import { MaterialIcons } from '@expo/vector-icons';
6
  import AsyncStorage from '@react-native-async-storage/async-storage';
7
+ import { BACKGROUND_SYNC_ENABLED_KEY, BACKEND_URL, getToken } from '../utils/syncManager';
8
  import { useAuth } from '../../components/AuthContext';
9
 
10
  export const SYNC_ON_CELLULAR_KEY = '@setting_sync_cellular';
 
14
  const [syncOnCellular, setSyncOnCellular] = useState(false);
15
  const [bgSync, setBgSync] = useState(true); // Default to true if not set
16
  const [isLoading, setIsLoading] = useState(true);
17
+ const [storageStats, setStorageStats] = useState<{ total: number, used: number, free: number } | null>(null);
18
 
19
  useEffect(() => {
20
  loadSettings();
 
35
  } else {
36
  await AsyncStorage.setItem(BACKGROUND_SYNC_ENABLED_KEY, 'true');
37
  }
38
+
39
+ // Fetch storage stats
40
+ try {
41
+ const token = await getToken();
42
+ if (token) {
43
+ const res = await fetch(`${BACKEND_URL}/storage/stats?token=${token}`);
44
+ if (res.ok) {
45
+ setStorageStats(await res.json());
46
+ }
47
+ }
48
+ } catch (e) {
49
+ console.warn('Could not fetch storage stats', e);
50
+ }
51
  } catch (e) {
52
  console.error('Failed to load settings', e);
53
  } finally {
 
158
  </View>
159
  )}
160
 
161
+ {!isLoading && storageStats && (
162
+ <View style={[styles.settingsList, { marginTop: 24 }]}>
163
+ <Text style={styles.sectionHeader}>Storage (Cloud Bucket)</Text>
164
+ <View style={{ padding: 16 }}>
165
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8 }}>
166
+ <Text style={styles.settingLabel}>
167
+ {(storageStats.used / (1024 * 1024 * 1024)).toFixed(1)} GB used
168
+ </Text>
169
+ <Text style={styles.settingDesc}>
170
+ {(storageStats.free / (1024 * 1024 * 1024)).toFixed(1)} GB free
171
+ </Text>
172
+ </View>
173
+ <View style={{ height: 8, backgroundColor: '#e8f0fe', borderRadius: 4, overflow: 'hidden' }}>
174
+ <View style={{
175
+ height: '100%',
176
+ backgroundColor: '#1a73e8',
177
+ width: `${Math.min(100, Math.max(0, (storageStats.used / storageStats.total) * 100))}%`
178
+ }} />
179
+ </View>
180
+ </View>
181
+ </View>
182
+ )}
183
+
184
  {!isLoading && (
185
  <View style={[styles.settingsList, { marginTop: 24 }]}>
186
  <TouchableOpacity style={styles.settingItem} onPress={handleLogout}>
 
201
  }
202
 
203
  const styles = StyleSheet.create({
204
+ container: { flex: 1, backgroundColor: '#F6EAE1' },
205
  center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
206
  title: { fontSize: 22, fontWeight: '700', color: '#202124', marginTop: 16 },
207
  subtitle: { fontSize: 15, color: '#5f6368', textAlign: 'center', marginTop: 8 },
208
 
209
+ header: { paddingHorizontal: 20, paddingTop: 8, paddingBottom: 16, backgroundColor: '#F6EAE1', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
210
  headerTitle: { fontSize: 28, fontWeight: '800', color: '#202124', letterSpacing: -0.5 },
211
 
212
  settingsList: { marginTop: 24, marginHorizontal: 16, backgroundColor: '#fff', borderRadius: 16, overflow: 'hidden' },
keystone-app/app/_layout.tsx CHANGED
@@ -29,7 +29,7 @@ function AppContent() {
29
 
30
  if (isAuthenticated === null) {
31
  return (
32
- <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#f8f9fa' }}>
33
  <ActivityIndicator size="large" color="#1a73e8" />
34
  </View>
35
  );
 
29
 
30
  if (isAuthenticated === null) {
31
  return (
32
+ <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#F6EAE1' }}>
33
  <ActivityIndicator size="large" color="#1a73e8" />
34
  </View>
35
  );
keystone-app/components/LoginScreen.tsx CHANGED
@@ -100,12 +100,12 @@ export default function LoginScreen({ onLoginSuccess }: Props) {
100
  const styles = StyleSheet.create({
101
  container: {
102
  flex: 1,
103
- backgroundColor: '#f8f9fa',
104
  justifyContent: 'center',
105
  padding: 24,
106
  },
107
  card: {
108
- backgroundColor: '#fff',
109
  borderRadius: 24,
110
  padding: 32,
111
  shadowColor: '#000',
 
100
  const styles = StyleSheet.create({
101
  container: {
102
  flex: 1,
103
+ backgroundColor: '#F6EAE1',
104
  justifyContent: 'center',
105
  padding: 24,
106
  },
107
  card: {
108
+ backgroundColor: '#F6EAE1',
109
  borderRadius: 24,
110
  padding: 32,
111
  shadowColor: '#000',
keystone-app/components/MediaViewer.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect } from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { Image } from 'expo-image';
4
+ import { useVideoPlayer, VideoView } from 'expo-video';
5
+
6
+ interface MediaViewerProps {
7
+ asset: any;
8
+ shouldPlay?: boolean;
9
+ }
10
+
11
+ export default function MediaViewer({ asset, shouldPlay = true }: MediaViewerProps) {
12
+ if (!asset) return null;
13
+
14
+ const isVideo =
15
+ asset.mediaType === 'video' ||
16
+ asset.filename?.toLowerCase().match(/\.(mp4|mov|avi|webm)$/i);
17
+
18
+ if (isVideo) {
19
+ return <VideoWrapper uri={asset.uri} shouldPlay={shouldPlay} />;
20
+ }
21
+
22
+ // expo-image natively supports GIFs and handles caching perfectly.
23
+ return (
24
+ <Image
25
+ source={{ uri: asset.uri }}
26
+ placeholder={{ uri: asset.cloudThumbUri }}
27
+ style={{ flex: 1, width: '100%', height: '100%' }}
28
+ contentFit="contain"
29
+ cachePolicy="memory-disk"
30
+ transition={200}
31
+ backgroundColor="transparent"
32
+ allowDownscaling={false}
33
+ />
34
+ );
35
+ }
36
+
37
+ function VideoWrapper({ uri, shouldPlay }: { uri: string; shouldPlay: boolean }) {
38
+ const player = useVideoPlayer(uri, (p) => {
39
+ p.loop = true;
40
+ if (shouldPlay) p.play();
41
+ });
42
+
43
+ useEffect(() => {
44
+ if (shouldPlay) {
45
+ player.play();
46
+ } else {
47
+ player.pause();
48
+ }
49
+ }, [shouldPlay, player]);
50
+
51
+ return (
52
+ <View style={styles.videoContainer}>
53
+ <VideoView
54
+ style={styles.video}
55
+ player={player}
56
+ allowsFullscreen
57
+ allowsPictureInPicture
58
+ nativeControls={true}
59
+ />
60
+ </View>
61
+ );
62
+ }
63
+
64
+ const styles = StyleSheet.create({
65
+ videoContainer: {
66
+ flex: 1,
67
+ width: '100%',
68
+ height: '100%',
69
+ justifyContent: 'center',
70
+ alignItems: 'center',
71
+ },
72
+ video: {
73
+ width: '100%',
74
+ height: '100%',
75
+ },
76
+ });
keystone-app/package-lock.json CHANGED
@@ -26,6 +26,7 @@
26
  "expo-sharing": "~14.0.8",
27
  "expo-status-bar": "~3.0.9",
28
  "expo-task-manager": "~14.0.9",
 
29
  "react": "19.1.0",
30
  "react-dom": "19.1.0",
31
  "react-native": "0.81.5",
@@ -5091,6 +5092,17 @@
5091
  "expo": "*"
5092
  }
5093
  },
 
 
 
 
 
 
 
 
 
 
 
5094
  "node_modules/expo/node_modules/@babel/code-frame": {
5095
  "version": "7.29.7",
5096
  "license": "MIT",
 
26
  "expo-sharing": "~14.0.8",
27
  "expo-status-bar": "~3.0.9",
28
  "expo-task-manager": "~14.0.9",
29
+ "expo-video": "~3.0.16",
30
  "react": "19.1.0",
31
  "react-dom": "19.1.0",
32
  "react-native": "0.81.5",
 
5092
  "expo": "*"
5093
  }
5094
  },
5095
+ "node_modules/expo-video": {
5096
+ "version": "3.0.16",
5097
+ "resolved": "https://registry.npmjs.org/expo-video/-/expo-video-3.0.16.tgz",
5098
+ "integrity": "sha512-H1HlxcHGomZItqisGfW3YL/G9BHtNBfVSimDJcLuyxyU87wFnV8loO9tCjuhufkfh/aTa2sW5BYAjLjg9DvnBQ==",
5099
+ "license": "MIT",
5100
+ "peerDependencies": {
5101
+ "expo": "*",
5102
+ "react": "*",
5103
+ "react-native": "*"
5104
+ }
5105
+ },
5106
  "node_modules/expo/node_modules/@babel/code-frame": {
5107
  "version": "7.29.7",
5108
  "license": "MIT",
keystone-app/package.json CHANGED
@@ -21,6 +21,7 @@
21
  "expo-sharing": "~14.0.8",
22
  "expo-status-bar": "~3.0.9",
23
  "expo-task-manager": "~14.0.9",
 
24
  "react": "19.1.0",
25
  "react-dom": "19.1.0",
26
  "react-native": "0.81.5",
 
21
  "expo-sharing": "~14.0.8",
22
  "expo-status-bar": "~3.0.9",
23
  "expo-task-manager": "~14.0.9",
24
+ "expo-video": "~3.0.16",
25
  "react": "19.1.0",
26
  "react-dom": "19.1.0",
27
  "react-native": "0.81.5",
keystone-app/store/authStore.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from 'react';
2
+ import { useAuth } from '../components/AuthContext';
3
+ import { BACKEND_URL } from '../app/utils/syncManager';
4
+ import AsyncStorage from '@react-native-async-storage/async-storage';
5
+
6
+ export const useAuthStore = () => {
7
+ const [isLoading, setIsLoading] = useState(false);
8
+ const { login } = useAuth();
9
+
10
+ const signIn = async (email: string, password: string) => {
11
+ setIsLoading(true);
12
+ try {
13
+ const res = await fetch(`${BACKEND_URL}/auth/login`, {
14
+ method: 'POST',
15
+ headers: { 'Content-Type': 'application/json' },
16
+ body: JSON.stringify({ username: email, password })
17
+ });
18
+
19
+ const data = await res.json();
20
+ if (!res.ok) {
21
+ throw new Error(data.detail || 'Login failed');
22
+ }
23
+
24
+ await AsyncStorage.setItem('jwt_token', data.token);
25
+ login(data.token);
26
+ } finally {
27
+ setIsLoading(false);
28
+ }
29
+ };
30
+
31
+ const signUp = async (email: string, password: string) => {
32
+ setIsLoading(true);
33
+ try {
34
+ // Mock signup or implement if backend supports it
35
+ throw new Error("Signup is not currently supported by the backend.");
36
+ } finally {
37
+ setIsLoading(false);
38
+ }
39
+ };
40
+
41
+ const resetPassword = async (email: string) => {
42
+ setIsLoading(true);
43
+ try {
44
+ // Mock reset password
45
+ await new Promise(resolve => setTimeout(resolve, 1000));
46
+ } finally {
47
+ setIsLoading(false);
48
+ }
49
+ };
50
+
51
+ return { signIn, signUp, resetPassword, isLoading };
52
+ };
keystone-backend/main.py CHANGED
@@ -116,6 +116,16 @@ async def health_check():
116
  "storage_path": BASE_STORAGE_DIR
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
119
  @app.post("/auth/login")
120
  async def login(req: LoginRequest):
121
  if req.username not in VALID_USERS or VALID_USERS[req.username] != req.password:
@@ -138,15 +148,41 @@ async def upload_photo(
138
 
139
  # Determine the date for folder structure
140
  dt = datetime.now()
141
- if creation_time:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  try:
143
- # creation_time might be epoch milliseconds or an ISO string
144
  if creation_time.isdigit():
145
  dt = datetime.fromtimestamp(int(creation_time) / 1000.0)
146
  else:
147
  dt = datetime.fromisoformat(creation_time.replace('Z', '+00:00'))
148
  except Exception:
149
- pass # Fallback to now() if parsing fails
150
 
151
  year_folder = dt.strftime("%Y")
152
  month_folder = dt.strftime("%m")
 
116
  "storage_path": BASE_STORAGE_DIR
117
  }
118
 
119
+ @app.get("/storage/stats")
120
+ async def storage_stats(username: str = Depends(verify_jwt_token)):
121
+ # Get overall bucket storage stats
122
+ total, used, free = shutil.disk_usage(BASE_STORAGE_DIR)
123
+ return {
124
+ "total": total,
125
+ "used": used,
126
+ "free": free
127
+ }
128
+
129
  @app.post("/auth/login")
130
  async def login(req: LoginRequest):
131
  if req.username not in VALID_USERS or VALID_USERS[req.username] != req.password:
 
148
 
149
  # Determine the date for folder structure
150
  dt = datetime.now()
151
+
152
+ # 1. Try EXIF Date Taken (most accurate)
153
+ exif_dt = None
154
+ if file.filename.lower().endswith(('.jpg', '.jpeg', '.heic')):
155
+ try:
156
+ # Read first chunk into memory to parse EXIF without consuming whole stream
157
+ header_chunk = await file.read(65536)
158
+ try:
159
+ with Image.open(io.BytesIO(header_chunk)) as img:
160
+ exif = img.getexif()
161
+ if exif:
162
+ # 36867 is the EXIF tag for DateTimeOriginal
163
+ dt_original = exif.get(36867)
164
+ if dt_original:
165
+ # Format: 'YYYY:MM:DD HH:MM:SS'
166
+ exif_dt = datetime.strptime(dt_original, '%Y:%m:%d %H:%M:%S')
167
+ except Exception:
168
+ pass
169
+ finally:
170
+ # Seek back to start for the actual file save!
171
+ await file.seek(0)
172
+ except Exception:
173
+ pass
174
+
175
+ if exif_dt:
176
+ dt = exif_dt
177
+ elif creation_time:
178
+ # 2. Try creation_time provided by the app
179
  try:
 
180
  if creation_time.isdigit():
181
  dt = datetime.fromtimestamp(int(creation_time) / 1000.0)
182
  else:
183
  dt = datetime.fromisoformat(creation_time.replace('Z', '+00:00'))
184
  except Exception:
185
+ pass # Fallback to now()
186
 
187
  year_folder = dt.strftime("%Y")
188
  month_folder = dt.strftime("%m")