mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
add sort media cards dropdown
This commit is contained in:
parent
605abb9aab
commit
ac7e44f234
6 changed files with 644 additions and 70 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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
114
src/utils/mediaSorting.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue