Spaces:
Paused
Paused
| 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<UserData['sortCriteria']>; | |
| const SORT_KEY_LABEL: Record<string, string> = { | |
| 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 ( | |
| <div ref={setNodeRef} style={style}> | |
| <div className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative"> | |
| <div | |
| className="rounded-full w-6 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move" | |
| {...attributes} | |
| {...listeners} | |
| /> | |
| <div className="flex-1 flex flex-col justify-center min-w-0"> | |
| <span className="font-mono text-base truncate">{name}</span> | |
| <span className="text-[--muted] text-sm truncate">{description}</span> | |
| </div> | |
| <IconButton | |
| size="sm" | |
| rounded | |
| icon={direction === 'asc' ? <ArrowUpAZ /> : <ArrowDownAZ />} | |
| intent="primary-subtle" | |
| onClick={onDirectionChange} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| 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 ( | |
| <div className="space-y-2"> | |
| {items.length === 0 && inheritedFrom && ( | |
| <p className="text-xs text-[--muted] italic"> | |
| Empty — using{' '} | |
| <span className="not-italic text-[--foreground]"> | |
| {inheritedFrom} | |
| </span>{' '} | |
| as fallback. Add criteria below to override. | |
| </p> | |
| )} | |
| {items.length === 0 && !inheritedFrom && ( | |
| <p className="text-xs text-[--muted] italic"> | |
| No criteria defined yet. | |
| </p> | |
| )} | |
| <Combobox | |
| multiple | |
| value={items.map((i) => 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 && ( | |
| <DndContext | |
| modifiers={[restrictToVerticalAxis]} | |
| onDragEnd={handleDragEnd} | |
| onDragStart={() => setIsDragging(true)} | |
| sensors={sensors} | |
| > | |
| <SortableContext | |
| items={items.map((i) => `${i.key}-${i.direction}`)} | |
| strategy={verticalListSortingStrategy} | |
| > | |
| <div className="space-y-2"> | |
| {items.map((item) => ( | |
| <SortableItem | |
| key={`${item.key}-${item.direction}`} | |
| id={`${item.key}-${item.direction}`} | |
| name={SORT_CRITERIA_DETAILS[item.key].name} | |
| description={ | |
| item.direction === 'asc' | |
| ? `${SORT_CRITERIA_DETAILS[item.key].description}, ${SORT_CRITERIA_DETAILS[item.key].ascendingDescription}` | |
| : `${SORT_CRITERIA_DETAILS[item.key].description}, ${SORT_CRITERIA_DETAILS[item.key].descendingDescription}` | |
| } | |
| direction={item.direction} | |
| onDirectionChange={() => { | |
| const dir = | |
| item.direction === 'asc' | |
| ? ('desc' as const) | |
| : ('asc' as const); | |
| update( | |
| items.map((i) => | |
| i.key === item.key ? { ...i, direction: dir } : i | |
| ) | |
| ); | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| </SortableContext> | |
| </DndContext> | |
| )} | |
| </div> | |
| ); | |
| } | |
| /** 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 ( | |
| <span className="flex flex-wrap items-center gap-1"> | |
| {shown.map((c) => ( | |
| <span | |
| key={c.key} | |
| className="text-xs px-1.5 py-0.5 rounded bg-[--subtle] text-[--muted] border border-[--border]" | |
| > | |
| {SORT_CRITERIA_DETAILS[c.key].name} | |
| </span> | |
| ))} | |
| {items.length > 3 && ( | |
| <span className="text-xs text-[--muted]">+{items.length - 3}</span> | |
| )} | |
| </span> | |
| ); | |
| } | |
| /** 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 ( | |
| <span | |
| className={cn( | |
| 'text-xs px-1.5 py-0.5 rounded border font-medium shrink-0', | |
| isGlobal | |
| ? 'bg-violet-500/10 text-violet-400 border-violet-500/20' | |
| : 'bg-blue-500/10 text-blue-400 border-blue-500/20' | |
| )} | |
| > | |
| {SORT_KEY_LABEL[sortKey]} | |
| </span> | |
| ); | |
| } | |
| function SortPreviewCard() { | |
| const { userData } = useUserData(); | |
| const rows = [ | |
| { | |
| type: 'movie' as const, | |
| label: 'Movies', | |
| icon: <Film className="w-3.5 h-3.5" />, | |
| }, | |
| { | |
| type: 'series' as const, | |
| label: 'Series', | |
| icon: <Tv className="w-3.5 h-3.5" />, | |
| }, | |
| { | |
| type: 'anime' as const, | |
| label: 'Anime', | |
| icon: <Star className="w-3.5 h-3.5" />, | |
| }, | |
| ]; | |
| return ( | |
| <SettingsCard | |
| title="Active Sort Configuration" | |
| description="The sort orders that will actually be applied for each content type." | |
| > | |
| <div className="divide-y divide-[--border]"> | |
| {rows.map(({ type, label, icon }) => { | |
| const info = resolveSort(userData, type); | |
| return ( | |
| <div key={type} className="py-3 first:pt-0 last:pb-0 space-y-2"> | |
| {/* Header */} | |
| <div className="flex items-center gap-2 text-sm font-medium"> | |
| <span className="text-[--muted]">{icon}</span> | |
| {label} | |
| {info.splitActive && ( | |
| <span className="flex items-center gap-0.5 text-xs font-normal text-green-400"> | |
| <CheckCircle2 className="w-3 h-3" /> | |
| split | |
| </span> | |
| )} | |
| </div> | |
| {/* Sort rows */} | |
| {!info.splitActive ? ( | |
| /* Non-split: single line — source pill → criteria */ | |
| <div className="ml-5 flex items-center gap-1.5 flex-wrap"> | |
| <SourcePill sortKey={info.primaryKey} /> | |
| {info.primary.length > 0 && ( | |
| <span className="text-[--muted] text-xs shrink-0">→</span> | |
| )} | |
| <CriteriaTags items={info.primary} /> | |
| {info.primary.length === 0 && ( | |
| <span className="text-xs text-[--muted] italic"> | |
| no criteria defined | |
| </span> | |
| )} | |
| </div> | |
| ) : ( | |
| /* Split mode: two sub-rows, one per cache state */ | |
| <div className="ml-5 space-y-1.5"> | |
| {( | |
| [ | |
| { | |
| 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 }) => ( | |
| <div | |
| key={subLabel} | |
| className="flex items-center gap-1.5 flex-wrap" | |
| > | |
| <span | |
| className={cn('w-1.5 h-1.5 rounded-full shrink-0', dot)} | |
| /> | |
| <SourcePill sortKey={key} /> | |
| {items.length > 0 && ( | |
| <span className="text-[--muted] text-xs shrink-0"> | |
| → | |
| </span> | |
| )} | |
| <CriteriaTags items={items} /> | |
| {items.length === 0 && ( | |
| <span className="text-xs text-[--muted] italic"> | |
| no criteria defined | |
| </span> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* Warning: cached/uncached sorts are defined but split mode isn't active */} | |
| {!info.splitActive && (info.cachedKey || info.uncachedKey) && ( | |
| <div className="ml-5 flex items-start gap-1.5 text-xs text-yellow-500/80"> | |
| <MinusCircle className="w-3.5 h-3.5 shrink-0 mt-px" /> | |
| <span> | |
| Cached sorts are defined but inactive — make{' '} | |
| <span className="text-yellow-400">Cached</span> the first | |
| primary criterion to enable split mode | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </SettingsCard> | |
| ); | |
| } | |
| interface TabConfig { | |
| primaryKey: SortKey; | |
| cachedKey: SortKey; | |
| uncachedKey: SortKey; | |
| primaryLabel: string; | |
| primaryDescription: string; | |
| primaryInherit?: string; | |
| cachedInherit?: string; | |
| uncachedInherit?: string; | |
| } | |
| const TAB_CONFIGS: Record<string, TabConfig> = { | |
| 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 ( | |
| <div className="space-y-4"> | |
| <SettingsCard | |
| title={cfg.primaryLabel} | |
| description={cfg.primaryDescription} | |
| > | |
| <SortOrderEditor | |
| sortKey={cfg.primaryKey} | |
| inheritedFrom={cfg.primaryInherit} | |
| /> | |
| </SettingsCard> | |
| <SettingsCard | |
| title="Split by Cache" | |
| description={ | |
| <> | |
| When <strong>"Cached"</strong> is the{' '} | |
| <strong>first criterion</strong> above and both sorts below are | |
| defined, cached and uncached streams are sorted independently then | |
| merged. | |
| </> | |
| } | |
| > | |
| <div className="space-y-6"> | |
| {/* Cached */} | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-2 h-2 rounded-full bg-green-400 shrink-0" /> | |
| <span className="text-sm font-medium">Cached Streams</span> | |
| {getSortItems(userData, cfg.cachedKey).length === 0 && ( | |
| <span className="text-xs text-[--muted]"> | |
| {cachedInherit() | |
| ? `— using ${cachedInherit()} as fallback` | |
| : '— undefined, split requires this'} | |
| </span> | |
| )} | |
| </div> | |
| <SortOrderEditor | |
| sortKey={cfg.cachedKey} | |
| inheritedFrom={cachedInherit()} | |
| /> | |
| </div> | |
| <div className="border-t border-[--border]" /> | |
| {/* Uncached */} | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-2 h-2 rounded-full bg-yellow-400 shrink-0" /> | |
| <span className="text-sm font-medium">Uncached Streams</span> | |
| {getSortItems(userData, cfg.uncachedKey).length === 0 && ( | |
| <span className="text-xs text-[--muted]"> | |
| {uncachedInherit() | |
| ? `— using ${uncachedInherit()} as fallback` | |
| : '— undefined, split requires this'} | |
| </span> | |
| )} | |
| </div> | |
| <SortOrderEditor | |
| sortKey={cfg.uncachedKey} | |
| inheritedFrom={uncachedInherit()} | |
| /> | |
| </div> | |
| </div> | |
| </SettingsCard> | |
| </div> | |
| ); | |
| } | |
| export function SortingMenu() { | |
| return ( | |
| <PageWrapper className="space-y-4 p-4 sm:p-8"> | |
| <Content /> | |
| </PageWrapper> | |
| ); | |
| } | |
| 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 ( | |
| <span className="flex items-center gap-1.5"> | |
| {name} | |
| {active && ( | |
| <span className="w-1.5 h-1.5 rounded-full bg-blue-400 inline-block" /> | |
| )} | |
| </span> | |
| ); | |
| } | |
| return ( | |
| <> | |
| <div className="flex items-center w-full"> | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| <h2>Sorting</h2> | |
| {hasParent && isInherited('sorting') && ( | |
| <InheritedBadge section="sorting" /> | |
| )} | |
| </div> | |
| <p className="text-[--muted]"> | |
| Configure how your streams are sorted and organised. | |
| </p> | |
| </div> | |
| <div className="hidden lg:block lg:ml-auto"> | |
| <PageControls /> | |
| </div> | |
| </div> | |
| <SortPreviewCard /> | |
| <MenuTabs | |
| tabs={[ | |
| { | |
| value: 'global', | |
| label: 'Global', | |
| icon: <Globe className="w-4 h-4" />, | |
| content: <SortTabContent type="global" />, | |
| }, | |
| { | |
| value: 'movies', | |
| label: tabLabel('Movies', hasMovies), | |
| icon: <Film className="w-4 h-4" />, | |
| content: <SortTabContent type="movies" />, | |
| }, | |
| { | |
| value: 'series', | |
| label: tabLabel('Series', hasSeries), | |
| icon: <Tv className="w-4 h-4" />, | |
| content: <SortTabContent type="series" />, | |
| }, | |
| { | |
| value: 'anime', | |
| label: tabLabel('Anime', hasAnime), | |
| icon: <Star className="w-4 h-4" />, | |
| content: <SortTabContent type="anime" />, | |
| }, | |
| ]} | |
| activeTab={activeTab} | |
| onTabChange={setActiveTab} | |
| /> | |
| </> | |
| ); | |
| } | |