brunner56's picture
implement app
0bfe2e3
'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 (
<PageWrapper className="space-y-4 p-4 sm:p-8">
<Content />
</PageWrapper>
);
}
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<string[]>([]);
const [streamTypeFilters, setStreamTypeFilters] = useState<
constants.StreamType[]
>([]);
const [resourceFilters, setResourceFilters] = useState<string[]>([]);
// Modal states
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
const [modalPreset, setModalPreset] = useState<any | null>(null);
const [modalInitialValues, setModalInitialValues] = useState<
Record<string, any>
>({});
const [editingAddonId, setEditingAddonId] = useState<string | null>(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<string, any>) {
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 (
<>
{/* <div className="flex items-center w-full">
<div>
<h2>Addons</h2>
<p className="text-[--muted]">Manage your installed addons or</p>
</div>
<div className="flex flex-1"></div>
</div> */}
<div className="flex items-center justify-between">
<StaticTabs
className="h-10 w-fit border rounded-full"
triggerClass="px-4 py-1 text-md"
items={[
{
name: 'Installed',
isCurrent: page === 'installed',
onClick: () => setPage('installed'),
iconType: LuDownload,
},
{
name: 'Marketplace',
isCurrent: page === 'marketplace',
onClick: () => setPage('marketplace'),
iconType: LuGlobe,
},
]}
/>
<div className="hidden lg:block lg:ml-auto">
<PageControls />
</div>
</div>
<AnimatePresence mode="wait">
{page === 'installed' && (
<PageWrapper
{...{
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, scale: 0.99 },
transition: {
duration: 0.35,
},
}}
key="installed"
className="pt-0 space-y-8 relative z-[4]"
>
<div>
<h2>Installed Addons</h2>
<p className="text-[--muted] text-sm">
Manage your installed addons.
</p>
</div>
<SettingsCard
title="My Addons"
description="Edit, remove, and reorder your installed addons. If you reorder your addons, you will have to refresh the catalogs if you have made any changes, and also reinstall the addon."
>
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext
items={userData.presets.map((a) => a.instanceId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
<ul className="space-y-2">
{userData.presets.length === 0 ? (
<li>
<div className="flex flex-col items-center justify-center py-12">
<span className="text-lg text-muted-foreground font-semibold text-center">
Looks like you don't have any addons...
<br />
Add some from the marketplace!
</span>
</div>
</li>
) : (
userData.presets.map((preset) => {
const presetMetadata = status?.settings?.presets.find(
(p: any) => p.ID === preset.type
);
return (
<SortableAddonItem
key={getPresetUniqueKey(preset)}
preset={preset}
presetMetadata={presetMetadata}
onEdit={() => {
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
),
}));
}}
/>
);
})
)}
</ul>
</div>
</SortableContext>
</DndContext>
</SettingsCard>
{userData.presets.length > 0 && <CatalogSettingsCard />}
{userData.presets.length > 0 && <AddonGroupCard />}
</PageWrapper>
)}
{page === 'marketplace' && (
<PageWrapper
{...{
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, scale: 0.99 },
transition: {
duration: 0.35,
},
}}
key="marketplace"
className="pt-0 space-y-8 relative z-[4]"
>
<div>
<h2>Marketplace</h2>
<p className="text-[--muted] text-sm">
Browse and install addons from the marketplace.
</p>
</div>
<div className="bg-[--card] border border-[--border] rounded-xl p-4 mb-6 shadow-sm">
<div className="flex justify-center mb-4">
<div className="w-full sm:w-[500px] flex gap-2">
<TextInput
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearch(e.target.value)
}
placeholder="Search addons..."
className="flex-1"
leftIcon={<SearchIcon className="w-4 h-4" />}
/>
<AddonFilterPopover
serviceOptions={serviceOptions}
streamTypeOptions={streamTypeOptions}
resourceOptions={resourceOptions}
serviceFilters={serviceFilters}
setServiceFilters={setServiceFilters}
streamTypeFilters={streamTypeFilters}
setStreamTypeFilters={setStreamTypeFilters}
resourceFilters={resourceFilters}
setResourceFilters={setResourceFilters}
>
<IconButton
icon={<FilterIcon className="w-5 h-5" />}
intent={
activeFilterCount > 0 ? 'primary' : 'primary-outline'
}
aria-label="Filters"
/>
</AddonFilterPopover>
</div>
</div>
{/* Scrollable Addon Cards Grid */}
<div className="h-[calc(100vh-300px)] overflow-y-auto pr-1">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredPresets.map((preset: any) => {
// Always allow adding, never show edit
return (
<AddonCard
key={preset.ID}
preset={preset}
isAdded={false}
onAdd={() => handleAddPreset(preset)}
/>
);
})}
</div>
</div>
</div>
</PageWrapper>
)}
{/* Add/Edit Addon Modal (ensure both tabs can use it)*/}
<AddonModal
open={modalOpen}
onOpenChange={setModalOpen}
mode={modalMode}
presetMetadata={modalPreset}
initialValues={modalInitialValues as any}
onSubmit={handleModalSubmit}
/>
</AnimatePresence>
</>
);
}
// Helper to generate a key based on an addons id and options
function getPresetUniqueKey(preset: {
type: string;
instanceId: string;
enabled: boolean;
options: Record<string, any>;
}) {
// 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 (
<li ref={setNodeRef} style={style}>
<div className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-2 sm:gap-3 relative">
<div
className="rounded-full w-6 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move flex-shrink-0"
{...attributes}
{...listeners}
/>
<div className="flex items-center gap-2 sm:gap-3 flex-1 min-w-0">
<div className="relative flex-shrink-0 h-8 w-8 hidden sm:block">
{presetMetadata.ID === 'custom' ? (
<PlusIcon className="w-full h-full object-contain" />
) : (
<Image
src={presetMetadata.LOGO}
alt={presetMetadata.NAME}
fill
className="w-full h-full object-contain rounded-md"
/>
)}
</div>
<p className="text-base line-clamp-1 truncate block">
{preset.options.name}
</p>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<Switch
value={!!preset.enabled}
onValueChange={onToggleEnabled}
size="sm"
/>
<IconButton
className="rounded-full h-8 w-8 md:h-10 md:w-10"
icon={<BiEdit />}
intent="primary-subtle"
onClick={onEdit}
/>
<IconButton
className="rounded-full h-8 w-8 md:h-10 md:w-10"
icon={<BiTrash />}
intent="alert-subtle"
onClick={onRemove}
/>
</div>
</div>
</li>
);
}
// AddonCard component
function AddonCard({
preset,
isAdded,
onAdd,
}: {
preset: any;
isAdded: boolean;
onAdd: () => void;
}) {
return (
<div className="flex flex-col min-h-72 h-auto bg-[--background] border border-[--border] rounded-lg shadow-sm p-4 relative">
{/* Top: Logo + Name/Description */}
<div className="flex gap-4 items-start">
{preset.ID === 'custom' ? (
<div className="w-28 h-28 min-w-[7rem] min-h-[7rem] flex items-center justify-center rounded-lg bg-gray-900 text-[--brand] text-4xl">
<PlusIcon className="w-12 h-12" />
</div>
) : preset.LOGO ? (
<img
src={preset.LOGO}
alt={preset.NAME}
className="w-28 h-28 min-w-[7rem] min-h-[7rem] object-contain rounded-lg bg-gray-800"
/>
) : (
<div className="w-28 h-28 min-w-[7rem] min-h-[7rem] flex items-center justify-center rounded-lg bg-gray-900 text-[--brand] text-4xl">
<IoExtensionPuzzle className="w-15 h-15" />
</div>
)}
<div className="flex flex-col min-w-0 flex-1">
<div className="font-bold text-lg mb-1 truncate">{preset.NAME}</div>
<div className="text-sm text-muted-foreground mb-2 line-clamp-3 whitespace-pre-line">
<MarkdownLite>{preset.DESCRIPTION}</MarkdownLite>
</div>
</div>
</div>
{/* Tags Section */}
<div className="flex flex-col gap-1 mt-2">
<div className="flex flex-wrap gap-1 items-center min-h-[1.5rem]">
{preset.SUPPORTED_SERVICES?.length > 0 && (
<span className="font-semibold text-xs text-[--muted] mr-1">
Services:
</span>
)}
{preset.SUPPORTED_SERVICES?.map((sid: string) => {
const service =
constants.SERVICE_DETAILS[
sid as keyof typeof constants.SERVICE_DETAILS
];
return (
<Tooltip
key={sid}
side="top"
trigger={
<span className="bg-gray-800 text-xs px-2 py-0.5 rounded text-[--brand] font-mono">
{service?.shortName || sid}
</span>
}
>
<span className="bg-gray-800 text-xs px-2 py-0.5 rounded text-[--brand] font-mono">
{service?.name || sid}
</span>
</Tooltip>
);
})}
</div>
<div className="flex flex-wrap gap-1 items-center min-h-[1.5rem]">
{preset.SUPPORTED_RESOURCES?.length > 0 && (
<span className="font-semibold text-xs text-[--muted] mr-1">
Resources:
</span>
)}
{preset.SUPPORTED_RESOURCES?.map((res: string) => (
<span
key={res}
className="bg-gray-800 text-xs px-2 py-0.5 rounded text-blue-400 font-mono"
>
{res}
</span>
))}
</div>
<div className="flex flex-wrap gap-1 items-center min-h-[1.5rem]">
{preset.SUPPORTED_STREAM_TYPES?.length > 0 && (
<span className="font-semibold text-xs text-[--muted] mr-1">
Stream Types:
</span>
)}
{preset.SUPPORTED_STREAM_TYPES?.map((type: string) => (
<span
key={type}
className="bg-gray-800 text-xs px-2 py-0.5 rounded text-green-400 font-mono"
>
{type}
</span>
))}
</div>
</div>
{preset.DISABLED ? (
<div className="mt-auto pt-3 flex items-end">
<Alert
intent="alert"
className="w-full overflow-x-auto whitespace-nowrap"
description={<MarkdownLite>{preset.DISABLED.reason}</MarkdownLite>}
/>
</div>
) : (
<div className="mt-auto pt-3 flex items-end">
<Button size="md" className="w-full" onClick={onAdd}>
Configure
</Button>
</div>
)}
</div>
);
}
function AddonModal({
open,
onOpenChange,
mode,
presetMetadata,
initialValues = {},
onSubmit,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
mode: 'add' | 'edit';
presetMetadata?: any;
initialValues?: Record<string, any>;
onSubmit: (values: Record<string, any>) => void;
}) {
const [values, setValues] = useState<Record<string, any>>(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 (
<Modal
open={open}
onOpenChange={onOpenChange}
title={
mode === 'add'
? `Install ${presetMetadata?.NAME}`
: `Edit ${presetMetadata?.NAME}`
}
>
<form className="space-y-4" onSubmit={handleFormSubmit}>
{dynamicOptions.map((opt: any) => (
<div key={opt.id} className="mb-2">
<TemplateOption
option={opt}
value={values.options?.[opt.id]}
onChange={(v: any) =>
setValues((val) => ({
...val,
options: { ...val.options, [opt.id]: v },
}))
}
disabled={false}
/>
</div>
))}
<Button
className="w-full mt-2"
type="submit"
disabled={!allRequiredFilled}
>
{mode === 'add' ? 'Install' : 'Update'}
</Button>
</form>
</Modal>
);
}
function AddonFilterPopover({
serviceOptions,
streamTypeOptions,
resourceOptions,
serviceFilters,
setServiceFilters,
streamTypeFilters,
setStreamTypeFilters,
resourceFilters,
setResourceFilters,
children,
}: any) {
const [open, setOpen] = useState(false);
return (
<Popover
open={open}
onOpenChange={setOpen}
trigger={children}
modal={false}
className="p-4 max-w-full w-full"
>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="flex flex-col max-h-60 overflow-y-auto">
<div className="mb-2 font-semibold text-sm text-muted-foreground">
Services
</div>
{serviceOptions.map((opt: any) => (
<div
key={opt.value}
className="flex items-center gap-2 mb-2 last:mb-0"
>
<Switch
value={serviceFilters.includes(opt.value)}
onValueChange={(checked: boolean) => {
setServiceFilters((prev: string[]) =>
checked
? [...prev, opt.value]
: prev.filter((v) => v !== opt.value)
);
}}
size="sm"
/>
<span className="text-xs">{opt.label}</span>
</div>
))}
</div>
<div className="flex flex-col max-h-60 overflow-y-auto">
<div className="mb-2 font-semibold text-sm text-muted-foreground">
Stream Types
</div>
{streamTypeOptions.map((opt: any) => (
<div
key={opt.value}
className="flex items-center gap-2 mb-2 last:mb-0"
>
<Switch
value={streamTypeFilters.includes(opt.value)}
onValueChange={(checked: boolean) => {
setStreamTypeFilters((prev: string[]) =>
checked
? [...prev, opt.value]
: prev.filter((v) => v !== opt.value)
);
}}
size="sm"
/>
<span className="text-xs">{opt.label}</span>
</div>
))}
</div>
<div className="flex flex-col max-h-60 overflow-y-auto">
<div className="mb-2 font-semibold text-sm text-muted-foreground">
Resources
</div>
{resourceOptions.map((opt: any) => (
<div
key={opt.value}
className="flex items-center gap-2 mb-2 last:mb-0"
>
<Switch
value={resourceFilters.includes(opt.value)}
onValueChange={(checked: boolean) => {
setResourceFilters((prev: string[]) =>
checked
? [...prev, opt.value]
: prev.filter((v) => v !== opt.value)
);
}}
size="sm"
/>
<span className="text-xs">{opt.label}</span>
</div>
))}
</div>
</div>
<Button className="w-full mt-2" onClick={() => setOpen(false)}>
Done
</Button>
</div>
</Popover>
);
}
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 (
<SettingsCard
title="Groups"
// description="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. For a guide and a reference to the group system,"
>
<div className="text-sm text-[--muted] mb-2">
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{' '}
<a
href="https://github.com/Viren070/AIOStreams/wiki/Groups"
target="_blank"
rel="noopener noreferrer"
className="text-[--brand] hover:text-[--brand]/80 hover:underline"
>
wiki
</a>
for a detailed guide to using groups.
</div>
{(userData.groups || []).map((group, index) => (
<div key={index} className="flex gap-2">
<div className="flex-1 flex gap-2">
<div className="flex-1">
<Combobox
multiple
value={group.addons}
options={getAvailablePresets(index)}
emptyMessage="You haven't installed any addons yet or they are already in a group"
label="Addons"
placeholder="Select addons"
onValueChange={(value) => {
updateGroup(index, { addons: value });
}}
/>
</div>
<div className="flex-1">
<TextInput
value={index === 0 ? 'true' : group.condition}
disabled={index === 0}
label="Condition"
placeholder="Enter condition"
onValueChange={(value) => {
updateGroup(index, { condition: value });
}}
/>
</div>
</div>
<IconButton
size="sm"
rounded
icon={<FaRegTrashAlt />}
intent="alert-subtle"
onClick={() => {
setUserData((prev) => {
const newGroups = [...(prev.groups || [])];
newGroups.splice(index, 1);
return {
...prev,
groups: newGroups,
};
});
}}
/>
</div>
))}
<div className="mt-2 flex gap-2 items-center">
<IconButton
rounded
size="sm"
intent="primary-subtle"
icon={<FaPlus />}
onClick={() => {
setUserData((prev) => {
const currentGroups = prev.groups || [];
return {
...prev,
groups: [...currentGroups, { addons: [], condition: '' }],
};
});
}}
/>
</div>
</SettingsCard>
);
}
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 (
<div className="rounded-[--radius] border bg-[--paper] shadow-sm p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-xl text-[--muted] transition-colors hover:text-[--brand]">
Catalogs
</h3>
<p className="text-[--muted] text-sm">
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
</p>
</div>
<IconButton
size="sm"
intent="warning-subtle"
icon={<MdRefresh />}
rounded
onClick={() => {
if (userData.catalogModifications?.length) {
confirmRefreshCatalogs.open();
} else {
fetchCatalogs();
}
}}
loading={loading}
/>
</div>
{!userData.catalogModifications?.length && (
<p className="text-[--muted] text-base text-center my-8">
Your addons don't have any catalogs... or you haven't fetched them yet
:/
</p>
)}
{userData.catalogModifications &&
userData.catalogModifications.length > 0 && (
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext
items={(userData.catalogModifications || []).map(
(c) => `${c.id}-${c.type}`
)}
strategy={verticalListSortingStrategy}
>
<ul className="space-y-2">
{(userData.catalogModifications || []).map(
(catalog: CatalogModification) => (
<SortableCatalogItem
key={`${catalog.id}-${catalog.type}`}
catalog={catalog}
onToggleEnabled={(enabled) => {
setUserData((prev) => ({
...prev,
catalogModifications: prev.catalogModifications?.map(
(c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, enabled }
: c
),
}));
}}
capitalise={capitalise}
/>
)
)}
</ul>
</SortableContext>
</DndContext>
)}
<ConfirmationDialog {...confirmRefreshCatalogs} />
</div>
);
}
// 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 (
<li ref={setNodeRef} style={style}>
<div className="relative px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border overflow-hidden">
{/* Full-height drag handle - rounded vertical oval with spacing */}
<div
className="absolute top-2 bottom-2 left-2 w-5 bg-[var(--muted)] md:bg-[var(--subtle)] md:hover:bg-[var(--subtle-highlight)] cursor-move flex-shrink-0 rounded-full"
{...attributes}
{...listeners}
/>
{/* Content wrapper */}
<div className="pl-8 pr-3 py-3">
{/* Header section */}
<div className="mb-4 md:mb-6 md:pr-40">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm md:text-base font-medium line-clamp-1 truncate text-ellipsis">
{catalog.addonName} - {catalog.name ?? catalog.id}
</h3>
<IconButton
className="rounded-full h-5 w-5 md:h-6 md:w-6 flex-shrink-0"
icon={<BiEdit />}
intent="primary-subtle"
onClick={() => setModalOpen(true)}
/>
</div>
<p className="text-xs md:text-sm text-[var(--muted-foreground)] capitalize mb-2 md:mb-0">
{catalog.overrideType !== undefined &&
catalog.overrideType !== catalog.type
? `${catalog.overrideType} (${catalog.type})`
: catalog.type}
</p>
{/* Mobile Controls Row - only visible on small screens */}
<div className="flex md:hidden items-center justify-between">
{/* Position controls - aligned left */}
<div className="flex items-center gap-1">
<IconButton
rounded
className={dynamicIconSize}
icon={<LuChevronsUp />}
intent="primary-subtle"
onClick={moveToTop}
title="Move to top"
/>
<IconButton
rounded
className={dynamicIconSize}
icon={<LuChevronsDown />}
intent="primary-subtle"
onClick={moveToBottom}
title="Move to bottom"
/>
</div>
{/* Enable/disable toggle - aligned right */}
<Switch
value={catalog.enabled ?? true}
onValueChange={onToggleEnabled}
moreHelp="Enable or disable this catalog from being used"
/>
</div>
{/* Desktop Controls - only visible on medium screens and up */}
<div className="hidden md:flex items-center justify-end gap-2 absolute top-4 right-4">
<div className="flex items-center gap-1">
<IconButton
rounded
icon={<LuChevronsUp />}
intent="primary-subtle"
onClick={moveToTop}
title="Move to top"
/>
<IconButton
rounded
icon={<LuChevronsDown />}
intent="primary-subtle"
onClick={moveToBottom}
title="Move to bottom"
/>
</div>
<Switch
value={catalog.enabled ?? true}
onValueChange={onToggleEnabled}
moreHelp="Enable or disable this catalog from being used"
/>
</div>
</div>
{/* Settings section */}
<Accordion type="single" collapsible>
<AccordionItem value="settings">
<AccordionTrigger>
<div className="flex items-center justify-between w-full">
<h4 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
Settings
</h4>
{/* Active modifier icons */}
<div className="flex items-center gap-2 mr-2">
<Tooltip
trigger={
<IconButton
className={dynamicIconSize}
icon={
catalog.shuffle ? (
<FaShuffle />
) : (
<FaArrowRightLong />
)
}
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
</Tooltip>
<Tooltip
trigger={
<IconButton
className={dynamicIconSize}
icon={catalog.rpdb ? <PiStarFill /> : <PiStarBold />}
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
</Tooltip>
{catalog.hideable && (
<Tooltip
trigger={
<IconButton
className={dynamicIconSize}
icon={
catalog.onlyOnDiscover ? (
<TbSmartHomeOff />
) : (
<TbSmartHome />
)
}
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
</Tooltip>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
{/* Large screens: horizontal layout, Medium and below: vertical layout */}
<div className="flex flex-col gap-4">
<Switch
label="Shuffle"
help="Randomize the order of catalog items on each request"
side="right"
value={catalog.shuffle ?? false}
onValueChange={(shuffle) => {
setUserData((prev) => ({
...prev,
catalogModifications: prev.catalogModifications?.map(
(c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, shuffle }
: c
),
}));
}}
/>
<div className="flex flex-col md:flex-row md:items-center gap-2 -mx-2 px-2 hover:bg-[var(--subtle-highlight)] rounded-md">
<div className="flex-1 py-2">
<label className="text-sm font-medium">
Persist Shuffle For
</label>
<p className="text-xs text-[--muted]">
The amount of hours to keep a given shuffled catalog
order before shuffling again. Defaults to 0 (Shuffle
on every request).
</p>
</div>
<div className="w-full md:w-32 py-2">
<NumberInput
value={catalog.persistShuffleFor ?? 0}
min={0}
step={1}
max={24}
onValueChange={(value) => {
setUserData((prev) => ({
...prev,
catalogModifications:
prev.catalogModifications?.map((c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, persistShuffleFor: value }
: c
),
}));
}}
/>
</div>
</div>
<Switch
label="RPDB"
help="Replace movie/show posters with RPDB posters when supported"
side="right"
value={catalog.rpdb ?? false}
onValueChange={(rpdb) => {
setUserData((prev) => ({
...prev,
catalogModifications: prev.catalogModifications?.map(
(c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, rpdb }
: c
),
}));
}}
/>
{catalog.hideable && (
<Switch
label="Discover Only"
help="Hide this catalog from the home page and only show it on the Discover page"
moreHelp="This can potentially break the catalog!"
side="right"
value={catalog.onlyOnDiscover ?? false}
onValueChange={(onlyOnDiscover) => {
setUserData((prev) => ({
...prev,
catalogModifications:
prev.catalogModifications?.map((c) =>
c.id === catalog.id && c.type === catalog.type
? { ...c, onlyOnDiscover }
: c
),
}));
}}
/>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
{/* Name edit modal */}
<Modal
open={modalOpen}
onOpenChange={setModalOpen}
title="Edit Catalog Name"
>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleNameAndTypeEdit();
}}
>
<TextInput
label="Name"
placeholder="Enter catalog name"
value={newName}
onValueChange={setNewName}
/>
<TextInput
label="Type"
placeholder="Enter catalog type"
value={newType}
onValueChange={setNewType}
required
help="Override the type of the catalog. This can break the catalog and its behaviour."
/>
<Button className="w-full" type="submit">
Save Changes
</Button>
</form>
</Modal>
</li>
);
}