feat: add SVG icons and implement AlbumPickerModal and CenteredState components

This commit is contained in:
bobbert committed 2026-04-16 16:36:35 +01:00
1 parent 126f545c44
commit f855dab467
30 files changed
+1302 -10

No files matched your search

+1
View File
@@ -12,3 +12,4 @@
.env
!.env.example.txt
ServerFiles/data/
/package-lock.json
+5
View File
@@ -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

+3
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

+4
View File
@@ -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

+5
View File
@@ -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

+3
View File
@@ -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

+6
View File
@@ -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

+8
View File
@@ -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

+4
View File
@@ -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

+3
View File
@@ -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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+12
View File
@@ -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;
+1
View File
@@ -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",
+120
View File
@@ -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;
+63
View File
@@ -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;
+278
View File
@@ -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;
+64
View File
@@ -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;
+33
View File
@@ -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;
+90
View File
@@ -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;
+58
View File
@@ -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;
+20
View File
@@ -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;
+32
View File
@@ -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 */
}
}
+49 -10
View File
@@ -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 [];
+415
View File
@@ -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,
};