qdkm
Browse files- 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 +3 -2
- keystone-app/app/(tabs)/index.tsx +373 -77
- keystone-app/app/(tabs)/library.tsx +216 -9
- keystone-app/app/(tabs)/settings.tsx +59 -1
- keystone-app/app/_layout.tsx +29 -5
- keystone-app/app/utils/syncManager.ts +70 -25
- keystone-app/assets/android-icon-foreground.png +2 -2
- keystone-app/assets/favicon.png +2 -2
- keystone-app/assets/icon.png +2 -2
- keystone-app/assets/splash-icon.png +2 -2
- keystone-app/components/AuthContext.tsx +61 -0
- keystone-app/components/LoginScreen.tsx +169 -0
- keystone-backend/main.py +100 -29
- keystone-backend/requirements.txt +2 -1
- logo.png +3 -0
keystone-app/agent-creations/generated_13b76991.webp
ADDED
|
keystone-app/agent-creations/generated_14c7b769.webp
ADDED
|
keystone-app/agent-creations/generated_2f2c9fbb.webp
ADDED
|
keystone-app/agent-creations/generated_32ffb560.webp
ADDED
|
keystone-app/agent-creations/generated_4a0d43a3.webp
ADDED
|
keystone-app/agent-creations/generated_51e5ebf4.webp
ADDED
|
keystone-app/agent-creations/generated_63679208.webp
ADDED
|
keystone-app/agent-creations/generated_64a66005.webp
ADDED
|
keystone-app/agent-creations/generated_9c5738b9.webp
ADDED
|
keystone-app/agent-creations/generated_a2a6239c.webp
ADDED
|
keystone-app/agent-creations/generated_ac24836b.webp
ADDED
|
keystone-app/app.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
"splash": {
|
| 8 |
"image": "./assets/splash-icon.png",
|
| 9 |
"resizeMode": "contain",
|
| 10 |
-
"backgroundColor": "#
|
| 11 |
},
|
| 12 |
"plugins": [
|
| 13 |
[
|
|
@@ -34,8 +34,9 @@
|
|
| 34 |
"package": "com.dedeep.keystoneapp",
|
| 35 |
"adaptiveIcon": {
|
| 36 |
"foregroundImage": "./assets/android-icon-foreground.png",
|
| 37 |
-
"
|
| 38 |
}
|
| 39 |
}
|
| 40 |
}
|
| 41 |
}
|
|
|
|
|
|
| 7 |
"splash": {
|
| 8 |
"image": "./assets/splash-icon.png",
|
| 9 |
"resizeMode": "contain",
|
| 10 |
+
"backgroundColor": "#f0ebe3"
|
| 11 |
},
|
| 12 |
"plugins": [
|
| 13 |
[
|
|
|
|
| 34 |
"package": "com.dedeep.keystoneapp",
|
| 35 |
"adaptiveIcon": {
|
| 36 |
"foregroundImage": "./assets/android-icon-foreground.png",
|
| 37 |
+
"backgroundColor": "#f0ebe3"
|
| 38 |
}
|
| 39 |
}
|
| 40 |
}
|
| 41 |
}
|
| 42 |
+
|
keystone-app/app/(tabs)/index.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
| 12 |
ActivityIndicator,
|
| 13 |
Modal,
|
| 14 |
AppState,
|
|
|
|
| 15 |
} from 'react-native';
|
| 16 |
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
| 17 |
import * as MediaLibrary from 'expo-media-library';
|
|
@@ -26,17 +27,33 @@ import { format, isToday, isYesterday } from 'date-fns';
|
|
| 26 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 27 |
|
| 28 |
import { SYNC_ON_CELLULAR_KEY } from './settings';
|
| 29 |
-
import { runSyncCycle } from '../utils/syncManager';
|
|
|
|
| 30 |
|
| 31 |
-
const
|
| 32 |
|
| 33 |
const { width, height } = Dimensions.get('window');
|
| 34 |
const NUM_COLUMNS = 4;
|
| 35 |
const SPACING = 2;
|
| 36 |
const TILE_SIZE = (width - SPACING * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
|
| 37 |
|
| 38 |
-
type
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
// ─── Sync progress bar ───────────────────────────────────────────
|
| 42 |
function SyncProgressBar({ progress, total }: { progress: number; total: number }) {
|
|
@@ -51,13 +68,14 @@ function SyncProgressBar({ progress, total }: { progress: number; total: number
|
|
| 51 |
// ─── Main component ──────────────────────────────────────────────
|
| 52 |
export default function PhotosScreen() {
|
| 53 |
const insets = useSafeAreaInsets();
|
|
|
|
| 54 |
|
| 55 |
// Permissions
|
| 56 |
const [permissionResponse, setPermissionResponse] = useState<any>(null);
|
| 57 |
|
| 58 |
// Data
|
| 59 |
const [sections, setSections] = useState<SectionData[]>([]);
|
| 60 |
-
const [allAssets, setAllAssets] = useState<
|
| 61 |
const [isLoading, setIsLoading] = useState(true);
|
| 62 |
|
| 63 |
// Selection
|
|
@@ -74,7 +92,11 @@ export default function PhotosScreen() {
|
|
| 74 |
const [itemsToBackup, setItemsToBackup] = useState(0);
|
| 75 |
|
| 76 |
// Viewer
|
| 77 |
-
const [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
// ── Network & Settings Listeners ──────────────────────────────
|
| 80 |
useEffect(() => {
|
|
@@ -138,8 +160,8 @@ export default function PhotosScreen() {
|
|
| 138 |
}, []);
|
| 139 |
|
| 140 |
// ── Load media ────────────────────────────────────────────────
|
| 141 |
-
const groupAssetsByDate = useCallback((assets:
|
| 142 |
-
const groups: Record<string,
|
| 143 |
for (const asset of assets) {
|
| 144 |
const date = new Date(asset.creationTime);
|
| 145 |
const key = isToday(date) ? 'Today' : isYesterday(date) ? 'Yesterday' : format(date, 'EEE, MMM d');
|
|
@@ -147,40 +169,108 @@ export default function PhotosScreen() {
|
|
| 147 |
groups[key].push(asset);
|
| 148 |
}
|
| 149 |
return Object.keys(groups).map(key => {
|
| 150 |
-
const rows:
|
| 151 |
for (let i = 0; i < groups[key].length; i += NUM_COLUMNS)
|
| 152 |
rows.push(groups[key].slice(i, i + NUM_COLUMNS));
|
| 153 |
return { title: key, data: rows };
|
| 154 |
});
|
| 155 |
}, []);
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
const loadAssets = useCallback(async () => {
|
| 158 |
try {
|
|
|
|
|
|
|
| 159 |
const media = await MediaLibrary.getAssetsAsync({
|
| 160 |
first: 800,
|
| 161 |
sortBy: [MediaLibrary.SortBy.creationTime],
|
| 162 |
mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
|
| 163 |
});
|
| 164 |
-
setAllAssets(media.assets);
|
| 165 |
-
setSections(groupAssetsByDate(media.assets));
|
| 166 |
|
| 167 |
-
//
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
} catch (e) {
|
| 172 |
console.error('loadAssets error', e);
|
|
|
|
| 173 |
}
|
| 174 |
}, [groupAssetsByDate]);
|
| 175 |
|
| 176 |
-
// ── Sync ──────────────────────────────────────────────────────
|
| 177 |
-
const getSyncedList = async (): Promise<string[]> => {
|
| 178 |
-
try {
|
| 179 |
-
const v = await AsyncStorage.getItem(SYNCED_ASSETS_KEY);
|
| 180 |
-
return v ? JSON.parse(v) : [];
|
| 181 |
-
} catch { return []; }
|
| 182 |
-
};
|
| 183 |
-
|
| 184 |
const syncAssets = async () => {
|
| 185 |
if (isSyncing || itemsToBackup === 0) return;
|
| 186 |
|
|
@@ -218,20 +308,23 @@ export default function PhotosScreen() {
|
|
| 218 |
// ── Delete ────────────────────────────────────────────────────
|
| 219 |
const deleteSelected = () => {
|
| 220 |
Alert.alert(
|
| 221 |
-
`
|
| 222 |
-
'
|
| 223 |
[
|
| 224 |
{ text: 'Cancel', style: 'cancel' },
|
| 225 |
{
|
| 226 |
-
text: '
|
| 227 |
style: 'destructive',
|
| 228 |
onPress: async () => {
|
| 229 |
try {
|
| 230 |
-
await
|
|
|
|
|
|
|
|
|
|
| 231 |
setSelectedIds(new Set());
|
| 232 |
await loadAssets();
|
| 233 |
} catch (e) {
|
| 234 |
-
Alert.alert('Error', 'Could not
|
| 235 |
}
|
| 236 |
},
|
| 237 |
},
|
|
@@ -246,10 +339,12 @@ export default function PhotosScreen() {
|
|
| 246 |
const infos = await Promise.all(
|
| 247 |
ids.map(id => {
|
| 248 |
const asset = allAssets.find(a => a.id === id);
|
| 249 |
-
|
|
|
|
|
|
|
| 250 |
})
|
| 251 |
);
|
| 252 |
-
const uris = infos.map(i => i?.localUri || i?.uri).filter(Boolean) as string[];
|
| 253 |
if (!uris.length) return;
|
| 254 |
try {
|
| 255 |
if (uris.length === 1) {
|
|
@@ -272,9 +367,11 @@ export default function PhotosScreen() {
|
|
| 272 |
});
|
| 273 |
};
|
| 274 |
|
| 275 |
-
const openViewer = (asset:
|
| 276 |
if (isSelecting) { toggleSelect(asset.id); return; }
|
| 277 |
-
|
|
|
|
|
|
|
| 278 |
};
|
| 279 |
|
| 280 |
// ── UI Status Helpers ─────────────────────────────────────────
|
|
@@ -301,7 +398,7 @@ export default function PhotosScreen() {
|
|
| 301 |
};
|
| 302 |
|
| 303 |
// ── Render helpers ────────────────────────────────────────────
|
| 304 |
-
const renderRow = useCallback(({ item: row }: { item:
|
| 305 |
<View style={styles.row}>
|
| 306 |
{row.map(asset => {
|
| 307 |
const selected = selectedIds.has(asset.id);
|
|
@@ -323,9 +420,25 @@ export default function PhotosScreen() {
|
|
| 323 |
{isVideo && (
|
| 324 |
<View style={styles.videoOverlay}>
|
| 325 |
<MaterialIcons name="play-circle-fill" size={22} color="rgba(255,255,255,0.9)" />
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
</View>
|
| 330 |
)}
|
| 331 |
{selected && (
|
|
@@ -409,7 +522,7 @@ export default function PhotosScreen() {
|
|
| 409 |
) : (
|
| 410 |
<View style={styles.header}>
|
| 411 |
<Text style={styles.headerLogo}>KeyStone</Text>
|
| 412 |
-
<View style={{ flexDirection: 'row', alignItems: 'center', gap:
|
| 413 |
<Text style={[styles.syncCountText, { color: statusUI.color }]}>{statusUI.text}</Text>
|
| 414 |
<TouchableOpacity
|
| 415 |
style={styles.syncIconContainer}
|
|
@@ -422,6 +535,11 @@ export default function PhotosScreen() {
|
|
| 422 |
<MaterialIcons name={statusUI.icon as any} size={24} color={statusUI.color} />
|
| 423 |
)}
|
| 424 |
</TouchableOpacity>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
</View>
|
| 426 |
</View>
|
| 427 |
)}
|
|
@@ -448,69 +566,95 @@ export default function PhotosScreen() {
|
|
| 448 |
|
| 449 |
{/* ── Full-screen viewer modal ── */}
|
| 450 |
<Modal
|
| 451 |
-
visible={!
|
| 452 |
transparent={false}
|
| 453 |
animationType="fade"
|
| 454 |
statusBarTranslucent
|
| 455 |
-
onRequestClose={() =>
|
| 456 |
>
|
| 457 |
<View style={styles.viewer}>
|
| 458 |
-
<TouchableWithoutFeedback onPress={() =>
|
| 459 |
<View style={styles.viewerClose}>
|
| 460 |
<MaterialIcons name="arrow-back" size={28} color="#fff" />
|
| 461 |
</View>
|
| 462 |
</TouchableWithoutFeedback>
|
| 463 |
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
{/* Viewer action bar */}
|
| 482 |
<View style={[styles.viewerActions, { paddingBottom: insets.bottom + 8 }]}>
|
| 483 |
<TouchableOpacity
|
| 484 |
style={styles.viewerActionBtn}
|
| 485 |
onPress={async () => {
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
}}
|
| 491 |
>
|
| 492 |
<MaterialIcons name="share" size={26} color="#fff" />
|
| 493 |
<Text style={styles.viewerActionLabel}>Share</Text>
|
| 494 |
</TouchableOpacity>
|
|
|
|
| 495 |
<TouchableOpacity
|
| 496 |
style={styles.viewerActionBtn}
|
| 497 |
-
onPress={() =>
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
await loadAssets();
|
| 507 |
-
} catch {
|
| 508 |
-
Alert.alert('Error', 'Could not delete. Check permissions.');
|
| 509 |
-
}
|
| 510 |
-
},
|
| 511 |
-
},
|
| 512 |
-
]);
|
| 513 |
-
}}
|
| 514 |
>
|
| 515 |
<MaterialIcons name="delete-outline" size={26} color="#ff6b6b" />
|
| 516 |
<Text style={[styles.viewerActionLabel, { color: '#ff6b6b' }]}>Delete</Text>
|
|
@@ -518,6 +662,142 @@ export default function PhotosScreen() {
|
|
| 518 |
</View>
|
| 519 |
</View>
|
| 520 |
</Modal>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
</SafeAreaView>
|
| 522 |
);
|
| 523 |
}
|
|
@@ -538,6 +818,13 @@ const styles = StyleSheet.create({
|
|
| 538 |
},
|
| 539 |
headerLogo: { fontSize: 22, fontWeight: '700', color: '#202124', letterSpacing: -0.5 },
|
| 540 |
selectionCount: { fontSize: 18, fontWeight: '600', color: '#202124' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 541 |
syncCountText: { fontSize: 12, fontWeight: '500' },
|
| 542 |
syncIconContainer: { padding: 8, borderRadius: 24, backgroundColor: '#f1f3f4', marginLeft: 4 },
|
| 543 |
iconBtn: { padding: 8, borderRadius: 20, backgroundColor: '#f1f3f4', marginLeft: 4 },
|
|
@@ -563,6 +850,8 @@ const styles = StyleSheet.create({
|
|
| 563 |
flexDirection: 'row', alignItems: 'center', gap: 3,
|
| 564 |
},
|
| 565 |
videoDuration: { color: '#fff', fontSize: 11, fontWeight: '600', textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4, textShadowOffset: { width: 0, height: 1 } },
|
|
|
|
|
|
|
| 566 |
selectedOverlay: {
|
| 567 |
...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(26,115,232,0.15)',
|
| 568 |
justifyContent: 'flex-start', alignItems: 'flex-end', padding: 4,
|
|
@@ -571,6 +860,7 @@ const styles = StyleSheet.create({
|
|
| 571 |
viewer: { flex: 1, backgroundColor: '#000' },
|
| 572 |
viewerClose: { position: 'absolute', top: 48, left: 16, zIndex: 10, padding: 8, backgroundColor: 'rgba(0,0,0,0.4)', borderRadius: 20 },
|
| 573 |
viewerMedia: { flex: 1, width: '100%', height: '100%' },
|
|
|
|
| 574 |
viewerActions: {
|
| 575 |
position: 'absolute', bottom: 0, left: 0, right: 0,
|
| 576 |
flexDirection: 'row', justifyContent: 'space-around',
|
|
@@ -578,4 +868,10 @@ const styles = StyleSheet.create({
|
|
| 578 |
},
|
| 579 |
viewerActionBtn: { alignItems: 'center', gap: 4, paddingHorizontal: 24 },
|
| 580 |
viewerActionLabel: { color: '#fff', fontSize: 13, fontWeight: '500' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
});
|
|
|
|
| 12 |
ActivityIndicator,
|
| 13 |
Modal,
|
| 14 |
AppState,
|
| 15 |
+
FlatList,
|
| 16 |
} from 'react-native';
|
| 17 |
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
| 18 |
import * as MediaLibrary from 'expo-media-library';
|
|
|
|
| 27 |
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 28 |
|
| 29 |
import { SYNC_ON_CELLULAR_KEY } from './settings';
|
| 30 |
+
import { runSyncCycle, BACKEND_URL, getToken, getSyncedList, deleteFromCloud } from '../utils/syncManager';
|
| 31 |
+
import { useAuth } from '../../components/AuthContext';
|
| 32 |
|
| 33 |
+
export const TRASHED_ASSETS_KEY = '@trashed_assets';
|
| 34 |
|
| 35 |
const { width, height } = Dimensions.get('window');
|
| 36 |
const NUM_COLUMNS = 4;
|
| 37 |
const SPACING = 2;
|
| 38 |
const TILE_SIZE = (width - SPACING * (NUM_COLUMNS + 1)) / NUM_COLUMNS;
|
| 39 |
|
| 40 |
+
export type UnifiedAsset = {
|
| 41 |
+
id: string; // unique key (local ID or cloud path)
|
| 42 |
+
originalId?: string; // local ID if it exists
|
| 43 |
+
uri: string; // local uri or remote URL
|
| 44 |
+
cloudUri?: string; // remote URL if backed up
|
| 45 |
+
filename: string;
|
| 46 |
+
mediaType: 'photo' | 'video';
|
| 47 |
+
creationTime: number;
|
| 48 |
+
duration: number;
|
| 49 |
+
width: number;
|
| 50 |
+
height: number;
|
| 51 |
+
isSynced: boolean;
|
| 52 |
+
isCloudOnly: boolean;
|
| 53 |
+
size?: number;
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
type SectionData = { title: string; data: UnifiedAsset[][] };
|
| 57 |
|
| 58 |
// ─── Sync progress bar ───────────────────────────────────────────
|
| 59 |
function SyncProgressBar({ progress, total }: { progress: number; total: number }) {
|
|
|
|
| 68 |
// ─── Main component ──────────────────────────────────────────────
|
| 69 |
export default function PhotosScreen() {
|
| 70 |
const insets = useSafeAreaInsets();
|
| 71 |
+
const { username } = useAuth();
|
| 72 |
|
| 73 |
// Permissions
|
| 74 |
const [permissionResponse, setPermissionResponse] = useState<any>(null);
|
| 75 |
|
| 76 |
// Data
|
| 77 |
const [sections, setSections] = useState<SectionData[]>([]);
|
| 78 |
+
const [allAssets, setAllAssets] = useState<UnifiedAsset[]>([]);
|
| 79 |
const [isLoading, setIsLoading] = useState(true);
|
| 80 |
|
| 81 |
// Selection
|
|
|
|
| 92 |
const [itemsToBackup, setItemsToBackup] = useState(0);
|
| 93 |
|
| 94 |
// Viewer
|
| 95 |
+
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
|
| 96 |
+
const viewerRef = useRef<any>(null);
|
| 97 |
+
const [showInfo, setShowInfo] = useState(false);
|
| 98 |
+
const [showDeleteMenu, setShowDeleteMenu] = useState(false);
|
| 99 |
+
const [isPlaying, setIsPlaying] = useState(true);
|
| 100 |
|
| 101 |
// ── Network & Settings Listeners ──────────────────────────────
|
| 102 |
useEffect(() => {
|
|
|
|
| 160 |
}, []);
|
| 161 |
|
| 162 |
// ── Load media ────────────────────────────────────────────────
|
| 163 |
+
const groupAssetsByDate = useCallback((assets: UnifiedAsset[]): SectionData[] => {
|
| 164 |
+
const groups: Record<string, UnifiedAsset[]> = {};
|
| 165 |
for (const asset of assets) {
|
| 166 |
const date = new Date(asset.creationTime);
|
| 167 |
const key = isToday(date) ? 'Today' : isYesterday(date) ? 'Yesterday' : format(date, 'EEE, MMM d');
|
|
|
|
| 169 |
groups[key].push(asset);
|
| 170 |
}
|
| 171 |
return Object.keys(groups).map(key => {
|
| 172 |
+
const rows: UnifiedAsset[][] = [];
|
| 173 |
for (let i = 0; i < groups[key].length; i += NUM_COLUMNS)
|
| 174 |
rows.push(groups[key].slice(i, i + NUM_COLUMNS));
|
| 175 |
return { title: key, data: rows };
|
| 176 |
});
|
| 177 |
}, []);
|
| 178 |
|
| 179 |
+
const getTrashedAssets = async (): Promise<UnifiedAsset[]> => {
|
| 180 |
+
try {
|
| 181 |
+
const v = await AsyncStorage.getItem(TRASHED_ASSETS_KEY);
|
| 182 |
+
return v ? JSON.parse(v) : [];
|
| 183 |
+
} catch { return []; }
|
| 184 |
+
};
|
| 185 |
+
|
| 186 |
const loadAssets = useCallback(async () => {
|
| 187 |
try {
|
| 188 |
+
setIsLoading(true);
|
| 189 |
+
// 1. Fetch Local
|
| 190 |
const media = await MediaLibrary.getAssetsAsync({
|
| 191 |
first: 800,
|
| 192 |
sortBy: [MediaLibrary.SortBy.creationTime],
|
| 193 |
mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
|
| 194 |
});
|
|
|
|
|
|
|
| 195 |
|
| 196 |
+
// 2. Fetch Cloud
|
| 197 |
+
let cloudAssets: any[] = [];
|
| 198 |
+
try {
|
| 199 |
+
const token = await getToken();
|
| 200 |
+
if (token) {
|
| 201 |
+
const res = await fetch(`${BACKEND_URL}/photos?token=${token}`);
|
| 202 |
+
const data = await res.json();
|
| 203 |
+
cloudAssets = data.assets || [];
|
| 204 |
+
}
|
| 205 |
+
} catch (e) {
|
| 206 |
+
console.warn('Could not fetch cloud timeline', e);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// 3. Fetch Synced & Trashed
|
| 210 |
+
const syncedIds = await getSyncedList();
|
| 211 |
+
const trashedAssets = await getTrashedAssets();
|
| 212 |
+
const trashedIds = trashedAssets.map(t => t.id);
|
| 213 |
+
|
| 214 |
+
// 4. Merge
|
| 215 |
+
const unified: UnifiedAsset[] = [];
|
| 216 |
+
const localFilenames = new Set();
|
| 217 |
+
|
| 218 |
+
for (const a of media.assets) {
|
| 219 |
+
if (trashedIds.includes(a.id)) continue;
|
| 220 |
+
|
| 221 |
+
const isSynced = syncedIds.includes(a.id);
|
| 222 |
+
const matchCloud = cloudAssets.find(c => c.filename === a.filename);
|
| 223 |
+
localFilenames.add(a.filename);
|
| 224 |
+
|
| 225 |
+
unified.push({
|
| 226 |
+
id: a.id,
|
| 227 |
+
originalId: a.id,
|
| 228 |
+
uri: a.uri,
|
| 229 |
+
filename: a.filename,
|
| 230 |
+
mediaType: a.mediaType as any,
|
| 231 |
+
creationTime: a.creationTime,
|
| 232 |
+
duration: a.duration || 0,
|
| 233 |
+
width: a.width,
|
| 234 |
+
height: a.height,
|
| 235 |
+
isSynced: isSynced || !!matchCloud,
|
| 236 |
+
isCloudOnly: false,
|
| 237 |
+
cloudUri: matchCloud ? `${BACKEND_URL}${matchCloud.uri}?token=${await getToken()}` : undefined,
|
| 238 |
+
});
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
for (const c of cloudAssets) {
|
| 242 |
+
if (!localFilenames.has(c.filename) && !trashedIds.includes(c.id)) {
|
| 243 |
+
unified.push({
|
| 244 |
+
id: c.id,
|
| 245 |
+
uri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
|
| 246 |
+
cloudUri: `${BACKEND_URL}${c.uri}?token=${await getToken()}`,
|
| 247 |
+
filename: c.filename,
|
| 248 |
+
mediaType: c.mediaType,
|
| 249 |
+
creationTime: c.creationTime,
|
| 250 |
+
duration: 0,
|
| 251 |
+
width: 1920,
|
| 252 |
+
height: 1080,
|
| 253 |
+
size: c.size,
|
| 254 |
+
isSynced: true,
|
| 255 |
+
isCloudOnly: true,
|
| 256 |
+
});
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
unified.sort((a, b) => b.creationTime - a.creationTime);
|
| 261 |
+
|
| 262 |
+
setAllAssets(unified);
|
| 263 |
+
setSections(groupAssetsByDate(unified));
|
| 264 |
+
|
| 265 |
+
const toSync = unified.filter(a => !a.isSynced).length;
|
| 266 |
+
setItemsToBackup(toSync);
|
| 267 |
+
setIsLoading(false);
|
| 268 |
} catch (e) {
|
| 269 |
console.error('loadAssets error', e);
|
| 270 |
+
setIsLoading(false);
|
| 271 |
}
|
| 272 |
}, [groupAssetsByDate]);
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
const syncAssets = async () => {
|
| 275 |
if (isSyncing || itemsToBackup === 0) return;
|
| 276 |
|
|
|
|
| 308 |
// ── Delete ────────────────────────────────────────────────────
|
| 309 |
const deleteSelected = () => {
|
| 310 |
Alert.alert(
|
| 311 |
+
`Move ${selectedIds.size} item${selectedIds.size > 1 ? 's' : ''} to Bin?`,
|
| 312 |
+
'Items will be moved to your local bin.',
|
| 313 |
[
|
| 314 |
{ text: 'Cancel', style: 'cancel' },
|
| 315 |
{
|
| 316 |
+
text: 'Move to Bin',
|
| 317 |
style: 'destructive',
|
| 318 |
onPress: async () => {
|
| 319 |
try {
|
| 320 |
+
const trashed = await getTrashedAssets();
|
| 321 |
+
const itemsToTrash = allAssets.filter(a => selectedIds.has(a.id));
|
| 322 |
+
const newTrashed = [...trashed, ...itemsToTrash];
|
| 323 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
|
| 324 |
setSelectedIds(new Set());
|
| 325 |
await loadAssets();
|
| 326 |
} catch (e) {
|
| 327 |
+
Alert.alert('Error', 'Could not move items to bin.');
|
| 328 |
}
|
| 329 |
},
|
| 330 |
},
|
|
|
|
| 339 |
const infos = await Promise.all(
|
| 340 |
ids.map(id => {
|
| 341 |
const asset = allAssets.find(a => a.id === id);
|
| 342 |
+
// If it's cloud only, we'd need to download it first. For now, share uri directly (might fail on some apps if not local)
|
| 343 |
+
if (asset?.isCloudOnly) return { localUri: asset.uri };
|
| 344 |
+
return asset && asset.originalId ? MediaLibrary.getAssetInfoAsync({ id: asset.originalId } as any) : null;
|
| 345 |
})
|
| 346 |
);
|
| 347 |
+
const uris = infos.map(i => i?.localUri || (i as any)?.uri).filter(Boolean) as string[];
|
| 348 |
if (!uris.length) return;
|
| 349 |
try {
|
| 350 |
if (uris.length === 1) {
|
|
|
|
| 367 |
});
|
| 368 |
};
|
| 369 |
|
| 370 |
+
const openViewer = (asset: UnifiedAsset) => {
|
| 371 |
if (isSelecting) { toggleSelect(asset.id); return; }
|
| 372 |
+
const idx = allAssets.findIndex(a => a.id === asset.id);
|
| 373 |
+
setViewerIndex(idx >= 0 ? idx : null);
|
| 374 |
+
setIsPlaying(true);
|
| 375 |
};
|
| 376 |
|
| 377 |
// ── UI Status Helpers ─────────────────────────────────────────
|
|
|
|
| 398 |
};
|
| 399 |
|
| 400 |
// ── Render helpers ────────────────────────────────────────────
|
| 401 |
+
const renderRow = useCallback(({ item: row }: { item: UnifiedAsset[] }) => (
|
| 402 |
<View style={styles.row}>
|
| 403 |
{row.map(asset => {
|
| 404 |
const selected = selectedIds.has(asset.id);
|
|
|
|
| 420 |
{isVideo && (
|
| 421 |
<View style={styles.videoOverlay}>
|
| 422 |
<MaterialIcons name="play-circle-fill" size={22} color="rgba(255,255,255,0.9)" />
|
| 423 |
+
{asset.duration > 0 && (
|
| 424 |
+
<Text style={styles.videoDuration}>
|
| 425 |
+
{Math.floor(asset.duration / 60)}:{String(Math.floor(asset.duration % 60)).padStart(2, '0')}
|
| 426 |
+
</Text>
|
| 427 |
+
)}
|
| 428 |
+
</View>
|
| 429 |
+
)}
|
| 430 |
+
|
| 431 |
+
{/* Unsynced Cloud Indicator */}
|
| 432 |
+
{!asset.isSynced && !asset.isCloudOnly && (
|
| 433 |
+
<View style={styles.unsyncedOverlay}>
|
| 434 |
+
<MaterialIcons name="cloud-off" size={16} color="rgba(255,255,255,0.8)" />
|
| 435 |
+
</View>
|
| 436 |
+
)}
|
| 437 |
+
|
| 438 |
+
{/* Cloud-Only Indicator */}
|
| 439 |
+
{asset.isCloudOnly && (
|
| 440 |
+
<View style={styles.cloudOnlyOverlay}>
|
| 441 |
+
<MaterialIcons name="cloud" size={16} color="rgba(255,255,255,0.8)" />
|
| 442 |
</View>
|
| 443 |
)}
|
| 444 |
{selected && (
|
|
|
|
| 522 |
) : (
|
| 523 |
<View style={styles.header}>
|
| 524 |
<Text style={styles.headerLogo}>KeyStone</Text>
|
| 525 |
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
| 526 |
<Text style={[styles.syncCountText, { color: statusUI.color }]}>{statusUI.text}</Text>
|
| 527 |
<TouchableOpacity
|
| 528 |
style={styles.syncIconContainer}
|
|
|
|
| 535 |
<MaterialIcons name={statusUI.icon as any} size={24} color={statusUI.color} />
|
| 536 |
)}
|
| 537 |
</TouchableOpacity>
|
| 538 |
+
{username && (
|
| 539 |
+
<View style={styles.avatarCircle}>
|
| 540 |
+
<Text style={styles.avatarText}>{username.charAt(0).toUpperCase()}</Text>
|
| 541 |
+
</View>
|
| 542 |
+
)}
|
| 543 |
</View>
|
| 544 |
</View>
|
| 545 |
)}
|
|
|
|
| 566 |
|
| 567 |
{/* ── Full-screen viewer modal ── */}
|
| 568 |
<Modal
|
| 569 |
+
visible={viewerIndex !== null}
|
| 570 |
transparent={false}
|
| 571 |
animationType="fade"
|
| 572 |
statusBarTranslucent
|
| 573 |
+
onRequestClose={() => setViewerIndex(null)}
|
| 574 |
>
|
| 575 |
<View style={styles.viewer}>
|
| 576 |
+
<TouchableWithoutFeedback onPress={() => setViewerIndex(null)}>
|
| 577 |
<View style={styles.viewerClose}>
|
| 578 |
<MaterialIcons name="arrow-back" size={28} color="#fff" />
|
| 579 |
</View>
|
| 580 |
</TouchableWithoutFeedback>
|
| 581 |
|
| 582 |
+
<FlatList
|
| 583 |
+
ref={viewerRef}
|
| 584 |
+
data={allAssets}
|
| 585 |
+
horizontal
|
| 586 |
+
pagingEnabled
|
| 587 |
+
initialScrollIndex={viewerIndex ?? 0}
|
| 588 |
+
getItemLayout={(_, index) => ({ length: width, offset: width * index, index })}
|
| 589 |
+
keyExtractor={item => item.id}
|
| 590 |
+
showsHorizontalScrollIndicator={false}
|
| 591 |
+
onMomentumScrollEnd={(e) => {
|
| 592 |
+
const newIndex = Math.round(e.nativeEvent.contentOffset.x / width);
|
| 593 |
+
setViewerIndex(newIndex);
|
| 594 |
+
setIsPlaying(true);
|
| 595 |
+
}}
|
| 596 |
+
renderItem={({ item, index }) => (
|
| 597 |
+
<View style={{ width, height: '100%', justifyContent: 'center' }}>
|
| 598 |
+
{item.mediaType === 'video' ? (
|
| 599 |
+
<>
|
| 600 |
+
<Video
|
| 601 |
+
source={{ uri: item.uri }}
|
| 602 |
+
style={styles.viewerMedia}
|
| 603 |
+
resizeMode={ResizeMode.CONTAIN}
|
| 604 |
+
useNativeControls={false}
|
| 605 |
+
shouldPlay={isPlaying && viewerIndex === index}
|
| 606 |
+
/>
|
| 607 |
+
<TouchableOpacity
|
| 608 |
+
style={styles.playPauseOverlay}
|
| 609 |
+
onPress={() => setIsPlaying(!isPlaying)}
|
| 610 |
+
>
|
| 611 |
+
{!isPlaying && (
|
| 612 |
+
<MaterialIcons name="play-circle-fill" size={72} color="rgba(255,255,255,0.8)" />
|
| 613 |
+
)}
|
| 614 |
+
</TouchableOpacity>
|
| 615 |
+
</>
|
| 616 |
+
) : (
|
| 617 |
+
<Image
|
| 618 |
+
source={{ uri: item.uri }}
|
| 619 |
+
style={styles.viewerMedia}
|
| 620 |
+
contentFit="contain"
|
| 621 |
+
cachePolicy="memory-disk"
|
| 622 |
+
/>
|
| 623 |
+
)}
|
| 624 |
+
</View>
|
| 625 |
+
)}
|
| 626 |
+
/>
|
| 627 |
|
| 628 |
{/* Viewer action bar */}
|
| 629 |
<View style={[styles.viewerActions, { paddingBottom: insets.bottom + 8 }]}>
|
| 630 |
<TouchableOpacity
|
| 631 |
style={styles.viewerActionBtn}
|
| 632 |
onPress={async () => {
|
| 633 |
+
const asset = viewerIndex !== null ? allAssets[viewerIndex] : null;
|
| 634 |
+
if (!asset) return;
|
| 635 |
+
let uriToShare = asset.uri;
|
| 636 |
+
if (!asset.isCloudOnly && asset.originalId) {
|
| 637 |
+
const info = await MediaLibrary.getAssetInfoAsync({ id: asset.originalId } as any);
|
| 638 |
+
uriToShare = info.localUri || asset.uri;
|
| 639 |
+
}
|
| 640 |
+
await Sharing.shareAsync(uriToShare);
|
| 641 |
}}
|
| 642 |
>
|
| 643 |
<MaterialIcons name="share" size={26} color="#fff" />
|
| 644 |
<Text style={styles.viewerActionLabel}>Share</Text>
|
| 645 |
</TouchableOpacity>
|
| 646 |
+
|
| 647 |
<TouchableOpacity
|
| 648 |
style={styles.viewerActionBtn}
|
| 649 |
+
onPress={() => setShowInfo(true)}
|
| 650 |
+
>
|
| 651 |
+
<MaterialIcons name="info-outline" size={26} color="#fff" />
|
| 652 |
+
<Text style={styles.viewerActionLabel}>Info</Text>
|
| 653 |
+
</TouchableOpacity>
|
| 654 |
+
|
| 655 |
+
<TouchableOpacity
|
| 656 |
+
style={styles.viewerActionBtn}
|
| 657 |
+
onPress={() => setShowDeleteMenu(true)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
>
|
| 659 |
<MaterialIcons name="delete-outline" size={26} color="#ff6b6b" />
|
| 660 |
<Text style={[styles.viewerActionLabel, { color: '#ff6b6b' }]}>Delete</Text>
|
|
|
|
| 662 |
</View>
|
| 663 |
</View>
|
| 664 |
</Modal>
|
| 665 |
+
|
| 666 |
+
{/* Info Modal */}
|
| 667 |
+
{showInfo && viewerIndex !== null && (
|
| 668 |
+
<Modal transparent animationType="slide" onRequestClose={() => setShowInfo(false)}>
|
| 669 |
+
<TouchableOpacity style={styles.infoModalBg} activeOpacity={1} onPress={() => setShowInfo(false)}>
|
| 670 |
+
<TouchableWithoutFeedback>
|
| 671 |
+
<View style={[styles.infoSheet, { paddingBottom: insets.bottom + 20 }]}>
|
| 672 |
+
<Text style={styles.infoTitle}>Details</Text>
|
| 673 |
+
<View style={styles.infoRow}>
|
| 674 |
+
<MaterialIcons name="insert-drive-file" size={20} color="#5f6368" />
|
| 675 |
+
<Text style={styles.infoText}>{allAssets[viewerIndex].filename}</Text>
|
| 676 |
+
</View>
|
| 677 |
+
<View style={styles.infoRow}>
|
| 678 |
+
<MaterialIcons name="event" size={20} color="#5f6368" />
|
| 679 |
+
<Text style={styles.infoText}>
|
| 680 |
+
{format(new Date(allAssets[viewerIndex].creationTime), 'PPpp')}
|
| 681 |
+
</Text>
|
| 682 |
+
</View>
|
| 683 |
+
<View style={styles.infoRow}>
|
| 684 |
+
<MaterialIcons name="aspect-ratio" size={20} color="#5f6368" />
|
| 685 |
+
<Text style={styles.infoText}>
|
| 686 |
+
{allAssets[viewerIndex].width} x {allAssets[viewerIndex].height}
|
| 687 |
+
</Text>
|
| 688 |
+
</View>
|
| 689 |
+
{allAssets[viewerIndex].isCloudOnly ? (
|
| 690 |
+
<View style={styles.infoRow}>
|
| 691 |
+
<MaterialIcons name="cloud" size={20} color="#1a73e8" />
|
| 692 |
+
<Text style={[styles.infoText, { color: '#1a73e8' }]}>Cloud Only (Not on device)</Text>
|
| 693 |
+
</View>
|
| 694 |
+
) : (
|
| 695 |
+
<View style={styles.infoRow}>
|
| 696 |
+
<MaterialIcons name={allAssets[viewerIndex].isSynced ? "cloud-done" : "cloud-off"} size={20} color={allAssets[viewerIndex].isSynced ? "#34a853" : "#d93025"} />
|
| 697 |
+
<Text style={[styles.infoText, { color: allAssets[viewerIndex].isSynced ? "#34a853" : "#d93025" }]}>
|
| 698 |
+
{allAssets[viewerIndex].isSynced ? 'Backed up securely' : 'Not backed up yet'}
|
| 699 |
+
</Text>
|
| 700 |
+
</View>
|
| 701 |
+
)}
|
| 702 |
+
</View>
|
| 703 |
+
</TouchableWithoutFeedback>
|
| 704 |
+
</TouchableOpacity>
|
| 705 |
+
</Modal>
|
| 706 |
+
)}
|
| 707 |
+
|
| 708 |
+
{/* Delete Action Sheet Modal */}
|
| 709 |
+
{showDeleteMenu && viewerIndex !== null && (
|
| 710 |
+
<Modal transparent animationType="slide" onRequestClose={() => setShowDeleteMenu(false)}>
|
| 711 |
+
<TouchableOpacity style={styles.infoModalBg} activeOpacity={1} onPress={() => setShowDeleteMenu(false)}>
|
| 712 |
+
<TouchableWithoutFeedback>
|
| 713 |
+
<View style={[styles.infoSheet, { paddingBottom: insets.bottom + 20 }]}>
|
| 714 |
+
<Text style={styles.infoTitle}>Delete Photo</Text>
|
| 715 |
+
|
| 716 |
+
<TouchableOpacity style={styles.infoRow} onPress={async () => {
|
| 717 |
+
setShowDeleteMenu(false);
|
| 718 |
+
const asset = allAssets[viewerIndex];
|
| 719 |
+
try {
|
| 720 |
+
const trashed = await getTrashedAssets();
|
| 721 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify([...trashed, asset]));
|
| 722 |
+
setViewerIndex(null);
|
| 723 |
+
await loadAssets();
|
| 724 |
+
} catch { Alert.alert('Error', 'Could not move to bin.'); }
|
| 725 |
+
}}>
|
| 726 |
+
<MaterialIcons name="delete-outline" size={24} color="#202124" />
|
| 727 |
+
<View>
|
| 728 |
+
<Text style={[styles.infoText, { fontWeight: '600' }]}>Move to Bin</Text>
|
| 729 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Hide from timeline, keep on device and cloud.</Text>
|
| 730 |
+
</View>
|
| 731 |
+
</TouchableOpacity>
|
| 732 |
+
|
| 733 |
+
{!allAssets[viewerIndex].isCloudOnly && (
|
| 734 |
+
<TouchableOpacity style={styles.infoRow} onPress={async () => {
|
| 735 |
+
setShowDeleteMenu(false);
|
| 736 |
+
const asset = allAssets[viewerIndex];
|
| 737 |
+
try {
|
| 738 |
+
if (asset.originalId) {
|
| 739 |
+
await MediaLibrary.deleteAssetsAsync([{ id: asset.originalId }] as any);
|
| 740 |
+
setViewerIndex(null);
|
| 741 |
+
await loadAssets();
|
| 742 |
+
}
|
| 743 |
+
} catch { Alert.alert('Error', 'Could not delete from device.'); }
|
| 744 |
+
}}>
|
| 745 |
+
<MaterialIcons name="smartphone" size={24} color="#d93025" />
|
| 746 |
+
<View>
|
| 747 |
+
<Text style={[styles.infoText, { fontWeight: '600', color: '#d93025' }]}>Delete from Device Only</Text>
|
| 748 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Remove from phone. Keep in cloud.</Text>
|
| 749 |
+
</View>
|
| 750 |
+
</TouchableOpacity>
|
| 751 |
+
)}
|
| 752 |
+
|
| 753 |
+
{allAssets[viewerIndex].isSynced && (
|
| 754 |
+
<TouchableOpacity style={styles.infoRow} onPress={async () => {
|
| 755 |
+
setShowDeleteMenu(false);
|
| 756 |
+
const asset = allAssets[viewerIndex];
|
| 757 |
+
try {
|
| 758 |
+
const success = await deleteFromCloud(asset.id);
|
| 759 |
+
if (success) {
|
| 760 |
+
setViewerIndex(null);
|
| 761 |
+
await loadAssets();
|
| 762 |
+
} else {
|
| 763 |
+
Alert.alert('Error', 'Could not delete from cloud.');
|
| 764 |
+
}
|
| 765 |
+
} catch { Alert.alert('Error', 'Could not delete from cloud.'); }
|
| 766 |
+
}}>
|
| 767 |
+
<MaterialIcons name="cloud-off" size={24} color="#d93025" />
|
| 768 |
+
<View>
|
| 769 |
+
<Text style={[styles.infoText, { fontWeight: '600', color: '#d93025' }]}>Delete from Cloud Only</Text>
|
| 770 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Remove from backup. Keep on phone.</Text>
|
| 771 |
+
</View>
|
| 772 |
+
</TouchableOpacity>
|
| 773 |
+
)}
|
| 774 |
+
|
| 775 |
+
<TouchableOpacity style={styles.infoRow} onPress={async () => {
|
| 776 |
+
setShowDeleteMenu(false);
|
| 777 |
+
const asset = allAssets[viewerIndex];
|
| 778 |
+
try {
|
| 779 |
+
if (!asset.isCloudOnly && asset.originalId) {
|
| 780 |
+
await MediaLibrary.deleteAssetsAsync([{ id: asset.originalId }] as any);
|
| 781 |
+
}
|
| 782 |
+
if (asset.isSynced) {
|
| 783 |
+
await deleteFromCloud(asset.id);
|
| 784 |
+
}
|
| 785 |
+
setViewerIndex(null);
|
| 786 |
+
await loadAssets();
|
| 787 |
+
} catch { Alert.alert('Error', 'Could not delete.'); }
|
| 788 |
+
}}>
|
| 789 |
+
<MaterialIcons name="delete-forever" size={24} color="#d93025" />
|
| 790 |
+
<View>
|
| 791 |
+
<Text style={[styles.infoText, { fontWeight: '600', color: '#d93025' }]}>Delete from Both</Text>
|
| 792 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Permanently obliterate from device and cloud.</Text>
|
| 793 |
+
</View>
|
| 794 |
+
</TouchableOpacity>
|
| 795 |
+
</View>
|
| 796 |
+
</TouchableWithoutFeedback>
|
| 797 |
+
</TouchableOpacity>
|
| 798 |
+
</Modal>
|
| 799 |
+
)}
|
| 800 |
+
|
| 801 |
</SafeAreaView>
|
| 802 |
);
|
| 803 |
}
|
|
|
|
| 818 |
},
|
| 819 |
headerLogo: { fontSize: 22, fontWeight: '700', color: '#202124', letterSpacing: -0.5 },
|
| 820 |
selectionCount: { fontSize: 18, fontWeight: '600', color: '#202124' },
|
| 821 |
+
avatarCircle: {
|
| 822 |
+
width: 34, height: 34, borderRadius: 17,
|
| 823 |
+
backgroundColor: '#1a73e8',
|
| 824 |
+
justifyContent: 'center', alignItems: 'center',
|
| 825 |
+
marginLeft: 2,
|
| 826 |
+
},
|
| 827 |
+
avatarText: { color: '#fff', fontSize: 15, fontWeight: '700', letterSpacing: 0.5 },
|
| 828 |
syncCountText: { fontSize: 12, fontWeight: '500' },
|
| 829 |
syncIconContainer: { padding: 8, borderRadius: 24, backgroundColor: '#f1f3f4', marginLeft: 4 },
|
| 830 |
iconBtn: { padding: 8, borderRadius: 20, backgroundColor: '#f1f3f4', marginLeft: 4 },
|
|
|
|
| 850 |
flexDirection: 'row', alignItems: 'center', gap: 3,
|
| 851 |
},
|
| 852 |
videoDuration: { color: '#fff', fontSize: 11, fontWeight: '600', textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4, textShadowOffset: { width: 0, height: 1 } },
|
| 853 |
+
unsyncedOverlay: { position: 'absolute', top: 4, right: 4, backgroundColor: 'rgba(0,0,0,0.4)', borderRadius: 12, padding: 3 },
|
| 854 |
+
cloudOnlyOverlay: { position: 'absolute', top: 4, right: 4, backgroundColor: 'rgba(26,115,232,0.8)', borderRadius: 12, padding: 3 },
|
| 855 |
selectedOverlay: {
|
| 856 |
...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(26,115,232,0.15)',
|
| 857 |
justifyContent: 'flex-start', alignItems: 'flex-end', padding: 4,
|
|
|
|
| 860 |
viewer: { flex: 1, backgroundColor: '#000' },
|
| 861 |
viewerClose: { position: 'absolute', top: 48, left: 16, zIndex: 10, padding: 8, backgroundColor: 'rgba(0,0,0,0.4)', borderRadius: 20 },
|
| 862 |
viewerMedia: { flex: 1, width: '100%', height: '100%' },
|
| 863 |
+
playPauseOverlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center' },
|
| 864 |
viewerActions: {
|
| 865 |
position: 'absolute', bottom: 0, left: 0, right: 0,
|
| 866 |
flexDirection: 'row', justifyContent: 'space-around',
|
|
|
|
| 868 |
},
|
| 869 |
viewerActionBtn: { alignItems: 'center', gap: 4, paddingHorizontal: 24 },
|
| 870 |
viewerActionLabel: { color: '#fff', fontSize: 13, fontWeight: '500' },
|
| 871 |
+
|
| 872 |
+
infoModalBg: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' },
|
| 873 |
+
infoSheet: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24 },
|
| 874 |
+
infoTitle: { fontSize: 20, fontWeight: '700', color: '#202124', marginBottom: 16 },
|
| 875 |
+
infoRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 16, gap: 12 },
|
| 876 |
+
infoText: { fontSize: 15, color: '#3c4043', flex: 1 },
|
| 877 |
});
|
keystone-app/app/(tabs)/library.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
| 10 |
Platform,
|
| 11 |
SectionList,
|
| 12 |
Animated,
|
|
|
|
| 13 |
} from 'react-native';
|
| 14 |
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 15 |
import * as MediaLibrary from 'expo-media-library';
|
|
@@ -17,6 +18,9 @@ import { Image } from 'expo-image';
|
|
| 17 |
import { StatusBar } from 'expo-status-bar';
|
| 18 |
import { MaterialIcons } from '@expo/vector-icons';
|
| 19 |
import { format } from 'date-fns';
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const { width } = Dimensions.get('window');
|
| 22 |
const ALBUM_TILE = (width - 48) / 2;
|
|
@@ -49,8 +53,13 @@ export default function LibraryScreen() {
|
|
| 49 |
const [openAlbum, setOpenAlbum] = useState<AlbumWithCover | null>(null);
|
| 50 |
const [albumAssets, setAlbumAssets] = useState<MediaLibrary.Asset[]>([]);
|
| 51 |
const [loadingAlbum, setLoadingAlbum] = useState(false);
|
| 52 |
-
const [viewerAsset, setViewerAsset] = useState<MediaLibrary.Asset | null>(null);
|
|
|
|
| 53 |
const [stats, setStats] = useState({ photos: 0, videos: 0, sizeMB: 0 });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
useEffect(() => {
|
| 56 |
if (Platform.OS === 'web') { setIsLoading(false); return; }
|
|
@@ -64,6 +73,9 @@ export default function LibraryScreen() {
|
|
| 64 |
catch { res = { granted: false }; }
|
| 65 |
if (!res.granted) { setIsLoading(false); return; }
|
| 66 |
setHasPermission(true);
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
// Load all albums
|
| 69 |
const albumsResult = await MediaLibrary.getAlbumsAsync({ includeSmartAlbums: true });
|
|
@@ -183,7 +195,21 @@ export default function LibraryScreen() {
|
|
| 183 |
maxToRenderPerBatch={10}
|
| 184 |
windowSize={5}
|
| 185 |
removeClippedSubviews={true}
|
| 186 |
-
ListHeaderComponent={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
renderItem={({ item: album }) => (
|
| 188 |
<TouchableOpacity
|
| 189 |
style={styles.albumCard}
|
|
@@ -256,12 +282,39 @@ export default function LibraryScreen() {
|
|
| 256 |
</SafeAreaView>
|
| 257 |
</Modal>
|
| 258 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
{/* Full-screen viewer */}
|
| 260 |
-
<Modal visible={!!viewerAsset} transparent={false} animationType="fade" onRequestClose={() => setViewerAsset(null)}>
|
| 261 |
<View style={{ flex: 1, backgroundColor: '#000' }}>
|
| 262 |
<TouchableOpacity
|
| 263 |
style={{ position: 'absolute', top: 52, left: 16, zIndex: 10, padding: 8, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 20 }}
|
| 264 |
-
onPress={() => setViewerAsset(null)}
|
| 265 |
>
|
| 266 |
<MaterialIcons name="close" size={26} color="#fff" />
|
| 267 |
</TouchableOpacity>
|
|
@@ -271,14 +324,162 @@ export default function LibraryScreen() {
|
|
| 271 |
contentFit="contain"
|
| 272 |
cachePolicy="memory-disk"
|
| 273 |
/>
|
| 274 |
-
{viewerAsset && (
|
| 275 |
-
<View style={{ position: 'absolute', bottom:
|
| 276 |
-
<
|
| 277 |
-
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
</View>
|
| 280 |
)}
|
| 281 |
</View>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
</Modal>
|
| 283 |
</SafeAreaView>
|
| 284 |
);
|
|
@@ -315,4 +516,10 @@ const styles = StyleSheet.create({
|
|
| 315 |
modalHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
|
| 316 |
modalTitle: { flex: 1, fontSize: 18, fontWeight: '700', color: '#202124', marginLeft: 8 },
|
| 317 |
modalCount: { fontSize: 13, color: '#5f6368', marginRight: 8 },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
});
|
|
|
|
| 10 |
Platform,
|
| 11 |
SectionList,
|
| 12 |
Animated,
|
| 13 |
+
TouchableWithoutFeedback,
|
| 14 |
} from 'react-native';
|
| 15 |
import { SafeAreaView } from 'react-native-safe-area-context';
|
| 16 |
import * as MediaLibrary from 'expo-media-library';
|
|
|
|
| 18 |
import { StatusBar } from 'expo-status-bar';
|
| 19 |
import { MaterialIcons } from '@expo/vector-icons';
|
| 20 |
import { format } from 'date-fns';
|
| 21 |
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 22 |
+
import { TRASHED_ASSETS_KEY, UnifiedAsset } from './index';
|
| 23 |
+
import { deleteFromCloud, downloadToDevice } from '../utils/syncManager';
|
| 24 |
|
| 25 |
const { width } = Dimensions.get('window');
|
| 26 |
const ALBUM_TILE = (width - 48) / 2;
|
|
|
|
| 53 |
const [openAlbum, setOpenAlbum] = useState<AlbumWithCover | null>(null);
|
| 54 |
const [albumAssets, setAlbumAssets] = useState<MediaLibrary.Asset[]>([]);
|
| 55 |
const [loadingAlbum, setLoadingAlbum] = useState(false);
|
| 56 |
+
const [viewerAsset, setViewerAsset] = useState<MediaLibrary.Asset | UnifiedAsset | null>(null);
|
| 57 |
+
const [isViewerFromBin, setIsViewerFromBin] = useState(false);
|
| 58 |
const [stats, setStats] = useState({ photos: 0, videos: 0, sizeMB: 0 });
|
| 59 |
+
const [trashedAssets, setTrashedAssets] = useState<UnifiedAsset[]>([]);
|
| 60 |
+
const [viewingBin, setViewingBin] = useState(false);
|
| 61 |
+
const [showRestoreMenu, setShowRestoreMenu] = useState(false);
|
| 62 |
+
const [showBinDeleteMenu, setShowBinDeleteMenu] = useState(false);
|
| 63 |
|
| 64 |
useEffect(() => {
|
| 65 |
if (Platform.OS === 'web') { setIsLoading(false); return; }
|
|
|
|
| 73 |
catch { res = { granted: false }; }
|
| 74 |
if (!res.granted) { setIsLoading(false); return; }
|
| 75 |
setHasPermission(true);
|
| 76 |
+
|
| 77 |
+
const trashedRaw = await AsyncStorage.getItem(TRASHED_ASSETS_KEY);
|
| 78 |
+
setTrashedAssets(trashedRaw ? JSON.parse(trashedRaw) : []);
|
| 79 |
|
| 80 |
// Load all albums
|
| 81 |
const albumsResult = await MediaLibrary.getAlbumsAsync({ includeSmartAlbums: true });
|
|
|
|
| 195 |
maxToRenderPerBatch={10}
|
| 196 |
windowSize={5}
|
| 197 |
removeClippedSubviews={true}
|
| 198 |
+
ListHeaderComponent={
|
| 199 |
+
<>
|
| 200 |
+
<Text style={styles.sectionLabel}>Library</Text>
|
| 201 |
+
<View style={styles.grid}>
|
| 202 |
+
<TouchableOpacity style={styles.albumCard} onPress={() => setViewingBin(true)}>
|
| 203 |
+
<View style={[styles.albumThumb, { backgroundColor: '#fce8e6', justifyContent: 'center', alignItems: 'center' }]}>
|
| 204 |
+
<MaterialIcons name="delete" size={36} color="#d93025" />
|
| 205 |
+
</View>
|
| 206 |
+
<Text style={styles.albumName}>Recently Deleted</Text>
|
| 207 |
+
<Text style={styles.albumCount}>{trashedAssets.length} items</Text>
|
| 208 |
+
</TouchableOpacity>
|
| 209 |
+
</View>
|
| 210 |
+
<Text style={styles.sectionLabel}>Albums</Text>
|
| 211 |
+
</>
|
| 212 |
+
}
|
| 213 |
renderItem={({ item: album }) => (
|
| 214 |
<TouchableOpacity
|
| 215 |
style={styles.albumCard}
|
|
|
|
| 282 |
</SafeAreaView>
|
| 283 |
</Modal>
|
| 284 |
|
| 285 |
+
{/* Bin detail modal */}
|
| 286 |
+
<Modal visible={viewingBin} animationType="slide" onRequestClose={() => setViewingBin(false)}>
|
| 287 |
+
<SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }}>
|
| 288 |
+
<View style={styles.modalHeader}>
|
| 289 |
+
<TouchableOpacity onPress={() => setViewingBin(false)} style={{ padding: 8 }}>
|
| 290 |
+
<MaterialIcons name="arrow-back" size={24} color="#202124" />
|
| 291 |
+
</TouchableOpacity>
|
| 292 |
+
<Text style={styles.modalTitle}>Recently Deleted</Text>
|
| 293 |
+
<Text style={styles.modalCount}>{trashedAssets.length} items</Text>
|
| 294 |
+
</View>
|
| 295 |
+
<FlatList
|
| 296 |
+
data={trashedAssets}
|
| 297 |
+
numColumns={3}
|
| 298 |
+
keyExtractor={a => a.id}
|
| 299 |
+
contentContainerStyle={{ padding: 4 }}
|
| 300 |
+
renderItem={({ item }) => (
|
| 301 |
+
<TouchableOpacity
|
| 302 |
+
style={{ flex: 1, margin: 2, aspectRatio: 1, borderRadius: 4, overflow: 'hidden', backgroundColor: '#e0e0e0' }}
|
| 303 |
+
onPress={() => { setViewerAsset(item); setIsViewerFromBin(true); }}
|
| 304 |
+
>
|
| 305 |
+
<Image source={{ uri: item.uri }} style={StyleSheet.absoluteFill} contentFit="cover" cachePolicy="memory-disk" />
|
| 306 |
+
</TouchableOpacity>
|
| 307 |
+
)}
|
| 308 |
+
/>
|
| 309 |
+
</SafeAreaView>
|
| 310 |
+
</Modal>
|
| 311 |
+
|
| 312 |
{/* Full-screen viewer */}
|
| 313 |
+
<Modal visible={!!viewerAsset} transparent={false} animationType="fade" onRequestClose={() => { setViewerAsset(null); setIsViewerFromBin(false); }}>
|
| 314 |
<View style={{ flex: 1, backgroundColor: '#000' }}>
|
| 315 |
<TouchableOpacity
|
| 316 |
style={{ position: 'absolute', top: 52, left: 16, zIndex: 10, padding: 8, backgroundColor: 'rgba(0,0,0,0.5)', borderRadius: 20 }}
|
| 317 |
+
onPress={() => { setViewerAsset(null); setIsViewerFromBin(false); }}
|
| 318 |
>
|
| 319 |
<MaterialIcons name="close" size={26} color="#fff" />
|
| 320 |
</TouchableOpacity>
|
|
|
|
| 324 |
contentFit="contain"
|
| 325 |
cachePolicy="memory-disk"
|
| 326 |
/>
|
| 327 |
+
{viewerAsset && isViewerFromBin && (
|
| 328 |
+
<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: 32 }}>
|
| 329 |
+
<TouchableOpacity onPress={() => setShowRestoreMenu(true)} style={{ alignItems: 'center' }}>
|
| 330 |
+
<MaterialIcons name="restore" size={26} color="#1a73e8" />
|
| 331 |
+
<Text style={{ color: '#1a73e8', fontSize: 13, marginTop: 4 }}>Restore</Text>
|
| 332 |
+
</TouchableOpacity>
|
| 333 |
+
|
| 334 |
+
<TouchableOpacity onPress={() => setShowBinDeleteMenu(true)} style={{ alignItems: 'center' }}>
|
| 335 |
+
<MaterialIcons name="delete-forever" size={26} color="#ff6b6b" />
|
| 336 |
+
<Text style={{ color: '#ff6b6b', fontSize: 13, marginTop: 4 }}>Delete Permanently</Text>
|
| 337 |
+
</TouchableOpacity>
|
| 338 |
</View>
|
| 339 |
)}
|
| 340 |
</View>
|
| 341 |
+
|
| 342 |
+
{/* Restore Action Sheet Modal */}
|
| 343 |
+
{showRestoreMenu && viewerAsset && (
|
| 344 |
+
<Modal transparent animationType="slide" onRequestClose={() => setShowRestoreMenu(false)}>
|
| 345 |
+
<TouchableOpacity style={styles.actionSheetBg} activeOpacity={1} onPress={() => setShowRestoreMenu(false)}>
|
| 346 |
+
<TouchableWithoutFeedback>
|
| 347 |
+
<View style={styles.actionSheet}>
|
| 348 |
+
<Text style={styles.actionSheetTitle}>Restore Photo</Text>
|
| 349 |
+
|
| 350 |
+
<TouchableOpacity style={styles.actionSheetRow} onPress={async () => {
|
| 351 |
+
setShowRestoreMenu(false);
|
| 352 |
+
const unified = viewerAsset as UnifiedAsset;
|
| 353 |
+
if (unified.isCloudOnly && unified.cloudUri) {
|
| 354 |
+
await downloadToDevice(unified.cloudUri, unified.filename);
|
| 355 |
+
}
|
| 356 |
+
const newTrashed = trashedAssets.filter(a => a.id !== viewerAsset.id);
|
| 357 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
|
| 358 |
+
setTrashedAssets(newTrashed);
|
| 359 |
+
setViewerAsset(null); setIsViewerFromBin(false);
|
| 360 |
+
}}>
|
| 361 |
+
<MaterialIcons name="sync" size={24} color="#1a73e8" />
|
| 362 |
+
<View>
|
| 363 |
+
<Text style={[styles.actionSheetText, { color: '#1a73e8' }]}>Recover to Both</Text>
|
| 364 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Ensures photo is on device and cloud.</Text>
|
| 365 |
+
</View>
|
| 366 |
+
</TouchableOpacity>
|
| 367 |
+
|
| 368 |
+
<TouchableOpacity style={styles.actionSheetRow} onPress={async () => {
|
| 369 |
+
setShowRestoreMenu(false);
|
| 370 |
+
const unified = viewerAsset as UnifiedAsset;
|
| 371 |
+
if (!unified.isCloudOnly && unified.originalId) {
|
| 372 |
+
await MediaLibrary.deleteAssetsAsync([{ id: unified.originalId }] as any);
|
| 373 |
+
}
|
| 374 |
+
const newTrashed = trashedAssets.filter(a => a.id !== viewerAsset.id);
|
| 375 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
|
| 376 |
+
setTrashedAssets(newTrashed);
|
| 377 |
+
setViewerAsset(null); setIsViewerFromBin(false);
|
| 378 |
+
}}>
|
| 379 |
+
<MaterialIcons name="cloud-upload" size={24} color="#202124" />
|
| 380 |
+
<View>
|
| 381 |
+
<Text style={styles.actionSheetText}>Recover to Cloud Only</Text>
|
| 382 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Deletes local copy, keeps in cloud.</Text>
|
| 383 |
+
</View>
|
| 384 |
+
</TouchableOpacity>
|
| 385 |
+
|
| 386 |
+
<TouchableOpacity style={styles.actionSheetRow} onPress={async () => {
|
| 387 |
+
setShowRestoreMenu(false);
|
| 388 |
+
const unified = viewerAsset as UnifiedAsset;
|
| 389 |
+
if (unified.isCloudOnly && unified.cloudUri) {
|
| 390 |
+
await downloadToDevice(unified.cloudUri, unified.filename);
|
| 391 |
+
}
|
| 392 |
+
if (unified.isSynced) {
|
| 393 |
+
await deleteFromCloud(unified.id);
|
| 394 |
+
}
|
| 395 |
+
const newTrashed = trashedAssets.filter(a => a.id !== viewerAsset.id);
|
| 396 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
|
| 397 |
+
setTrashedAssets(newTrashed);
|
| 398 |
+
setViewerAsset(null); setIsViewerFromBin(false);
|
| 399 |
+
}}>
|
| 400 |
+
<MaterialIcons name="smartphone" size={24} color="#202124" />
|
| 401 |
+
<View>
|
| 402 |
+
<Text style={styles.actionSheetText}>Recover to Device Only</Text>
|
| 403 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Downloads if needed, deletes from cloud.</Text>
|
| 404 |
+
</View>
|
| 405 |
+
</TouchableOpacity>
|
| 406 |
+
</View>
|
| 407 |
+
</TouchableWithoutFeedback>
|
| 408 |
+
</TouchableOpacity>
|
| 409 |
+
</Modal>
|
| 410 |
+
)}
|
| 411 |
+
|
| 412 |
+
{/* Delete Action Sheet Modal */}
|
| 413 |
+
{showBinDeleteMenu && viewerAsset && (
|
| 414 |
+
<Modal transparent animationType="slide" onRequestClose={() => setShowBinDeleteMenu(false)}>
|
| 415 |
+
<TouchableOpacity style={styles.actionSheetBg} activeOpacity={1} onPress={() => setShowBinDeleteMenu(false)}>
|
| 416 |
+
<TouchableWithoutFeedback>
|
| 417 |
+
<View style={styles.actionSheet}>
|
| 418 |
+
<Text style={styles.actionSheetTitle}>Delete Permanently</Text>
|
| 419 |
+
|
| 420 |
+
<TouchableOpacity style={styles.actionSheetRow} onPress={async () => {
|
| 421 |
+
setShowBinDeleteMenu(false);
|
| 422 |
+
const unified = viewerAsset as UnifiedAsset;
|
| 423 |
+
if (!unified.isCloudOnly && unified.originalId) {
|
| 424 |
+
await MediaLibrary.deleteAssetsAsync([{ id: unified.originalId }] as any);
|
| 425 |
+
}
|
| 426 |
+
// It's still in the cloud, so it should stay in Bin or return to timeline as Cloud Only?
|
| 427 |
+
// Usually this removes it from Bin. Let's remove it.
|
| 428 |
+
const newTrashed = trashedAssets.filter(a => a.id !== viewerAsset.id);
|
| 429 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
|
| 430 |
+
setTrashedAssets(newTrashed);
|
| 431 |
+
setViewerAsset(null); setIsViewerFromBin(false);
|
| 432 |
+
}}>
|
| 433 |
+
<MaterialIcons name="smartphone" size={24} color="#d93025" />
|
| 434 |
+
<View>
|
| 435 |
+
<Text style={[styles.actionSheetText, { color: '#d93025' }]}>Delete from Device Only</Text>
|
| 436 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Permanently remove from phone.</Text>
|
| 437 |
+
</View>
|
| 438 |
+
</TouchableOpacity>
|
| 439 |
+
|
| 440 |
+
<TouchableOpacity style={styles.actionSheetRow} onPress={async () => {
|
| 441 |
+
setShowBinDeleteMenu(false);
|
| 442 |
+
const unified = viewerAsset as UnifiedAsset;
|
| 443 |
+
if (unified.isSynced) {
|
| 444 |
+
await deleteFromCloud(unified.id);
|
| 445 |
+
}
|
| 446 |
+
const newTrashed = trashedAssets.filter(a => a.id !== viewerAsset.id);
|
| 447 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
|
| 448 |
+
setTrashedAssets(newTrashed);
|
| 449 |
+
setViewerAsset(null); setIsViewerFromBin(false);
|
| 450 |
+
}}>
|
| 451 |
+
<MaterialIcons name="cloud-off" size={24} color="#d93025" />
|
| 452 |
+
<View>
|
| 453 |
+
<Text style={[styles.actionSheetText, { color: '#d93025' }]}>Delete from Cloud Only</Text>
|
| 454 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Permanently remove from backup.</Text>
|
| 455 |
+
</View>
|
| 456 |
+
</TouchableOpacity>
|
| 457 |
+
|
| 458 |
+
<TouchableOpacity style={styles.actionSheetRow} onPress={async () => {
|
| 459 |
+
setShowBinDeleteMenu(false);
|
| 460 |
+
const unified = viewerAsset as UnifiedAsset;
|
| 461 |
+
if (!unified.isCloudOnly && unified.originalId) {
|
| 462 |
+
await MediaLibrary.deleteAssetsAsync([{ id: unified.originalId }] as any);
|
| 463 |
+
}
|
| 464 |
+
if (unified.isSynced) {
|
| 465 |
+
await deleteFromCloud(unified.id);
|
| 466 |
+
}
|
| 467 |
+
const newTrashed = trashedAssets.filter(a => a.id !== viewerAsset.id);
|
| 468 |
+
await AsyncStorage.setItem(TRASHED_ASSETS_KEY, JSON.stringify(newTrashed));
|
| 469 |
+
setTrashedAssets(newTrashed);
|
| 470 |
+
setViewerAsset(null); setIsViewerFromBin(false);
|
| 471 |
+
}}>
|
| 472 |
+
<MaterialIcons name="delete-forever" size={24} color="#d93025" />
|
| 473 |
+
<View>
|
| 474 |
+
<Text style={[styles.actionSheetText, { color: '#d93025' }]}>Delete from Both</Text>
|
| 475 |
+
<Text style={{ fontSize: 13, color: '#5f6368' }}>Completely obliterate everywhere.</Text>
|
| 476 |
+
</View>
|
| 477 |
+
</TouchableOpacity>
|
| 478 |
+
</View>
|
| 479 |
+
</TouchableWithoutFeedback>
|
| 480 |
+
</TouchableOpacity>
|
| 481 |
+
</Modal>
|
| 482 |
+
)}
|
| 483 |
</Modal>
|
| 484 |
</SafeAreaView>
|
| 485 |
);
|
|
|
|
| 516 |
modalHeader: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 8, paddingVertical: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#e0e0e0' },
|
| 517 |
modalTitle: { flex: 1, fontSize: 18, fontWeight: '700', color: '#202124', marginLeft: 8 },
|
| 518 |
modalCount: { fontSize: 13, color: '#5f6368', marginRight: 8 },
|
| 519 |
+
|
| 520 |
+
actionSheetBg: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' },
|
| 521 |
+
actionSheet: { backgroundColor: '#fff', borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 24, paddingBottom: 40 },
|
| 522 |
+
actionSheetTitle: { fontSize: 20, fontWeight: '700', color: '#202124', marginBottom: 16 },
|
| 523 |
+
actionSheetRow: { flexDirection: 'row', alignItems: 'center', marginBottom: 20, gap: 12 },
|
| 524 |
+
actionSheetText: { fontSize: 15, fontWeight: '600', color: '#202124' },
|
| 525 |
});
|
keystone-app/app/(tabs)/settings.tsx
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
-
import { View, Text, StyleSheet, Switch, Platform } from 'react-native';
|
| 3 |
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 |
|
| 9 |
export const SYNC_ON_CELLULAR_KEY = '@setting_sync_cellular';
|
| 10 |
|
| 11 |
export default function SettingsScreen() {
|
|
|
|
| 12 |
const [syncOnCellular, setSyncOnCellular] = useState(false);
|
| 13 |
const [bgSync, setBgSync] = useState(true); // Default to true if not set
|
| 14 |
const [isLoading, setIsLoading] = useState(true);
|
|
@@ -57,6 +59,17 @@ export default function SettingsScreen() {
|
|
| 57 |
}
|
| 58 |
};
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if (Platform.OS === 'web') {
|
| 61 |
return (
|
| 62 |
<View style={styles.center}>
|
|
@@ -74,6 +87,19 @@ export default function SettingsScreen() {
|
|
| 74 |
<Text style={styles.headerTitle}>Settings</Text>
|
| 75 |
</View>
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
{!isLoading && (
|
| 78 |
<View style={styles.settingsList}>
|
| 79 |
<Text style={styles.sectionHeader}>Sync & Backup</Text>
|
|
@@ -117,6 +143,22 @@ export default function SettingsScreen() {
|
|
| 117 |
</View>
|
| 118 |
</View>
|
| 119 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
</SafeAreaView>
|
| 121 |
);
|
| 122 |
}
|
|
@@ -139,4 +181,20 @@ const styles = StyleSheet.create({
|
|
| 139 |
iconBox: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#e8f0fe', justifyContent: 'center', alignItems: 'center', marginRight: 16 },
|
| 140 |
settingLabel: { fontSize: 16, fontWeight: '600', color: '#202124' },
|
| 141 |
settingDesc: { fontSize: 13, color: '#5f6368', marginTop: 2 },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
});
|
|
|
|
| 1 |
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { View, Text, StyleSheet, Switch, Platform, TouchableOpacity, Alert } from 'react-native';
|
| 3 |
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';
|
| 11 |
|
| 12 |
export default function SettingsScreen() {
|
| 13 |
+
const { logout, username } = useAuth();
|
| 14 |
const [syncOnCellular, setSyncOnCellular] = useState(false);
|
| 15 |
const [bgSync, setBgSync] = useState(true); // Default to true if not set
|
| 16 |
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
| 59 |
}
|
| 60 |
};
|
| 61 |
|
| 62 |
+
const handleLogout = async () => {
|
| 63 |
+
Alert.alert('Log Out', 'Are you sure you want to sign out?', [
|
| 64 |
+
{ text: 'Cancel', style: 'cancel' },
|
| 65 |
+
{
|
| 66 |
+
text: 'Log Out', style: 'destructive', onPress: async () => {
|
| 67 |
+
await logout();
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
]);
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
if (Platform.OS === 'web') {
|
| 74 |
return (
|
| 75 |
<View style={styles.center}>
|
|
|
|
| 87 |
<Text style={styles.headerTitle}>Settings</Text>
|
| 88 |
</View>
|
| 89 |
|
| 90 |
+
{/* Profile Card */}
|
| 91 |
+
{username && (
|
| 92 |
+
<View style={styles.profileCard}>
|
| 93 |
+
<View style={styles.profileAvatar}>
|
| 94 |
+
<Text style={styles.profileAvatarText}>{username.charAt(0).toUpperCase()}</Text>
|
| 95 |
+
</View>
|
| 96 |
+
<View>
|
| 97 |
+
<Text style={styles.profileName}>{username}</Text>
|
| 98 |
+
<Text style={styles.profileSub}>Signed in</Text>
|
| 99 |
+
</View>
|
| 100 |
+
</View>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
{!isLoading && (
|
| 104 |
<View style={styles.settingsList}>
|
| 105 |
<Text style={styles.sectionHeader}>Sync & Backup</Text>
|
|
|
|
| 143 |
</View>
|
| 144 |
</View>
|
| 145 |
)}
|
| 146 |
+
|
| 147 |
+
{!isLoading && (
|
| 148 |
+
<View style={[styles.settingsList, { marginTop: 24 }]}>
|
| 149 |
+
<TouchableOpacity style={styles.settingItem} onPress={handleLogout}>
|
| 150 |
+
<View style={styles.settingLeft}>
|
| 151 |
+
<View style={[styles.iconBox, { backgroundColor: '#fce8e6' }]}>
|
| 152 |
+
<MaterialIcons name="logout" size={24} color="#d93025" />
|
| 153 |
+
</View>
|
| 154 |
+
<View>
|
| 155 |
+
<Text style={[styles.settingLabel, { color: '#d93025' }]}>Log Out</Text>
|
| 156 |
+
<Text style={styles.settingDesc}>Sign out of this device.</Text>
|
| 157 |
+
</View>
|
| 158 |
+
</View>
|
| 159 |
+
</TouchableOpacity>
|
| 160 |
+
</View>
|
| 161 |
+
)}
|
| 162 |
</SafeAreaView>
|
| 163 |
);
|
| 164 |
}
|
|
|
|
| 181 |
iconBox: { width: 40, height: 40, borderRadius: 20, backgroundColor: '#e8f0fe', justifyContent: 'center', alignItems: 'center', marginRight: 16 },
|
| 182 |
settingLabel: { fontSize: 16, fontWeight: '600', color: '#202124' },
|
| 183 |
settingDesc: { fontSize: 13, color: '#5f6368', marginTop: 2 },
|
| 184 |
+
|
| 185 |
+
profileCard: {
|
| 186 |
+
flexDirection: 'row', alignItems: 'center', gap: 16,
|
| 187 |
+
marginHorizontal: 16, marginTop: 16, marginBottom: 4,
|
| 188 |
+
backgroundColor: '#fff', borderRadius: 16, padding: 16,
|
| 189 |
+
shadowColor: '#000', shadowOffset: { width: 0, height: 1 },
|
| 190 |
+
shadowOpacity: 0.04, shadowRadius: 4, elevation: 1,
|
| 191 |
+
},
|
| 192 |
+
profileAvatar: {
|
| 193 |
+
width: 52, height: 52, borderRadius: 26,
|
| 194 |
+
backgroundColor: '#1a73e8',
|
| 195 |
+
justifyContent: 'center', alignItems: 'center',
|
| 196 |
+
},
|
| 197 |
+
profileAvatarText: { color: '#fff', fontSize: 22, fontWeight: '700' },
|
| 198 |
+
profileName: { fontSize: 17, fontWeight: '700', color: '#202124' },
|
| 199 |
+
profileSub: { fontSize: 13, color: '#5f6368', marginTop: 2 },
|
| 200 |
});
|
keystone-app/app/_layout.tsx
CHANGED
|
@@ -2,19 +2,23 @@ import { Stack } from 'expo-router';
|
|
| 2 |
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
| 3 |
import * as BackgroundFetch from 'expo-background-fetch';
|
| 4 |
import { useEffect } from 'react';
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
// Import syncManager to ensure TaskManager.defineTask is called in the global scope
|
| 7 |
import { BACKGROUND_SYNC_TASK } from './utils/syncManager';
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
useEffect(() => {
|
| 11 |
-
// Register the background fetch task with the OS
|
| 12 |
const registerTask = async () => {
|
| 13 |
try {
|
| 14 |
await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
|
| 15 |
-
minimumInterval: 60 * 15,
|
| 16 |
-
stopOnTerminate: false,
|
| 17 |
-
startOnBoot: true,
|
| 18 |
});
|
| 19 |
} catch (err) {
|
| 20 |
console.warn('Background fetch failed to register:', err);
|
|
@@ -23,6 +27,18 @@ export default function Layout() {
|
|
| 23 |
registerTask();
|
| 24 |
}, []);
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
return (
|
| 27 |
<SafeAreaProvider>
|
| 28 |
<Stack>
|
|
@@ -30,4 +46,12 @@ export default function Layout() {
|
|
| 30 |
</Stack>
|
| 31 |
</SafeAreaProvider>
|
| 32 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
}
|
|
|
|
| 2 |
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
| 3 |
import * as BackgroundFetch from 'expo-background-fetch';
|
| 4 |
import { useEffect } from 'react';
|
| 5 |
+
import { View, ActivityIndicator } from 'react-native';
|
| 6 |
+
import LoginScreen from '../components/LoginScreen';
|
| 7 |
+
import { AuthProvider, useAuth } from '../components/AuthContext';
|
| 8 |
|
| 9 |
// Import syncManager to ensure TaskManager.defineTask is called in the global scope
|
| 10 |
import { BACKGROUND_SYNC_TASK } from './utils/syncManager';
|
| 11 |
|
| 12 |
+
function AppContent() {
|
| 13 |
+
const { isAuthenticated, login } = useAuth();
|
| 14 |
+
|
| 15 |
useEffect(() => {
|
|
|
|
| 16 |
const registerTask = async () => {
|
| 17 |
try {
|
| 18 |
await BackgroundFetch.registerTaskAsync(BACKGROUND_SYNC_TASK, {
|
| 19 |
+
minimumInterval: 60 * 15,
|
| 20 |
+
stopOnTerminate: false,
|
| 21 |
+
startOnBoot: true,
|
| 22 |
});
|
| 23 |
} catch (err) {
|
| 24 |
console.warn('Background fetch failed to register:', err);
|
|
|
|
| 27 |
registerTask();
|
| 28 |
}, []);
|
| 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 |
+
);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (!isAuthenticated) {
|
| 39 |
+
return <LoginScreen onLoginSuccess={(token: string) => login(token)} />;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
return (
|
| 43 |
<SafeAreaProvider>
|
| 44 |
<Stack>
|
|
|
|
| 46 |
</Stack>
|
| 47 |
</SafeAreaProvider>
|
| 48 |
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export default function Layout() {
|
| 52 |
+
return (
|
| 53 |
+
<AuthProvider>
|
| 54 |
+
<AppContent />
|
| 55 |
+
</AuthProvider>
|
| 56 |
+
);
|
| 57 |
}
|
keystone-app/app/utils/syncManager.ts
CHANGED
|
@@ -9,9 +9,13 @@ import { SYNC_ON_CELLULAR_KEY } from '../(tabs)/settings';
|
|
| 9 |
export const BACKGROUND_SYNC_TASK = 'BACKGROUND_SYNC_TASK';
|
| 10 |
export const BACKGROUND_SYNC_ENABLED_KEY = '@setting_background_sync';
|
| 11 |
|
| 12 |
-
const BACKEND_URL = 'https://dpv007-keystone.hf.space';
|
| 13 |
-
|
| 14 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
export type SyncCallbacks = {
|
| 17 |
onProgress?: (current: number, total: number) => void;
|
|
@@ -19,7 +23,7 @@ export type SyncCallbacks = {
|
|
| 19 |
onInterrupted?: (reason: string) => void;
|
| 20 |
};
|
| 21 |
|
| 22 |
-
const getSyncedList = async (): Promise<string[]> => {
|
| 23 |
try {
|
| 24 |
const v = await AsyncStorage.getItem(SYNCED_ASSETS_KEY);
|
| 25 |
return v ? JSON.parse(v) : [];
|
|
@@ -28,7 +32,7 @@ const getSyncedList = async (): Promise<string[]> => {
|
|
| 28 |
}
|
| 29 |
};
|
| 30 |
|
| 31 |
-
const addSyncedId = async (id: string, list: string[]): Promise<string[]> => {
|
| 32 |
const next = [...list, id];
|
| 33 |
try {
|
| 34 |
await AsyncStorage.setItem(SYNCED_ASSETS_KEY, JSON.stringify(next));
|
|
@@ -36,6 +40,34 @@ const addSyncedId = async (id: string, list: string[]): Promise<string[]> => {
|
|
| 36 |
return next;
|
| 37 |
};
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
/**
|
| 40 |
* Core sync logic used by both foreground UI and background fetch.
|
| 41 |
*/
|
|
@@ -92,27 +124,15 @@ export const runSyncCycle = async (callbacks?: SyncCallbacks) => {
|
|
| 92 |
for (let i = 0; i < toSync.length; i++) {
|
| 93 |
callbacks?.onProgress?.(i + 1, toSync.length);
|
| 94 |
const asset = toSync[i];
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
|
| 103 |
-
parameters: { creation_time: asset.creationTime.toString() },
|
| 104 |
-
headers: { authorization: `Bearer ${MOCK_TOKEN}` },
|
| 105 |
-
});
|
| 106 |
-
|
| 107 |
-
if (resp.status === 200 || resp.status === 201) {
|
| 108 |
-
synced = await addSyncedId(asset.id, synced);
|
| 109 |
-
ok++;
|
| 110 |
-
callbacks?.onStatusChange?.('syncing', toSync.length - ok);
|
| 111 |
-
}
|
| 112 |
-
} catch (e) {
|
| 113 |
-
console.error('Upload error', e);
|
| 114 |
callbacks?.onInterrupted?.('Sync Interrupted');
|
| 115 |
-
return false;
|
| 116 |
}
|
| 117 |
}
|
| 118 |
|
|
@@ -120,6 +140,31 @@ export const runSyncCycle = async (callbacks?: SyncCallbacks) => {
|
|
| 120 |
return true;
|
| 121 |
};
|
| 122 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
// ─── Background Task Definition ─────────────────────────────────────
|
| 124 |
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
|
| 125 |
try {
|
|
|
|
| 9 |
export const BACKGROUND_SYNC_TASK = 'BACKGROUND_SYNC_TASK';
|
| 10 |
export const BACKGROUND_SYNC_ENABLED_KEY = '@setting_background_sync';
|
| 11 |
|
| 12 |
+
export const BACKEND_URL = 'https://dpv007-keystone.hf.space';
|
| 13 |
+
|
| 14 |
+
export const getToken = async () => {
|
| 15 |
+
return await AsyncStorage.getItem('jwt_token');
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export const SYNCED_ASSETS_KEY = '@synced_assets_v2';
|
| 19 |
|
| 20 |
export type SyncCallbacks = {
|
| 21 |
onProgress?: (current: number, total: number) => void;
|
|
|
|
| 23 |
onInterrupted?: (reason: string) => void;
|
| 24 |
};
|
| 25 |
|
| 26 |
+
export const getSyncedList = async (): Promise<string[]> => {
|
| 27 |
try {
|
| 28 |
const v = await AsyncStorage.getItem(SYNCED_ASSETS_KEY);
|
| 29 |
return v ? JSON.parse(v) : [];
|
|
|
|
| 32 |
}
|
| 33 |
};
|
| 34 |
|
| 35 |
+
export const addSyncedId = async (id: string, list: string[]): Promise<string[]> => {
|
| 36 |
const next = [...list, id];
|
| 37 |
try {
|
| 38 |
await AsyncStorage.setItem(SYNCED_ASSETS_KEY, JSON.stringify(next));
|
|
|
|
| 40 |
return next;
|
| 41 |
};
|
| 42 |
|
| 43 |
+
/**
|
| 44 |
+
* Upload a single asset to the backend using the stored JWT.
|
| 45 |
+
*/
|
| 46 |
+
const uploadAsset = async (asset: MediaLibrary.Asset): Promise<boolean> => {
|
| 47 |
+
try {
|
| 48 |
+
const token = await getToken();
|
| 49 |
+
if (!token) return false;
|
| 50 |
+
|
| 51 |
+
const info = await MediaLibrary.getAssetInfoAsync(asset);
|
| 52 |
+
const uri = info.localUri || asset.uri;
|
| 53 |
+
|
| 54 |
+
const res = await FileSystem.uploadAsync(`${BACKEND_URL}/upload`, uri, {
|
| 55 |
+
httpMethod: 'POST',
|
| 56 |
+
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
|
| 57 |
+
fieldName: 'file',
|
| 58 |
+
parameters: {
|
| 59 |
+
creation_time: asset.creationTime.toString(),
|
| 60 |
+
token: token,
|
| 61 |
+
},
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
return res.status === 200 || res.status === 201;
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error(`Error uploading asset ${asset.id}:`, error);
|
| 67 |
+
return false;
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
/**
|
| 72 |
* Core sync logic used by both foreground UI and background fetch.
|
| 73 |
*/
|
|
|
|
| 124 |
for (let i = 0; i < toSync.length; i++) {
|
| 125 |
callbacks?.onProgress?.(i + 1, toSync.length);
|
| 126 |
const asset = toSync[i];
|
| 127 |
+
|
| 128 |
+
const success = await uploadAsset(asset);
|
| 129 |
+
if (success) {
|
| 130 |
+
synced = await addSyncedId(asset.id, synced);
|
| 131 |
+
ok++;
|
| 132 |
+
callbacks?.onStatusChange?.('syncing', toSync.length - ok);
|
| 133 |
+
} else {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
callbacks?.onInterrupted?.('Sync Interrupted');
|
| 135 |
+
return false;
|
| 136 |
}
|
| 137 |
}
|
| 138 |
|
|
|
|
| 140 |
return true;
|
| 141 |
};
|
| 142 |
|
| 143 |
+
export const deleteFromCloud = async (cloudId: string): Promise<boolean> => {
|
| 144 |
+
try {
|
| 145 |
+
const token = await getToken();
|
| 146 |
+
if (!token) return false;
|
| 147 |
+
|
| 148 |
+
const res = await fetch(`${BACKEND_URL}/photos/delete/${cloudId}?token=${token}`, { method: 'DELETE' });
|
| 149 |
+
return res.ok;
|
| 150 |
+
} catch (e) {
|
| 151 |
+
console.error('Failed to delete from cloud', e);
|
| 152 |
+
return false;
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
export const downloadToDevice = async (cloudUri: string, filename: string): Promise<boolean> => {
|
| 157 |
+
try {
|
| 158 |
+
const fileUri = `${FileSystem.documentDirectory}${filename}`;
|
| 159 |
+
const { uri } = await FileSystem.downloadAsync(cloudUri, fileUri);
|
| 160 |
+
const asset = await MediaLibrary.createAssetAsync(uri);
|
| 161 |
+
return !!asset;
|
| 162 |
+
} catch (e) {
|
| 163 |
+
console.error('Failed to download to device', e);
|
| 164 |
+
return false;
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
// ─── Background Task Definition ─────────────────────────────────────
|
| 169 |
TaskManager.defineTask(BACKGROUND_SYNC_TASK, async () => {
|
| 170 |
try {
|
keystone-app/assets/android-icon-foreground.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
keystone-app/assets/favicon.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
keystone-app/assets/icon.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
keystone-app/assets/splash-icon.png
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
keystone-app/components/AuthContext.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useState, useEffect } from 'react';
|
| 2 |
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 3 |
+
|
| 4 |
+
interface AuthContextType {
|
| 5 |
+
isAuthenticated: boolean | null;
|
| 6 |
+
username: string | null;
|
| 7 |
+
login: (token: string) => void;
|
| 8 |
+
logout: () => Promise<void>;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const AuthContext = createContext<AuthContextType>({
|
| 12 |
+
isAuthenticated: null,
|
| 13 |
+
username: null,
|
| 14 |
+
login: () => {},
|
| 15 |
+
logout: async () => {},
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
| 19 |
+
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
|
| 20 |
+
const [username, setUsername] = useState<string | null>(null);
|
| 21 |
+
|
| 22 |
+
const decodeJwt = (token: string): string | null => {
|
| 23 |
+
try {
|
| 24 |
+
const base64Payload = token.split('.')[1];
|
| 25 |
+
const payload = JSON.parse(atob(base64Payload));
|
| 26 |
+
return payload.sub || null;
|
| 27 |
+
} catch {
|
| 28 |
+
return null;
|
| 29 |
+
}
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
AsyncStorage.getItem('jwt_token').then(token => {
|
| 34 |
+
if (token) {
|
| 35 |
+
setUsername(decodeJwt(token));
|
| 36 |
+
setIsAuthenticated(true);
|
| 37 |
+
} else {
|
| 38 |
+
setIsAuthenticated(false);
|
| 39 |
+
}
|
| 40 |
+
});
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const login = (token: string) => {
|
| 44 |
+
setUsername(decodeJwt(token));
|
| 45 |
+
setIsAuthenticated(true);
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
const logout = async () => {
|
| 49 |
+
await AsyncStorage.removeItem('jwt_token');
|
| 50 |
+
setUsername(null);
|
| 51 |
+
setIsAuthenticated(false);
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
return (
|
| 55 |
+
<AuthContext.Provider value={{ isAuthenticated, username, login, logout }}>
|
| 56 |
+
{children}
|
| 57 |
+
</AuthContext.Provider>
|
| 58 |
+
);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export const useAuth = () => useContext(AuthContext);
|
keystone-app/components/LoginScreen.tsx
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react';
|
| 2 |
+
import { View, Text, TextInput, TouchableOpacity, StyleSheet, ActivityIndicator, KeyboardAvoidingView, Platform, Alert } from 'react-native';
|
| 3 |
+
import { MaterialIcons } from '@expo/vector-icons';
|
| 4 |
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
| 5 |
+
import { BACKEND_URL } from '../app/utils/syncManager';
|
| 6 |
+
|
| 7 |
+
interface Props {
|
| 8 |
+
onLoginSuccess: (token: string) => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export default function LoginScreen({ onLoginSuccess }: Props) {
|
| 12 |
+
const [username, setUsername] = useState('');
|
| 13 |
+
const [password, setPassword] = useState('');
|
| 14 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 15 |
+
|
| 16 |
+
const handleLogin = async () => {
|
| 17 |
+
if (!username || !password) {
|
| 18 |
+
Alert.alert('Error', 'Please enter both username and password.');
|
| 19 |
+
return;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
setIsLoading(true);
|
| 23 |
+
try {
|
| 24 |
+
const res = await fetch(`${BACKEND_URL}/auth/login`, {
|
| 25 |
+
method: 'POST',
|
| 26 |
+
headers: { 'Content-Type': 'application/json' },
|
| 27 |
+
body: JSON.stringify({ username, password }),
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
const data = await res.json();
|
| 31 |
+
|
| 32 |
+
if (res.ok && data.token) {
|
| 33 |
+
await AsyncStorage.setItem('jwt_token', data.token);
|
| 34 |
+
onLoginSuccess(data.token);
|
| 35 |
+
} else {
|
| 36 |
+
Alert.alert('Login Failed', data.detail || 'Invalid credentials');
|
| 37 |
+
}
|
| 38 |
+
} catch (e) {
|
| 39 |
+
Alert.alert('Network Error', 'Could not connect to server.');
|
| 40 |
+
} finally {
|
| 41 |
+
setIsLoading(false);
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<KeyboardAvoidingView
|
| 47 |
+
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
| 48 |
+
style={styles.container}
|
| 49 |
+
>
|
| 50 |
+
<View style={styles.card}>
|
| 51 |
+
<View style={styles.header}>
|
| 52 |
+
<MaterialIcons name="cloud-sync" size={48} color="#1a73e8" />
|
| 53 |
+
<Text style={styles.title}>KeyStone</Text>
|
| 54 |
+
<Text style={styles.subtitle}>Sign in to your private cloud</Text>
|
| 55 |
+
</View>
|
| 56 |
+
|
| 57 |
+
<View style={styles.form}>
|
| 58 |
+
<View style={styles.inputContainer}>
|
| 59 |
+
<MaterialIcons name="person" size={20} color="#5f6368" style={styles.inputIcon} />
|
| 60 |
+
<TextInput
|
| 61 |
+
style={styles.input}
|
| 62 |
+
placeholder="Username"
|
| 63 |
+
placeholderTextColor="#9aa0a6"
|
| 64 |
+
value={username}
|
| 65 |
+
onChangeText={setUsername}
|
| 66 |
+
autoCapitalize="none"
|
| 67 |
+
autoCorrect={false}
|
| 68 |
+
/>
|
| 69 |
+
</View>
|
| 70 |
+
|
| 71 |
+
<View style={styles.inputContainer}>
|
| 72 |
+
<MaterialIcons name="lock" size={20} color="#5f6368" style={styles.inputIcon} />
|
| 73 |
+
<TextInput
|
| 74 |
+
style={styles.input}
|
| 75 |
+
placeholder="Password"
|
| 76 |
+
placeholderTextColor="#9aa0a6"
|
| 77 |
+
secureTextEntry
|
| 78 |
+
value={password}
|
| 79 |
+
onChangeText={setPassword}
|
| 80 |
+
/>
|
| 81 |
+
</View>
|
| 82 |
+
|
| 83 |
+
<TouchableOpacity
|
| 84 |
+
style={[styles.button, isLoading && styles.buttonDisabled]}
|
| 85 |
+
onPress={handleLogin}
|
| 86 |
+
disabled={isLoading}
|
| 87 |
+
>
|
| 88 |
+
{isLoading ? (
|
| 89 |
+
<ActivityIndicator color="#fff" />
|
| 90 |
+
) : (
|
| 91 |
+
<Text style={styles.buttonText}>Sign In</Text>
|
| 92 |
+
)}
|
| 93 |
+
</TouchableOpacity>
|
| 94 |
+
</View>
|
| 95 |
+
</View>
|
| 96 |
+
</KeyboardAvoidingView>
|
| 97 |
+
);
|
| 98 |
+
}
|
| 99 |
+
|
| 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',
|
| 112 |
+
shadowOffset: { width: 0, height: 8 },
|
| 113 |
+
shadowOpacity: 0.05,
|
| 114 |
+
shadowRadius: 24,
|
| 115 |
+
elevation: 4,
|
| 116 |
+
},
|
| 117 |
+
header: {
|
| 118 |
+
alignItems: 'center',
|
| 119 |
+
marginBottom: 32,
|
| 120 |
+
},
|
| 121 |
+
title: {
|
| 122 |
+
fontSize: 28,
|
| 123 |
+
fontWeight: '800',
|
| 124 |
+
color: '#202124',
|
| 125 |
+
marginTop: 16,
|
| 126 |
+
letterSpacing: -0.5,
|
| 127 |
+
},
|
| 128 |
+
subtitle: {
|
| 129 |
+
fontSize: 15,
|
| 130 |
+
color: '#5f6368',
|
| 131 |
+
marginTop: 8,
|
| 132 |
+
},
|
| 133 |
+
form: {
|
| 134 |
+
gap: 16,
|
| 135 |
+
},
|
| 136 |
+
inputContainer: {
|
| 137 |
+
flexDirection: 'row',
|
| 138 |
+
alignItems: 'center',
|
| 139 |
+
backgroundColor: '#f1f3f4',
|
| 140 |
+
borderRadius: 12,
|
| 141 |
+
paddingHorizontal: 16,
|
| 142 |
+
height: 56,
|
| 143 |
+
},
|
| 144 |
+
inputIcon: {
|
| 145 |
+
marginRight: 12,
|
| 146 |
+
},
|
| 147 |
+
input: {
|
| 148 |
+
flex: 1,
|
| 149 |
+
fontSize: 16,
|
| 150 |
+
color: '#202124',
|
| 151 |
+
height: '100%',
|
| 152 |
+
},
|
| 153 |
+
button: {
|
| 154 |
+
backgroundColor: '#1a73e8',
|
| 155 |
+
height: 56,
|
| 156 |
+
borderRadius: 12,
|
| 157 |
+
justifyContent: 'center',
|
| 158 |
+
alignItems: 'center',
|
| 159 |
+
marginTop: 8,
|
| 160 |
+
},
|
| 161 |
+
buttonDisabled: {
|
| 162 |
+
opacity: 0.7,
|
| 163 |
+
},
|
| 164 |
+
buttonText: {
|
| 165 |
+
color: '#fff',
|
| 166 |
+
fontSize: 16,
|
| 167 |
+
fontWeight: '700',
|
| 168 |
+
},
|
| 169 |
+
});
|
keystone-backend/main.py
CHANGED
|
@@ -1,19 +1,31 @@
|
|
| 1 |
import os
|
| 2 |
-
from datetime import datetime
|
| 3 |
from fastapi import FastAPI, HTTPException, Header, Depends, UploadFile, File, Form, Request
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 5 |
from pydantic import BaseModel
|
| 6 |
from dotenv import load_dotenv
|
|
|
|
| 7 |
|
| 8 |
# Load local environment variables
|
| 9 |
load_dotenv()
|
| 10 |
|
| 11 |
app = FastAPI(title="KeyStone API", version="0.1.0")
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# --- BUCKET STORAGE ENFORCEMENT ---
|
| 19 |
# The Hugging Face bucket MUST be mounted at /data for this service to operate.
|
|
@@ -55,22 +67,20 @@ app.add_middleware(
|
|
| 55 |
allow_headers=["*"],
|
| 56 |
)
|
| 57 |
|
| 58 |
-
class
|
| 59 |
username: str
|
| 60 |
password: str
|
| 61 |
|
| 62 |
-
def
|
| 63 |
-
token = None
|
| 64 |
-
if authorization and authorization.startswith("Bearer "):
|
| 65 |
-
token = authorization.split(" ")[1]
|
| 66 |
-
|
| 67 |
-
# Fallback to query parameter (useful if proxy strips Authorization header)
|
| 68 |
if not token:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
@app.get("/")
|
| 76 |
async def root():
|
|
@@ -89,21 +99,25 @@ async def health_check():
|
|
| 89 |
}
|
| 90 |
|
| 91 |
@app.post("/auth/login")
|
| 92 |
-
async def
|
| 93 |
-
if
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
| 100 |
|
| 101 |
@app.post("/upload")
|
| 102 |
async def upload_photo(
|
| 103 |
file: UploadFile = File(...),
|
| 104 |
creation_time: str = Form(None),
|
| 105 |
-
token: str =
|
| 106 |
):
|
|
|
|
|
|
|
| 107 |
# Determine the date for folder structure
|
| 108 |
dt = datetime.now()
|
| 109 |
if creation_time:
|
|
@@ -119,8 +133,8 @@ async def upload_photo(
|
|
| 119 |
year_folder = dt.strftime("%Y")
|
| 120 |
month_folder = dt.strftime("%m")
|
| 121 |
|
| 122 |
-
# Create target directory: e.g., /data/photos/2024/07
|
| 123 |
-
target_dir = os.path.join(BASE_STORAGE_DIR, year_folder, month_folder)
|
| 124 |
os.makedirs(target_dir, exist_ok=True)
|
| 125 |
|
| 126 |
file_path = os.path.join(target_dir, file.filename)
|
|
@@ -139,6 +153,11 @@ async def upload_photo(
|
|
| 139 |
with open(file_path, "wb") as buffer:
|
| 140 |
while content := await file.read(1024 * 1024):
|
| 141 |
buffer.write(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
except Exception as e:
|
| 143 |
raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
|
| 144 |
|
|
@@ -147,4 +166,56 @@ async def upload_photo(
|
|
| 147 |
"filename": file.filename,
|
| 148 |
"path": file_path,
|
| 149 |
"status": "uploaded"
|
| 150 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
from fastapi import FastAPI, HTTPException, Header, Depends, UploadFile, File, Form, Request
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from fastapi.responses import FileResponse
|
| 6 |
from pydantic import BaseModel
|
| 7 |
from dotenv import load_dotenv
|
| 8 |
+
import jwt
|
| 9 |
|
| 10 |
# Load local environment variables
|
| 11 |
load_dotenv()
|
| 12 |
|
| 13 |
app = FastAPI(title="KeyStone API", version="0.1.0")
|
| 14 |
|
| 15 |
+
# JWT Configuration
|
| 16 |
+
JWT_SECRET = os.getenv("JWT_SECRET", "super-secret-default-key-change-in-prod")
|
| 17 |
+
JWT_ALGORITHM = "HS256"
|
| 18 |
+
JWT_EXPIRATION_DAYS = 365
|
| 19 |
+
|
| 20 |
+
# Load Users dynamically from Environment Variables
|
| 21 |
+
# Format: USER_1=john, PASSWORD_1=secret
|
| 22 |
+
VALID_USERS = {}
|
| 23 |
+
for key, value in os.environ.items():
|
| 24 |
+
if key.startswith("USER_"):
|
| 25 |
+
idx = key.split("_")[1]
|
| 26 |
+
password = os.environ.get(f"PASSWORD_{idx}")
|
| 27 |
+
if password:
|
| 28 |
+
VALID_USERS[value] = password
|
| 29 |
|
| 30 |
# --- BUCKET STORAGE ENFORCEMENT ---
|
| 31 |
# The Hugging Face bucket MUST be mounted at /data for this service to operate.
|
|
|
|
| 67 |
allow_headers=["*"],
|
| 68 |
)
|
| 69 |
|
| 70 |
+
class LoginRequest(BaseModel):
|
| 71 |
username: str
|
| 72 |
password: str
|
| 73 |
|
| 74 |
+
def verify_jwt_token(token: str = None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
if not token:
|
| 76 |
+
raise HTTPException(status_code=401, detail="Missing token")
|
| 77 |
+
try:
|
| 78 |
+
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
| 79 |
+
return payload.get("sub")
|
| 80 |
+
except jwt.ExpiredSignatureError:
|
| 81 |
+
raise HTTPException(status_code=401, detail="Token expired")
|
| 82 |
+
except jwt.InvalidTokenError:
|
| 83 |
+
raise HTTPException(status_code=401, detail="Invalid token")
|
| 84 |
|
| 85 |
@app.get("/")
|
| 86 |
async def root():
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
@app.post("/auth/login")
|
| 102 |
+
async def login(req: LoginRequest):
|
| 103 |
+
if req.username not in VALID_USERS or VALID_USERS[req.username] != req.password:
|
| 104 |
+
raise HTTPException(status_code=401, detail="Invalid username or password")
|
| 105 |
+
|
| 106 |
+
payload = {
|
| 107 |
+
"sub": req.username,
|
| 108 |
+
"exp": datetime.utcnow() + timedelta(days=JWT_EXPIRATION_DAYS)
|
| 109 |
+
}
|
| 110 |
+
token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
| 111 |
+
return {"token": token, "username": req.username}
|
| 112 |
|
| 113 |
@app.post("/upload")
|
| 114 |
async def upload_photo(
|
| 115 |
file: UploadFile = File(...),
|
| 116 |
creation_time: str = Form(None),
|
| 117 |
+
token: str = Form(...)
|
| 118 |
):
|
| 119 |
+
username = verify_jwt_token(token)
|
| 120 |
+
|
| 121 |
# Determine the date for folder structure
|
| 122 |
dt = datetime.now()
|
| 123 |
if creation_time:
|
|
|
|
| 133 |
year_folder = dt.strftime("%Y")
|
| 134 |
month_folder = dt.strftime("%m")
|
| 135 |
|
| 136 |
+
# Create target directory: e.g., /data/photos/username/2024/07
|
| 137 |
+
target_dir = os.path.join(BASE_STORAGE_DIR, username, year_folder, month_folder)
|
| 138 |
os.makedirs(target_dir, exist_ok=True)
|
| 139 |
|
| 140 |
file_path = os.path.join(target_dir, file.filename)
|
|
|
|
| 153 |
with open(file_path, "wb") as buffer:
|
| 154 |
while content := await file.read(1024 * 1024):
|
| 155 |
buffer.write(content)
|
| 156 |
+
|
| 157 |
+
# Preserve original creation time on the filesystem for the GET /photos API
|
| 158 |
+
ts = dt.timestamp()
|
| 159 |
+
os.utime(file_path, (ts, ts))
|
| 160 |
+
|
| 161 |
except Exception as e:
|
| 162 |
raise HTTPException(status_code=500, detail=f"Could not save file: {str(e)}")
|
| 163 |
|
|
|
|
| 166 |
"filename": file.filename,
|
| 167 |
"path": file_path,
|
| 168 |
"status": "uploaded"
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
@app.get("/photos")
|
| 172 |
+
async def list_photos(username: str = Depends(verify_jwt_token)):
|
| 173 |
+
photos = []
|
| 174 |
+
user_dir = os.path.join(BASE_STORAGE_DIR, username)
|
| 175 |
+
if not os.path.exists(user_dir):
|
| 176 |
+
return {"assets": []}
|
| 177 |
+
|
| 178 |
+
for root_dir, dirs, files in os.walk(user_dir):
|
| 179 |
+
for f in files:
|
| 180 |
+
full_path = os.path.join(root_dir, f)
|
| 181 |
+
rel_path = os.path.relpath(full_path, user_dir)
|
| 182 |
+
stat = os.stat(full_path)
|
| 183 |
+
|
| 184 |
+
clean_rel = rel_path.replace("\\", "/")
|
| 185 |
+
serve_url = f"/photos/serve/{clean_rel}"
|
| 186 |
+
|
| 187 |
+
media_type = "video" if f.lower().endswith(('.mp4', '.mov', '.avi', '.webm')) else "photo"
|
| 188 |
+
|
| 189 |
+
photos.append({
|
| 190 |
+
"id": clean_rel,
|
| 191 |
+
"uri": serve_url,
|
| 192 |
+
"creationTime": int(stat.st_mtime * 1000),
|
| 193 |
+
"mediaType": media_type,
|
| 194 |
+
"filename": f,
|
| 195 |
+
"size": stat.st_size
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
return {"assets": photos}
|
| 199 |
+
|
| 200 |
+
@app.get("/photos/serve/{year}/{month}/{filename}")
|
| 201 |
+
async def serve_photo(
|
| 202 |
+
year: str, month: str, filename: str,
|
| 203 |
+
username: str = Depends(verify_jwt_token)
|
| 204 |
+
):
|
| 205 |
+
file_path = os.path.join(BASE_STORAGE_DIR, username, year, month, filename)
|
| 206 |
+
if not os.path.exists(file_path):
|
| 207 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 208 |
+
return FileResponse(file_path)
|
| 209 |
+
|
| 210 |
+
@app.delete("/photos/delete/{file_path:path}")
|
| 211 |
+
async def delete_photo(file_path: str, username: str = Depends(verify_jwt_token)):
|
| 212 |
+
clean_path = os.path.normpath(file_path)
|
| 213 |
+
if clean_path.startswith("..") or os.path.isabs(clean_path):
|
| 214 |
+
raise HTTPException(status_code=400, detail="Invalid path")
|
| 215 |
+
|
| 216 |
+
full_path = os.path.join(BASE_STORAGE_DIR, username, clean_path)
|
| 217 |
+
if os.path.exists(full_path):
|
| 218 |
+
os.remove(full_path)
|
| 219 |
+
return {"message": "Deleted successfully"}
|
| 220 |
+
else:
|
| 221 |
+
raise HTTPException(status_code=404, detail="File not found")
|
keystone-backend/requirements.txt
CHANGED
|
@@ -5,4 +5,5 @@ alembic
|
|
| 5 |
supabase
|
| 6 |
python-multipart
|
| 7 |
pillow
|
| 8 |
-
python-dotenv
|
|
|
|
|
|
| 5 |
supabase
|
| 6 |
python-multipart
|
| 7 |
pillow
|
| 8 |
+
python-dotenv
|
| 9 |
+
PyJWT
|
logo.png
ADDED
|
Git LFS Details
|