Fix storage stats bar and increase MediaLibrary photo fetch limit for WhatsApp
Browse files- keystone-app/app/(tabs)/index.tsx +112 -51
- keystone-app/app/utils/syncManager.ts +17 -5
- keystone-app/package-lock.json +15 -0
- keystone-app/package.json +1 -0
- keystone-backend/main.py +20 -2
keystone-app/app/(tabs)/index.tsx
CHANGED
|
@@ -12,9 +12,10 @@ import {
|
|
| 12 |
ActivityIndicator,
|
| 13 |
Modal,
|
| 14 |
AppState,
|
| 15 |
-
FlatList,
|
| 16 |
RefreshControl,
|
|
|
|
| 17 |
} from 'react-native';
|
|
|
|
| 18 |
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
| 19 |
import * as MediaLibrary from 'expo-media-library';
|
| 20 |
import { Image } from 'expo-image';
|
|
@@ -105,6 +106,7 @@ const GalleryRow = React.memo(({
|
|
| 105 |
contentFit="cover"
|
| 106 |
cachePolicy="memory-disk"
|
| 107 |
transition={200}
|
|
|
|
| 108 |
backgroundColor="transparent"
|
| 109 |
/>
|
| 110 |
{isVideo && (
|
|
@@ -178,7 +180,8 @@ export default function PhotosScreen() {
|
|
| 178 |
const [permissionResponse, setPermissionResponse] = useState<any>(null);
|
| 179 |
|
| 180 |
// Data
|
| 181 |
-
const [
|
|
|
|
| 182 |
const [allAssets, setAllAssets] = useState<UnifiedAsset[]>([]);
|
| 183 |
const [isLoading, setIsLoading] = useState(true);
|
| 184 |
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
@@ -267,7 +270,7 @@ export default function PhotosScreen() {
|
|
| 267 |
}, []);
|
| 268 |
|
| 269 |
// ── Load media ────────────────────────────────────────────────
|
| 270 |
-
const
|
| 271 |
const groups: Record<string, UnifiedAsset[]> = {};
|
| 272 |
for (const asset of assets) {
|
| 273 |
const date = new Date(asset.creationTime);
|
|
@@ -275,12 +278,21 @@ export default function PhotosScreen() {
|
|
| 275 |
if (!groups[key]) groups[key] = [];
|
| 276 |
groups[key].push(asset);
|
| 277 |
}
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
}, []);
|
| 285 |
|
| 286 |
const getTrashedAssets = async (): Promise<UnifiedAsset[]> => {
|
|
@@ -301,7 +313,9 @@ export default function PhotosScreen() {
|
|
| 301 |
|
| 302 |
// Paint cached assets immediately for instant UI
|
| 303 |
setAllAssets(parsedAssets);
|
| 304 |
-
|
|
|
|
|
|
|
| 305 |
setIsLoading(false); // hide spinner right away
|
| 306 |
hasCached = true;
|
| 307 |
|
|
@@ -325,7 +339,9 @@ export default function PhotosScreen() {
|
|
| 325 |
return asset;
|
| 326 |
});
|
| 327 |
setAllAssets(refreshed);
|
| 328 |
-
|
|
|
|
|
|
|
| 329 |
|
| 330 |
// Prefetch cloud-only thumbnails in background
|
| 331 |
const cloudThumbUrls = refreshed
|
|
@@ -346,11 +362,22 @@ export default function PhotosScreen() {
|
|
| 346 |
const token = await getToken();
|
| 347 |
|
| 348 |
// 1. Fetch Local
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
|
| 355 |
// 2. Fetch Cloud
|
| 356 |
let cloudAssets: any[] = [];
|
|
@@ -373,7 +400,7 @@ export default function PhotosScreen() {
|
|
| 373 |
const unified: UnifiedAsset[] = [];
|
| 374 |
const localFilenames = new Set();
|
| 375 |
|
| 376 |
-
for (const a of
|
| 377 |
if (trashedIds.includes(a.id)) continue;
|
| 378 |
|
| 379 |
const isSynced = syncedIds.includes(a.id);
|
|
@@ -422,7 +449,9 @@ export default function PhotosScreen() {
|
|
| 422 |
unified.sort((a, b) => b.creationTime - a.creationTime);
|
| 423 |
|
| 424 |
setAllAssets(unified);
|
| 425 |
-
|
|
|
|
|
|
|
| 426 |
|
| 427 |
// Save to cache silently
|
| 428 |
AsyncStorage.setItem(CACHED_ASSETS_KEY, JSON.stringify(unified)).catch(() => {});
|
|
@@ -443,7 +472,8 @@ export default function PhotosScreen() {
|
|
| 443 |
console.error('loadAssets error', e);
|
| 444 |
setIsLoading(false);
|
| 445 |
}
|
| 446 |
-
|
|
|
|
| 447 |
|
| 448 |
|
| 449 |
|
|
@@ -607,8 +637,18 @@ export default function PhotosScreen() {
|
|
| 607 |
};
|
| 608 |
|
| 609 |
// Select / deselect all assets in a given section title
|
| 610 |
-
const toggleDaySelection = useCallback((
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
setSelectedIds(prev => {
|
| 613 |
const next = new Set(prev);
|
| 614 |
const allSelected = ids.every(id => next.has(id));
|
|
@@ -621,10 +661,19 @@ export default function PhotosScreen() {
|
|
| 621 |
});
|
| 622 |
}, []);
|
| 623 |
|
| 624 |
-
const isDayFullySelected = useCallback((
|
| 625 |
-
const ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
return ids.length > 0 && ids.every(id => selectedIds.has(id));
|
| 627 |
-
}, [selectedIds]);
|
| 628 |
|
| 629 |
const openViewer = (asset: UnifiedAsset) => {
|
| 630 |
if (isSelecting) { toggleSelect(asset.id); return; }
|
|
@@ -667,14 +716,14 @@ export default function PhotosScreen() {
|
|
| 667 |
/>
|
| 668 |
), [selectedIds, isSelecting, openViewer, toggleSelect]);
|
| 669 |
|
| 670 |
-
const renderSectionHeader = useCallback((
|
| 671 |
-
const allDaySelected = isDayFullySelected(
|
| 672 |
return (
|
| 673 |
<View style={styles.sectionHeader}>
|
| 674 |
-
<Text style={styles.sectionTitle}>{
|
| 675 |
{isSelecting && (
|
| 676 |
<TouchableOpacity
|
| 677 |
-
onPress={() => toggleDaySelection(
|
| 678 |
style={styles.dayCheckBtn}
|
| 679 |
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
| 680 |
>
|
|
@@ -688,6 +737,19 @@ export default function PhotosScreen() {
|
|
| 688 |
</View>
|
| 689 |
);
|
| 690 |
}, [isSelecting, isDayFullySelected, toggleDaySelection]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
|
| 692 |
const renderLoadingSkeleton = () => (
|
| 693 |
<View style={{ flex: 1, padding: 24, justifyContent: 'center', alignItems: 'center' }}>
|
|
@@ -779,29 +841,28 @@ export default function PhotosScreen() {
|
|
| 779 |
|
| 780 |
{/* ── Gallery ── */}
|
| 781 |
{isLoading ? renderLoadingSkeleton() : (
|
| 782 |
-
<
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
/>
|
| 805 |
)}
|
| 806 |
|
| 807 |
{/* ── Full-screen viewer modal ── */}
|
|
|
|
| 12 |
ActivityIndicator,
|
| 13 |
Modal,
|
| 14 |
AppState,
|
|
|
|
| 15 |
RefreshControl,
|
| 16 |
+
Animated,
|
| 17 |
} from 'react-native';
|
| 18 |
+
import { FlashList } from '@shopify/flash-list';
|
| 19 |
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
| 20 |
import * as MediaLibrary from 'expo-media-library';
|
| 21 |
import { Image } from 'expo-image';
|
|
|
|
| 106 |
contentFit="cover"
|
| 107 |
cachePolicy="memory-disk"
|
| 108 |
transition={200}
|
| 109 |
+
allowDownscaling={true}
|
| 110 |
backgroundColor="transparent"
|
| 111 |
/>
|
| 112 |
{isVideo && (
|
|
|
|
| 180 |
const [permissionResponse, setPermissionResponse] = useState<any>(null);
|
| 181 |
|
| 182 |
// Data
|
| 183 |
+
const [flattenedData, setFlattenedData] = useState<any[]>([]);
|
| 184 |
+
const [stickyIndices, setStickyIndices] = useState<number[]>([]);
|
| 185 |
const [allAssets, setAllAssets] = useState<UnifiedAsset[]>([]);
|
| 186 |
const [isLoading, setIsLoading] = useState(true);
|
| 187 |
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
|
|
| 270 |
}, []);
|
| 271 |
|
| 272 |
// ── Load media ────────────────────────────────────────────────
|
| 273 |
+
const flattenAssets = useCallback((assets: UnifiedAsset[]) => {
|
| 274 |
const groups: Record<string, UnifiedAsset[]> = {};
|
| 275 |
for (const asset of assets) {
|
| 276 |
const date = new Date(asset.creationTime);
|
|
|
|
| 278 |
if (!groups[key]) groups[key] = [];
|
| 279 |
groups[key].push(asset);
|
| 280 |
}
|
| 281 |
+
|
| 282 |
+
const flat: any[] = [];
|
| 283 |
+
const indices: number[] = [];
|
| 284 |
+
let currentIndex = 0;
|
| 285 |
+
|
| 286 |
+
for (const key of Object.keys(groups)) {
|
| 287 |
+
flat.push({ type: 'header', title: key });
|
| 288 |
+
indices.push(currentIndex);
|
| 289 |
+
currentIndex++;
|
| 290 |
+
for (let i = 0; i < groups[key].length; i += NUM_COLUMNS) {
|
| 291 |
+
flat.push({ type: 'row', data: groups[key].slice(i, i + NUM_COLUMNS) });
|
| 292 |
+
currentIndex++;
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
return { flat, indices };
|
| 296 |
}, []);
|
| 297 |
|
| 298 |
const getTrashedAssets = async (): Promise<UnifiedAsset[]> => {
|
|
|
|
| 313 |
|
| 314 |
// Paint cached assets immediately for instant UI
|
| 315 |
setAllAssets(parsedAssets);
|
| 316 |
+
const { flat, indices } = flattenAssets(parsedAssets);
|
| 317 |
+
setFlattenedData(flat);
|
| 318 |
+
setStickyIndices(indices);
|
| 319 |
setIsLoading(false); // hide spinner right away
|
| 320 |
hasCached = true;
|
| 321 |
|
|
|
|
| 339 |
return asset;
|
| 340 |
});
|
| 341 |
setAllAssets(refreshed);
|
| 342 |
+
const { flat, indices } = flattenAssets(refreshed);
|
| 343 |
+
setFlattenedData(flat);
|
| 344 |
+
setStickyIndices(indices);
|
| 345 |
|
| 346 |
// Prefetch cloud-only thumbnails in background
|
| 347 |
const cloudThumbUrls = refreshed
|
|
|
|
| 362 |
const token = await getToken();
|
| 363 |
|
| 364 |
// 1. Fetch Local
|
| 365 |
+
let localAssets: MediaLibrary.Asset[] = [];
|
| 366 |
+
let hasNextPage = true;
|
| 367 |
+
let after: string | undefined = undefined;
|
| 368 |
+
|
| 369 |
+
while (hasNextPage) {
|
| 370 |
+
const mediaPage = await MediaLibrary.getAssetsAsync({
|
| 371 |
+
first: 1500,
|
| 372 |
+
after,
|
| 373 |
+
sortBy: [MediaLibrary.SortBy.creationTime],
|
| 374 |
+
mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
|
| 375 |
+
});
|
| 376 |
+
localAssets.push(...mediaPage.assets);
|
| 377 |
+
hasNextPage = mediaPage.hasNextPage;
|
| 378 |
+
after = mediaPage.endCursor;
|
| 379 |
+
if (localAssets.length >= 10000) break; // Safe limit
|
| 380 |
+
}
|
| 381 |
|
| 382 |
// 2. Fetch Cloud
|
| 383 |
let cloudAssets: any[] = [];
|
|
|
|
| 400 |
const unified: UnifiedAsset[] = [];
|
| 401 |
const localFilenames = new Set();
|
| 402 |
|
| 403 |
+
for (const a of localAssets) {
|
| 404 |
if (trashedIds.includes(a.id)) continue;
|
| 405 |
|
| 406 |
const isSynced = syncedIds.includes(a.id);
|
|
|
|
| 449 |
unified.sort((a, b) => b.creationTime - a.creationTime);
|
| 450 |
|
| 451 |
setAllAssets(unified);
|
| 452 |
+
const { flat, indices } = flattenAssets(unified);
|
| 453 |
+
setFlattenedData(flat);
|
| 454 |
+
setStickyIndices(indices);
|
| 455 |
|
| 456 |
// Save to cache silently
|
| 457 |
AsyncStorage.setItem(CACHED_ASSETS_KEY, JSON.stringify(unified)).catch(() => {});
|
|
|
|
| 472 |
console.error('loadAssets error', e);
|
| 473 |
setIsLoading(false);
|
| 474 |
}
|
| 475 |
+
}
|
| 476 |
+
}, [flattenAssets]);
|
| 477 |
|
| 478 |
|
| 479 |
|
|
|
|
| 637 |
};
|
| 638 |
|
| 639 |
// Select / deselect all assets in a given section title
|
| 640 |
+
const toggleDaySelection = useCallback((title: string) => {
|
| 641 |
+
// Find all assets in flattenedData under this header
|
| 642 |
+
const ids: string[] = [];
|
| 643 |
+
let collecting = false;
|
| 644 |
+
for (const item of flattenedData) {
|
| 645 |
+
if (item.type === 'header') {
|
| 646 |
+
if (collecting) break; // Reached next header
|
| 647 |
+
if (item.title === title) collecting = true;
|
| 648 |
+
} else if (item.type === 'row' && collecting) {
|
| 649 |
+
item.data.forEach((a: UnifiedAsset) => ids.push(a.id));
|
| 650 |
+
}
|
| 651 |
+
}
|
| 652 |
setSelectedIds(prev => {
|
| 653 |
const next = new Set(prev);
|
| 654 |
const allSelected = ids.every(id => next.has(id));
|
|
|
|
| 661 |
});
|
| 662 |
}, []);
|
| 663 |
|
| 664 |
+
const isDayFullySelected = useCallback((title: string) => {
|
| 665 |
+
const ids: string[] = [];
|
| 666 |
+
let collecting = false;
|
| 667 |
+
for (const item of flattenedData) {
|
| 668 |
+
if (item.type === 'header') {
|
| 669 |
+
if (collecting) break;
|
| 670 |
+
if (item.title === title) collecting = true;
|
| 671 |
+
} else if (item.type === 'row' && collecting) {
|
| 672 |
+
item.data.forEach((a: UnifiedAsset) => ids.push(a.id));
|
| 673 |
+
}
|
| 674 |
+
}
|
| 675 |
return ids.length > 0 && ids.every(id => selectedIds.has(id));
|
| 676 |
+
}, [selectedIds, flattenedData]);
|
| 677 |
|
| 678 |
const openViewer = (asset: UnifiedAsset) => {
|
| 679 |
if (isSelecting) { toggleSelect(asset.id); return; }
|
|
|
|
| 716 |
/>
|
| 717 |
), [selectedIds, isSelecting, openViewer, toggleSelect]);
|
| 718 |
|
| 719 |
+
const renderSectionHeader = useCallback((title: string) => {
|
| 720 |
+
const allDaySelected = isDayFullySelected(title);
|
| 721 |
return (
|
| 722 |
<View style={styles.sectionHeader}>
|
| 723 |
+
<Text style={styles.sectionTitle}>{title}</Text>
|
| 724 |
{isSelecting && (
|
| 725 |
<TouchableOpacity
|
| 726 |
+
onPress={() => toggleDaySelection(title)}
|
| 727 |
style={styles.dayCheckBtn}
|
| 728 |
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
| 729 |
>
|
|
|
|
| 737 |
</View>
|
| 738 |
);
|
| 739 |
}, [isSelecting, isDayFullySelected, toggleDaySelection]);
|
| 740 |
+
|
| 741 |
+
const renderItem = useCallback(({ item }: { item: any }) => {
|
| 742 |
+
if (item.type === 'header') {
|
| 743 |
+
return renderSectionHeader(item.title);
|
| 744 |
+
}
|
| 745 |
+
return <GalleryRow
|
| 746 |
+
row={item.data}
|
| 747 |
+
selectedIds={selectedIds}
|
| 748 |
+
isSelecting={isSelecting}
|
| 749 |
+
openViewer={openViewer}
|
| 750 |
+
toggleSelect={toggleSelect}
|
| 751 |
+
/>;
|
| 752 |
+
}, [renderSectionHeader, selectedIds, isSelecting, openViewer, toggleSelect]);
|
| 753 |
|
| 754 |
const renderLoadingSkeleton = () => (
|
| 755 |
<View style={{ flex: 1, padding: 24, justifyContent: 'center', alignItems: 'center' }}>
|
|
|
|
| 841 |
|
| 842 |
{/* ── Gallery ── */}
|
| 843 |
{isLoading ? renderLoadingSkeleton() : (
|
| 844 |
+
<View style={{ flex: 1 }}>
|
| 845 |
+
<FlashList
|
| 846 |
+
data={flattenedData}
|
| 847 |
+
keyExtractor={(_, idx) => `item-${idx}`}
|
| 848 |
+
renderItem={renderItem}
|
| 849 |
+
getItemType={(item) => item.type}
|
| 850 |
+
stickyHeaderIndices={stickyIndices}
|
| 851 |
+
showsVerticalScrollIndicator={false}
|
| 852 |
+
contentContainerStyle={styles.listContent}
|
| 853 |
+
onScroll={(e) => { listOffsetYRef.current = e.nativeEvent.contentOffset.y; }}
|
| 854 |
+
scrollEventThrottle={16}
|
| 855 |
+
estimatedItemSize={120}
|
| 856 |
+
refreshControl={
|
| 857 |
+
<RefreshControl
|
| 858 |
+
refreshing={isRefreshing}
|
| 859 |
+
onRefresh={onRefresh}
|
| 860 |
+
tintColor="#1a73e8"
|
| 861 |
+
colors={['#1a73e8']}
|
| 862 |
+
/>
|
| 863 |
+
}
|
| 864 |
+
/>
|
| 865 |
+
</View>
|
|
|
|
| 866 |
)}
|
| 867 |
|
| 868 |
{/* ── Full-screen viewer modal ── */}
|
keystone-app/app/utils/syncManager.ts
CHANGED
|
@@ -98,11 +98,23 @@ export const runSyncCycle = async (callbacks?: SyncCallbacks) => {
|
|
| 98 |
return false;
|
| 99 |
}
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
} catch (e) {
|
| 107 |
callbacks?.onInterrupted?.('Media Library Error');
|
| 108 |
return false;
|
|
|
|
| 98 |
return false;
|
| 99 |
}
|
| 100 |
|
| 101 |
+
let localAssets: MediaLibrary.Asset[] = [];
|
| 102 |
+
let hasNextPage = true;
|
| 103 |
+
let after: string | undefined = undefined;
|
| 104 |
+
|
| 105 |
+
while (hasNextPage) {
|
| 106 |
+
const mediaPage = await MediaLibrary.getAssetsAsync({
|
| 107 |
+
first: 1500,
|
| 108 |
+
after,
|
| 109 |
+
sortBy: [MediaLibrary.SortBy.creationTime],
|
| 110 |
+
mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
|
| 111 |
+
});
|
| 112 |
+
localAssets.push(...mediaPage.assets);
|
| 113 |
+
hasNextPage = mediaPage.hasNextPage;
|
| 114 |
+
after = mediaPage.endCursor;
|
| 115 |
+
if (localAssets.length >= 10000) break; // Safe limit
|
| 116 |
+
}
|
| 117 |
+
media = { assets: localAssets } as any;
|
| 118 |
} catch (e) {
|
| 119 |
callbacks?.onInterrupted?.('Media Library Error');
|
| 120 |
return false;
|
keystone-app/package-lock.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
| 11 |
"@expo/metro-runtime": "~6.1.2",
|
| 12 |
"@expo/vector-icons": "^15.0.3",
|
| 13 |
"@react-native-async-storage/async-storage": "2.2.0",
|
|
|
|
| 14 |
"date-fns": "^4.4.0",
|
| 15 |
"expo": "^54.0.36",
|
| 16 |
"expo-av": "~16.0.8",
|
|
@@ -3706,6 +3707,20 @@
|
|
| 3706 |
"nanoid": "^3.3.11"
|
| 3707 |
}
|
| 3708 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3709 |
"node_modules/@sinclair/typebox": {
|
| 3710 |
"version": "0.27.12",
|
| 3711 |
"license": "MIT"
|
|
|
|
| 11 |
"@expo/metro-runtime": "~6.1.2",
|
| 12 |
"@expo/vector-icons": "^15.0.3",
|
| 13 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 14 |
+
"@shopify/flash-list": "2.0.2",
|
| 15 |
"date-fns": "^4.4.0",
|
| 16 |
"expo": "^54.0.36",
|
| 17 |
"expo-av": "~16.0.8",
|
|
|
|
| 3707 |
"nanoid": "^3.3.11"
|
| 3708 |
}
|
| 3709 |
},
|
| 3710 |
+
"node_modules/@shopify/flash-list": {
|
| 3711 |
+
"version": "2.0.2",
|
| 3712 |
+
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.0.2.tgz",
|
| 3713 |
+
"integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==",
|
| 3714 |
+
"license": "MIT",
|
| 3715 |
+
"dependencies": {
|
| 3716 |
+
"tslib": "2.8.1"
|
| 3717 |
+
},
|
| 3718 |
+
"peerDependencies": {
|
| 3719 |
+
"@babel/runtime": "*",
|
| 3720 |
+
"react": "*",
|
| 3721 |
+
"react-native": "*"
|
| 3722 |
+
}
|
| 3723 |
+
},
|
| 3724 |
"node_modules/@sinclair/typebox": {
|
| 3725 |
"version": "0.27.12",
|
| 3726 |
"license": "MIT"
|
keystone-app/package.json
CHANGED
|
@@ -6,6 +6,7 @@
|
|
| 6 |
"@expo/metro-runtime": "~6.1.2",
|
| 7 |
"@expo/vector-icons": "^15.0.3",
|
| 8 |
"@react-native-async-storage/async-storage": "2.2.0",
|
|
|
|
| 9 |
"date-fns": "^4.4.0",
|
| 10 |
"expo": "^54.0.36",
|
| 11 |
"expo-av": "~16.0.8",
|
|
|
|
| 6 |
"@expo/metro-runtime": "~6.1.2",
|
| 7 |
"@expo/vector-icons": "^15.0.3",
|
| 8 |
"@react-native-async-storage/async-storage": "2.2.0",
|
| 9 |
+
"@shopify/flash-list": "2.0.2",
|
| 10 |
"date-fns": "^4.4.0",
|
| 11 |
"expo": "^54.0.36",
|
| 12 |
"expo-av": "~16.0.8",
|
keystone-backend/main.py
CHANGED
|
@@ -117,10 +117,28 @@ async def health_check():
|
|
| 117 |
"storage_path": BASE_STORAGE_DIR
|
| 118 |
}
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@app.get("/storage/stats")
|
| 121 |
async def storage_stats(username: str = Depends(verify_jwt_token)):
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
return {
|
| 125 |
"total": total,
|
| 126 |
"used": used,
|
|
|
|
| 117 |
"storage_path": BASE_STORAGE_DIR
|
| 118 |
}
|
| 119 |
|
| 120 |
+
def get_dir_size(path):
|
| 121 |
+
total = 0
|
| 122 |
+
try:
|
| 123 |
+
with os.scandir(path) as it:
|
| 124 |
+
for entry in it:
|
| 125 |
+
if entry.is_file():
|
| 126 |
+
total += entry.stat().st_size
|
| 127 |
+
elif entry.is_dir():
|
| 128 |
+
total += get_dir_size(entry.path)
|
| 129 |
+
except FileNotFoundError:
|
| 130 |
+
pass
|
| 131 |
+
return total
|
| 132 |
+
|
| 133 |
@app.get("/storage/stats")
|
| 134 |
async def storage_stats(username: str = Depends(verify_jwt_token)):
|
| 135 |
+
user_dir = get_user_dir(username)
|
| 136 |
+
used = get_dir_size(user_dir)
|
| 137 |
+
|
| 138 |
+
# Define a default quota of 15 GB
|
| 139 |
+
total = 15 * 1024 * 1024 * 1024
|
| 140 |
+
free = max(0, total - used)
|
| 141 |
+
|
| 142 |
return {
|
| 143 |
"total": total,
|
| 144 |
"used": used,
|