mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 07:51:46 +00:00
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:
parent
784c59fcdd
commit
360f7c29ee
3 changed files with 474 additions and 20 deletions
|
|
@ -255,6 +255,20 @@ const CollectionEditorScreen = () => {
|
||||||
setShowEmojiPicker(false);
|
setShowEmojiPicker(false);
|
||||||
}, [editingFolder]);
|
}, [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) => {
|
const handleToggleCatalogSource = useCallback((source: CollectionCatalogSource) => {
|
||||||
if (!editingFolder) return;
|
if (!editingFolder) return;
|
||||||
const existing = editingFolder.catalogSources.find(
|
const existing = editingFolder.catalogSources.find(
|
||||||
|
|
@ -429,12 +443,20 @@ const CollectionEditorScreen = () => {
|
||||||
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}
|
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<View style={styles.catalogSourceActions}>
|
||||||
onPress={() => handleToggleCatalogSource(source)}
|
<TouchableOpacity onPress={() => handleMoveCatalogSourceUp(idx)} disabled={idx === 0} style={styles.iconBtn}>
|
||||||
style={styles.removeCatalogButton}
|
<MaterialIcons name="arrow-upward" size={18} color={idx === 0 ? colors.disabled : colors.text} />
|
||||||
>
|
</TouchableOpacity>
|
||||||
<MaterialIcons name="close" size={20} color={colors.error} />
|
<TouchableOpacity onPress={() => handleMoveCatalogSourceDown(idx)} disabled={idx === editingFolder.catalogSources.length - 1} style={styles.iconBtn}>
|
||||||
</TouchableOpacity>
|
<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>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -757,6 +779,11 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
catalogSourceActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
removeCatalogButton: {
|
removeCatalogButton: {
|
||||||
padding: 6,
|
padding: 6,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,14 @@ import {
|
||||||
Switch,
|
Switch,
|
||||||
Platform,
|
Platform,
|
||||||
Alert,
|
Alert,
|
||||||
|
Modal,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import * as Clipboard from 'expo-clipboard';
|
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 { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
@ -38,6 +44,14 @@ const CollectionManagementScreen = () => {
|
||||||
const [alertMessage, setAlertMessage] = useState('');
|
const [alertMessage, setAlertMessage] = useState('');
|
||||||
const [alertActions, setAlertActions] = useState<any[]>([]);
|
const [alertActions, setAlertActions] = useState<any[]>([]);
|
||||||
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
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 loadEnabledState = useCallback(async () => {
|
||||||
const settings = await collectionsService.getCollectionSettings();
|
const settings = await collectionsService.getCollectionSettings();
|
||||||
|
|
@ -92,26 +106,125 @@ const CollectionManagementScreen = () => {
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const json = await collectionsService.exportToJson();
|
const json = await collectionsService.exportToJson();
|
||||||
await Clipboard.setStringAsync(json);
|
const fileUri = cacheDirectory + 'nuvio-collections.json';
|
||||||
showInfo('Copied to clipboard');
|
await writeAsStringAsync(fileUri, json, { encoding: EncodingType.UTF8 });
|
||||||
|
await Sharing.shareAsync(fileUri, {
|
||||||
|
mimeType: 'application/json',
|
||||||
|
dialogTitle: 'Export Collections',
|
||||||
|
UTI: 'public.json',
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
showError('Export failed');
|
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 {
|
try {
|
||||||
const text = await Clipboard.getStringAsync();
|
setImportLoading(true);
|
||||||
if (!text?.trim()) {
|
setImportError(null);
|
||||||
showError('Clipboard is empty');
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: 'application/json',
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
if (result.canceled) {
|
||||||
|
setImportLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await collectionsService.importFromJson(text);
|
const fileUri = result.assets[0].uri;
|
||||||
showInfo(`Imported: ${result.added} added, ${result.updated} updated`);
|
const content = await readAsStringAsync(fileUri, { encoding: EncodingType.UTF8 });
|
||||||
|
setImportText(content);
|
||||||
|
validateAndPreview(content);
|
||||||
} catch {
|
} 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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
|
||||||
|
|
@ -144,7 +257,7 @@ const CollectionManagementScreen = () => {
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
|
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
onPress={handleImport}
|
onPress={handleOpenImport}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="file-download" size={18} color={colors.text} />
|
<MaterialIcons name="file-download" size={18} color={colors.text} />
|
||||||
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import</Text>
|
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import</Text>
|
||||||
|
|
@ -226,15 +339,164 @@ const CollectionManagementScreen = () => {
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
|
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
onPress={handleImport}
|
onPress={handleOpenImport}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="file-download" size={18} color={colors.text} />
|
<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>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</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
|
<CustomAlert
|
||||||
visible={alertVisible}
|
visible={alertVisible}
|
||||||
title={alertTitle}
|
title={alertTitle}
|
||||||
|
|
@ -341,6 +603,120 @@ const styles = StyleSheet.create({
|
||||||
disabledButton: {
|
disabledButton: {
|
||||||
opacity: 0.4,
|
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;
|
export default CollectionManagementScreen;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
generateId(): string {
|
||||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue