From 360f7c29ee634726db932e7c4a3975cc2aa0e7ca Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Sat, 28 Mar 2026 22:38:19 -0700 Subject: [PATCH] Add catalog source reorder in folders + enhanced import/export - Add up/down reorder buttons for catalog sources within collection folders - Export: save as JSON file via share sheet + system file save dialog - Import: 3 methods - paste JSON, pick .json file, fetch from URL - Add deep JSON validation with preview before importing Co-Authored-By: Claude Opus 4.6 (1M context) --- src/screens/CollectionEditorScreen.tsx | 39 +- src/screens/CollectionManagementScreen.tsx | 404 ++++++++++++++++++++- src/services/collectionsService.ts | 51 +++ 3 files changed, 474 insertions(+), 20 deletions(-) diff --git a/src/screens/CollectionEditorScreen.tsx b/src/screens/CollectionEditorScreen.tsx index 7cc05281..0ea690ca 100644 --- a/src/screens/CollectionEditorScreen.tsx +++ b/src/screens/CollectionEditorScreen.tsx @@ -255,6 +255,20 @@ const CollectionEditorScreen = () => { setShowEmojiPicker(false); }, [editingFolder]); + const handleMoveCatalogSourceUp = useCallback((index: number) => { + if (!editingFolder || index <= 0) return; + const sources = [...editingFolder.catalogSources]; + [sources[index - 1], sources[index]] = [sources[index], sources[index - 1]]; + setEditingFolder({ ...editingFolder, catalogSources: sources }); + }, [editingFolder]); + + const handleMoveCatalogSourceDown = useCallback((index: number) => { + if (!editingFolder || index >= editingFolder.catalogSources.length - 1) return; + const sources = [...editingFolder.catalogSources]; + [sources[index], sources[index + 1]] = [sources[index + 1], sources[index]]; + setEditingFolder({ ...editingFolder, catalogSources: sources }); + }, [editingFolder]); + const handleToggleCatalogSource = useCallback((source: CollectionCatalogSource) => { if (!editingFolder) return; const existing = editingFolder.catalogSources.find( @@ -429,12 +443,20 @@ const CollectionEditorScreen = () => { {isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} ยท ${source.type}`} - handleToggleCatalogSource(source)} - style={styles.removeCatalogButton} - > - - + + handleMoveCatalogSourceUp(idx)} disabled={idx === 0} style={styles.iconBtn}> + + + handleMoveCatalogSourceDown(idx)} disabled={idx === editingFolder.catalogSources.length - 1} style={styles.iconBtn}> + + + handleToggleCatalogSource(source)} + style={styles.removeCatalogButton} + > + + + ); })} @@ -757,6 +779,11 @@ const styles = StyleSheet.create({ fontSize: 12, marginTop: 2, }, + catalogSourceActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, removeCatalogButton: { padding: 6, }, diff --git a/src/screens/CollectionManagementScreen.tsx b/src/screens/CollectionManagementScreen.tsx index 34accadd..4953237a 100644 --- a/src/screens/CollectionManagementScreen.tsx +++ b/src/screens/CollectionManagementScreen.tsx @@ -9,8 +9,14 @@ import { Switch, Platform, Alert, + Modal, + TextInput, + ActivityIndicator, } from 'react-native'; import * as Clipboard from 'expo-clipboard'; +import * as DocumentPicker from 'expo-document-picker'; +import { cacheDirectory, writeAsStringAsync, readAsStringAsync, EncodingType } from 'expo-file-system/legacy'; +import * as Sharing from 'expo-sharing'; import { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; @@ -38,6 +44,14 @@ const CollectionManagementScreen = () => { const [alertMessage, setAlertMessage] = useState(''); const [alertActions, setAlertActions] = useState([]); const [enabledMap, setEnabledMap] = useState>({}); + const [showImportModal, setShowImportModal] = useState(false); + const [importTab, setImportTab] = useState<'paste' | 'file' | 'url'>('paste'); + const [importText, setImportText] = useState(''); + const [importUrl, setImportUrl] = useState(''); + const [importLoading, setImportLoading] = useState(false); + const [importPreview, setImportPreview] = useState<{ collectionCount: number; folderCount: number } | null>(null); + const [importError, setImportError] = useState(null); + const [pendingJson, setPendingJson] = useState(null); const loadEnabledState = useCallback(async () => { const settings = await collectionsService.getCollectionSettings(); @@ -92,26 +106,125 @@ const CollectionManagementScreen = () => { const handleExport = useCallback(async () => { try { const json = await collectionsService.exportToJson(); - await Clipboard.setStringAsync(json); - showInfo('Copied to clipboard'); + const fileUri = cacheDirectory + 'nuvio-collections.json'; + await writeAsStringAsync(fileUri, json, { encoding: EncodingType.UTF8 }); + await Sharing.shareAsync(fileUri, { + mimeType: 'application/json', + dialogTitle: 'Export Collections', + UTI: 'public.json', + }); } catch { showError('Export failed'); } - }, [showInfo, showError]); + }, [showError]); - const handleImport = useCallback(async () => { + const resetImportState = useCallback(() => { + setImportText(''); + setImportUrl(''); + setImportError(null); + setImportPreview(null); + setPendingJson(null); + setImportLoading(false); + setImportTab('paste'); + }, []); + + const handleOpenImport = useCallback(() => { + resetImportState(); + setShowImportModal(true); + }, [resetImportState]); + + const handleCloseImport = useCallback(() => { + setShowImportModal(false); + resetImportState(); + }, [resetImportState]); + + const validateAndPreview = useCallback((json: string) => { + const result = collectionsService.validateCollectionsJson(json); + if (result.valid) { + setImportError(null); + setImportPreview({ collectionCount: result.collectionCount, folderCount: result.folderCount }); + setPendingJson(json); + } else { + setImportError(result.error || 'Invalid data'); + setImportPreview(null); + setPendingJson(null); + } + }, []); + + const handlePasteValidate = useCallback(async () => { + const text = await Clipboard.getStringAsync(); + if (!text?.trim()) { + setImportError('Clipboard is empty'); + return; + } + setImportText(text); + validateAndPreview(text); + }, [validateAndPreview]); + + const handleTextValidate = useCallback(() => { + if (!importText.trim()) { + setImportError('Please paste or type JSON'); + return; + } + validateAndPreview(importText); + }, [importText, validateAndPreview]); + + const handleFilePick = useCallback(async () => { try { - const text = await Clipboard.getStringAsync(); - if (!text?.trim()) { - showError('Clipboard is empty'); + setImportLoading(true); + setImportError(null); + const result = await DocumentPicker.getDocumentAsync({ + type: 'application/json', + copyToCacheDirectory: true, + }); + if (result.canceled) { + setImportLoading(false); return; } - const result = await collectionsService.importFromJson(text); - showInfo(`Imported: ${result.added} added, ${result.updated} updated`); + const fileUri = result.assets[0].uri; + const content = await readAsStringAsync(fileUri, { encoding: EncodingType.UTF8 }); + setImportText(content); + validateAndPreview(content); } catch { - showError('Invalid collection data'); + setImportError('Failed to read file'); + } finally { + setImportLoading(false); } - }, [showInfo, showError]); + }, [validateAndPreview]); + + const handleUrlFetch = useCallback(async () => { + if (!importUrl.trim()) { + setImportError('Please enter a URL'); + return; + } + try { + setImportLoading(true); + setImportError(null); + const response = await fetch(importUrl.trim()); + if (!response.ok) { + setImportError(`Failed to fetch: HTTP ${response.status}`); + return; + } + const text = await response.text(); + setImportText(text); + validateAndPreview(text); + } catch { + setImportError('Failed to fetch URL'); + } finally { + setImportLoading(false); + } + }, [importUrl, validateAndPreview]); + + const handleConfirmImport = useCallback(async () => { + if (!pendingJson) return; + try { + const result = await collectionsService.importFromJson(pendingJson); + showInfo(`Imported: ${result.added} added, ${result.updated} updated`); + handleCloseImport(); + } catch { + showError('Import failed'); + } + }, [pendingJson, showInfo, showError, handleCloseImport]); return ( @@ -144,7 +257,7 @@ const CollectionManagementScreen = () => { Import @@ -226,15 +339,164 @@ const CollectionManagementScreen = () => { - Import from Clipboard + Import )} + {/* Import Modal */} + + + + {/* Header */} + + Import Collections + + + + + + {/* Tab Selector */} + + {([ + { key: 'paste' as const, label: 'Paste JSON', icon: 'content-paste' as const }, + { key: 'file' as const, label: 'Pick File', icon: 'folder-open' as const }, + { key: 'url' as const, label: 'From URL', icon: 'link' as const }, + ]).map(tab => ( + { setImportTab(tab.key); setImportError(null); setImportPreview(null); setPendingJson(null); }} + > + + {tab.label} + + ))} + + + + {/* Paste Tab */} + {importTab === 'paste' && ( + + + + Paste from Clipboard + + or type/paste below: + { setImportText(text); setImportError(null); setImportPreview(null); setPendingJson(null); }} + placeholder="Paste collections JSON here..." + placeholderTextColor={colors.disabled} + multiline + textAlignVertical="top" + /> + {!importPreview && !importError && importText.trim().length > 0 && ( + + Validate + + )} + + )} + + {/* File Tab */} + {importTab === 'file' && ( + + {importLoading ? ( + + ) : ( + + )} + + {importLoading ? 'Reading file...' : 'Choose .json File'} + + + )} + + {/* URL Tab */} + {importTab === 'url' && ( + + { setImportUrl(text); setImportError(null); setImportPreview(null); setPendingJson(null); }} + placeholder="https://example.com/collections.json" + placeholderTextColor={colors.disabled} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + + {importLoading ? ( + + ) : ( + + )} + + {importLoading ? 'Fetching...' : 'Fetch & Validate'} + + + + )} + + {/* Error */} + {importError && ( + + + {importError} + + )} + + {/* Preview */} + {importPreview && ( + + + + Valid: {importPreview.collectionCount} collection{importPreview.collectionCount !== 1 ? 's' : ''}, {importPreview.folderCount} folder{importPreview.folderCount !== 1 ? 's' : ''} + + + )} + + + {/* Import Button */} + {importPreview && pendingJson && ( + + + Import {importPreview.collectionCount} Collection{importPreview.collectionCount !== 1 ? 's' : ''} + + )} + + + +