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) <noreply@anthropic.com>
This commit is contained in:
Amarjit Singh 2026-03-28 22:38:19 -07:00
parent 784c59fcdd
commit 360f7c29ee
3 changed files with 474 additions and 20 deletions

View file

@ -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}`}
</Text>
</View>
<TouchableOpacity
onPress={() => handleToggleCatalogSource(source)}
style={styles.removeCatalogButton}
>
<MaterialIcons name="close" size={20} color={colors.error} />
</TouchableOpacity>
<View style={styles.catalogSourceActions}>
<TouchableOpacity onPress={() => handleMoveCatalogSourceUp(idx)} disabled={idx === 0} style={styles.iconBtn}>
<MaterialIcons name="arrow-upward" size={18} color={idx === 0 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity onPress={() => handleMoveCatalogSourceDown(idx)} disabled={idx === editingFolder.catalogSources.length - 1} style={styles.iconBtn}>
<MaterialIcons name="arrow-downward" size={18} color={idx === editingFolder.catalogSources.length - 1 ? colors.disabled : colors.text} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleToggleCatalogSource(source)}
style={styles.removeCatalogButton}
>
<MaterialIcons name="close" size={20} color={colors.error} />
</TouchableOpacity>
</View>
</View>
);
})}
@ -757,6 +779,11 @@ const styles = StyleSheet.create({
fontSize: 12,
marginTop: 2,
},
catalogSourceActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
removeCatalogButton: {
padding: 6,
},

View file

@ -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<any[]>([]);
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
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<string | null>(null);
const [pendingJson, setPendingJson] = useState<string | null>(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 (
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
@ -144,7 +257,7 @@ const CollectionManagementScreen = () => {
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
activeOpacity={0.7}
onPress={handleImport}
onPress={handleOpenImport}
>
<MaterialIcons name="file-download" size={18} color={colors.text} />
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import</Text>
@ -226,15 +339,164 @@ const CollectionManagementScreen = () => {
<TouchableOpacity
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
activeOpacity={0.7}
onPress={handleImport}
onPress={handleOpenImport}
>
<MaterialIcons name="file-download" size={18} color={colors.text} />
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import from Clipboard</Text>
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
{/* Import Modal */}
<Modal visible={showImportModal} animationType="slide" transparent>
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.7)' }]}>
<View style={[styles.importModal, { backgroundColor: colors.darkBackground }]}>
{/* Header */}
<View style={styles.importModalHeader}>
<Text style={[styles.importModalTitle, { color: colors.text }]}>Import Collections</Text>
<TouchableOpacity onPress={handleCloseImport}>
<MaterialIcons name="close" size={24} color={colors.textMuted} />
</TouchableOpacity>
</View>
{/* Tab Selector */}
<View style={styles.importTabRow}>
{([
{ 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 => (
<TouchableOpacity
key={tab.key}
style={[
styles.importTab,
{
backgroundColor: importTab === tab.key ? colors.primary : colors.elevation1,
borderColor: importTab === tab.key ? colors.primary : colors.border,
},
]}
onPress={() => { setImportTab(tab.key); setImportError(null); setImportPreview(null); setPendingJson(null); }}
>
<MaterialIcons name={tab.icon} size={16} color={importTab === tab.key ? '#fff' : colors.textMuted} />
<Text style={[styles.importTabText, { color: importTab === tab.key ? '#fff' : colors.text }]}>{tab.label}</Text>
</TouchableOpacity>
))}
</View>
<ScrollView style={styles.importModalBody} contentContainerStyle={{ paddingBottom: 20 }}>
{/* Paste Tab */}
{importTab === 'paste' && (
<View>
<TouchableOpacity
style={[styles.importActionBtn, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
onPress={handlePasteValidate}
>
<MaterialIcons name="content-paste" size={18} color={colors.primary} />
<Text style={[styles.importActionBtnText, { color: colors.text }]}>Paste from Clipboard</Text>
</TouchableOpacity>
<Text style={[styles.importOrText, { color: colors.textMuted }]}>or type/paste below:</Text>
<TextInput
style={[styles.importTextInput, { backgroundColor: colors.elevation1, color: colors.text, borderColor: importError ? colors.error : colors.border }]}
value={importText}
onChangeText={(text) => { 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 && (
<TouchableOpacity
style={[styles.validateBtn, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
onPress={handleTextValidate}
>
<Text style={[styles.validateBtnText, { color: colors.primary }]}>Validate</Text>
</TouchableOpacity>
)}
</View>
)}
{/* File Tab */}
{importTab === 'file' && (
<TouchableOpacity
style={[styles.importActionBtn, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
onPress={handleFilePick}
disabled={importLoading}
>
{importLoading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<MaterialIcons name="folder-open" size={18} color={colors.primary} />
)}
<Text style={[styles.importActionBtnText, { color: colors.text }]}>
{importLoading ? 'Reading file...' : 'Choose .json File'}
</Text>
</TouchableOpacity>
)}
{/* URL Tab */}
{importTab === 'url' && (
<View>
<TextInput
style={[styles.importUrlInput, { backgroundColor: colors.elevation1, color: colors.text, borderColor: importError ? colors.error : colors.border }]}
value={importUrl}
onChangeText={(text) => { setImportUrl(text); setImportError(null); setImportPreview(null); setPendingJson(null); }}
placeholder="https://example.com/collections.json"
placeholderTextColor={colors.disabled}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<TouchableOpacity
style={[styles.importActionBtn, { backgroundColor: colors.elevation1, borderColor: colors.border, marginTop: 10 }]}
onPress={handleUrlFetch}
disabled={importLoading}
>
{importLoading ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<MaterialIcons name="download" size={18} color={colors.primary} />
)}
<Text style={[styles.importActionBtnText, { color: colors.text }]}>
{importLoading ? 'Fetching...' : 'Fetch & Validate'}
</Text>
</TouchableOpacity>
</View>
)}
{/* Error */}
{importError && (
<View style={[styles.importResultBox, { backgroundColor: colors.error + '15', borderColor: colors.error }]}>
<MaterialIcons name="error-outline" size={18} color={colors.error} />
<Text style={[styles.importResultText, { color: colors.error }]}>{importError}</Text>
</View>
)}
{/* Preview */}
{importPreview && (
<View style={[styles.importResultBox, { backgroundColor: colors.primary + '15', borderColor: colors.primary }]}>
<MaterialIcons name="check-circle" size={18} color={colors.primary} />
<Text style={[styles.importResultText, { color: colors.text }]}>
Valid: {importPreview.collectionCount} collection{importPreview.collectionCount !== 1 ? 's' : ''}, {importPreview.folderCount} folder{importPreview.folderCount !== 1 ? 's' : ''}
</Text>
</View>
)}
</ScrollView>
{/* Import Button */}
{importPreview && pendingJson && (
<TouchableOpacity
style={[styles.confirmImportBtn, { backgroundColor: colors.primary }]}
onPress={handleConfirmImport}
>
<MaterialIcons name="file-download" size={20} color="#fff" />
<Text style={styles.confirmImportBtnText}>Import {importPreview.collectionCount} Collection{importPreview.collectionCount !== 1 ? 's' : ''}</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
<CustomAlert
visible={alertVisible}
title={alertTitle}
@ -341,6 +603,120 @@ const styles = StyleSheet.create({
disabledButton: {
opacity: 0.4,
},
modalOverlay: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
importModal: {
width: '92%',
maxHeight: '85%',
borderRadius: 16,
padding: 20,
},
importModalHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
importModalTitle: {
fontSize: 20,
fontWeight: '700',
},
importTabRow: {
flexDirection: 'row',
gap: 8,
marginBottom: 16,
},
importTab: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
importTabText: {
fontSize: 12,
fontWeight: '600',
},
importModalBody: {
flexGrow: 0,
},
importActionBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: 14,
borderRadius: 10,
borderWidth: 1,
},
importActionBtnText: {
fontSize: 14,
fontWeight: '500',
},
importOrText: {
textAlign: 'center',
fontSize: 12,
marginVertical: 10,
},
importTextInput: {
borderWidth: 1,
borderRadius: 10,
padding: 12,
fontSize: 13,
minHeight: 150,
maxHeight: 250,
},
importUrlInput: {
borderWidth: 1,
borderRadius: 10,
padding: 12,
fontSize: 14,
},
validateBtn: {
alignSelf: 'flex-start',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
marginTop: 10,
},
validateBtnText: {
fontSize: 14,
fontWeight: '600',
},
importResultBox: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
padding: 12,
borderRadius: 10,
borderWidth: 1,
marginTop: 12,
},
importResultText: {
fontSize: 13,
flex: 1,
},
confirmImportBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
padding: 14,
borderRadius: 12,
marginTop: 12,
},
confirmImportBtnText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
export default CollectionManagementScreen;

View file

@ -154,6 +154,57 @@ class CollectionsService {
}
}
validateCollectionsJson(json: string): { valid: boolean; error?: string; collectionCount: number; folderCount: number } {
try {
const parsed = JSON.parse(json);
if (!Array.isArray(parsed)) {
return { valid: false, error: 'Invalid format: expected an array of collections', collectionCount: 0, folderCount: 0 };
}
if (parsed.length === 0) {
return { valid: false, error: 'Empty array: no collections found', collectionCount: 0, folderCount: 0 };
}
let folderCount = 0;
const validTileShapes = ['poster', 'wide', 'square', 'POSTER', 'LANDSCAPE', 'SQUARE'];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
if (!item || typeof item.id !== 'string' || !item.id.trim()) {
return { valid: false, error: `Collection ${i + 1}: missing or invalid "id"`, collectionCount: 0, folderCount: 0 };
}
if (typeof item.title !== 'string') {
return { valid: false, error: `Collection "${item.id}": missing or invalid "title"`, collectionCount: 0, folderCount: 0 };
}
if (!Array.isArray(item.folders)) {
return { valid: false, error: `Collection "${item.title || item.id}": "folders" must be an array`, collectionCount: 0, folderCount: 0 };
}
for (let j = 0; j < item.folders.length; j++) {
const folder = item.folders[j];
if (!folder || typeof folder.id !== 'string' || !folder.id.trim()) {
return { valid: false, error: `Collection "${item.title}", folder ${j + 1}: missing or invalid "id"`, collectionCount: 0, folderCount: 0 };
}
if (typeof folder.title !== 'string') {
return { valid: false, error: `Collection "${item.title}", folder "${folder.id}": missing or invalid "title"`, collectionCount: 0, folderCount: 0 };
}
if (!Array.isArray(folder.catalogSources)) {
return { valid: false, error: `Collection "${item.title}", folder "${folder.title || folder.id}": "catalogSources" must be an array`, collectionCount: 0, folderCount: 0 };
}
if (folder.tileShape && !validTileShapes.includes(folder.tileShape)) {
return { valid: false, error: `Collection "${item.title}", folder "${folder.title}": invalid tileShape "${folder.tileShape}"`, collectionCount: 0, folderCount: 0 };
}
for (let k = 0; k < folder.catalogSources.length; k++) {
const source = folder.catalogSources[k];
if (!source || typeof source.addonId !== 'string' || typeof source.type !== 'string' || typeof source.catalogId !== 'string') {
return { valid: false, error: `Collection "${item.title}", folder "${folder.title}", source ${k + 1}: missing required fields (addonId, type, catalogId)`, collectionCount: 0, folderCount: 0 };
}
}
folderCount++;
}
}
return { valid: true, collectionCount: parsed.length, folderCount };
} catch (e: any) {
return { valid: false, error: `JSON parse error: ${e.message}`, collectionCount: 0, folderCount: 0 };
}
}
generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}