dpv007 commited on
Commit
20a9726
·
1 Parent(s): e0405f2
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": "#1a1f2e"
11
  },
12
  "plugins": [
13
  [
@@ -34,8 +34,9 @@
34
  "package": "com.dedeep.keystoneapp",
35
  "adaptiveIcon": {
36
  "foregroundImage": "./assets/android-icon-foreground.png",
37
- "backgroundImage": "./assets/android-icon-background.png"
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 SYNCED_ASSETS_KEY = '@synced_assets_v2';
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 Asset = MediaLibrary.Asset;
39
- type SectionData = { title: string; data: Asset[][] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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<Asset[]>([]);
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 [viewerAsset, setViewerAsset] = useState<Asset | null>(null);
 
 
 
 
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: Asset[]): SectionData[] => {
142
- const groups: Record<string, Asset[]> = {};
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: Asset[][] = [];
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
- // Calculate pending backups
168
- const synced = await getSyncedList();
169
- const toSync = media.assets.filter(a => !synced.includes(a.id));
170
- setItemsToBackup(toSync.length);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- `Delete ${selectedIds.size} item${selectedIds.size > 1 ? 's' : ''}?`,
222
- 'This will permanently delete them from your device.',
223
  [
224
  { text: 'Cancel', style: 'cancel' },
225
  {
226
- text: 'Delete',
227
  style: 'destructive',
228
  onPress: async () => {
229
  try {
230
- await MediaLibrary.deleteAssetsAsync([...selectedIds]);
 
 
 
231
  setSelectedIds(new Set());
232
  await loadAssets();
233
  } catch (e) {
234
- Alert.alert('Error', 'Could not delete items. Make sure permissions are granted.');
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
- return asset ? MediaLibrary.getAssetInfoAsync(asset) : null;
 
 
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: Asset) => {
276
  if (isSelecting) { toggleSelect(asset.id); return; }
277
- setViewerAsset(asset);
 
 
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: Asset[] }) => (
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
- <Text style={styles.videoDuration}>
327
- {Math.floor(asset.duration / 60)}:{String(Math.floor(asset.duration % 60)).padStart(2, '0')}
328
- </Text>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 6 }}>
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={!!viewerAsset}
452
  transparent={false}
453
  animationType="fade"
454
  statusBarTranslucent
455
- onRequestClose={() => setViewerAsset(null)}
456
  >
457
  <View style={styles.viewer}>
458
- <TouchableWithoutFeedback onPress={() => setViewerAsset(null)}>
459
  <View style={styles.viewerClose}>
460
  <MaterialIcons name="arrow-back" size={28} color="#fff" />
461
  </View>
462
  </TouchableWithoutFeedback>
463
 
464
- {viewerAsset?.mediaType === MediaLibrary.MediaType.video ? (
465
- <Video
466
- source={{ uri: viewerAsset.uri }}
467
- style={styles.viewerMedia}
468
- resizeMode={ResizeMode.CONTAIN}
469
- useNativeControls
470
- shouldPlay
471
- />
472
- ) : (
473
- <Image
474
- source={{ uri: viewerAsset?.uri }}
475
- style={styles.viewerMedia}
476
- contentFit="contain"
477
- cachePolicy="memory-disk"
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
- if (!viewerAsset) return;
487
- const info = await MediaLibrary.getAssetInfoAsync(viewerAsset);
488
- const uri = info.localUri || viewerAsset.uri;
489
- await Sharing.shareAsync(uri);
 
 
 
 
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
- if (!viewerAsset) return;
499
- Alert.alert('Delete?', 'This will permanently delete the item from your device.', [
500
- { text: 'Cancel', style: 'cancel' },
501
- {
502
- text: 'Delete', style: 'destructive', onPress: async () => {
503
- try {
504
- await MediaLibrary.deleteAssetsAsync([viewerAsset.id]);
505
- setViewerAsset(null);
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={<Text style={styles.sectionLabel}>Albums</Text>}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 40, left: 0, right: 0, alignItems: 'center' }}>
276
- <Text style={{ color: '#fff', fontSize: 13, backgroundColor: 'rgba(0,0,0,0.5)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16 }}>
277
- {format(new Date(viewerAsset.creationTime), 'MMM d, yyyy · h:mm a')}
278
- </Text>
 
 
 
 
 
 
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
- export default function Layout() {
 
 
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, // 15 minutes
16
- stopOnTerminate: false, // Android only
17
- startOnBoot: true, // Android only
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
- const MOCK_TOKEN = 'mock-jwt-token-xyz123';
14
- const SYNCED_ASSETS_KEY = '@synced_assets_v2';
 
 
 
 
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
- try {
96
- const info = await MediaLibrary.getAssetInfoAsync(asset);
97
- const localUri = info.localUri || asset.uri;
98
-
99
- const resp = await FileSystem.uploadAsync(`${BACKEND_URL}/upload?token=${MOCK_TOKEN}`, localUri, {
100
- fieldName: 'file',
101
- httpMethod: 'POST',
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; // Stop sync on failure to preserve battery/retries
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

  • SHA256: 5dd8d091949c249d3b9b75472e15f5b6639cb0642be8a37735f8dcbc12156994
  • Pointer size: 131 Bytes
  • Size of remote file: 398 kB

Git LFS Details

  • SHA256: fe0162b09a2e33d506b27db9154d4e8e5459fa8fe69203cbc0423db72b33e012
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB
keystone-app/assets/favicon.png CHANGED

Git LFS Details

  • SHA256: a4e030697a7571b3e95d31860e4da55d2f98e5e861e2b55e414f45a8556828ba
  • Pointer size: 129 Bytes
  • Size of remote file: 1.13 kB

Git LFS Details

  • SHA256: fe0162b09a2e33d506b27db9154d4e8e5459fa8fe69203cbc0423db72b33e012
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB
keystone-app/assets/icon.png CHANGED

Git LFS Details

  • SHA256: 5dd8d091949c249d3b9b75472e15f5b6639cb0642be8a37735f8dcbc12156994
  • Pointer size: 131 Bytes
  • Size of remote file: 398 kB

Git LFS Details

  • SHA256: fe0162b09a2e33d506b27db9154d4e8e5459fa8fe69203cbc0423db72b33e012
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB
keystone-app/assets/splash-icon.png CHANGED

Git LFS Details

  • SHA256: 5dd8d091949c249d3b9b75472e15f5b6639cb0642be8a37735f8dcbc12156994
  • Pointer size: 131 Bytes
  • Size of remote file: 398 kB

Git LFS Details

  • SHA256: fe0162b09a2e33d506b27db9154d4e8e5459fa8fe69203cbc0423db72b33e012
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB
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
- # Fetch credentials from environment variables
14
- DEV_USERNAME = os.getenv("DEV_USERNAME", "admin")
15
- DEV_PASSWORD = os.getenv("DEV_LOGIN_PASSWORD", "password123")
16
- MOCK_TOKEN = "mock-jwt-token-xyz123"
 
 
 
 
 
 
 
 
 
 
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 LoginPayload(BaseModel):
59
  username: str
60
  password: str
61
 
62
- def verify_mock_token(request: Request, authorization: str = Header(None)):
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
- token = request.query_params.get("token")
70
-
71
- if token != MOCK_TOKEN:
72
- raise HTTPException(status_code=401, detail="Unauthorized: Invalid Token")
73
- return token
 
 
 
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 mock_login(payload: LoginPayload):
93
- if payload.username == DEV_USERNAME and payload.password == DEV_PASSWORD:
94
- return {
95
- "access_token": MOCK_TOKEN,
96
- "token_type": "bearer",
97
- "user": {"username": payload.username, "id": "mock-uuid-1111-2222"}
98
- }
99
- raise HTTPException(status_code=400, detail="Incorrect username or password")
 
 
100
 
101
  @app.post("/upload")
102
  async def upload_photo(
103
  file: UploadFile = File(...),
104
  creation_time: str = Form(None),
105
- token: str = Depends(verify_mock_token)
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

  • SHA256: fe0162b09a2e33d506b27db9154d4e8e5459fa8fe69203cbc0423db72b33e012
  • Pointer size: 132 Bytes
  • Size of remote file: 1.18 MB