From c6216cb95dd5302f442d99f7872f949ece918eda Mon Sep 17 00:00:00 2001 From: Amarjit Singh Date: Sat, 21 Mar 2026 16:14:03 -0700 Subject: [PATCH] Add Collections feature for organizing catalogs into folders Collections let users group addon catalogs into named folders that appear as horizontal rows on the home screen. Each folder supports emoji or image URL covers, tile shape selection (poster/wide/square), and multiple catalog sources with tabbed browsing in the detail view. Includes emoji picker with 12 categories and all country flags, export/import via clipboard (compatible with NuvioTV format), and missing addon warnings. Co-Authored-By: Claude Opus 4.6 --- src/components/home/CollectionRowSection.tsx | 173 ++++ src/hooks/useCollections.ts | 35 + src/navigation/AppNavigator.tsx | 42 + src/screens/CollectionEditorScreen.tsx | 907 ++++++++++++++++++ src/screens/CollectionManagementScreen.tsx | 313 ++++++ src/screens/FolderDetailScreen.tsx | 281 ++++++ src/screens/HomeScreen.tsx | 40 +- .../ContentDiscoverySettingsScreen.tsx | 8 + src/services/collectionsService.ts | 128 +++ src/types/collections.ts | 21 + 10 files changed, 1944 insertions(+), 4 deletions(-) create mode 100644 src/components/home/CollectionRowSection.tsx create mode 100644 src/hooks/useCollections.ts create mode 100644 src/screens/CollectionEditorScreen.tsx create mode 100644 src/screens/CollectionManagementScreen.tsx create mode 100644 src/screens/FolderDetailScreen.tsx create mode 100644 src/services/collectionsService.ts create mode 100644 src/types/collections.ts diff --git a/src/components/home/CollectionRowSection.tsx b/src/components/home/CollectionRowSection.tsx new file mode 100644 index 00000000..9d7e03ad --- /dev/null +++ b/src/components/home/CollectionRowSection.tsx @@ -0,0 +1,173 @@ +import React, { useCallback } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, FlatList, Dimensions } from 'react-native'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import FastImage from '@d11/react-native-fast-image'; +import { useTheme } from '../../contexts/ThemeContext'; +import { Collection, CollectionFolder } from '../../types/collections'; +import { RootStackParamList } from '../../navigation/AppNavigator'; + +interface CollectionRowSectionProps { + collection: Collection; +} + +const { width: screenWidth } = Dimensions.get('window'); + +const TILE_SPACING = 10; +const LEFT_PADDING = 16; + +const getTileSize = (shape: 'poster' | 'wide' | 'square') => { + switch (shape) { + case 'poster': + return { width: 120, aspectRatio: 2 / 3 }; + case 'wide': + return { width: 200, aspectRatio: 16 / 9 }; + case 'square': + return { width: 140, aspectRatio: 1 }; + } +}; + +const FolderTile = React.memo(({ folder, collectionId }: { folder: CollectionFolder; collectionId: string }) => { + const { currentTheme } = useTheme(); + const navigation = useNavigation>(); + const tileSize = getTileSize(folder.tileShape); + + const handlePress = useCallback(() => { + navigation.navigate('FolderDetail', { collectionId, folderId: folder.id }); + }, [navigation, collectionId, folder.id]); + + const renderCover = () => { + if (folder.coverImageUrl) { + return ( + + ); + } + + if (folder.coverEmoji) { + return ( + + {folder.coverEmoji} + + ); + } + + // Initials fallback + const initials = folder.title + .split(' ') + .slice(0, 2) + .map(w => w[0]?.toUpperCase() || '') + .join(''); + return ( + + {initials} + + ); + }; + + return ( + + + {renderCover()} + + {!folder.hideTitle && ( + + {folder.title} + + )} + + ); +}); + +const CollectionRowSection = React.memo(({ collection }: CollectionRowSectionProps) => { + const { currentTheme } = useTheme(); + + const renderFolder = useCallback(({ item }: { item: CollectionFolder }) => ( + + ), [collection.id]); + + const keyExtractor = useCallback((item: CollectionFolder) => item.id, []); + + if (!collection.folders.length) return null; + + return ( + + + {collection.title} + + } + /> + + ); +}); + +const styles = StyleSheet.create({ + container: { + marginBottom: 24, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + paddingHorizontal: LEFT_PADDING, + marginBottom: 12, + }, + listContent: { + paddingHorizontal: LEFT_PADDING, + }, + tileContainer: { + marginRight: 2, + }, + tileImageContainer: { + width: '100%', + overflow: 'hidden', + borderRadius: 12, + }, + tileImage: { + width: '100%', + height: '100%', + }, + emojiCover: { + width: '100%', + height: '100%', + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + emojiText: { + fontSize: 48, + }, + initialsCover: { + width: '100%', + height: '100%', + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + initialsText: { + fontSize: 28, + fontWeight: '700', + }, + tileTitle: { + fontSize: 13, + fontWeight: '500', + marginTop: 6, + }, +}); + +export default CollectionRowSection; diff --git a/src/hooks/useCollections.ts b/src/hooks/useCollections.ts new file mode 100644 index 00000000..e7618499 --- /dev/null +++ b/src/hooks/useCollections.ts @@ -0,0 +1,35 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Collection } from '../types/collections'; +import { collectionsService, collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService'; + +export function useCollections() { + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(true); + + const loadCollections = useCallback(async () => { + try { + const data = await collectionsService.getCollections(); + setCollections(data); + } catch (error) { + if (__DEV__) console.error('[useCollections] Error loading:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadCollections(); + }, [loadCollections]); + + useEffect(() => { + const handler = () => { + loadCollections(); + }; + collectionsEmitter.on(COLLECTIONS_EVENTS.CHANGED, handler); + return () => { + collectionsEmitter.off(COLLECTIONS_EVENTS.CHANGED, handler); + }; + }, [loadCollections]); + + return { collections, loading, refresh: loadCollections }; +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b789cbed..8c5f7444 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -77,6 +77,9 @@ import BackdropGalleryScreen from '../screens/BackdropGalleryScreen'; import BackupScreen from '../screens/BackupScreen'; import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen'; import ContributorsScreen from '../screens/ContributorsScreen'; +import CollectionManagementScreen from '../screens/CollectionManagementScreen'; +import CollectionEditorScreen from '../screens/CollectionEditorScreen'; +import FolderDetailScreen from '../screens/FolderDetailScreen'; import { ContentDiscoverySettingsScreen, @@ -224,6 +227,9 @@ export type RootStackParamList = { }; ContinueWatchingSettings: undefined; Contributors: undefined; + Collections: undefined; + CollectionEditor: { collectionId?: string }; + FolderDetail: { collectionId: string; folderId: string }; // New organized settings screens ContentDiscoverySettings: undefined; @@ -1897,6 +1903,42 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta component={SyncSettingsScreen} options={{ headerShown: false }} /> + + + diff --git a/src/screens/CollectionEditorScreen.tsx b/src/screens/CollectionEditorScreen.tsx new file mode 100644 index 00000000..05ecc6d5 --- /dev/null +++ b/src/screens/CollectionEditorScreen.tsx @@ -0,0 +1,907 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + TextInput, + StatusBar, + Platform, + Modal, + FlatList, + Dimensions, +} from 'react-native'; +import { useNavigation, useRoute, NavigationProp } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import FastImage from '@d11/react-native-fast-image'; +import { useTheme } from '../contexts/ThemeContext'; +import { useToast } from '../contexts/ToastContext'; +import { Collection, CollectionFolder, CollectionCatalogSource } from '../types/collections'; +import { collectionsService } from '../services/collectionsService'; +import { stremioService } from '../services/stremioService'; +import ScreenHeader from '../components/common/ScreenHeader'; +import CustomAlert from '../components/CustomAlert'; +import { RootStackParamList } from '../navigation/AppNavigator'; + +const { width: screenWidth } = Dimensions.get('window'); + +// ─── Emoji Data ─────────────────────────────────────────── +const EMOJI_CATEGORIES: { name: string; emojis: string[] }[] = [ + { + name: 'Streaming', + emojis: ['🎬', '🎥', '📺', '🎞️', '📽️', '🎭', '🎪', '🍿', '📡', '🔴', '▶️', '⏯️', '🎙️', '📻', '📹', '🖥️', '💿', '📀', '🎧', '🎤'], + }, + { + name: 'Genres', + emojis: ['💀', '👻', '🧟', '🧛', '🦇', '🔪', '💣', '🔫', '🚀', '👽', '🤖', '🧙', '🧚', '🐉', '🦸', '🦹', '💕', '💋', '😂', '😈', '🎯', '🔥', '⚡', '🌪️', '🌊', '🏆', '🎲', '🃏'], + }, + { + name: 'Sports', + emojis: ['⚽', '🏀', '🏈', '⚾', '🎾', '🏐', '🏉', '🎱', '🏓', '🏸', '🏒', '⛳', '🏹', '🥊', '🥋', '⛷️', '🏂', '🏄', '🚴', '🏊', '🤸', '🏋️', '🤺', '🏇'], + }, + { + name: 'Music', + emojis: ['🎵', '🎶', '🎸', '🎹', '🥁', '🎺', '🎻', '🪕', '🎷', '🎼', '🎤', '🎧', '📯', '🪗', '🪘', '🪇'], + }, + { + name: 'Nature', + emojis: ['🌍', '🌎', '🌏', '🌋', '🏔️', '🏕️', '🏖️', '🏜️', '🌅', '🌄', '🌠', '🌌', '🌈', '☀️', '🌙', '⭐', '🌸', '🌺', '🌻', '🌲', '🌴', '🍂', '❄️', '🔥'], + }, + { + name: 'Animals', + emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🦅', '🦈', '🐙', '🦋', '🐢', '🐍', '🦎', '🐊'], + }, + { + name: 'Food', + emojis: ['🍕', '🍔', '🌮', '🍣', '🍜', '🍱', '🍩', '🍪', '🎂', '🍰', '🧁', '🍫', '🍬', '🍿', '☕', '🍵', '🍷', '🍺', '🧋', '🥤', '🍹', '🧃'], + }, + { + name: 'Travel', + emojis: ['✈️', '🚀', '🚂', '🚢', '🏰', '🗼', '🗽', '🏛️', '⛩️', '🕌', '🕍', '⛪', '🏠', '🏢', '🏙️', '🌃', '🌉', '🗺️', '🧭', '⛺'], + }, + { + name: 'People', + emojis: ['👶', '👦', '👧', '👨', '👩', '👴', '👵', '👮', '🕵️', '👷', '👸', '🤴', '🧑‍🚀', '🧑‍🔬', '🧑‍🎨', '🧑‍🍳', '🧑‍✈️', '🧑‍🚒', '🧑‍⚕️', '🧑‍🏫', '🧑‍💻', '🤹', '💃', '🕺'], + }, + { + name: 'Objects', + emojis: ['📱', '💻', '⌨️', '🖨️', '📷', '📸', '🔭', '🔬', '💡', '🔦', '🕯️', '📚', '📖', '📝', '✏️', '🖊️', '📌', '📎', '🔑', '🗝️', '🔒', '💎', '👑', '🏅'], + }, + { + name: 'Flags', + emojis: [ + '🏁', '🏳️‍🌈', '🏴‍☠️', + '🇦🇫','🇦🇱','🇩🇿','🇦🇸','🇦🇩','🇦🇴','🇦🇬','🇦🇷','🇦🇲','🇦🇺','🇦🇹','🇦🇿', + '🇧🇸','🇧🇭','🇧🇩','🇧🇧','🇧🇾','🇧🇪','🇧🇿','🇧🇯','🇧🇹','🇧🇴','🇧🇦','🇧🇼','🇧🇷','🇧🇳','🇧🇬','🇧🇫','🇧🇮', + '🇨🇻','🇰🇭','🇨🇲','🇨🇦','🇨🇫','🇹🇩','🇨🇱','🇨🇳','🇨🇴','🇰🇲','🇨🇬','🇨🇩','🇨🇷','🇭🇷','🇨🇺','🇨🇾','🇨🇿', + '🇩🇰','🇩🇯','🇩🇲','🇩🇴','🇪🇨','🇪🇬','🇸🇻','🇬🇶','🇪🇷','🇪🇪','🇸🇿','🇪🇹', + '🇫🇯','🇫🇮','🇫🇷','🇬🇦','🇬🇲','🇬🇪','🇩🇪','🇬🇭','🇬🇷','🇬🇩','🇬🇹','🇬🇳','🇬🇼','🇬🇾', + '🇭🇹','🇭🇳','🇭🇺','🇮🇸','🇮🇳','🇮🇩','🇮🇷','🇮🇶','🇮🇪','🇮🇱','🇮🇹','🇨🇮', + '🇯🇲','🇯🇵','🇯🇴','🇰🇿','🇰🇪','🇰🇮','🇰🇵','🇰🇷','🇰🇼','🇰🇬', + '🇱🇦','🇱🇻','🇱🇧','🇱🇸','🇱🇷','🇱🇾','🇱🇮','🇱🇹','🇱🇺', + '🇲🇬','🇲🇼','🇲🇾','🇲🇻','🇲🇱','🇲🇹','🇲🇭','🇲🇷','🇲🇺','🇲🇽','🇫🇲','🇲🇩','🇲🇨','🇲🇳','🇲🇪','🇲🇦','🇲🇿','🇲🇲', + '🇳🇦','🇳🇷','🇳🇵','🇳🇱','🇳🇿','🇳🇮','🇳🇪','🇳🇬','🇲🇰','🇳🇴', + '🇴🇲','🇵🇰','🇵🇼','🇵🇸','🇵🇦','🇵🇬','🇵🇾','🇵🇪','🇵🇭','🇵🇱','🇵🇹', + '🇶🇦','🇷🇴','🇷🇺','🇷🇼', + '🇰🇳','🇱🇨','🇻🇨','🇼🇸','🇸🇲','🇸🇹','🇸🇦','🇸🇳','🇷🇸','🇸🇨','🇸🇱','🇸🇬','🇸🇰','🇸🇮','🇸🇧','🇸🇴','🇿🇦','🇸🇸','🇪🇸','🇱🇰','🇸🇩','🇸🇷','🇸🇪','🇨🇭','🇸🇾', + '🇹🇼','🇹🇯','🇹🇿','🇹🇭','🇹🇱','🇹🇬','🇹🇴','🇹🇹','🇹🇳','🇹🇷','🇹🇲','🇹🇻', + '🇺🇬','🇺🇦','🇦🇪','🇬🇧','🇺🇸','🇺🇾','🇺🇿', + '🇻🇺','🇻🇪','🇻🇳', + '🇾🇪','🇿🇲','🇿🇼', + ], + }, + { + name: 'Symbols', + emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '💯', '✅', '❌', '⭕', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '♠️', '♥️', '♦️', '♣️', '🔔', '🔕', '💤', '🏳️'], + }, +]; + +// ─── Main Screen ────────────────────────────────────────── +const CollectionEditorScreen = () => { + const navigation = useNavigation>(); + const route = useRoute(); + const { currentTheme } = useTheme(); + const { showInfo, showError } = useToast(); + const colors = currentTheme.colors; + + const collectionId = (route.params as any)?.collectionId as string | undefined; + const isNew = !collectionId; + + const [title, setTitle] = useState(''); + const [folders, setFolders] = useState([]); + const [editingFolder, setEditingFolder] = useState(null); + const [editingFolderIndex, setEditingFolderIndex] = useState(-1); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [showCatalogPicker, setShowCatalogPicker] = useState(false); + + // Catalog picker state + const [availableCatalogs, setAvailableCatalogs] = useState<{ addonId: string; addonName: string; type: string; catalogId: string; name: string }[]>([]); + const [installedAddonIds, setInstalledAddonIds] = useState>(new Set()); + + useEffect(() => { + if (collectionId) { + loadCollection(); + } + loadAvailableCatalogs(); + }, [collectionId]); + + const loadCollection = async () => { + const collections = await collectionsService.getCollections(); + const found = collections.find(c => c.id === collectionId); + if (found) { + setTitle(found.title); + setFolders(found.folders); + } + }; + + const loadAvailableCatalogs = async () => { + try { + const addons = await stremioService.getInstalledAddonsAsync(); + const addonIds = new Set(addons.map((a: any) => a.id)); + setInstalledAddonIds(addonIds); + + const catalogs: typeof availableCatalogs = []; + for (const addon of addons) { + if (addon.catalogs) { + for (const catalog of addon.catalogs) { + catalogs.push({ + addonId: addon.id, + addonName: addon.name, + type: catalog.type, + catalogId: catalog.id, + name: catalog.name || catalog.id, + }); + } + } + } + setAvailableCatalogs(catalogs); + } catch (error) { + if (__DEV__) console.error('Error loading catalogs:', error); + } + }; + + const handleSave = useCallback(async () => { + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + showError('Please enter a collection title'); + return; + } + if (folders.length === 0) { + showError('Add at least one folder'); + return; + } + + const collection: Collection = { + id: collectionId || collectionsService.generateId(), + title: trimmedTitle, + folders, + }; + + if (isNew) { + await collectionsService.addCollection(collection); + } else { + await collectionsService.updateCollection(collection); + } + navigation.goBack(); + }, [title, folders, collectionId, isNew, navigation, showError]); + + const handleAddFolder = useCallback(() => { + const newFolder: CollectionFolder = { + id: collectionsService.generateId(), + title: '', + tileShape: 'poster', + hideTitle: false, + catalogSources: [], + }; + setEditingFolder(newFolder); + setEditingFolderIndex(-1); + }, []); + + const handleEditFolder = useCallback((index: number) => { + setEditingFolder({ ...folders[index] }); + setEditingFolderIndex(index); + }, [folders]); + + const handleDeleteFolder = useCallback((index: number) => { + setFolders(prev => prev.filter((_, i) => i !== index)); + }, []); + + const handleMoveFolderUp = useCallback((index: number) => { + if (index === 0) return; + setFolders(prev => { + const arr = [...prev]; + [arr[index - 1], arr[index]] = [arr[index], arr[index - 1]]; + return arr; + }); + }, []); + + const handleMoveFolderDown = useCallback((index: number) => { + setFolders(prev => { + if (index >= prev.length - 1) return prev; + const arr = [...prev]; + [arr[index], arr[index + 1]] = [arr[index + 1], arr[index]]; + return arr; + }); + }, []); + + const handleSaveFolder = useCallback(() => { + if (!editingFolder) return; + if (!editingFolder.title.trim()) { + showError('Please enter a folder title'); + return; + } + if (editingFolder.catalogSources.length === 0) { + showError('Add at least one catalog'); + return; + } + + setFolders(prev => { + if (editingFolderIndex >= 0) { + const arr = [...prev]; + arr[editingFolderIndex] = editingFolder; + return arr; + } + return [...prev, editingFolder]; + }); + setEditingFolder(null); + setEditingFolderIndex(-1); + }, [editingFolder, editingFolderIndex, showError]); + + const handleSelectEmoji = useCallback((emoji: string) => { + if (editingFolder) { + setEditingFolder({ ...editingFolder, coverEmoji: emoji, coverImageUrl: undefined }); + } + setShowEmojiPicker(false); + }, [editingFolder]); + + const handleToggleCatalogSource = useCallback((source: CollectionCatalogSource) => { + if (!editingFolder) return; + const existing = editingFolder.catalogSources.find( + s => s.addonId === source.addonId && s.type === source.type && s.catalogId === source.catalogId + ); + if (existing) { + setEditingFolder({ + ...editingFolder, + catalogSources: editingFolder.catalogSources.filter( + s => !(s.addonId === source.addonId && s.type === source.type && s.catalogId === source.catalogId) + ), + }); + } else { + setEditingFolder({ + ...editingFolder, + catalogSources: [...editingFolder.catalogSources, source], + }); + } + }, [editingFolder]); + + // ─── Folder Editor View ────────────────────────── + if (editingFolder) { + const missingAddons = editingFolder.catalogSources.filter(s => !installedAddonIds.has(s.addonId)); + + return ( + + + = 0 ? 'Edit Folder' : 'New Folder'} + showBackButton + onBackPress={() => { setEditingFolder(null); setEditingFolderIndex(-1); }} + /> + + {/* Folder Title */} + Folder Title + setEditingFolder({ ...editingFolder, title: text })} + placeholder="Enter folder title" + placeholderTextColor={colors.disabled} + autoFocus + /> + + {/* Cover */} + Cover + + setShowEmojiPicker(true)} + > + {editingFolder.coverEmoji ? ( + {editingFolder.coverEmoji} + ) : ( + + )} + Emoji + + + { + setEditingFolder({ + ...editingFolder, + coverImageUrl: editingFolder.coverImageUrl || '', + coverEmoji: undefined, + }); + }} + > + {editingFolder.coverImageUrl ? ( + + ) : ( + + )} + Image URL + + + setEditingFolder({ ...editingFolder, coverEmoji: undefined, coverImageUrl: undefined })} + > + + None + + + + {/* Image URL input */} + {editingFolder.coverImageUrl !== undefined && ( + setEditingFolder({ ...editingFolder, coverImageUrl: text })} + placeholder="Paste image URL" + placeholderTextColor={colors.disabled} + autoCapitalize="none" + autoCorrect={false} + /> + )} + + {/* Tile Shape */} + Tile Shape + + {(['poster', 'wide', 'square'] as const).map(shape => ( + setEditingFolder({ ...editingFolder, tileShape: shape })} + > + + {shape.charAt(0).toUpperCase() + shape.slice(1)} + + + ))} + + + {/* Hide Title */} + setEditingFolder({ ...editingFolder, hideTitle: !editingFolder.hideTitle })} + > + Hide Title + + + + + + {/* Catalog Sources */} + + Catalogs + setShowCatalogPicker(true)}> + + + + + {editingFolder.catalogSources.length === 0 && ( + + No catalogs added. Tap + to add catalogs from installed addons. + + )} + + {editingFolder.catalogSources.map((source, idx) => { + const isMissing = !installedAddonIds.has(source.addonId); + return ( + + + + {source.catalogId} + + + {isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`} + + + handleToggleCatalogSource(source)} + style={styles.removeCatalogButton} + > + + + + ); + })} + + {/* Save/Cancel Buttons */} + + + Save Folder + + { setEditingFolder(null); setEditingFolderIndex(-1); }} + > + Cancel + + + + + {/* Emoji Picker Modal */} + + + + + Select Emoji + setShowEmojiPicker(false)}> + + + + + {EMOJI_CATEGORIES.map(category => ( + + {category.name} + + {category.emojis.map((emoji, idx) => ( + handleSelectEmoji(emoji)} + > + {emoji} + + ))} + + + ))} + + + + + + {/* Catalog Picker Modal */} + + + + + Select Catalogs + setShowCatalogPicker(false)}> + Done + + + `${item.addonId}-${item.type}-${item.catalogId}-${idx}`} + renderItem={({ item }) => { + const isSelected = editingFolder?.catalogSources.some( + s => s.addonId === item.addonId && s.type === item.type && s.catalogId === item.catalogId + ); + return ( + handleToggleCatalogSource({ addonId: item.addonId, type: item.type, catalogId: item.catalogId })} + > + + + {item.name} + + + {item.addonName} · {item.type} + + + {isSelected && } + + ); + }} + /> + + + + + ); + } + + // ─── Main Collection Editor View ────────────────── + return ( + + + navigation.goBack()} + rightActionComponent={ + + Save + + } + /> + + {/* Collection Title */} + Collection Title + + + {/* Folders */} + + Folders + + + + + + {folders.length === 0 && ( + + No folders yet. Tap + to add a folder. + + )} + + {folders.map((folder, index) => ( + + + + {folder.coverEmoji ? ( + {folder.coverEmoji} + ) : folder.coverImageUrl ? ( + + ) : ( + + )} + + + {folder.title || 'Untitled'} + + + {folder.catalogSources.length} catalog{folder.catalogSources.length !== 1 ? 's' : ''} · {folder.tileShape} + + + + + handleMoveFolderUp(index)} disabled={index === 0} style={styles.iconBtn}> + + + handleMoveFolderDown(index)} disabled={index === folders.length - 1} style={styles.iconBtn}> + + + handleEditFolder(index)} style={styles.iconBtn}> + + + handleDeleteFolder(index)} style={styles.iconBtn}> + + + + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + padding: 16, + paddingBottom: 40, + }, + label: { + fontSize: 13, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 8, + marginTop: 16, + }, + textInput: { + fontSize: 16, + padding: 14, + borderRadius: 10, + borderWidth: 1, + }, + coverRow: { + flexDirection: 'row', + gap: 10, + }, + coverOption: { + flex: 1, + aspectRatio: 1, + borderRadius: 12, + borderWidth: 2, + justifyContent: 'center', + alignItems: 'center', + gap: 6, + }, + coverEmojiPreview: { + fontSize: 36, + }, + coverImagePreview: { + width: 40, + height: 40, + borderRadius: 8, + }, + coverOptionLabel: { + fontSize: 12, + fontWeight: '500', + }, + shapeRow: { + flexDirection: 'row', + gap: 10, + }, + shapeButton: { + flex: 1, + padding: 12, + borderRadius: 10, + borderWidth: 1, + alignItems: 'center', + }, + shapeButtonText: { + fontSize: 14, + fontWeight: '600', + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 14, + borderRadius: 10, + borderWidth: 1, + marginTop: 16, + }, + toggleLabel: { + fontSize: 16, + fontWeight: '500', + }, + toggleSwitch: { + width: 48, + height: 28, + borderRadius: 14, + justifyContent: 'center', + paddingHorizontal: 2, + }, + toggleThumb: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: '#fff', + }, + toggleThumbActive: { + alignSelf: 'flex-end', + }, + catalogsHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 16, + marginBottom: 8, + }, + foldersHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginTop: 16, + marginBottom: 8, + }, + noCatalogs: { + fontSize: 14, + textAlign: 'center', + paddingVertical: 24, + }, + catalogSourceRow: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 10, + borderWidth: 1, + marginBottom: 8, + }, + catalogSourceInfo: { + flex: 1, + }, + catalogSourceName: { + fontSize: 15, + fontWeight: '500', + }, + catalogSourceMeta: { + fontSize: 12, + marginTop: 2, + }, + removeCatalogButton: { + padding: 6, + }, + folderActions: { + marginTop: 24, + gap: 10, + }, + saveButton: { + padding: 14, + borderRadius: 12, + alignItems: 'center', + }, + saveButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + cancelButton: { + padding: 14, + borderRadius: 12, + alignItems: 'center', + borderWidth: 1, + }, + cancelButtonText: { + fontSize: 16, + fontWeight: '500', + }, + headerSaveButton: { + padding: 8, + }, + headerSaveText: { + fontSize: 17, + fontWeight: '600', + }, + folderCard: { + borderRadius: 12, + padding: 12, + marginBottom: 8, + borderWidth: 1, + }, + folderCardHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + folderCardLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + gap: 10, + }, + folderCardEmoji: { + fontSize: 24, + }, + folderCardImage: { + width: 32, + height: 32, + borderRadius: 6, + }, + folderCardInfo: { + flex: 1, + }, + folderCardTitle: { + fontSize: 15, + fontWeight: '600', + }, + folderCardMeta: { + fontSize: 12, + marginTop: 1, + }, + folderCardActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 2, + }, + iconBtn: { + padding: 5, + }, + // Modal styles + modalOverlay: { + flex: 1, + justifyContent: 'flex-end', + }, + modalContent: { + height: '75%', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 16, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + paddingTop: 4, + }, + modalTitle: { + fontSize: 20, + fontWeight: '700', + }, + doneText: { + fontSize: 17, + fontWeight: '600', + }, + emojiCategory: { + marginBottom: 20, + }, + emojiCategoryName: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + }, + emojiGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + }, + emojiButton: { + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 10, + borderWidth: 1, + borderColor: 'transparent', + }, + emojiButtonText: { + fontSize: 26, + }, + catalogPickerItem: { + flexDirection: 'row', + alignItems: 'center', + padding: 14, + borderRadius: 10, + borderWidth: 1, + marginBottom: 6, + }, + catalogPickerInfo: { + flex: 1, + }, + catalogPickerName: { + fontSize: 15, + fontWeight: '500', + }, + catalogPickerMeta: { + fontSize: 12, + marginTop: 2, + }, +}); + +export default CollectionEditorScreen; diff --git a/src/screens/CollectionManagementScreen.tsx b/src/screens/CollectionManagementScreen.tsx new file mode 100644 index 00000000..401747f8 --- /dev/null +++ b/src/screens/CollectionManagementScreen.tsx @@ -0,0 +1,313 @@ +import React, { useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + StatusBar, + Platform, + Alert, +} from 'react-native'; +import * as Clipboard from 'expo-clipboard'; +import { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../contexts/ThemeContext'; +import { useToast } from '../contexts/ToastContext'; +import { Collection } from '../types/collections'; +import { collectionsService } from '../services/collectionsService'; +import { useCollections } from '../hooks/useCollections'; +import ScreenHeader from '../components/common/ScreenHeader'; +import CustomAlert from '../components/CustomAlert'; +import { RootStackParamList } from '../navigation/AppNavigator'; +import { useTranslation } from 'react-i18next'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +const CollectionManagementScreen = () => { + const { t } = useTranslation(); + const navigation = useNavigation>(); + const { currentTheme } = useTheme(); + const { showInfo, showError } = useToast(); + const { collections, refresh } = useCollections(); + const colors = currentTheme.colors; + + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState([]); + + useFocusEffect( + useCallback(() => { + refresh(); + }, [refresh]) + ); + + const handleNewCollection = useCallback(() => { + navigation.navigate('CollectionEditor' as any, {}); + }, [navigation]); + + const handleEdit = useCallback((id: string) => { + navigation.navigate('CollectionEditor' as any, { collectionId: id }); + }, [navigation]); + + const handleDelete = useCallback((collection: Collection) => { + setAlertTitle('Delete Collection'); + setAlertMessage(`Are you sure you want to delete "${collection.title}"?`); + setAlertActions([ + { label: 'Cancel', onPress: () => setAlertVisible(false) }, + { + label: 'Delete', + onPress: async () => { + setAlertVisible(false); + await collectionsService.deleteCollection(collection.id); + }, + style: 'destructive', + }, + ]); + setAlertVisible(true); + }, []); + + const handleMoveUp = useCallback(async (id: string) => { + await collectionsService.moveCollection(id, 'up'); + }, []); + + const handleMoveDown = useCallback(async (id: string) => { + await collectionsService.moveCollection(id, 'down'); + }, []); + + const handleExport = useCallback(async () => { + try { + const json = await collectionsService.exportToJson(); + await Clipboard.setStringAsync(json); + showInfo('Copied to clipboard'); + } catch { + showError('Export failed'); + } + }, [showInfo, showError]); + + const handleImport = useCallback(async () => { + try { + const text = await Clipboard.getStringAsync(); + if (!text?.trim()) { + showError('Clipboard is empty'); + return; + } + const result = await collectionsService.importFromJson(text); + showInfo(`Imported: ${result.added} added, ${result.updated} updated`); + } catch { + showError('Invalid collection data'); + } + }, [showInfo, showError]); + + return ( + + + navigation.goBack()} + /> + + + + New Collection + + + {collections.length > 0 && ( + + + + Export + + + + Import + + + )} + + {collections.length === 0 && ( + + + + No collections yet + + + Create a collection to organize your catalogs into folders + + + )} + + {collections.map((collection, index) => ( + + + + + {collection.title} + + + {collection.folders.length} folder{collection.folders.length !== 1 ? 's' : ''} + + + + handleMoveUp(collection.id)} + disabled={index === 0} + style={[styles.iconButton, index === 0 && styles.disabledButton]} + > + + + handleMoveDown(collection.id)} + disabled={index === collections.length - 1} + style={[styles.iconButton, index === collections.length - 1 && styles.disabledButton]} + > + + + handleEdit(collection.id)} style={styles.iconButton}> + + + handleDelete(collection)} style={styles.iconButton}> + + + + + + ))} + + {collections.length === 0 && ( + + + + Import from Clipboard + + + )} + + + setAlertVisible(false)} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + scrollContent: { + padding: 16, + paddingBottom: 40, + }, + newButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 14, + borderRadius: 12, + marginBottom: 16, + }, + newButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + exportImportRow: { + flexDirection: 'row', + gap: 10, + marginBottom: 16, + }, + importOnlyRow: { + marginTop: 16, + }, + actionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 10, + borderRadius: 10, + gap: 6, + }, + actionButtonText: { + fontSize: 14, + fontWeight: '500', + }, + emptyContainer: { + alignItems: 'center', + paddingVertical: 48, + }, + emptyText: { + fontSize: 18, + fontWeight: '600', + marginTop: 12, + }, + emptySubtext: { + fontSize: 14, + marginTop: 6, + textAlign: 'center', + paddingHorizontal: 32, + }, + collectionCard: { + borderRadius: 12, + padding: 14, + marginBottom: 10, + borderWidth: 1, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + cardTitleRow: { + flex: 1, + marginRight: 8, + }, + collectionTitle: { + fontSize: 16, + fontWeight: '600', + }, + folderCount: { + fontSize: 12, + marginTop: 2, + }, + cardActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + iconButton: { + padding: 6, + }, + disabledButton: { + opacity: 0.4, + }, +}); + +export default CollectionManagementScreen; diff --git a/src/screens/FolderDetailScreen.tsx b/src/screens/FolderDetailScreen.tsx new file mode 100644 index 00000000..e532023e --- /dev/null +++ b/src/screens/FolderDetailScreen.tsx @@ -0,0 +1,281 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + StatusBar, + Dimensions, + Platform, + FlatList, +} from 'react-native'; +import { useNavigation, useRoute, NavigationProp } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import FastImage from '@d11/react-native-fast-image'; +import { useTheme } from '../contexts/ThemeContext'; +import { Collection, CollectionFolder, CollectionCatalogSource } from '../types/collections'; +import { collectionsService } from '../services/collectionsService'; +import { stremioService } from '../services/stremioService'; +import { StreamingContent } from '../services/catalogService'; +import ScreenHeader from '../components/common/ScreenHeader'; +import LoadingSpinner from '../components/common/LoadingSpinner'; +import { RootStackParamList } from '../navigation/AppNavigator'; + +const { width: screenWidth } = Dimensions.get('window'); +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +const NUM_COLUMNS = screenWidth >= 1024 ? 5 : screenWidth >= 768 ? 4 : 3; +const ITEM_SPACING = 8; +const HORIZONTAL_PADDING = 16; +const ITEM_WIDTH = (screenWidth - HORIZONTAL_PADDING * 2 - ITEM_SPACING * (NUM_COLUMNS - 1)) / NUM_COLUMNS; + +const FolderDetailScreen = () => { + const navigation = useNavigation>(); + const route = useRoute(); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + + const { collectionId, folderId } = route.params as { collectionId: string; folderId: string }; + + const [folder, setFolder] = useState(null); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [tabLabels, setTabLabels] = useState<{ name: string; type: string }[]>([]); + + useEffect(() => { + loadFolder(); + }, [collectionId, folderId]); + + const loadFolder = async () => { + const collections = await collectionsService.getCollections(); + const collection = collections.find(c => c.id === collectionId); + if (!collection) return; + const f = collection.folders.find(f => f.id === folderId); + if (!f) return; + setFolder(f); + + // Build tab labels + const labels = f.catalogSources.map(source => ({ + name: source.catalogId, + type: source.type, + })); + setTabLabels(labels); + + // Load first tab + if (f.catalogSources.length > 0) { + loadCatalogData(f.catalogSources[0]); + } + }; + + const loadCatalogData = async (source: CollectionCatalogSource) => { + setLoading(true); + setItems([]); + try { + const addons = await stremioService.getInstalledAddonsAsync(); + const addon = addons.find((a: any) => a.id === source.addonId); + if (!addon) { + setLoading(false); + return; + } + + const metas = await stremioService.getCatalog(addon, source.type, source.catalogId, 1); + if (metas && metas.length > 0) { + const mapped: StreamingContent[] = metas.map((meta: any) => ({ + id: meta.id, + type: meta.type, + name: meta.name, + poster: meta.poster, + posterShape: meta.posterShape, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + })); + setItems(mapped); + } + } catch (error) { + if (__DEV__) console.error('[FolderDetail] Error loading catalog:', error); + } finally { + setLoading(false); + } + }; + + const handleTabPress = useCallback((index: number) => { + if (!folder) return; + setSelectedTabIndex(index); + loadCatalogData(folder.catalogSources[index]); + }, [folder]); + + const handleItemPress = useCallback((id: string, type: string) => { + navigation.navigate('Metadata', { id, type }); + }, [navigation]); + + const renderItem = useCallback(({ item }: { item: StreamingContent }) => ( + handleItemPress(item.id, item.type)} + style={[styles.gridItem, { width: ITEM_WIDTH }]} + > + + + {item.name} + + + ), [handleItemPress, colors.text]); + + const keyExtractor = useCallback((item: StreamingContent) => item.id, []); + + if (!folder) { + return ( + + + + + ); + } + + return ( + + + navigation.goBack()} + /> + + {/* Tabs */} + {folder.catalogSources.length > 1 && ( + + `tab-${idx}`} + renderItem={({ item: tab, index }) => ( + handleTabPress(index)} + > + + {tab.name} + + + {tab.type} + + + )} + /> + + )} + + {/* Content Grid */} + {loading ? ( + + + + ) : items.length === 0 ? ( + + + No content found + + ) : ( + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + tabContainer: { + paddingVertical: 8, + }, + tabList: { + paddingHorizontal: HORIZONTAL_PADDING, + gap: 8, + }, + tab: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 10, + borderWidth: 1, + alignItems: 'center', + }, + tabName: { + fontSize: 14, + fontWeight: '600', + }, + tabType: { + fontSize: 11, + marginTop: 1, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: 12, + }, + emptyText: { + fontSize: 16, + }, + gridContent: { + padding: HORIZONTAL_PADDING, + }, + gridRow: { + gap: ITEM_SPACING, + marginBottom: ITEM_SPACING, + }, + gridItem: { + marginBottom: 4, + }, + gridPoster: { + width: '100%', + aspectRatio: 2 / 3, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.05)', + }, + gridTitle: { + fontSize: 12, + fontWeight: '500', + marginTop: 4, + }, +}); + +export default FolderDetailScreen; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index b7baa4c0..3e68a183 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -71,6 +71,9 @@ import FirstTimeWelcome from '../components/FirstTimeWelcome'; import { HeaderVisibility } from '../contexts/HeaderVisibility'; import { useTrailer } from '../contexts/TrailerContext'; import { useScrollToTop } from '../contexts/ScrollToTopContext'; +import CollectionRowSection from '../components/home/CollectionRowSection'; +import { useCollections } from '../hooks/useCollections'; +import { collectionsEmitter, COLLECTIONS_EVENTS } from '../services/collectionsService'; // Constants const CATALOG_SETTINGS_KEY = 'catalog_settings'; @@ -117,6 +120,7 @@ type HomeScreenListItem = | { type: 'thisWeek'; key: string } | { type: 'continueWatching'; key: string } | { type: 'catalog'; catalog: CatalogContent; key: string } + | { type: 'collection'; collection: import('../types/collections').Collection; key: string } | { type: 'placeholder'; key: string } | { type: 'welcome'; key: string } | { type: 'loadMore'; key: string }; @@ -143,6 +147,7 @@ const HomeScreen = () => { const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes const { showInfo } = useToast(); const { setTrailerPlaying } = useTrailer(); + const { collections } = useCollections(); const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); const refreshTimeoutRef = useRef(null); @@ -758,22 +763,47 @@ const HomeScreen = () => { // Only show a limited number of catalogs initially for performance const catalogsToShow = catalogs.slice(0, visibleCatalogCount); + // Build catalog items first + const catalogItems: HomeScreenListItem[] = []; catalogsToShow.forEach((catalog, index) => { if (catalog) { - data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` }); + catalogItems.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` }); } else if (catalogsLoading && pendingCatalogIndexes[index]) { - // Add a key for placeholders - data.push({ type: 'placeholder', key: `placeholder-${index}` }); + catalogItems.push({ type: 'placeholder', key: `placeholder-${index}` }); } }); + // Interleave collections after the first 2 catalog rows, then after each subsequent collection + let catalogIndex = 0; + const collectionsToInsert = [...collections]; + const INSERT_AFTER_ROW = 2; // Insert collections after 2nd catalog row + + for (let i = 0; i < catalogItems.length; i++) { + data.push(catalogItems[i]); + catalogIndex++; + + if (catalogIndex === INSERT_AFTER_ROW && collectionsToInsert.length > 0) { + for (const collection of collectionsToInsert) { + data.push({ type: 'collection', collection, key: `collection-${collection.id}` }); + } + collectionsToInsert.length = 0; + } + } + + // If we didn't reach INSERT_AFTER_ROW, append remaining collections at the end + if (collectionsToInsert.length > 0) { + for (const collection of collectionsToInsert) { + data.push({ type: 'collection', collection, key: `collection-${collection.id}` }); + } + } + // Add a "Load More" button if there are more catalogs to show if (catalogs.length > visibleCatalogCount && catalogs.filter(c => c).length > visibleCatalogCount) { data.push({ type: 'loadMore', key: 'load-more' }); } return data; - }, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection]); + }, [hasAddons, catalogs, catalogsLoading, pendingCatalogIndexes, visibleCatalogCount, settings.showThisWeekSection, collections]); const handleLoadMoreCatalogs = useCallback(() => { setVisibleCatalogCount(prev => Math.min(prev + 3, catalogs.length)); @@ -865,6 +895,8 @@ const HomeScreen = () => { return null; // Moved to ListHeaderComponent to avoid remounts on scroll case 'catalog': return ; + case 'collection': + return ; case 'placeholder': return ( diff --git a/src/screens/settings/ContentDiscoverySettingsScreen.tsx b/src/screens/settings/ContentDiscoverySettingsScreen.tsx index 95a2afb9..df427039 100644 --- a/src/screens/settings/ContentDiscoverySettingsScreen.tsx +++ b/src/screens/settings/ContentDiscoverySettingsScreen.tsx @@ -120,6 +120,14 @@ export const ContentDiscoverySettingsContent: React.FC )} + } + onPress={() => navigation.navigate('Collections')} + isTablet={isTablet} + /> {isItemVisible('home_screen') && ( { + const scope = await mmkvStorage.getItem('@user:current') || 'local'; + return `@user:${scope}:${COLLECTIONS_STORAGE_KEY}`; + } + + async getCollections(): Promise { + try { + const key = await this.getStorageKey(); + const json = await mmkvStorage.getItem(key); + if (!json) return []; + const parsed = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + return parsed; + } catch (error) { + logger.error('[CollectionsService] Error loading collections:', error); + return []; + } + } + + async saveCollections(collections: Collection[]): Promise { + try { + const key = await this.getStorageKey(); + await mmkvStorage.setItem(key, JSON.stringify(collections)); + collectionsEmitter.emit(COLLECTIONS_EVENTS.CHANGED); + } catch (error) { + logger.error('[CollectionsService] Error saving collections:', error); + } + } + + async addCollection(collection: Collection): Promise { + const collections = await this.getCollections(); + collections.push(collection); + await this.saveCollections(collections); + } + + async updateCollection(updated: Collection): Promise { + const collections = await this.getCollections(); + const index = collections.findIndex(c => c.id === updated.id); + if (index >= 0) { + collections[index] = updated; + await this.saveCollections(collections); + } + } + + async deleteCollection(id: string): Promise { + const collections = await this.getCollections(); + await this.saveCollections(collections.filter(c => c.id !== id)); + } + + async moveCollection(id: string, direction: 'up' | 'down'): Promise { + const collections = await this.getCollections(); + const index = collections.findIndex(c => c.id === id); + if (index < 0) return; + const newIndex = direction === 'up' ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= collections.length) return; + const temp = collections[index]; + collections[index] = collections[newIndex]; + collections[newIndex] = temp; + await this.saveCollections(collections); + } + + async exportToJson(): Promise { + const collections = await this.getCollections(); + return JSON.stringify(collections, null, 2); + } + + async importFromJson(json: string): Promise<{ added: number; updated: number }> { + try { + const imported = JSON.parse(json); + if (!Array.isArray(imported)) { + throw new Error('Invalid format: expected an array'); + } + + const existing = await this.getCollections(); + const existingMap = new Map(existing.map(c => [c.id, c])); + + let added = 0; + let updated = 0; + + for (const item of imported) { + if (!item.id || !item.title || !Array.isArray(item.folders)) continue; + if (existingMap.has(item.id)) { + existingMap.set(item.id, item); + updated++; + } else { + existingMap.set(item.id, item); + added++; + } + } + + await this.saveCollections(Array.from(existingMap.values())); + return { added, updated }; + } catch (error) { + logger.error('[CollectionsService] Import error:', error); + throw error; + } + } + + generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + } +} + +export const collectionsService = CollectionsService.getInstance(); diff --git a/src/types/collections.ts b/src/types/collections.ts new file mode 100644 index 00000000..8676aead --- /dev/null +++ b/src/types/collections.ts @@ -0,0 +1,21 @@ +export interface CollectionCatalogSource { + addonId: string; + type: string; + catalogId: string; +} + +export interface CollectionFolder { + id: string; + title: string; + coverImageUrl?: string; + coverEmoji?: string; + tileShape: 'poster' | 'wide' | 'square'; + hideTitle: boolean; + catalogSources: CollectionCatalogSource[]; +} + +export interface Collection { + id: string; + title: string; + folders: CollectionFolder[]; +}