added
Browse files- keystone-app/App.tsx +1 -1
- keystone-app/agent-creations/generated_13b76991.webp +0 -0
- keystone-app/agent-creations/generated_14c7b769.webp +0 -0
- keystone-app/agent-creations/generated_2f2c9fbb.webp +0 -0
- keystone-app/agent-creations/generated_32ffb560.webp +0 -0
- keystone-app/agent-creations/generated_4a0d43a3.webp +0 -0
- keystone-app/agent-creations/generated_51e5ebf4.webp +0 -0
- keystone-app/agent-creations/generated_63679208.webp +0 -0
- keystone-app/agent-creations/generated_64a66005.webp +0 -0
- keystone-app/agent-creations/generated_9c5738b9.webp +0 -0
- keystone-app/agent-creations/generated_a2a6239c.webp +0 -0
- keystone-app/agent-creations/generated_ac24836b.webp +0 -0
- keystone-app/app.json +2 -2
- keystone-app/app/(auth)/_layout.tsx +12 -0
- keystone-app/app/(auth)/forgot-password.tsx +94 -0
- keystone-app/app/(auth)/login.tsx +207 -0
- keystone-app/app/(auth)/register.tsx +176 -0
- keystone-app/app/(tabs)/index.tsx +24 -35
- keystone-app/app/(tabs)/library.tsx +8 -12
- keystone-app/app/(tabs)/memories.tsx +9 -10
- keystone-app/app/(tabs)/search.tsx +5 -8
- keystone-app/app/(tabs)/settings.tsx +40 -3
- keystone-app/app/_layout.tsx +1 -1
- keystone-app/components/LoginScreen.tsx +2 -2
- keystone-app/components/MediaViewer.tsx +76 -0
- keystone-app/package-lock.json +12 -0
- keystone-app/package.json +1 -0
- keystone-app/store/authStore.ts +52 -0
- keystone-backend/main.py +39 -3
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: '#
|
| 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 &&
|
| 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 |
-
<
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
|
|
|
| 834 |
);
|
| 835 |
}}
|
| 836 |
/>
|
|
@@ -936,8 +925,8 @@ export default function PhotosScreen() {
|
|
| 936 |
}
|
| 937 |
|
| 938 |
const styles = StyleSheet.create({
|
| 939 |
-
container: { flex: 1, backgroundColor: '#
|
| 940 |
-
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, backgroundColor: '#
|
| 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: '#
|
| 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: '#
|
| 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: '#
|
| 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: '#
|
| 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: '#
|
| 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 |
-
<
|
| 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: '#
|
| 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: '#
|
| 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 |
-
<
|
| 229 |
-
|
| 230 |
-
|
| 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: '#
|
| 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 |
-
<
|
| 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: '#
|
| 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: '#
|
| 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: '#
|
| 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: '#
|
| 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: '#
|
| 104 |
justifyContent: 'center',
|
| 105 |
padding: 24,
|
| 106 |
},
|
| 107 |
card: {
|
| 108 |
-
backgroundColor: '#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
| 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")
|