f4b404's picture
Upload folder using huggingface_hub
f55c62a verified
Raw
History Blame Contribute Delete
23.4 kB
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}
/>
</>
);
}