add sort media cards dropdown

This commit is contained in:
Pas 2025-12-21 22:42:04 -07:00
parent 605abb9aab
commit ac7e44f234
6 changed files with 644 additions and 70 deletions

View file

@ -320,10 +320,30 @@
"titlePlaceholder": "Enter a title for your bookmark",
"yearLabel": "Year",
"yearPlaceholder": "Enter a year for your bookmark"
},
"sorting": {
"label": "Sort by",
"options": {
"date": "Default (Date added)",
"titleAsc": "Title A-Z",
"titleDesc": "Title Z-A",
"yearAsc": "Release Date Oldest-Newest",
"yearDesc": "Release Date Newest-Oldest"
}
}
},
"continueWatching": {
"sectionTitle": "Continue Watching..."
"sectionTitle": "Continue Watching...",
"sorting": {
"label": "Sort by",
"options": {
"date": "Default (Date added)",
"titleAsc": "Title A-Z",
"titleDesc": "Title Z-A",
"yearAsc": "Release Date Oldest-Newest",
"yearDesc": "Release Date Newest-Oldest"
}
}
},
"mediaList": {
"stopEditing": "Stop editing"

View file

@ -1,9 +1,11 @@
import React, { useMemo, useState } from "react";
import { Listbox } from "@headlessui/react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { EditButton } from "@/components/buttons/EditButton";
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
@ -17,6 +19,7 @@ import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButto
import { useBookmarkStore } from "@/stores/bookmarks";
import { useGroupOrderStore } from "@/stores/groupOrder";
import { useProgressStore } from "@/stores/progress";
import { SortOption, sortMediaItems } from "@/utils/mediaSorting";
import { MediaItem } from "@/utils/mediaTypes";
function parseGroupString(group: string): { icon: UserIcons; name: string } {
@ -88,8 +91,16 @@ export function BookmarksCarousel({
const browser = !!window.chrome;
let isScrolling = false;
const [editing, setEditing] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>(() => {
const saved = localStorage.getItem("__MW::bookmarksSort");
return (saved as SortOption) || "date";
});
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
useEffect(() => {
localStorage.setItem("__MW::bookmarksSort", sortBy);
}, [sortBy]);
// Editing modals
const editBookmarkModal = useModal("bookmark-edit-carousel");
const editGroupModal = useModal("bookmark-edit-group-carousel");
@ -113,26 +124,15 @@ export function BookmarksCarousel({
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
const items = useMemo(() => {
let output: MediaItem[] = [];
const output: MediaItem[] = [];
Object.entries(bookmarks).forEach((entry) => {
output.push({
id: entry[0],
...entry[1],
});
});
output = output.sort((a, b) => {
const bookmarkA = bookmarks[a.id];
const bookmarkB = bookmarks[b.id];
const progressA = progressItems[a.id];
const progressB = progressItems[b.id];
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
return dateB - dateA;
});
return output;
}, [bookmarks, progressItems]);
return sortMediaItems(output, sortBy, bookmarks, progressItems);
}, [bookmarks, progressItems, sortBy]);
const { groupedItems, regularItems } = useMemo(() => {
const grouped: Record<string, MediaItem[]> = {};
@ -152,23 +152,26 @@ export function BookmarksCarousel({
}
});
// Sort items within each group by date
// Sort items within each group
Object.keys(grouped).forEach((group) => {
grouped[group].sort((a, b) => {
const bookmarkA = bookmarks[a.id];
const bookmarkB = bookmarks[b.id];
const progressA = progressItems[a.id];
const progressB = progressItems[b.id];
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
return dateB - dateA;
});
grouped[group] = sortMediaItems(
grouped[group],
sortBy,
bookmarks,
progressItems,
);
});
return { groupedItems: grouped, regularItems: regular };
}, [items, bookmarks, progressItems]);
// Sort regular items
const sortedRegular = sortMediaItems(
regular,
sortBy,
bookmarks,
progressItems,
);
return { groupedItems: grouped, regularItems: sortedRegular };
}, [items, bookmarks, progressItems, sortBy]);
const sortedSections = useMemo(() => {
const sections: Array<{
@ -279,6 +282,17 @@ export function BookmarksCarousel({
setEditingGroupName(null);
};
const sortOptions: OptionItem[] = [
{ id: "date", name: t("home.bookmarks.sorting.options.date") },
{ id: "title-asc", name: t("home.bookmarks.sorting.options.titleAsc") },
{ id: "title-desc", name: t("home.bookmarks.sorting.options.titleDesc") },
{ id: "year-asc", name: t("home.bookmarks.sorting.options.yearAsc") },
{ id: "year-desc", name: t("home.bookmarks.sorting.options.yearDesc") },
];
const selectedSortOption =
sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0];
const categorySlug = "bookmarks";
const SKELETON_COUNT = 10;
@ -320,6 +334,65 @@ export function BookmarksCarousel({
/>
</div>
</SectionHeading>
{editing && (
<div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::bookmarksSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={opt}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${section.group}`}
@ -375,7 +448,7 @@ export function BookmarksCarousel({
<SectionHeading
title={t("home.bookmarks.sectionTitle")}
icon={Icons.BOOKMARK}
className="ml-4 md:ml-12 mt-2 -mb-5"
className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]"
>
<div className="mr-4 lg:mr-[88px] flex items-center gap-2">
<EditButton
@ -385,6 +458,61 @@ export function BookmarksCarousel({
/>
</div>
</SectionHeading>
{editing && (
<div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => setSortBy(item.id as SortOption)}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={opt}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}

View file

@ -1,10 +1,12 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Listbox } from "@headlessui/react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
import { EditButtonWithText } from "@/components/buttons/EditButtonWithText";
import { Icons } from "@/components/Icon";
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
@ -15,6 +17,7 @@ import { UserIcon, UserIcons } from "@/components/UserIcon";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useGroupOrderStore } from "@/stores/groupOrder";
import { useProgressStore } from "@/stores/progress";
import { SortOption, sortMediaItems } from "@/utils/mediaSorting";
import { MediaItem } from "@/utils/mediaTypes";
function parseGroupString(group: string): { icon: UserIcons; name: string } {
@ -52,28 +55,25 @@ export function BookmarksPart({
const modifyBookmarksByGroup = useBookmarkStore(
(s) => s.modifyBookmarksByGroup,
);
const [sortBy, setSortBy] = useState<SortOption>(() => {
const saved = localStorage.getItem("__MW::bookmarksSort");
return (saved as SortOption) || "date";
});
useEffect(() => {
localStorage.setItem("__MW::bookmarksSort", sortBy);
}, [sortBy]);
const items = useMemo(() => {
let output: MediaItem[] = [];
const output: MediaItem[] = [];
Object.entries(bookmarks).forEach((entry) => {
output.push({
id: entry[0],
...entry[1],
});
});
output = output.sort((a, b) => {
const bookmarkA = bookmarks[a.id];
const bookmarkB = bookmarks[b.id];
const progressA = progressItems[a.id];
const progressB = progressItems[b.id];
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
return dateB - dateA;
});
return output;
}, [bookmarks, progressItems]);
return sortMediaItems(output, sortBy, bookmarks, progressItems);
}, [bookmarks, progressItems, sortBy]);
const { groupedItems, regularItems } = useMemo(() => {
const grouped: Record<string, MediaItem[]> = {};
@ -93,23 +93,26 @@ export function BookmarksPart({
}
});
// Sort items within each group by date
// Sort items within each group
Object.keys(grouped).forEach((group) => {
grouped[group].sort((a, b) => {
const bookmarkA = bookmarks[a.id];
const bookmarkB = bookmarks[b.id];
const progressA = progressItems[a.id];
const progressB = progressItems[b.id];
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
return dateB - dateA;
});
grouped[group] = sortMediaItems(
grouped[group],
sortBy,
bookmarks,
progressItems,
);
});
return { groupedItems: grouped, regularItems: regular };
}, [items, bookmarks, progressItems]);
// Sort regular items
const sortedRegular = sortMediaItems(
regular,
sortBy,
bookmarks,
progressItems,
);
return { groupedItems: grouped, regularItems: sortedRegular };
}, [items, bookmarks, progressItems, sortBy]);
const sortedSections = useMemo(() => {
const sections: Array<{
@ -199,6 +202,17 @@ export function BookmarksPart({
setEditingGroupName(null);
};
const sortOptions: OptionItem[] = [
{ id: "date", name: t("home.bookmarks.sorting.options.date") },
{ id: "title-asc", name: t("home.bookmarks.sorting.options.titleAsc") },
{ id: "title-desc", name: t("home.bookmarks.sorting.options.titleDesc") },
{ id: "year-asc", name: t("home.bookmarks.sorting.options.yearAsc") },
{ id: "year-desc", name: t("home.bookmarks.sorting.options.yearDesc") },
];
const selectedSortOption =
sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0];
if (items.length === 0) return null;
return (
@ -236,6 +250,65 @@ export function BookmarksPart({
/>
</div>
</SectionHeading>
{editing && (
<div className="mb-6 -mt-4">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::bookmarksSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={opt}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<MediaGrid>
{section.items.map((v) => (
<div
@ -273,6 +346,61 @@ export function BookmarksPart({
/>
</div>
</SectionHeading>
{editing && (
<div className="mb-6 -mt-4">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => setSortBy(item.id as SortOption)}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={opt}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<MediaGrid ref={gridRef}>
{section.items.map((v) => (
<div

View file

@ -1,14 +1,17 @@
import React, { useMemo, useState } from "react";
import { Listbox } from "@headlessui/react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
import { Icons } from "@/components/Icon";
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { useIsMobile } from "@/hooks/useIsMobile";
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { SortOption, sortMediaItems } from "@/utils/mediaSorting";
import { MediaItem } from "@/utils/mediaTypes";
interface WatchingCarouselProps {
@ -37,8 +40,16 @@ export function WatchingCarousel({
const browser = !!window.chrome;
let isScrolling = false;
const [editing, setEditing] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>(() => {
const saved = localStorage.getItem("__MW::watchingSort");
return (saved as SortOption) || "date";
});
const removeItem = useProgressStore((s) => s.removeItem);
useEffect(() => {
localStorage.setItem("__MW::watchingSort", sortBy);
}, [sortBy]);
const { isMobile } = useIsMobile();
const itemsLength = useProgressStore((state) => {
@ -53,15 +64,14 @@ export function WatchingCarousel({
const output: MediaItem[] = [];
Object.entries(progressItems)
.filter((entry) => shouldShowProgress(entry[1]).show)
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
.forEach((entry) => {
output.push({
id: entry[0],
...entry[1],
});
});
return output;
}, [progressItems]);
return sortMediaItems(output, sortBy, undefined, progressItems);
}, [progressItems, sortBy]);
const handleWheel = (e: React.WheelEvent) => {
if (isScrolling) return;
@ -81,6 +91,29 @@ export function WatchingCarousel({
}
};
const sortOptions: OptionItem[] = [
{ id: "date", name: t("home.continueWatching.sorting.options.date") },
{
id: "title-asc",
name: t("home.continueWatching.sorting.options.titleAsc"),
},
{
id: "title-desc",
name: t("home.continueWatching.sorting.options.titleDesc"),
},
{
id: "year-asc",
name: t("home.continueWatching.sorting.options.yearAsc"),
},
{
id: "year-desc",
name: t("home.continueWatching.sorting.options.yearDesc"),
},
];
const selectedSortOption =
sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0];
const categorySlug = "continue-watching";
const SKELETON_COUNT = 10;
@ -93,7 +126,7 @@ export function WatchingCarousel({
icon={Icons.CLOCK}
className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]"
>
<div className="mr-4 lg:mr-[88px]">
<div className="mr-4 lg:mr-[88px] flex items-center gap-2">
<EditButton
editing={editing}
onEdit={setEditing}
@ -101,6 +134,65 @@ export function WatchingCarousel({
/>
</div>
</SectionHeading>
{editing && (
<div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::watchingSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={opt}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}

View file

@ -1,14 +1,17 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Listbox } from "@headlessui/react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
import { Icons } from "@/components/Icon";
import { Dropdown, OptionItem } from "@/components/form/Dropdown";
import { Icon, Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { SortOption, sortMediaItems } from "@/utils/mediaSorting";
import { MediaItem } from "@/utils/mediaTypes";
export function WatchingPart({
@ -22,13 +25,20 @@ export function WatchingPart({
const progressItems = useProgressStore((s) => s.items);
const removeItem = useProgressStore((s) => s.removeItem);
const [editing, setEditing] = useState(false);
const [sortBy, setSortBy] = useState<SortOption>(() => {
const saved = localStorage.getItem("__MW::watchingSort");
return (saved as SortOption) || "date";
});
const [gridRef] = useAutoAnimate<HTMLDivElement>();
useEffect(() => {
localStorage.setItem("__MW::watchingSort", sortBy);
}, [sortBy]);
const sortedProgressItems = useMemo(() => {
const output: MediaItem[] = [];
Object.entries(progressItems)
.filter((entry) => shouldShowProgress(entry[1]).show)
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
.forEach((entry) => {
output.push({
id: entry[0],
@ -36,13 +46,36 @@ export function WatchingPart({
});
});
return output;
}, [progressItems]);
return sortMediaItems(output, sortBy, undefined, progressItems);
}, [progressItems, sortBy]);
useEffect(() => {
onItemsChange(sortedProgressItems.length > 0);
}, [sortedProgressItems, onItemsChange]);
const sortOptions: OptionItem[] = [
{ id: "date", name: t("home.continueWatching.sorting.options.date") },
{
id: "title-asc",
name: t("home.continueWatching.sorting.options.titleAsc"),
},
{
id: "title-desc",
name: t("home.continueWatching.sorting.options.titleDesc"),
},
{
id: "year-asc",
name: t("home.continueWatching.sorting.options.yearAsc"),
},
{
id: "year-desc",
name: t("home.continueWatching.sorting.options.yearDesc"),
},
];
const selectedSortOption =
sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0];
if (sortedProgressItems.length === 0) return null;
return (
@ -57,6 +90,65 @@ export function WatchingPart({
id="edit-button-watching"
/>
</SectionHeading>
{editing && (
<div className="mb-6 -mt-4">
<Dropdown
selectedItem={selectedSortOption}
setSelectedItem={(item) => {
const newSort = item.id as SortOption;
setSortBy(newSort);
localStorage.setItem("__MW::watchingSort", newSort);
}}
options={sortOptions}
customButton={
<button
type="button"
className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1"
>
<span>{selectedSortOption.name}</span>
<Icon
icon={Icons.UP_DOWN_ARROW}
className="text-xs text-dropdown-secondary"
/>
</button>
}
side="left"
customMenu={
<Listbox.Options static className="py-1">
{sortOptions.map((opt) => (
<Listbox.Option
className={({ active }) =>
`cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${
active
? "bg-background-secondaryHover text-type-link"
: "text-type-secondary"
}`
}
key={opt.id}
value={opt}
>
{({ selected }) => (
<>
<span
className={`block ${selected ? "font-medium" : "font-normal"}`}
>
{opt.name}
</span>
{selected && (
<Icon
icon={Icons.CHECKMARK}
className="text-xs text-type-link"
/>
)}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
}
/>
</div>
)}
<MediaGrid ref={gridRef}>
{sortedProgressItems.map((v) => (
<div

114
src/utils/mediaSorting.ts Normal file
View file

@ -0,0 +1,114 @@
import { BookmarkMediaItem } from "@/stores/bookmarks";
import { ProgressMediaItem } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
export type SortOption =
| "date"
| "title-asc"
| "title-desc"
| "year-asc"
| "year-desc";
export function sortMediaItems(
items: MediaItem[],
sortBy: SortOption,
bookmarks?: Record<string, BookmarkMediaItem>,
progressItems?: Record<string, ProgressMediaItem>,
): MediaItem[] {
const sorted = [...items];
switch (sortBy) {
case "date": {
sorted.sort((a, b) => {
const bookmarkA = bookmarks?.[a.id];
const bookmarkB = bookmarks?.[b.id];
const progressA = progressItems?.[a.id];
const progressB = progressItems?.[b.id];
const dateA = Math.max(
bookmarkA?.updatedAt ?? 0,
progressA?.updatedAt ?? 0,
);
const dateB = Math.max(
bookmarkB?.updatedAt ?? 0,
progressB?.updatedAt ?? 0,
);
return dateB - dateA; // Newest first
});
break;
}
case "title-asc": {
sorted.sort((a, b) => {
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleA.localeCompare(titleB);
});
break;
}
case "title-desc": {
sorted.sort((a, b) => {
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleB.localeCompare(titleA);
});
break;
}
case "year-asc": {
sorted.sort((a, b) => {
const yearA = a.year ?? Number.MAX_SAFE_INTEGER;
const yearB = b.year ?? Number.MAX_SAFE_INTEGER;
if (yearA === yearB) {
// Secondary sort by title for same year
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleA.localeCompare(titleB);
}
return yearA - yearB;
});
break;
}
case "year-desc": {
sorted.sort((a, b) => {
const yearA = a.year ?? 0; // Put undefined years at the end
const yearB = b.year ?? 0;
if (yearA === yearB) {
// Secondary sort by title for same year
const titleA = a.title?.toLowerCase() ?? "";
const titleB = b.title?.toLowerCase() ?? "";
return titleA.localeCompare(titleB);
}
return yearB - yearA;
});
break;
}
default: {
// Fallback to date sorting for unknown sort options
sorted.sort((a, b) => {
const bookmarkA = bookmarks?.[a.id];
const bookmarkB = bookmarks?.[b.id];
const progressA = progressItems?.[a.id];
const progressB = progressItems?.[b.id];
const dateA = Math.max(
bookmarkA?.updatedAt ?? 0,
progressA?.updatedAt ?? 0,
);
const dateB = Math.max(
bookmarkB?.updatedAt ?? 0,
progressB?.updatedAt ?? 0,
);
return dateB - dateA; // Newest first
});
break;
}
}
return sorted;
}