feat: add SVG icons and implement AlbumPickerModal and CenteredState components
No files matched your search
@@ -12,3 +12,4 @@
|
||||
.env
|
||||
!.env.example.txt
|
||||
ServerFiles/data/
|
||||
/package-lock.json
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3.6" y="7.4" width="16.8" height="10.4" rx="2.4" stroke="currentColor" stroke-width="1.8"/>
|
||||
<path d="M8 7.4 9 5.8h6l1 1.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="12.7" r="3.1" stroke="currentColor" stroke-width="1.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m4.1 12.1 4.2 4.2 11.5-11.5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 210 B |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="7.2" y="2.8" width="9.6" height="18.4" rx="2.8" stroke="#4F46E5" stroke-width="1.8"/>
|
||||
<rect x="10.1" y="5.1" width="3.8" height="0.9" rx="0.45" fill="#4F46E5"/>
|
||||
<circle cx="12" cy="18.1" r="1" fill="#4F46E5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 305 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="5.2" y="4.7" width="10.6" height="12.2" rx="1.6" stroke="currentColor" stroke-width="1.6"/>
|
||||
<rect x="8.3" y="7.8" width="10.5" height="12" rx="1.6" stroke="currentColor" stroke-width="1.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3.6" y="6.3" width="16.8" height="11.8" rx="1.9" stroke="currentColor" stroke-width="1.7"/>
|
||||
<path d="m4.3 7 7.7 6.1L19.7 7" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3.2" y="4.2" width="17.6" height="15.6" rx="2.4" stroke="currentColor" stroke-width="1.6"/>
|
||||
<circle cx="9.1" cy="10.1" r="1.7" fill="currentColor"/>
|
||||
<path d="m7 17 4.2-4 3.1 2.7 2.8-2.5 3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.7 9.2h16.6l-1.9 7.4a2 2 0 0 1-1.95 1.5H6.4a2 2 0 0 1-1.96-1.54Z" stroke="currentColor" stroke-width="1.7"/>
|
||||
<path d="M4.2 9.2V7.4a2 2 0 0 1 2-2h4l1.6 1.6h6.2a2 2 0 0 1 2 2v.2" stroke="currentColor" stroke-width="1.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 315 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.8 7.5a2.2 2.2 0 0 1 2.2-2.2h4.1l1.6 1.7h6.3a2.2 2.2 0 0 1 2.2 2.2v7.3a2.2 2.2 0 0 1-2.2 2.2H6a2.2 2.2 0 0 1-2.2-2.2Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6.8" y="3.8" width="10.4" height="16.4" rx="3" stroke="#22C55E" stroke-width="1.8"/>
|
||||
<circle cx="12" cy="16.8" r="0.95" fill="#22C55E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 230 B |
@@ -0,0 +1,5 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.8 12s3.2-5.2 9.2-5.2S21.2 12 21.2 12s-3.2 5.2-9.2 5.2S2.8 12 2.8 12Z" stroke="currentColor" stroke-width="1.7"/>
|
||||
<circle cx="12" cy="12" r="2.7" stroke="currentColor" stroke-width="1.7"/>
|
||||
<path d="m4.2 19.2 15.6-14.4" stroke="currentColor" stroke-width="1.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.2 17.2h7.4a3.4 3.4 0 0 0 .7-6.73A4.8 4.8 0 0 0 7.1 9.2a3.6 3.6 0 0 0 1.1 8Z" stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 256 B |
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4.2" y="4.2" width="5.8" height="5.8" rx="1.2" stroke="currentColor" stroke-width="1.8"/>
|
||||
<rect x="14" y="4.2" width="5.8" height="5.8" rx="1.2" stroke="currentColor" stroke-width="1.8"/>
|
||||
<rect x="4.2" y="14" width="5.8" height="5.8" rx="1.2" stroke="currentColor" stroke-width="1.8"/>
|
||||
<rect x="14" y="14" width="5.8" height="5.8" rx="1.2" stroke="currentColor" stroke-width="1.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
@@ -0,0 +1,8 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4.2" y="5.2" width="15.6" height="4.1" rx="1.6" stroke="#A855F7" stroke-width="1.5"/>
|
||||
<rect x="4.2" y="9.95" width="15.6" height="4.1" rx="1.6" stroke="#A855F7" stroke-width="1.5"/>
|
||||
<rect x="4.2" y="14.7" width="15.6" height="4.1" rx="1.6" stroke="#A855F7" stroke-width="1.5"/>
|
||||
<circle cx="7" cy="7.25" r="0.65" fill="#A855F7"/>
|
||||
<circle cx="7" cy="12" r="0.65" fill="#A855F7"/>
|
||||
<circle cx="7" cy="16.75" r="0.65" fill="#A855F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10.3" cy="10.3" r="5.6" stroke="currentColor" stroke-width="1.9"/>
|
||||
<path d="m14.6 14.6 4.2 4.2" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.5 7h13m-10.5 0V5.6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1V7m-8.6 0 .8 11a1.4 1.4 0 0 0 1.4 1.3h4.8a1.4 1.4 0 0 0 1.4-1.3l.8-11" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,12 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Ensure SVGs are transformed into React components (Expo-compatible transformer).
|
||||
config.transformer.babelTransformerPath = require.resolve('react-native-svg-transformer/expo');
|
||||
config.resolver.assetExts = config.resolver.assetExts.filter((ext) => ext !== 'svg');
|
||||
if (!config.resolver.sourceExts.includes('svg')) {
|
||||
config.resolver.sourceExts.push('svg');
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
@@ -27,6 +27,7 @@
|
||||
"expo-status-bar": "~55.0.5",
|
||||
"expo-system-ui": "^55.0.15",
|
||||
"expo-web-browser": "~55.0.14",
|
||||
"fuse.js": "^7.1.0",
|
||||
"react": "19.2.0",
|
||||
"react-native": "0.83.4",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import ModalShell from './ModalShell';
|
||||
|
||||
const AlbumPickerModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
albums,
|
||||
onPickAlbum,
|
||||
onCreateAlbum,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const [name, setName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) setName('');
|
||||
}, [visible]);
|
||||
|
||||
const create = () => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
onCreateAlbum?.(n);
|
||||
setName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell visible={visible} onClose={onClose} cardStyle={{ backgroundColor: theme.card, maxHeight: '72%' }}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>Add to album</Text>
|
||||
<Text style={[styles.hint, { color: theme.textSecondary }]}>
|
||||
Choose an album or create a new one
|
||||
</Text>
|
||||
|
||||
<FlatList
|
||||
data={albums}
|
||||
keyExtractor={(a) => String(a.id)}
|
||||
style={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.albumRow, { borderColor: theme.border }]}
|
||||
onPress={() => onPickAlbum?.(item.id)}
|
||||
>
|
||||
<Text style={[styles.albumName, { color: theme.text }]}>{item.name}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<Text style={[styles.empty, { color: theme.textSecondary }]}>No albums yet — create one below.</Text>
|
||||
}
|
||||
/>
|
||||
|
||||
<Text style={[styles.label, { color: theme.textSecondary }]}>New album name</Text>
|
||||
<TextInput
|
||||
style={[styles.input, { color: theme.text, borderColor: theme.border, backgroundColor: theme.background }]}
|
||||
placeholder="e.g. Holiday 2026"
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
onSubmitEditing={create}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<TouchableOpacity style={[styles.secondary, { borderColor: theme.border }]} onPress={onClose}>
|
||||
<Text style={{ color: theme.textSecondary }}>Close</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.primary, { backgroundColor: theme.primary }]}
|
||||
onPress={create}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
<Text style={styles.primaryText}>Create & add</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
||||
hint: { fontSize: 13, marginBottom: 12 },
|
||||
list: { maxHeight: 200, marginBottom: 12 },
|
||||
albumRow: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
marginBottom: 8,
|
||||
},
|
||||
albumName: { fontSize: 15, fontWeight: '600' },
|
||||
empty: { fontSize: 13, paddingVertical: 8 },
|
||||
label: { fontSize: 12, marginBottom: 6 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
marginBottom: 14,
|
||||
},
|
||||
row: { flexDirection: 'row', justifyContent: 'flex-end', gap: 10 },
|
||||
secondary: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
primary: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 10,
|
||||
},
|
||||
primaryText: { color: '#fff', fontWeight: '600', fontSize: 14 },
|
||||
});
|
||||
|
||||
export default AlbumPickerModal;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
|
||||
const CenteredState = ({
|
||||
loading = false,
|
||||
icon = null,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
containerStyle,
|
||||
iconStyle,
|
||||
titleStyle,
|
||||
descriptionStyle,
|
||||
actionStyle,
|
||||
actionTextStyle,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="large" color={theme.primary} />
|
||||
) : (
|
||||
icon ? <View style={[styles.iconWrap, iconStyle]}>{icon}</View> : null
|
||||
)}
|
||||
{title ? <Text style={[styles.title, { color: theme.text }, titleStyle]}>{title}</Text> : null}
|
||||
{description ? (
|
||||
<Text style={[styles.description, { color: theme.textSecondary }, descriptionStyle]}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
{actionLabel && onAction ? (
|
||||
<TouchableOpacity style={[styles.action, { backgroundColor: theme.primary }, actionStyle]} onPress={onAction}>
|
||||
<Text style={[styles.actionText, actionTextStyle]}>{actionLabel}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
iconWrap: { marginBottom: 16 },
|
||||
title: { fontSize: 18, fontWeight: '600', marginBottom: 6, textAlign: 'center' },
|
||||
description: { fontSize: 13, textAlign: 'center', lineHeight: 19 },
|
||||
action: {
|
||||
marginTop: 20,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
actionText: { color: '#fff', fontSize: 14, fontWeight: '600' },
|
||||
});
|
||||
|
||||
export default CenteredState;
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
PanResponder,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import MediaMetadataService from '../services/MediaMetadataService';
|
||||
import ServiceGlyph from './ServiceGlyph';
|
||||
|
||||
const { height: SCREEN_H } = Dimensions.get('window');
|
||||
const COLLAPSED_H = 120;
|
||||
const EXPANDED_H = Math.min(SCREEN_H * 0.58, 460);
|
||||
|
||||
const SERVICE_NAMES = { DEVICE: 'On Device', GOOGLE_PHOTOS: 'Google Photos', ICLOUD_PHOTOS: 'iCloud', NAS: 'NAS' };
|
||||
|
||||
const labelCapture = (kind) => {
|
||||
if (kind === 'screenshot') return 'Screenshot';
|
||||
if (kind === 'camera') return 'Camera photo';
|
||||
return 'Unknown / other';
|
||||
};
|
||||
|
||||
const MediaDetailDrawer = ({
|
||||
theme,
|
||||
displayMedia,
|
||||
resolveNasMetadata,
|
||||
}) => {
|
||||
const heightAnim = useRef(new Animated.Value(COLLAPSED_H)).current;
|
||||
const dragStartHeight = useRef(COLLAPSED_H);
|
||||
const [nasLoading, setNasLoading] = useState(false);
|
||||
const [nasExtra, setNasExtra] = useState(null);
|
||||
const [measured, setMeasured] = useState({ width: undefined, height: undefined });
|
||||
const fetchedFor = useRef(null);
|
||||
|
||||
const meta = { ...MediaMetadataService.parseMetadata(displayMedia), ...nasExtra };
|
||||
|
||||
useEffect(() => {
|
||||
heightAnim.setValue(COLLAPSED_H);
|
||||
setNasExtra(null);
|
||||
setMeasured({ width: undefined, height: undefined });
|
||||
fetchedFor.current = null;
|
||||
}, [displayMedia?.source_id, displayMedia?.local_path, heightAnim]);
|
||||
|
||||
useEffect(() => {
|
||||
const uri = displayMedia?.local_path;
|
||||
if (!uri || displayMedia?.media_type === 'video') return;
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
uri,
|
||||
(w, h) => {
|
||||
if (!cancelled && w > 0 && h > 0) setMeasured({ width: w, height: h });
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
return () => { cancelled = true; };
|
||||
}, [displayMedia?.local_path, displayMedia?.media_type]);
|
||||
|
||||
const maybeFetchNas = useCallback(async () => {
|
||||
if (!resolveNasMetadata || displayMedia?.source_service !== 'NAS') return;
|
||||
const key = displayMedia.remote_path || displayMedia.title;
|
||||
if (!key || fetchedFor.current === key) return;
|
||||
fetchedFor.current = key;
|
||||
setNasLoading(true);
|
||||
try {
|
||||
const remote = await resolveNasMetadata(displayMedia);
|
||||
if (remote) setNasExtra(remote);
|
||||
} finally {
|
||||
setNasLoading(false);
|
||||
}
|
||||
}, [displayMedia, resolveNasMetadata]);
|
||||
|
||||
const fetchNasRef = useRef(maybeFetchNas);
|
||||
fetchNasRef.current = maybeFetchNas;
|
||||
|
||||
const snapTo = useCallback((target) => {
|
||||
Animated.spring(heightAnim, {
|
||||
toValue: target,
|
||||
useNativeDriver: false,
|
||||
friction: 9,
|
||||
tension: 65,
|
||||
}).start(({ finished }) => {
|
||||
if (finished && target >= EXPANDED_H - 8) fetchNasRef.current?.();
|
||||
});
|
||||
}, [heightAnim]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
heightAnim.stopAnimation((v) => {
|
||||
const open = v < (COLLAPSED_H + EXPANDED_H) / 2;
|
||||
snapTo(open ? EXPANDED_H : COLLAPSED_H);
|
||||
});
|
||||
}, [heightAnim, snapTo]);
|
||||
|
||||
const pan = useMemo(
|
||||
() => PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: () => {
|
||||
heightAnim.stopAnimation((v) => {
|
||||
dragStartHeight.current = v;
|
||||
});
|
||||
},
|
||||
onPanResponderMove: (_, g) => {
|
||||
const next = Math.min(
|
||||
EXPANDED_H,
|
||||
Math.max(COLLAPSED_H, dragStartHeight.current - g.dy)
|
||||
);
|
||||
heightAnim.setValue(next);
|
||||
},
|
||||
onPanResponderRelease: (_, g) => {
|
||||
if (Math.abs(g.dy) < 10 && Math.abs(g.dx) < 10) {
|
||||
toggleExpanded();
|
||||
return;
|
||||
}
|
||||
heightAnim.stopAnimation((v) => {
|
||||
const mid = (COLLAPSED_H + EXPANDED_H) / 2;
|
||||
let target = v >= mid ? EXPANDED_H : COLLAPSED_H;
|
||||
if (g.vy < -0.85) target = EXPANDED_H;
|
||||
if (g.vy > 0.85) target = COLLAPSED_H;
|
||||
snapTo(target);
|
||||
});
|
||||
},
|
||||
}),
|
||||
[heightAnim, snapTo, toggleExpanded]
|
||||
);
|
||||
|
||||
const formattedDate = MediaMetadataService.formatDateTakenForDisplay(displayMedia);
|
||||
|
||||
const w = meta.width || measured.width || displayMedia?.width;
|
||||
const h = meta.height || measured.height || displayMedia?.height;
|
||||
const resolution = w && h ? `${w} × ${h}` : '—';
|
||||
|
||||
const captureKind = meta.captureKind
|
||||
|| MediaMetadataService.inferCaptureKind(meta, displayMedia?.title);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.sheet, { height: heightAnim, backgroundColor: theme.modalInfoBg }]}>
|
||||
<View {...pan.panHandlers} style={styles.dragStrip}>
|
||||
<View style={styles.pressStrip}>
|
||||
<View style={[styles.handle, { backgroundColor: theme.textTertiary || '#666' }]} />
|
||||
<Text style={styles.hint}>Drag up/down · tap here to toggle</Text>
|
||||
</View>
|
||||
<Text style={[styles.title, { color: '#fff' }]} numberOfLines={1}>
|
||||
{displayMedia?.title || 'Photo'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Source</Text>
|
||||
<View style={styles.sourceLine}>
|
||||
{displayMedia?.sources && displayMedia.sources.length > 1 ? (
|
||||
<>
|
||||
{(displayMedia.sources || []).slice(0, 3).map((s) => (
|
||||
<ServiceGlyph key={s} serviceType={s} size={14} />
|
||||
))}
|
||||
<Text style={styles.value}>{(displayMedia.sources || []).map((s) => SERVICE_NAMES[s] || s).join(' · ')}</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ServiceGlyph serviceType={displayMedia?.source_service} size={14} />
|
||||
<Text style={styles.value}>{SERVICE_NAMES[displayMedia?.source_service] || displayMedia?.source_service}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{displayMedia?.sources && displayMedia.sources.length > 1 && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Available on</Text>
|
||||
<Text style={[styles.value, { color: '#8ab4f8' }]}>{displayMedia.sources.length} services</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Resolution</Text>
|
||||
<Text style={styles.value}>{resolution}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Date taken</Text>
|
||||
<Text style={styles.value}>{formattedDate}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Capture type</Text>
|
||||
<Text style={styles.value}>{labelCapture(captureKind)}</Text>
|
||||
</View>
|
||||
|
||||
{(meta.cameraMake || meta.cameraModel) && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Camera</Text>
|
||||
<Text style={styles.value}>
|
||||
{[meta.cameraMake, meta.cameraModel].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(meta.fileSize != null || displayMedia?.size != null) && (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>Size</Text>
|
||||
<Text style={styles.value}>
|
||||
{formatBytes(meta.fileSize ?? displayMedia?.size)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{nasLoading && (
|
||||
<View style={styles.loadingRow}>
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
<Text style={styles.loadingText}>Loading file details…</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
function formatBytes(n) {
|
||||
if (n == null || Number.isNaN(n)) return '—';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
overflow: 'hidden',
|
||||
/* Above thumbnail carousel (MediaViewer thumb strip uses lower z-index). */
|
||||
zIndex: 50,
|
||||
elevation: 50,
|
||||
},
|
||||
dragStrip: {
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 6,
|
||||
minHeight: 56,
|
||||
},
|
||||
pressStrip: { alignItems: 'center', paddingBottom: 6 },
|
||||
handle: {
|
||||
width: 40,
|
||||
height: 5,
|
||||
borderRadius: 3,
|
||||
marginBottom: 4,
|
||||
},
|
||||
hint: { color: '#9aa0a6', fontSize: 10, marginBottom: 2 },
|
||||
title: { fontSize: 15, fontWeight: '600', alignSelf: 'stretch' },
|
||||
scroll: { flexGrow: 1 },
|
||||
scrollContent: { paddingHorizontal: 16, paddingBottom: 28 },
|
||||
row: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 8, gap: 12 },
|
||||
label: { fontSize: 12, color: '#9aa0a6' },
|
||||
value: { fontSize: 12, color: '#e8eaed', fontWeight: '500', flex: 1, textAlign: 'right' },
|
||||
sourceLine: { flexDirection: 'row', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' },
|
||||
loadingRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
loadingText: { color: '#9aa0a6', fontSize: 12 },
|
||||
});
|
||||
|
||||
export default MediaDetailDrawer;
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
|
||||
const OPTIONS = [
|
||||
{ key: 'date_taken', label: 'Date taken' },
|
||||
{ key: 'date_added', label: 'Date added' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
];
|
||||
|
||||
const MediaSortControls = ({ value, onChange }) => {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<Text style={[styles.prefix, { color: theme.textSecondary }]}>Sort</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
|
||||
{OPTIONS.map((opt) => (
|
||||
<TouchableOpacity
|
||||
key={opt.key}
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
backgroundColor: value === opt.key ? theme.chipActiveBg : theme.chipBg,
|
||||
},
|
||||
]}
|
||||
onPress={() => onChange(opt.key)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.chipText,
|
||||
{ color: theme.text },
|
||||
value === opt.key && { color: theme.primary, fontWeight: '600' },
|
||||
]}
|
||||
>
|
||||
{opt.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 4,
|
||||
gap: 8,
|
||||
},
|
||||
prefix: { fontSize: 12, fontWeight: '600' },
|
||||
row: { gap: 6, alignItems: 'center', paddingRight: 8 },
|
||||
chip: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 14,
|
||||
height: 28,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
chipText: { fontSize: 12, fontWeight: '500' },
|
||||
});
|
||||
|
||||
export default MediaSortControls;
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
const ModalShell = ({ visible, onClose, children, cardStyle, overlayStyle }) => (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.overlay, overlayStyle]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
|
||||
<View style={[styles.card, cardStyle]}>{children}</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'center', paddingHorizontal: 20 },
|
||||
backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.45)' },
|
||||
card: {
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default ModalShell;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
import ModalShell from './ModalShell';
|
||||
|
||||
const RenameMediaModal = ({ visible, initialName, onClose, onSave, saving }) => {
|
||||
const { theme } = useTheme();
|
||||
const [name, setName] = useState(initialName || '');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) setName(initialName || '');
|
||||
}, [visible, initialName]);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = (name || '').trim();
|
||||
if (!trimmed || saving) return;
|
||||
onSave?.(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalShell visible={visible} onClose={onClose} cardStyle={{ backgroundColor: theme.card }}>
|
||||
<Text style={[styles.title, { color: theme.text }]}>Rename media</Text>
|
||||
<Text style={[styles.hint, { color: theme.textSecondary }]}>
|
||||
This updates the name shown in your library and keeps it after refresh.
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{ color: theme.text, borderColor: theme.border, backgroundColor: theme.background },
|
||||
]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Enter a new name"
|
||||
placeholderTextColor={theme.textTertiary}
|
||||
onSubmitEditing={submit}
|
||||
editable={!saving}
|
||||
/>
|
||||
<View style={styles.row}>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondary, { borderColor: theme.border }]}
|
||||
onPress={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
<Text style={{ color: theme.textSecondary }}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.primary, { backgroundColor: theme.primary, opacity: saving ? 0.6 : 1 }]}
|
||||
onPress={submit}
|
||||
disabled={saving || !name.trim()}
|
||||
>
|
||||
<Text style={styles.primaryText}>{saving ? 'Saving…' : 'Save'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: { fontSize: 18, fontWeight: '700', marginBottom: 4 },
|
||||
hint: { fontSize: 13, marginBottom: 12 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 15,
|
||||
marginBottom: 14,
|
||||
},
|
||||
row: { flexDirection: 'row', gap: 10, justifyContent: 'flex-end' },
|
||||
secondary: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
primary: {
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
primaryText: { color: '#fff', fontWeight: '700' },
|
||||
});
|
||||
|
||||
export default RenameMediaModal;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '../services/ThemeContext';
|
||||
|
||||
const SelectionToolbar = ({
|
||||
visible,
|
||||
selectedCount,
|
||||
onAddToAlbum,
|
||||
onClear,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
if (!visible || selectedCount < 1) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.bar, { backgroundColor: theme.card, borderBottomColor: theme.border }]}>
|
||||
<Text style={[styles.count, { color: theme.text }]}>
|
||||
{selectedCount} selected
|
||||
</Text>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.btn, { backgroundColor: theme.primary }]}
|
||||
onPress={onAddToAlbum}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.btnText}>Add to album</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={onClear} style={styles.clearBtn} hitSlop={12}>
|
||||
<Text style={[styles.clearText, { color: theme.textSecondary }]}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
zIndex: 50,
|
||||
elevation: 6,
|
||||
},
|
||||
count: { fontSize: 14, fontWeight: '600' },
|
||||
actions: { flexDirection: 'row', alignItems: 'center', gap: 12 },
|
||||
btn: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
},
|
||||
btnText: { color: '#fff', fontSize: 13, fontWeight: '600' },
|
||||
clearBtn: { paddingVertical: 6, paddingHorizontal: 4 },
|
||||
clearText: { fontSize: 14, fontWeight: '500' },
|
||||
});
|
||||
|
||||
export default SelectionToolbar;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import DeviceIcon from '../../assets/icons/device.svg';
|
||||
import GooglePhotosIcon from '../../assets/icons/google-photos.svg';
|
||||
import ICloudIcon from '../../assets/icons/icloud.svg';
|
||||
import NasIcon from '../../assets/icons/nas.svg';
|
||||
|
||||
const SERVICE_ICONS = {
|
||||
GOOGLE_PHOTOS: GooglePhotosIcon,
|
||||
ICLOUD_PHOTOS: ICloudIcon,
|
||||
NAS: NasIcon,
|
||||
DEVICE: DeviceIcon,
|
||||
};
|
||||
|
||||
const ServiceGlyph = React.memo(({ serviceType, size = 20, color }) => {
|
||||
const Icon = SERVICE_ICONS[serviceType] || DeviceIcon;
|
||||
return <Icon width={size} height={size} color={color} />;
|
||||
});
|
||||
|
||||
export default ServiceGlyph;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const USER_ID_KEY = 'auth_user_id';
|
||||
|
||||
export async function getStoredUserId() {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(USER_ID_KEY);
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStoredUserId(userId) {
|
||||
try {
|
||||
if (userId == null) return;
|
||||
await SecureStore.setItemAsync(USER_ID_KEY, String(userId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStoredUserId() {
|
||||
try {
|
||||
await SecureStore.deleteItemAsync(USER_ID_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import Fuse from 'fuse.js';
|
||||
import CredentialEncryption from './CredentialEncryption';
|
||||
import MediaMetadataService from './MediaMetadataService';
|
||||
import { logError } from './ErrorHandler';
|
||||
@@ -9,6 +10,17 @@ let writeQueue = Promise.resolve();
|
||||
const MEDIA_INSERT_SQL = `INSERT INTO media (user_id, source_service, source_id, title, description, local_path, remote_path, media_type, created_date, metadata_json, search_text)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
const EMPTY_PAGE = { mediaItems: [], endCursor: null, hasNextPage: false };
|
||||
const MEDIA_FUZZY_OPTIONS = {
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
ignoreLocation: true,
|
||||
threshold: 0.34,
|
||||
minMatchCharLength: 2,
|
||||
keys: [
|
||||
{ name: 'title', weight: 0.45 },
|
||||
{ name: '__fuzzyText', weight: 0.55 },
|
||||
],
|
||||
};
|
||||
|
||||
const hasValue = (value) => value || value === 0;
|
||||
const withDecryptedAccount = (row) => ({ ...row, account_data: CredentialEncryption.decrypt(row.account_data) });
|
||||
@@ -32,6 +44,42 @@ const parseJsonSafe = (value, fallback = {}) => {
|
||||
return { ...fallback };
|
||||
}
|
||||
};
|
||||
const createdDateMs = (row) => {
|
||||
const t = Date.parse(row?.created_date || '');
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
};
|
||||
const enrichRowForFuzzySearch = (row) => ({
|
||||
...row,
|
||||
__fuzzyText: [
|
||||
row.title,
|
||||
row.description,
|
||||
row.search_text,
|
||||
row.metadata_json,
|
||||
row.source_service,
|
||||
row.source_id,
|
||||
row.remote_path,
|
||||
row.local_path,
|
||||
]
|
||||
.filter((part) => part != null && String(part).length > 0)
|
||||
.join(' ')
|
||||
.toLowerCase(),
|
||||
});
|
||||
const runFuzzyMediaSearch = (rows, query) => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return [];
|
||||
const searchableRows = rows.map(enrichRowForFuzzySearch);
|
||||
const fuse = new Fuse(searchableRows, MEDIA_FUZZY_OPTIONS);
|
||||
return fuse
|
||||
.search(query)
|
||||
.sort((a, b) => {
|
||||
const scoreDelta = (a.score ?? 1) - (b.score ?? 1);
|
||||
if (Math.abs(scoreDelta) > 0.0001) return scoreDelta;
|
||||
return createdDateMs(b.item) - createdDateMs(a.item);
|
||||
})
|
||||
.map(({ item }) => {
|
||||
const { __fuzzyText, ...clean } = item;
|
||||
return clean;
|
||||
});
|
||||
};
|
||||
const enqueueDbWrite = async (operation) => {
|
||||
const run = async () => operation();
|
||||
const next = writeQueue.then(run, run);
|
||||
@@ -537,17 +585,8 @@ const DatabaseService = {
|
||||
if (!(await DatabaseService.ensureDb())) return [];
|
||||
const q = (query || '').trim();
|
||||
if (!q) return [];
|
||||
const searchTerm = `%${q}%`;
|
||||
const likeRows = await db.getAllAsync(
|
||||
`SELECT * FROM media WHERE user_id = ? AND (
|
||||
title LIKE ? OR description LIKE ? OR search_text LIKE ? OR metadata_json LIKE ?
|
||||
)`,
|
||||
[userId, searchTerm, searchTerm, searchTerm, searchTerm]
|
||||
);
|
||||
const ranked = MediaMetadataService.rankMediaBySearchQuery(likeRows, q);
|
||||
if (ranked.length > 0) return ranked;
|
||||
const allRows = await db.getAllAsync('SELECT * FROM media WHERE user_id = ?', [userId]);
|
||||
return MediaMetadataService.rankMediaBySearchQuery(allRows, q);
|
||||
return runFuzzyMediaSearch(allRows, q);
|
||||
} catch (error) {
|
||||
logError('Search Media', error);
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
export const mediaFingerprint = (item) => {
|
||||
const svc = item.source_service || item.sourceService || 'UNKNOWN';
|
||||
const sid = item.source_id || item.sourceId;
|
||||
if (sid) return `${svc}:${sid}`;
|
||||
const lp = item.local_path || item.localPath;
|
||||
if (lp) return `${svc}:${lp}`;
|
||||
const id = item.id != null ? String(item.id) : '';
|
||||
const title = item.title || '';
|
||||
return `${svc}:${id || title || 'item'}`;
|
||||
};
|
||||
|
||||
const numDim = (v) => {
|
||||
if (v == null || v === '') return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(String(v).replace(/[^\d.]/g, ''));
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses EXIF-style "YYYY:MM:DD HH:mm:ss", ISO strings, epoch seconds/ms, and Date.parse fallbacks.
|
||||
*/
|
||||
export const parseFlexibleDateToMs = (value) => {
|
||||
if (value == null || value === '') return null;
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
const ms = value > 1e12 ? value : value > 1e10 ? value : value * 1000;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : ms;
|
||||
}
|
||||
const s = String(value).trim();
|
||||
if (!s) return null;
|
||||
if (/^\d+$/.test(s)) {
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
const ms = n > 1e12 ? n : n > 1e10 ? n : n * 1000;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : ms;
|
||||
}
|
||||
const exif = s.match(/^(\d{4}):(\d{2}):(\d{2})[\sT](\d{2}):(\d{2}):(\d{2})/);
|
||||
if (exif) {
|
||||
const iso = `${exif[1]}-${exif[2]}-${exif[3]}T${exif[4]}:${exif[5]}:${exif[6]}`;
|
||||
const t = Date.parse(iso);
|
||||
return Number.isNaN(t) ? null : t;
|
||||
}
|
||||
const t = Date.parse(s);
|
||||
return Number.isNaN(t) ? null : t;
|
||||
};
|
||||
|
||||
export const dateTakenMsToIso = (ms) => {
|
||||
if (ms == null || !Number.isFinite(ms) || ms <= 0) return null;
|
||||
return new Date(ms).toISOString();
|
||||
};
|
||||
|
||||
/** Single ISO string for DB `created_date` (capture time), never import clock unless no signal. */
|
||||
export const resolveCreatedDateIsoForStorage = (item) => {
|
||||
const meta = parseMetadata(item);
|
||||
const chain = [
|
||||
meta.dateTaken,
|
||||
meta.DateTimeOriginal,
|
||||
meta.dateTimeDigitized,
|
||||
meta.CreateDate,
|
||||
meta.ModifyDate,
|
||||
item.creationTime,
|
||||
item.modifyTime,
|
||||
item.created_date,
|
||||
];
|
||||
for (const v of chain) {
|
||||
const ms = parseFlexibleDateToMs(v);
|
||||
if (ms != null && ms > 0) return new Date(ms).toISOString();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const parseMetadata = (itemOrJson) => {
|
||||
if (!itemOrJson) return {};
|
||||
if (typeof itemOrJson === 'object') {
|
||||
let m = {};
|
||||
if (itemOrJson.metadata_json && typeof itemOrJson.metadata_json === 'string') {
|
||||
try {
|
||||
m = JSON.parse(itemOrJson.metadata_json);
|
||||
} catch {
|
||||
m = {};
|
||||
}
|
||||
} else if (itemOrJson.metadata && typeof itemOrJson.metadata === 'object') {
|
||||
m = { ...itemOrJson.metadata };
|
||||
}
|
||||
const w = numDim(m.width) ?? numDim(itemOrJson.width);
|
||||
const h = numDim(m.height) ?? numDim(itemOrJson.height);
|
||||
if (w) m.width = w;
|
||||
if (h) m.height = h;
|
||||
return m;
|
||||
}
|
||||
const raw = typeof itemOrJson === 'string' ? itemOrJson : null;
|
||||
if (!raw || typeof raw !== 'string') return {};
|
||||
try {
|
||||
const m = JSON.parse(raw);
|
||||
const w = numDim(m.width);
|
||||
const h = numDim(m.height);
|
||||
if (w) m.width = w;
|
||||
if (h) m.height = h;
|
||||
return m;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const inferCaptureKind = (meta = {}, filename = '') => {
|
||||
const name = (filename || meta.filename || meta.title || '').toLowerCase();
|
||||
if (/screenshot|screen[_-]?shot|screencap|screen ?recording/i.test(name)) return 'screenshot';
|
||||
if (meta.isScreenshot) return 'screenshot';
|
||||
const make = `${meta.cameraMake || meta.make || ''}`.toLowerCase();
|
||||
const model = `${meta.cameraModel || meta.model || ''}`.toLowerCase();
|
||||
if (make || model) {
|
||||
if (/iphone|ipad|pixel|galaxy|nikon|canon|sony|olympus|fujifilm|dsc-|dsc/i.test(`${make} ${model}`)) {
|
||||
return 'camera';
|
||||
}
|
||||
return 'camera';
|
||||
}
|
||||
if (/^img_|^mvimg_|^dsc|^photo_\d|^p\d{6,}/i.test(name)) return 'camera';
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const buildSearchText = (item, metadataJsonString) => {
|
||||
let meta = {};
|
||||
try {
|
||||
meta = metadataJsonString ? JSON.parse(metadataJsonString) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
const parts = [
|
||||
item.title,
|
||||
item.description,
|
||||
item.source_service,
|
||||
meta.cameraMake,
|
||||
meta.cameraModel,
|
||||
meta.make,
|
||||
meta.model,
|
||||
meta.captureKind,
|
||||
meta.mimeType,
|
||||
meta.dateTaken,
|
||||
item.remote_path,
|
||||
item.local_path,
|
||||
];
|
||||
return parts.filter((p) => p != null && String(p).length > 0).join(' ').toLowerCase();
|
||||
};
|
||||
|
||||
export const mergeMetadataJson = (existingJson, incomingJson) => {
|
||||
let a = {};
|
||||
let b = {};
|
||||
try {
|
||||
if (existingJson) a = JSON.parse(existingJson);
|
||||
} catch {
|
||||
a = {};
|
||||
}
|
||||
try {
|
||||
if (incomingJson) b = typeof incomingJson === 'string' ? JSON.parse(incomingJson) : incomingJson;
|
||||
} catch {
|
||||
b = {};
|
||||
}
|
||||
return JSON.stringify({ ...a, ...b });
|
||||
};
|
||||
|
||||
|
||||
export const getDateTakenMs = (item) => {
|
||||
if (!item) return 0;
|
||||
const meta = parseMetadata(item);
|
||||
const chain = [
|
||||
meta.dateTaken,
|
||||
meta.DateTimeOriginal,
|
||||
meta.CreateDate,
|
||||
meta.ModifyDate,
|
||||
meta.dateTimeDigitized,
|
||||
item.creationTime,
|
||||
item.modifyTime,
|
||||
item.created_date,
|
||||
];
|
||||
for (const v of chain) {
|
||||
const ms = parseFlexibleDateToMs(v);
|
||||
if (ms != null && ms > 0) return ms;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const getDateAddedMs = (item) => {
|
||||
const raw = item.created_at || item.indexed_at;
|
||||
const ms = parseFlexibleDateToMs(raw);
|
||||
if (ms != null && ms > 0) return ms;
|
||||
return getDateTakenMs(item);
|
||||
};
|
||||
|
||||
export const sortMediaItems = (items, mode) => {
|
||||
const copy = [...items];
|
||||
if (mode === 'name') {
|
||||
copy.sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' }));
|
||||
return copy;
|
||||
}
|
||||
if (mode === 'date_added') {
|
||||
copy.sort((a, b) => getDateAddedMs(b) - getDateAddedMs(a));
|
||||
return copy;
|
||||
}
|
||||
copy.sort((a, b) => getDateTakenMs(b) - getDateTakenMs(a));
|
||||
return copy;
|
||||
};
|
||||
|
||||
const mapBasicAsset = (asset) => ({
|
||||
source_service: 'DEVICE',
|
||||
source_id: asset.id,
|
||||
title: asset.filename || 'Untitled',
|
||||
description: '',
|
||||
local_path: asset.uri,
|
||||
remote_path: '',
|
||||
media_type: asset.mediaType === 'video' ? 'video' : 'photo',
|
||||
created_date: (() => {
|
||||
const ms = parseFlexibleDateToMs(asset.creationTime);
|
||||
return ms != null && ms > 0 ? new Date(ms).toISOString() : null;
|
||||
})(),
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
duration: asset.duration || 0,
|
||||
});
|
||||
|
||||
export const deviceAssetToMediaItem = async (asset) => {
|
||||
const base = mapBasicAsset(asset);
|
||||
let exif = {};
|
||||
try {
|
||||
const info = await MediaLibrary.getAssetInfoAsync(asset);
|
||||
if (info?.exif && typeof info.exif === 'object') exif = info.exif;
|
||||
if (info?.width) base.width = info.width;
|
||||
if (info?.height) base.height = info.height;
|
||||
} catch {
|
||||
/* optional enrichment */
|
||||
}
|
||||
|
||||
const meta = {
|
||||
width: numDim(exif.ImageWidth) || numDim(exif.PixelXDimension) || numDim(base.width),
|
||||
height: numDim(exif.ImageHeight) || numDim(exif.PixelYDimension) || numDim(base.height),
|
||||
cameraMake: exif.Make || exif.LensMake,
|
||||
cameraModel: exif.Model || exif.LensModel,
|
||||
orientation: exif.Orientation,
|
||||
mimeType: exif.MIMEType || (base.media_type === 'video' ? 'video/mp4' : 'image/jpeg'),
|
||||
};
|
||||
|
||||
const takenMs = parseFlexibleDateToMs(asset.creationTime);
|
||||
if (takenMs != null && takenMs > 0) {
|
||||
meta.dateTaken = new Date(takenMs).toISOString();
|
||||
}
|
||||
|
||||
meta.captureKind = inferCaptureKind({ ...meta, filename: asset.filename, title: base.title });
|
||||
const metadata_json = JSON.stringify(meta);
|
||||
return {
|
||||
...base,
|
||||
creationTime: asset.creationTime,
|
||||
created_date: meta.dateTaken || base.created_date,
|
||||
metadata_json,
|
||||
search_text: buildSearchText({ ...base, metadata_json }, metadata_json),
|
||||
};
|
||||
};
|
||||
|
||||
export const enrichGooglePickerItem = (item) => {
|
||||
const meta = parseMetadata(item.metadata_json || '{}');
|
||||
const width = numDim(item.width) ?? numDim(meta.width);
|
||||
const height = numDim(item.height) ?? numDim(meta.height);
|
||||
const takenMs = parseFlexibleDateToMs(item.created_date) ?? parseFlexibleDateToMs(meta.dateTaken);
|
||||
const dateTakenIso = takenMs != null && takenMs > 0 ? new Date(takenMs).toISOString() : meta.dateTaken;
|
||||
const merged = {
|
||||
...meta,
|
||||
width,
|
||||
height,
|
||||
mimeType: item.mime_type || meta.mimeType,
|
||||
dateTaken: dateTakenIso,
|
||||
captureKind: inferCaptureKind({ ...meta, filename: item.title, title: item.title }),
|
||||
};
|
||||
const metadata_json = JSON.stringify(merged);
|
||||
const createdIso = dateTakenIso || item.created_date;
|
||||
return {
|
||||
...item,
|
||||
created_date: createdIso,
|
||||
metadata_json,
|
||||
search_text: buildSearchText({ ...item, metadata_json }, metadata_json),
|
||||
};
|
||||
};
|
||||
|
||||
export const enrichNasItem = (item, serverMeta = {}) => {
|
||||
const fromServerMs = parseFlexibleDateToMs(serverMeta.DateTimeOriginal)
|
||||
?? parseFlexibleDateToMs(serverMeta.dateTaken)
|
||||
?? parseFlexibleDateToMs(serverMeta.CreateDate);
|
||||
const dateTakenIso = fromServerMs != null && fromServerMs > 0
|
||||
? new Date(fromServerMs).toISOString()
|
||||
: (parseMetadata(item.metadata_json).dateTaken || item.created_date);
|
||||
const meta = {
|
||||
...parseMetadata(item.metadata_json),
|
||||
width: numDim(serverMeta.width) ?? numDim(serverMeta.ExifImageWidth) ?? numDim(item.width),
|
||||
height: numDim(serverMeta.height) ?? numDim(serverMeta.ExifImageHeight) ?? numDim(item.height),
|
||||
cameraMake: serverMeta.Make || serverMeta.make,
|
||||
cameraModel: serverMeta.Model || serverMeta.model,
|
||||
dateTaken: dateTakenIso,
|
||||
fileSize: serverMeta.fileSize ?? item.size,
|
||||
mimeType: serverMeta.MIMEType || serverMeta.mimeType,
|
||||
};
|
||||
if (!meta.captureKind) {
|
||||
meta.captureKind = inferCaptureKind({ ...meta, filename: item.title });
|
||||
}
|
||||
const metadata_json = JSON.stringify(meta);
|
||||
const createdIso = parseFlexibleDateToMs(dateTakenIso) ? dateTakenIso : item.created_date;
|
||||
return {
|
||||
...item,
|
||||
created_date: createdIso,
|
||||
metadata_json,
|
||||
search_text: buildSearchText({ ...item, metadata_json }, metadata_json),
|
||||
};
|
||||
};
|
||||
|
||||
export const chunkIntoRows = (items, columns = 3) => {
|
||||
const rows = [];
|
||||
for (let i = 0; i < items.length; i += columns) {
|
||||
rows.push(items.slice(i, i + columns));
|
||||
}
|
||||
return rows;
|
||||
};
|
||||
|
||||
const localDateKeyFromMs = (ms) => {
|
||||
const d = new Date(ms);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
const humanDaySectionTitle = (dateKey) => {
|
||||
const d = new Date(`${dateKey}T12:00:00`);
|
||||
const now = new Date();
|
||||
const todayKey = localDateKeyFromMs(now.getTime());
|
||||
const y = new Date(now);
|
||||
y.setDate(y.getDate() - 1);
|
||||
const yestKey = localDateKeyFromMs(y.getTime());
|
||||
const longFmt = d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
if (dateKey === todayKey) return `Today · ${longFmt}`;
|
||||
if (dateKey === yestKey) return `Yesterday · ${longFmt}`;
|
||||
return d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const UNKNOWN_DAY = '_unknown';
|
||||
|
||||
/** Google Photos–style sections: newest calendar day first; each section’s rows are `columns`-wide grid strips. */
|
||||
export const groupMediaByDayForTimeline = (items, columns = 3) => {
|
||||
const sorted = sortMediaItems([...items], 'date_taken');
|
||||
const byDay = new Map();
|
||||
for (const item of sorted) {
|
||||
const ms = getDateTakenMs(item);
|
||||
const key = ms > 0 ? localDateKeyFromMs(ms) : UNKNOWN_DAY;
|
||||
if (!byDay.has(key)) byDay.set(key, []);
|
||||
byDay.get(key).push(item);
|
||||
}
|
||||
const datedKeys = [...byDay.keys()].filter((k) => k !== UNKNOWN_DAY).sort((a, b) => b.localeCompare(a));
|
||||
const keys = byDay.has(UNKNOWN_DAY) ? [...datedKeys, UNKNOWN_DAY] : datedKeys;
|
||||
return keys.map((dateKey) => {
|
||||
const rows = chunkIntoRows(byDay.get(dateKey), columns);
|
||||
const data = rows.map((row, ri) => ({
|
||||
row,
|
||||
rowKey: `${dateKey}-${ri}-${row.map((c) => (c ? mediaFingerprint(c) : '·')).join('|')}`,
|
||||
}));
|
||||
return {
|
||||
dateKey,
|
||||
title: dateKey === UNKNOWN_DAY ? 'No capture date' : humanDaySectionTitle(dateKey),
|
||||
data,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/** For UI labels (drawer, etc.) */
|
||||
export const formatDateTakenForDisplay = (item) => {
|
||||
const ms = getDateTakenMs(item);
|
||||
if (!ms) return '—';
|
||||
try {
|
||||
return new Date(ms).toLocaleString();
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveAlbumEntriesToMediaItems = (entries, dbMedia) => {
|
||||
const byFp = new Map((dbMedia || []).map((m) => [mediaFingerprint(m), m]));
|
||||
return (entries || []).map((e) => {
|
||||
const hit = byFp.get(e.media_fingerprint);
|
||||
if (hit) return { ...hit };
|
||||
return {
|
||||
source_service: e.source_service || 'DEVICE',
|
||||
title: e.title_snapshot || 'Photo',
|
||||
local_path: e.local_path,
|
||||
remote_path: e.remote_path,
|
||||
metadata_json: e.metadata_json,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
mediaFingerprint,
|
||||
parseMetadata,
|
||||
inferCaptureKind,
|
||||
buildSearchText,
|
||||
mergeMetadataJson,
|
||||
parseFlexibleDateToMs,
|
||||
dateTakenMsToIso,
|
||||
resolveCreatedDateIsoForStorage,
|
||||
getDateTakenMs,
|
||||
getDateAddedMs,
|
||||
sortMediaItems,
|
||||
deviceAssetToMediaItem,
|
||||
enrichGooglePickerItem,
|
||||
enrichNasItem,
|
||||
resolveAlbumEntriesToMediaItems,
|
||||
chunkIntoRows,
|
||||
groupMediaByDayForTimeline,
|
||||
formatDateTakenForDisplay,
|
||||
};
|
||||