'use client'; import React, { useState, useMemo, useEffect } from 'react'; import { PageWrapper } from '../shared/page-wrapper'; import { useStatus } from '@/context/status'; import { useUserData } from '@/context/userData'; import { SettingsCard } from '../shared/settings-card'; import { Button, IconButton } from '../ui/button'; import { Modal } from '../ui/modal'; import { Switch } from '../ui/switch'; import { DndContext, useSensor, useSensors, PointerSensor, TouchSensor, } from '@dnd-kit/core'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { arrayMove, SortableContext, verticalListSortingStrategy, useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { PlusIcon, SearchIcon, FilterIcon } from 'lucide-react'; import TemplateOption from '../shared/template-option'; import * as constants from '../../../../core/src/utils/constants'; import { TextInput } from '../ui/text-input'; import { Popover } from '../ui/popover'; import { BiEdit, BiTrash } from 'react-icons/bi'; import { Option, Resource } from '@aiostreams/core'; import { toast } from 'sonner'; import { Tooltip } from '../ui/tooltip'; import { StaticTabs } from '../ui/tabs'; import { LuDownload, LuGlobe, LuChevronsUp, LuChevronsDown, LuShuffle, } from 'react-icons/lu'; import { TbSmartHome, TbSmartHomeOff } from 'react-icons/tb'; import { AnimatePresence } from 'framer-motion'; import { PageControls } from '../shared/page-controls'; import Image from 'next/image'; import { Combobox } from '../ui/combobox'; import { FaPlus, FaRegTrashAlt } from 'react-icons/fa'; import { UserConfigAPI } from '../../services/api'; import { ConfirmationDialog, useConfirmationDialog, } from '../shared/confirmation-dialog'; import { MdRefresh } from 'react-icons/md'; import { Alert } from '../ui/alert'; import MarkdownLite from '../shared/markdown-lite'; import { Accordion, AccordionTrigger, AccordionContent, AccordionItem, } from '../ui/accordion'; import { FaArrowRightLong, FaRankingStar, FaShuffle } from 'react-icons/fa6'; import { PiStarFill, PiStarBold } from 'react-icons/pi'; import { IoExtensionPuzzle } from 'react-icons/io5'; import { NumberInput } from '../ui/number-input'; interface CatalogModification { id: string; type: string; name?: string; overrideType?: string; enabled?: boolean; shuffle?: boolean; persistShuffleFor?: number; rpdb?: boolean; onlyOnDiscover?: boolean; hideable?: boolean; addonName?: string; } export function AddonsMenu() { return ( ); } function Content() { const { status } = useStatus(); const { userData, setUserData } = useUserData(); const [page, setPage] = useState<'installed' | 'marketplace'>('installed'); const [search, setSearch] = useState(''); // Filter states const [serviceFilters, setServiceFilters] = useState([]); const [streamTypeFilters, setStreamTypeFilters] = useState< constants.StreamType[] >([]); const [resourceFilters, setResourceFilters] = useState([]); // Modal states const [modalOpen, setModalOpen] = useState(false); const [modalMode, setModalMode] = useState<'add' | 'edit'>('add'); const [modalPreset, setModalPreset] = useState(null); const [modalInitialValues, setModalInitialValues] = useState< Record >({}); const [editingAddonId, setEditingAddonId] = useState(null); const [isDragging, setIsDragging] = useState(false); // Filtering and search for marketplace const filteredPresets = useMemo(() => { if (!status?.settings?.presets) return []; return status.settings.presets.filter((preset) => { if (preset.ID === 'custom') return true; const matchesService = serviceFilters.length === 0 || (preset.SUPPORTED_SERVICES && serviceFilters.every((s) => preset.SUPPORTED_SERVICES.includes(s))); const matchesStreamType = streamTypeFilters.length === 0 || (preset.SUPPORTED_STREAM_TYPES && streamTypeFilters.every((t) => preset.SUPPORTED_STREAM_TYPES.includes(t) )); const matchesResource = resourceFilters.length === 0 || (preset.SUPPORTED_RESOURCES && resourceFilters.every((r) => preset.SUPPORTED_RESOURCES.includes(r as Resource) )); const matchesSearch = !search || preset.NAME.toLowerCase().includes(search.toLowerCase()) || preset.DESCRIPTION.toLowerCase().includes(search.toLowerCase()); return ( matchesService && matchesStreamType && matchesResource && matchesSearch ); }); }, [status, search, serviceFilters, streamTypeFilters, resourceFilters]); // My Addons (user's enabled/added presets) // AddonModal handlers function handleAddPreset(preset: any) { setModalPreset(preset); setModalInitialValues({ options: Object.fromEntries( (preset.OPTIONS || []).map((opt: any) => [ opt.id, opt.default ?? undefined, ]) ), }); setModalMode('add'); setEditingAddonId(null); setModalOpen(true); } function getUniqueId() { // generate a 3 character long hex string, ensuring it doesn't already exist in the user's presets const id = Math.floor(Math.random() * 0xfff) .toString(16) .padStart(3, '0'); if (userData.presets.some((a) => a.instanceId === id)) { return getUniqueId(); } return id; } function handleModalSubmit(values: Record) { if (modalMode === 'add' && modalPreset) { // Always add a new preset with default values, never edit const newPreset = { type: modalPreset.ID, instanceId: getUniqueId(), enabled: true, options: values.options, }; const newKey = getPresetUniqueKey(newPreset); // Prevent adding if a preset with the same unique key already exists // dont use instanceId here, as that will always be unique // only prevent adding the same preset type with the same options // so we use getPresetUniqueKey here. if (userData.presets.some((a) => getPresetUniqueKey(a) === newKey)) { toast.error('You already have an addon with the same options added.'); setModalOpen(false); return; } setUserData((prev) => ({ ...prev, presets: [...prev.presets, newPreset], })); toast.info('Addon installed successfully!'); setModalOpen(false); } else if (modalMode === 'edit' && editingAddonId) { // Edit existing preset (should not be triggered from marketplace) setUserData((prev) => ({ ...prev, presets: prev.presets.map((a) => a.instanceId === editingAddonId ? { ...a, options: values.options } : a ), })); toast.info('Addon updated successfully!'); setModalOpen(false); } } // DND for My Addons function handleDragEnd(event: any) { const { active, over } = event; if (!over) return; if (active.id !== over.id) { const oldIndex = userData.presets.findIndex( (a) => a.instanceId === active.id ); const newIndex = userData.presets.findIndex( (a) => a.instanceId === over.id ); const newPresets = arrayMove(userData.presets, oldIndex, newIndex); setUserData((prev) => ({ ...prev, presets: newPresets, })); } setIsDragging(false); } function handleDragStart(event: any) { setIsDragging(true); } // Service, stream type, and resource options const serviceOptions = Object.values(constants.SERVICE_DETAILS).map( (service) => ({ label: service.name, value: service.id }) ); const streamTypeOptions = (constants.STREAM_TYPES || []) .filter((type) => type !== 'error') .map((type: string) => ({ label: type, value: type })); const resourceOptions = (constants.RESOURCES || []).map((res: string) => ({ label: res, value: res, })); const activeFilterCount = serviceFilters.length + streamTypeFilters.length + resourceFilters.length; // DND-kit setup const sensors = useSensors( useSensor(PointerSensor), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8, }, }) ); useEffect(() => { function preventTouchMove(e: TouchEvent) { if (isDragging) { e.preventDefault(); } } function handleDragEnd() { setIsDragging(false); } if (isDragging) { document.body.addEventListener('touchmove', preventTouchMove, { passive: false, }); // Add listeners for when drag ends outside context document.addEventListener('pointerup', handleDragEnd); document.addEventListener('touchend', handleDragEnd); } else { document.body.removeEventListener('touchmove', preventTouchMove); } return () => { document.body.removeEventListener('touchmove', preventTouchMove); document.removeEventListener('pointerup', handleDragEnd); document.removeEventListener('touchend', handleDragEnd); }; }, [isDragging]); return ( <> {/*

Addons

Manage your installed addons or

*/}
setPage('installed'), iconType: LuDownload, }, { name: 'Marketplace', isCurrent: page === 'marketplace', onClick: () => setPage('marketplace'), iconType: LuGlobe, }, ]} />
{page === 'installed' && (

Installed Addons

Manage your installed addons.

a.instanceId)} strategy={verticalListSortingStrategy} >
    {userData.presets.length === 0 ? (
  • Looks like you don't have any addons...
    Add some from the marketplace!
  • ) : ( userData.presets.map((preset) => { const presetMetadata = status?.settings?.presets.find( (p: any) => p.ID === preset.type ); return ( { setModalPreset(presetMetadata); setModalInitialValues({ options: { ...preset.options }, }); setModalMode('edit'); setEditingAddonId(preset.instanceId); setModalOpen(true); }} onRemove={() => { setUserData((prev) => ({ ...prev, presets: prev.presets.filter( (a) => a.instanceId !== preset.instanceId ), })); }} onToggleEnabled={(v: boolean) => { setUserData((prev) => ({ ...prev, presets: prev.presets.map((p) => p.instanceId === preset.instanceId ? { ...p, enabled: v } : p ), })); }} /> ); }) )}
{userData.presets.length > 0 && } {userData.presets.length > 0 && }
)} {page === 'marketplace' && (

Marketplace

Browse and install addons from the marketplace.

) => setSearch(e.target.value) } placeholder="Search addons..." className="flex-1" leftIcon={} /> } intent={ activeFilterCount > 0 ? 'primary' : 'primary-outline' } aria-label="Filters" />
{/* Scrollable Addon Cards Grid */}
{filteredPresets.map((preset: any) => { // Always allow adding, never show edit return ( handleAddPreset(preset)} /> ); })}
)} {/* Add/Edit Addon Modal (ensure both tabs can use it)*/}
); } // Helper to generate a key based on an addons id and options function getPresetUniqueKey(preset: { type: string; instanceId: string; enabled: boolean; options: Record; }) { // dont include the unique instanceId return JSON.stringify({ type: preset.type, enabled: preset.enabled, options: preset.options, }); } // Sortable Addon Item for DND (handles both preset and custom addon) function SortableAddonItem({ preset, presetMetadata, onEdit, onRemove, onToggleEnabled, }: { preset: any; presetMetadata: any; onEdit: () => void; onRemove: () => void; onToggleEnabled: (v: boolean) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: preset.instanceId, }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
  • {presetMetadata.ID === 'custom' ? ( ) : ( {presetMetadata.NAME} )}

    {preset.options.name}

    } intent="primary-subtle" onClick={onEdit} /> } intent="alert-subtle" onClick={onRemove} />
  • ); } // AddonCard component function AddonCard({ preset, isAdded, onAdd, }: { preset: any; isAdded: boolean; onAdd: () => void; }) { return (
    {/* Top: Logo + Name/Description */}
    {preset.ID === 'custom' ? (
    ) : preset.LOGO ? ( {preset.NAME} ) : (
    )}
    {preset.NAME}
    {preset.DESCRIPTION}
    {/* Tags Section */}
    {preset.SUPPORTED_SERVICES?.length > 0 && ( Services: )} {preset.SUPPORTED_SERVICES?.map((sid: string) => { const service = constants.SERVICE_DETAILS[ sid as keyof typeof constants.SERVICE_DETAILS ]; return ( {service?.shortName || sid} } > {service?.name || sid} ); })}
    {preset.SUPPORTED_RESOURCES?.length > 0 && ( Resources: )} {preset.SUPPORTED_RESOURCES?.map((res: string) => ( {res} ))}
    {preset.SUPPORTED_STREAM_TYPES?.length > 0 && ( Stream Types: )} {preset.SUPPORTED_STREAM_TYPES?.map((type: string) => ( {type} ))}
    {preset.DISABLED ? (
    {preset.DISABLED.reason}} />
    ) : (
    )}
    ); } function AddonModal({ open, onOpenChange, mode, presetMetadata, initialValues = {}, onSubmit, }: { open: boolean; onOpenChange: (v: boolean) => void; mode: 'add' | 'edit'; presetMetadata?: any; initialValues?: Record; onSubmit: (values: Record) => void; }) { const [values, setValues] = useState>(initialValues); useEffect(() => { if (open) { setValues(initialValues); } else { // when closing, delay the reset to allow the animation to finish // so that the user doesn't see the values being reset setTimeout(() => { setValues(initialValues); }, 150); } }, [open, initialValues]); const dynamicOptions: Option[] = presetMetadata?.OPTIONS || []; // Check if all required fields are filled const allRequiredFilled = dynamicOptions.every((opt: any) => { if (!opt.required) return true; const val = values.options?.[opt.id]; // For booleans, false is valid; for others, check for empty string/null/undefined if (opt.type === 'boolean') return typeof val === 'boolean'; return val !== undefined && val !== null && val !== ''; }); function handleFormSubmit(e: React.FormEvent) { e.preventDefault(); for (const opt of dynamicOptions) { if (opt.constraints) { const val = values.options?.[opt.id]; if (typeof val === 'string') { if (opt.constraints.min && val.length < opt.constraints.min) { toast.error( `${opt.name} must be at least ${opt.constraints.min} characters` ); return false; } if (opt.constraints.max && val.length > opt.constraints.max) { toast.error( `${opt.name} must be at most ${opt.constraints.max} characters` ); return false; } } else if (typeof val === 'number') { if (opt.constraints.min && val < opt.constraints.min) { toast.error(`${opt.name} must be at least ${opt.constraints.min}`); return false; } if (opt.constraints.max && val > opt.constraints.max) { toast.error(`${opt.name} must be at most ${opt.constraints.max}`); return false; } } } } if (allRequiredFilled) { onSubmit(values); } else { toast.error('Please fill in all required fields'); } } return (
    {dynamicOptions.map((opt: any) => (
    setValues((val) => ({ ...val, options: { ...val.options, [opt.id]: v }, })) } disabled={false} />
    ))}
    ); } function AddonFilterPopover({ serviceOptions, streamTypeOptions, resourceOptions, serviceFilters, setServiceFilters, streamTypeFilters, setStreamTypeFilters, resourceFilters, setResourceFilters, children, }: any) { const [open, setOpen] = useState(false); return (
    Services
    {serviceOptions.map((opt: any) => (
    { setServiceFilters((prev: string[]) => checked ? [...prev, opt.value] : prev.filter((v) => v !== opt.value) ); }} size="sm" /> {opt.label}
    ))}
    Stream Types
    {streamTypeOptions.map((opt: any) => (
    { setStreamTypeFilters((prev: string[]) => checked ? [...prev, opt.value] : prev.filter((v) => v !== opt.value) ); }} size="sm" /> {opt.label}
    ))}
    Resources
    {resourceOptions.map((opt: any) => (
    { setResourceFilters((prev: string[]) => checked ? [...prev, opt.value] : prev.filter((v) => v !== opt.value) ); }} size="sm" /> {opt.label}
    ))}
    ); } function AddonGroupCard() { const { userData, setUserData } = useUserData(); // Helper function to get presets that are not in any group except the current one const getAvailablePresets = (currentGroupIndex: number) => { const presetsInOtherGroups = new Set( userData.groups?.flatMap((group, idx) => idx !== currentGroupIndex ? group.addons : [] ) || [] ); return userData.presets .filter((preset) => { return !presetsInOtherGroups.has(preset.instanceId); }) .map((preset) => ({ label: preset.options.name, value: preset.instanceId, textValue: preset.options.name, })); }; const updateGroup = ( index: number, updates: Partial<{ addons: string[]; condition: string }> ) => { setUserData((prev) => { // Initialize groups array if it doesn't exist const currentGroups = prev.groups || []; // Create a new array with all existing groups const newGroups = [...currentGroups]; // Update the specific group with new values, preserving other fields newGroups[index] = { ...newGroups[index], ...updates, }; if (index === 0) { // set condition for first group to true newGroups[index].condition = 'true'; } return { ...prev, groups: newGroups, }; }); }; return (
    Optionally assign your addons to groups. Streams are only fetched from your first group initially, and only if a certain condition is met, will streams be fetched from the next group, and so on. Leaving this blank will mean streams are fetched from all addons. Check the{' '} wiki for a detailed guide to using groups.
    {(userData.groups || []).map((group, index) => (
    { updateGroup(index, { addons: value }); }} />
    { updateGroup(index, { condition: value }); }} />
    } intent="alert-subtle" onClick={() => { setUserData((prev) => { const newGroups = [...(prev.groups || [])]; newGroups.splice(index, 1); return { ...prev, groups: newGroups, }; }); }} />
    ))}
    } onClick={() => { setUserData((prev) => { const currentGroups = prev.groups || []; return { ...prev, groups: [...currentGroups, { addons: [], condition: '' }], }; }); }} />
    ); } function CatalogSettingsCard() { const { userData, setUserData } = useUserData(); const [loading, setLoading] = useState(false); const fetchCatalogs = async () => { setLoading(true); try { const response = await UserConfigAPI.getCatalogs(userData); if (response.success && response.data) { setUserData((prev) => { const existingMods = prev.catalogModifications || []; const existingIds = new Set( existingMods.map((mod) => `${mod.id}-${mod.type}`) ); // first we need to handle existing modifications, to ensure that they keep their order const modifications = existingMods.map((eMod) => { const nMod = response.data!.find( (c) => c.id === eMod.id && c.type === eMod.type ); if (nMod) { return { // keep all the existing attributes, except addonName, type, hideable ...eMod, addonName: nMod.addonName, type: nMod.type, hideable: nMod.hideable, }; } return eMod; }); // Add new catalogs at the bottom response.data!.forEach((catalog) => { if (!existingIds.has(`${catalog.id}-${catalog.type}`)) { modifications.push({ id: catalog.id, name: catalog.name, type: catalog.type, enabled: true, shuffle: false, rpdb: userData.rpdbApiKey ? true : false, hideable: catalog.hideable, addonName: catalog.addonName, }); } }); // Filter out modifications for catalogs that no longer exist const newCatalogIds = new Set( response.data!.map((c) => `${c.id}-${c.type}`) ); const filteredMods = modifications.filter((mod) => newCatalogIds.has(`${mod.id}-${mod.type}`) ); return { ...prev, catalogModifications: filteredMods, }; }); toast.success('Catalogs fetched successfully'); } else { toast.error(response.error?.message || 'Failed to fetch catalogs'); } } catch (error) { toast.error('Failed to fetch catalogs'); } finally { setLoading(false); } }; const capitalise = (str: string | undefined) => { if (!str) return ''; return str.charAt(0).toUpperCase() + str.slice(1); }; // DND handlers const sensors = useSensors( useSensor(PointerSensor), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8, }, }) ); const [isDragging, setIsDragging] = useState(false); useEffect(() => { function preventTouchMove(e: TouchEvent) { if (isDragging) { e.preventDefault(); } } function handleDragEnd() { setIsDragging(false); } if (isDragging) { document.body.addEventListener('touchmove', preventTouchMove, { passive: false, }); document.addEventListener('pointerup', handleDragEnd); document.addEventListener('touchend', handleDragEnd); } else { document.body.removeEventListener('touchmove', preventTouchMove); } return () => { document.body.removeEventListener('touchmove', preventTouchMove); document.removeEventListener('pointerup', handleDragEnd); document.removeEventListener('touchend', handleDragEnd); }; }, [isDragging]); const handleDragEnd = (event: any) => { const { active, over } = event; if (!over) return; if (active.id !== over.id) { setUserData((prev) => { const oldIndex = prev.catalogModifications?.findIndex( (c) => `${c.id}-${c.type}` === active.id ); const newIndex = prev.catalogModifications?.findIndex( (c) => `${c.id}-${c.type}` === over.id ); if ( oldIndex === undefined || newIndex === undefined || !prev.catalogModifications ) return prev; return { ...prev, catalogModifications: arrayMove( prev.catalogModifications, oldIndex, newIndex ), }; }); } setIsDragging(false); }; const handleDragStart = () => { setIsDragging(true); }; const confirmRefreshCatalogs = useConfirmationDialog({ title: 'Refresh Catalogs', description: 'Are you sure you want to refresh the catalogs? This will remove any catalogs that are no longer available', onConfirm: () => { fetchCatalogs(); }, }); return (

    Catalogs

    Rename, Reorder, and toggle your catalogs, and apply modifications like RPDB posters and shuffling. If you reorder the addons, you need to reinstall the addon

    } rounded onClick={() => { if (userData.catalogModifications?.length) { confirmRefreshCatalogs.open(); } else { fetchCatalogs(); } }} loading={loading} />
    {!userData.catalogModifications?.length && (

    Your addons don't have any catalogs... or you haven't fetched them yet :/

    )} {userData.catalogModifications && userData.catalogModifications.length > 0 && ( `${c.id}-${c.type}` )} strategy={verticalListSortingStrategy} >
      {(userData.catalogModifications || []).map( (catalog: CatalogModification) => ( { setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map( (c) => c.id === catalog.id && c.type === catalog.type ? { ...c, enabled } : c ), })); }} capitalise={capitalise} /> ) )}
    )}
    ); } // Add the SortableCatalogItem component function SortableCatalogItem({ catalog, onToggleEnabled, capitalise, }: { catalog: CatalogModification; onToggleEnabled: (enabled: boolean) => void; capitalise: (str: string | undefined) => string; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: `${catalog.id}-${catalog.type}`, }); const { setUserData } = useUserData(); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; const moveToTop = () => { setUserData((prev) => { if (!prev.catalogModifications) return prev; const index = prev.catalogModifications.findIndex( (c) => c.id === catalog.id && c.type === catalog.type ); if (index <= 0) return prev; const newMods = [...prev.catalogModifications]; const [item] = newMods.splice(index, 1); newMods.unshift(item); return { ...prev, catalogModifications: newMods }; }); }; const moveToBottom = () => { setUserData((prev) => { if (!prev.catalogModifications) return prev; const index = prev.catalogModifications.findIndex( (c) => c.id === catalog.id && c.type === catalog.type ); if (index === prev.catalogModifications.length - 1) return prev; const newMods = [...prev.catalogModifications]; const [item] = newMods.splice(index, 1); newMods.push(item); return { ...prev, catalogModifications: newMods }; }); }; const [modalOpen, setModalOpen] = useState(false); const [newName, setNewName] = useState(catalog.name || ''); const [newType, setNewType] = useState( catalog.overrideType || catalog.type || '' ); const dynamicIconSize = `text-xl h-8 w-8 lg:text-2xl lg:h-10 lg:w-10`; const handleNameAndTypeEdit = () => { setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map((c) => c.id === catalog.id && c.type === catalog.type ? { ...c, name: newName, overrideType: newType, } : c ), })); setModalOpen(false); }; return (
  • {/* Full-height drag handle - rounded vertical oval with spacing */}
    {/* Content wrapper */}
    {/* Header section */}

    {catalog.addonName} - {catalog.name ?? catalog.id}

    } intent="primary-subtle" onClick={() => setModalOpen(true)} />

    {catalog.overrideType !== undefined && catalog.overrideType !== catalog.type ? `${catalog.overrideType} (${catalog.type})` : catalog.type}

    {/* Mobile Controls Row - only visible on small screens */}
    {/* Position controls - aligned left */}
    } intent="primary-subtle" onClick={moveToTop} title="Move to top" /> } intent="primary-subtle" onClick={moveToBottom} title="Move to bottom" />
    {/* Enable/disable toggle - aligned right */}
    {/* Desktop Controls - only visible on medium screens and up */}
    } intent="primary-subtle" onClick={moveToTop} title="Move to top" /> } intent="primary-subtle" onClick={moveToBottom} title="Move to bottom" />
    {/* Settings section */}

    Settings

    {/* Active modifier icons */}
    ) : ( ) } intent="primary-subtle" rounded onClick={(e) => { e.stopPropagation(); setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map((c) => c.id === catalog.id && c.type === catalog.type ? { ...c, shuffle: !c.shuffle } : c ), })); }} /> } > Shuffle : } intent="primary-subtle" rounded onClick={(e) => { e.stopPropagation(); setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map((c) => c.id === catalog.id && c.type === catalog.type ? { ...c, rpdb: !c.rpdb } : c ), })); }} /> } > RPDB {catalog.hideable && ( ) : ( ) } intent="primary-subtle" rounded onClick={(e) => { e.stopPropagation(); setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map((c) => c.id === catalog.id && c.type === catalog.type ? { ...c, onlyOnDiscover: !c.onlyOnDiscover, } : c ), })); }} /> } > Discover Only )}
    {/* Large screens: horizontal layout, Medium and below: vertical layout */}
    { setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map( (c) => c.id === catalog.id && c.type === catalog.type ? { ...c, shuffle } : c ), })); }} />

    The amount of hours to keep a given shuffled catalog order before shuffling again. Defaults to 0 (Shuffle on every request).

    { setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map((c) => c.id === catalog.id && c.type === catalog.type ? { ...c, persistShuffleFor: value } : c ), })); }} />
    { setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map( (c) => c.id === catalog.id && c.type === catalog.type ? { ...c, rpdb } : c ), })); }} /> {catalog.hideable && ( { setUserData((prev) => ({ ...prev, catalogModifications: prev.catalogModifications?.map((c) => c.id === catalog.id && c.type === catalog.type ? { ...c, onlyOnDiscover } : c ), })); }} /> )}
    {/* Name edit modal */}
    { e.preventDefault(); handleNameAndTypeEdit(); }} >
  • ); }