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[]; +}