import { useState, useEffect } from 'react'; import { PageControls } from '../shared/page-controls'; import { PageWrapper } from '../shared/page-wrapper'; import { SettingsCard } from '../shared/settings-card'; import { Combobox } from '../ui/combobox'; import { useUserData, useParentInheritance } from '@/context/userData'; import { InheritedBadge } from '../shared/inherited-badge'; import { IconButton } from '../ui/button'; import { MenuTabs } from '../shared/menu-tabs'; import { SORT_CRITERIA, SORT_CRITERIA_DETAILS, SORT_DIRECTIONS, } from '../../../../core/src/utils/constants'; import { DndContext, useSensors, useSensor, PointerSensor, TouchSensor, } from '@dnd-kit/core'; import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { CSS } from '@dnd-kit/utilities'; import { ArrowDownAZ, ArrowUpAZ, CheckCircle2, MinusCircle, Globe, Film, Tv, Star, } from 'lucide-react'; import { cn } from '../ui/core/styling'; import type { UserData } from '@aiostreams/core'; type SortCriteriaItem = { key: (typeof SORT_CRITERIA)[number]; direction: (typeof SORT_DIRECTIONS)[number]; }; type SortKey = keyof NonNullable; const SORT_KEY_LABEL: Record = { global: 'Global', movies: 'Movies', series: 'Series', anime: 'Anime', cached: 'Global Cached', uncached: 'Global Uncached', cachedMovies: 'Movie Cached', uncachedMovies: 'Movie Uncached', cachedSeries: 'Series Cached', uncachedSeries: 'Series Uncached', cachedAnime: 'Anime Cached', uncachedAnime: 'Anime Uncached', }; function getSortItems(userData: UserData, key: SortKey): SortCriteriaItem[] { return (userData.sortCriteria?.[key] ?? []) as SortCriteriaItem[]; } interface ResolvedSort { primaryKey: SortKey; primary: SortCriteriaItem[]; cachedKey: SortKey | null; uncachedKey: SortKey | null; cachedItems: SortCriteriaItem[]; uncachedItems: SortCriteriaItem[]; splitActive: boolean; } function resolveSort( userData: UserData, type: 'movie' | 'series' | 'anime' ): ResolvedSort { const sc = userData.sortCriteria; if (!sc) { return { primaryKey: 'global', primary: [], cachedKey: null, uncachedKey: null, cachedItems: [], uncachedItems: [], splitActive: false, }; } let primaryKey: SortKey = 'global'; let cachedKey: SortKey | null = sc.cached?.length ? 'cached' : null; let uncachedKey: SortKey | null = sc.uncached?.length ? 'uncached' : null; if (type === 'movie') { if (sc.movies?.length) primaryKey = 'movies'; if (sc.cachedMovies?.length) cachedKey = 'cachedMovies'; else if (sc.cached?.length) cachedKey = 'cached'; if (sc.uncachedMovies?.length) uncachedKey = 'uncachedMovies'; else if (sc.uncached?.length) uncachedKey = 'uncached'; } else if (type === 'series') { if (sc.series?.length) primaryKey = 'series'; if (sc.cachedSeries?.length) cachedKey = 'cachedSeries'; else if (sc.cached?.length) cachedKey = 'cached'; if (sc.uncachedSeries?.length) uncachedKey = 'uncachedSeries'; else if (sc.uncached?.length) uncachedKey = 'uncached'; } else { if (sc.anime?.length) primaryKey = 'anime'; if (sc.cachedAnime?.length) cachedKey = 'cachedAnime'; else if (sc.cached?.length) cachedKey = 'cached'; if (sc.uncachedAnime?.length) uncachedKey = 'uncachedAnime'; else if (sc.uncached?.length) uncachedKey = 'uncached'; } const primary = getSortItems(userData, primaryKey); const splitActive = primary.length > 0 && primary[0].key === 'cached' && cachedKey !== null && uncachedKey !== null; const cachedItems = cachedKey ? getSortItems(userData, cachedKey) : []; const uncachedItems = uncachedKey ? getSortItems(userData, uncachedKey) : []; return { primaryKey, primary, cachedKey, uncachedKey, cachedItems, uncachedItems, splitActive, }; } /* -------------------------------------------------------------------------- */ /* SortableItem */ /* -------------------------------------------------------------------------- */ function SortableItem({ id, name, description, direction, onDirectionChange, }: { id: string; name: string; description: string; direction: (typeof SORT_DIRECTIONS)[number]; onDirectionChange: () => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
{name} {description}
: } intent="primary-subtle" onClick={onDirectionChange} />
); } function SortOrderEditor({ sortKey, inheritedFrom, }: { sortKey: SortKey; inheritedFrom?: string; }) { const { userData, setUserData } = useUserData(); const [isDragging, setIsDragging] = useState(false); const items = getSortItems(userData, sortKey); const sensors = useSensors( useSensor(PointerSensor), useSensor(TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 }, }) ); useEffect(() => { function preventTouchMove(e: TouchEvent) { if (isDragging) e.preventDefault(); } if (isDragging) { document.body.addEventListener('touchmove', preventTouchMove, { passive: false, }); } else { document.body.removeEventListener('touchmove', preventTouchMove); } return () => document.body.removeEventListener('touchmove', preventTouchMove); }, [isDragging]); function update(newItems: SortCriteriaItem[]) { setUserData((prev) => ({ ...prev, sortCriteria: { ...(prev.sortCriteria || {}), [sortKey]: newItems }, })); } function handleDragEnd(event: any) { const { active, over } = event; if (over && active.id !== over.id) { const oldIdx = items.findIndex( (i) => `${i.key}-${i.direction}` === active.id ); const newIdx = items.findIndex( (i) => `${i.key}-${i.direction}` === over.id ); update(arrayMove(items, oldIdx, newIdx)); } setIsDragging(false); } return (
{items.length === 0 && inheritedFrom && (

Empty — using{' '} {inheritedFrom} {' '} as fallback. Add criteria below to override.

)} {items.length === 0 && !inheritedFrom && (

No criteria defined yet.

)} i.key)} emptyMessage="No sort criteria available" onValueChange={(value) => { const keys = value as (typeof SORT_CRITERIA)[number][]; update( keys.map( (key) => items.find((i) => i.key === key) ?? { key, direction: SORT_CRITERIA_DETAILS[key].defaultDirection, } ) ); }} options={SORT_CRITERIA.map((c) => ({ label: SORT_CRITERIA_DETAILS[c].name, textValue: SORT_CRITERIA_DETAILS[c].name, value: c, }))} /> {items.length > 0 && ( setIsDragging(true)} sensors={sensors} > `${i.key}-${i.direction}`)} strategy={verticalListSortingStrategy} >
{items.map((item) => ( { const dir = item.direction === 'asc' ? ('desc' as const) : ('asc' as const); update( items.map((i) => i.key === item.key ? { ...i, direction: dir } : i ) ); }} /> ))}
)}
); } /** Chips showing the first few criteria names. */ function CriteriaTags({ items }: { items: SortCriteriaItem[] }) { if (items.length === 0) return null; const shown = items.slice(0, 3); return ( {shown.map((c) => ( {SORT_CRITERIA_DETAILS[c.key].name} ))} {items.length > 3 && ( +{items.length - 3} )} ); } /** Visually distinct pill for which sort-order definition is supplying the criteria. */ function SourcePill({ sortKey }: { sortKey: SortKey }) { // Global-level keys → violet; type-specific → blue const isGlobal = sortKey === 'global' || sortKey === 'cached' || sortKey === 'uncached'; return ( {SORT_KEY_LABEL[sortKey]} ); } function SortPreviewCard() { const { userData } = useUserData(); const rows = [ { type: 'movie' as const, label: 'Movies', icon: , }, { type: 'series' as const, label: 'Series', icon: , }, { type: 'anime' as const, label: 'Anime', icon: , }, ]; return (
{rows.map(({ type, label, icon }) => { const info = resolveSort(userData, type); return (
{/* Header */}
{icon} {label} {info.splitActive && ( split )}
{/* Sort rows */} {!info.splitActive ? ( /* Non-split: single line — source pill → criteria */
{info.primary.length > 0 && ( )} {info.primary.length === 0 && ( no criteria defined )}
) : ( /* Split mode: two sub-rows, one per cache state */
{( [ { dot: 'bg-green-400', label: 'Cached', key: info.cachedKey!, items: info.cachedItems, }, { dot: 'bg-yellow-400', label: 'Uncached', key: info.uncachedKey!, items: info.uncachedItems, }, ] as const ).map(({ dot, label: subLabel, key, items }) => (
{items.length > 0 && ( )} {items.length === 0 && ( no criteria defined )}
))}
)} {/* Warning: cached/uncached sorts are defined but split mode isn't active */} {!info.splitActive && (info.cachedKey || info.uncachedKey) && (
Cached sorts are defined but inactive — make{' '} Cached the first primary criterion to enable split mode
)}
); })}
); } interface TabConfig { primaryKey: SortKey; cachedKey: SortKey; uncachedKey: SortKey; primaryLabel: string; primaryDescription: string; primaryInherit?: string; cachedInherit?: string; uncachedInherit?: string; } const TAB_CONFIGS: Record = { global: { primaryKey: 'global', cachedKey: 'cached', uncachedKey: 'uncached', primaryLabel: 'Primary Sort Order', primaryDescription: 'The default sort applied to all content. Movie, Series, and Anime tabs can each define their own override.', }, movies: { primaryKey: 'movies', cachedKey: 'cachedMovies', uncachedKey: 'uncachedMovies', primaryLabel: 'Movie Primary Sort', primaryDescription: 'Overrides the Global primary sort for movies. Leave empty to fall back to Global.', primaryInherit: 'Global', cachedInherit: 'Global Cached', uncachedInherit: 'Global Uncached', }, series: { primaryKey: 'series', cachedKey: 'cachedSeries', uncachedKey: 'uncachedSeries', primaryLabel: 'Series Primary Sort', primaryDescription: 'Overrides the Global primary sort for series. Leave empty to fall back to Global.', primaryInherit: 'Global', cachedInherit: 'Global Cached', uncachedInherit: 'Global Uncached', }, anime: { primaryKey: 'anime', cachedKey: 'cachedAnime', uncachedKey: 'uncachedAnime', primaryLabel: 'Anime Primary Sort', primaryDescription: 'Overrides the Global primary sort for anime. Leave empty to fall back to Global.', primaryInherit: 'Global', cachedInherit: 'Global Cached', uncachedInherit: 'Global Uncached', }, }; function SortTabContent({ type, }: { type: 'global' | 'movies' | 'series' | 'anime'; }) { const { userData } = useUserData(); const cfg = TAB_CONFIGS[type]; const sc = userData.sortCriteria; // Compute inherited-from labels for the cached/uncached sub-sorts function cachedInherit(): string | undefined { if (type === 'global') return undefined; // global cached has no parent const globalCachedDefined = (sc?.cached?.length ?? 0) > 0; return globalCachedDefined ? 'Global Cached' : undefined; } function uncachedInherit(): string | undefined { if (type === 'global') return undefined; const globalUncachedDefined = (sc?.uncached?.length ?? 0) > 0; return globalUncachedDefined ? 'Global Uncached' : undefined; } return (
When "Cached" is the{' '} first criterion above and both sorts below are defined, cached and uncached streams are sorted independently then merged. } >
{/* Cached */}
Cached Streams {getSortItems(userData, cfg.cachedKey).length === 0 && ( {cachedInherit() ? `— using ${cachedInherit()} as fallback` : '— undefined, split requires this'} )}
{/* Uncached */}
Uncached Streams {getSortItems(userData, cfg.uncachedKey).length === 0 && ( {uncachedInherit() ? `— using ${uncachedInherit()} as fallback` : '— undefined, split requires this'} )}
); } export function SortingMenu() { return ( ); } function Content() { const { userData, setUserData } = useUserData(); const { isInherited, hasParent } = useParentInheritance(); const [activeTab, setActiveTab] = useState('global'); useEffect(() => { if (!userData.sortCriteria) { setUserData((prev) => ({ ...prev, sortCriteria: { global: [], movies: [], series: [], anime: [], cached: [], uncached: [], cachedMovies: [], uncachedMovies: [], cachedSeries: [], uncachedSeries: [], cachedAnime: [], uncachedAnime: [], }, })); } }, []); const sc = userData.sortCriteria; // Whether a non-global tab has any custom criteria const hasMovies = (sc?.movies?.length ?? 0) > 0 || (sc?.cachedMovies?.length ?? 0) > 0 || (sc?.uncachedMovies?.length ?? 0) > 0; const hasSeries = (sc?.series?.length ?? 0) > 0 || (sc?.cachedSeries?.length ?? 0) > 0 || (sc?.uncachedSeries?.length ?? 0) > 0; const hasAnime = (sc?.anime?.length ?? 0) > 0 || (sc?.cachedAnime?.length ?? 0) > 0 || (sc?.uncachedAnime?.length ?? 0) > 0; function tabLabel(name: string, active: boolean) { return ( {name} {active && ( )} ); } return ( <>

Sorting

{hasParent && isInherited('sorting') && ( )}

Configure how your streams are sorted and organised.

, content: , }, { value: 'movies', label: tabLabel('Movies', hasMovies), icon: , content: , }, { value: 'series', label: tabLabel('Series', hasSeries), icon: , content: , }, { value: 'anime', label: tabLabel('Anime', hasAnime), icon: , content: , }, ]} activeTab={activeTab} onTabChange={setActiveTab} /> ); }