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' : ''}
+
+ )}
+
+
+
+