From ac7e44f23456d3b07373a7ffb2d7579825db3ff4 Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sun, 21 Dec 2025 22:42:04 -0700 Subject: [PATCH] add sort media cards dropdown --- src/assets/locales/en.json | 22 ++- src/pages/parts/home/BookmarksCarousel.tsx | 188 +++++++++++++++++---- src/pages/parts/home/BookmarksPart.tsx | 186 ++++++++++++++++---- src/pages/parts/home/WatchingCarousel.tsx | 104 +++++++++++- src/pages/parts/home/WatchingPart.tsx | 100 ++++++++++- src/utils/mediaSorting.ts | 114 +++++++++++++ 6 files changed, 644 insertions(+), 70 deletions(-) create mode 100644 src/utils/mediaSorting.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 4fc7e564..de8218c5 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -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" diff --git a/src/pages/parts/home/BookmarksCarousel.tsx b/src/pages/parts/home/BookmarksCarousel.tsx index 9524c696..13183d1b 100644 --- a/src/pages/parts/home/BookmarksCarousel.tsx +++ b/src/pages/parts/home/BookmarksCarousel.tsx @@ -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(() => { + 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 = {}; @@ -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({ /> + {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::bookmarksSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `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 }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )}
+ {editing && ( +
+ setSortBy(item.id as SortOption)} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `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 }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )}
s.modifyBookmarksByGroup, ); + const [sortBy, setSortBy] = useState(() => { + 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 = {}; @@ -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({ />
+ {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::bookmarksSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `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 }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )} {section.items.map((v) => (
+ {editing && ( +
+ setSortBy(item.id as SortOption)} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `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 }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )} {section.items.map((v) => (
(() => { + 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]" > -
+
+ {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::watchingSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `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 }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )}
s.items); const removeItem = useProgressStore((s) => s.removeItem); const [editing, setEditing] = useState(false); + const [sortBy, setSortBy] = useState(() => { + const saved = localStorage.getItem("__MW::watchingSort"); + return (saved as SortOption) || "date"; + }); const [gridRef] = useAutoAnimate(); + 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" /> + {editing && ( +
+ { + const newSort = item.id as SortOption; + setSortBy(newSort); + localStorage.setItem("__MW::watchingSort", newSort); + }} + options={sortOptions} + customButton={ + + } + side="left" + customMenu={ + + {sortOptions.map((opt) => ( + + `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 }) => ( + <> + + {opt.name} + + {selected && ( + + )} + + )} + + ))} + + } + /> +
+ )} {sortedProgressItems.map((v) => (
, + progressItems?: Record, +): 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; +}