pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add sort media cards dropdown

Pas ac7e44f2 605abb9a

+644 -70
+21 -1
src/assets/locales/en.json
··· 320 320 "titlePlaceholder": "Enter a title for your bookmark", 321 321 "yearLabel": "Year", 322 322 "yearPlaceholder": "Enter a year for your bookmark" 323 + }, 324 + "sorting": { 325 + "label": "Sort by", 326 + "options": { 327 + "date": "Default (Date added)", 328 + "titleAsc": "Title A-Z", 329 + "titleDesc": "Title Z-A", 330 + "yearAsc": "Release Date Oldest-Newest", 331 + "yearDesc": "Release Date Newest-Oldest" 332 + } 323 333 } 324 334 }, 325 335 "continueWatching": { 326 - "sectionTitle": "Continue Watching..." 336 + "sectionTitle": "Continue Watching...", 337 + "sorting": { 338 + "label": "Sort by", 339 + "options": { 340 + "date": "Default (Date added)", 341 + "titleAsc": "Title A-Z", 342 + "titleDesc": "Title Z-A", 343 + "yearAsc": "Release Date Oldest-Newest", 344 + "yearDesc": "Release Date Newest-Oldest" 345 + } 346 + } 327 347 }, 328 348 "mediaList": { 329 349 "stopEditing": "Stop editing"
+158 -30
src/pages/parts/home/BookmarksCarousel.tsx
··· 1 - import React, { useMemo, useState } from "react"; 1 + import { Listbox } from "@headlessui/react"; 2 + import React, { useEffect, useMemo, useState } from "react"; 2 3 import { useTranslation } from "react-i18next"; 3 4 import { Link } from "react-router-dom"; 4 5 5 6 import { EditButton } from "@/components/buttons/EditButton"; 6 7 import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; 8 + import { Dropdown, OptionItem } from "@/components/form/Dropdown"; 7 9 import { Icon, Icons } from "@/components/Icon"; 8 10 import { SectionHeading } from "@/components/layout/SectionHeading"; 9 11 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; ··· 17 19 import { useBookmarkStore } from "@/stores/bookmarks"; 18 20 import { useGroupOrderStore } from "@/stores/groupOrder"; 19 21 import { useProgressStore } from "@/stores/progress"; 22 + import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; 20 23 import { MediaItem } from "@/utils/mediaTypes"; 21 24 22 25 function parseGroupString(group: string): { icon: UserIcons; name: string } { ··· 88 91 const browser = !!window.chrome; 89 92 let isScrolling = false; 90 93 const [editing, setEditing] = useState(false); 94 + const [sortBy, setSortBy] = useState<SortOption>(() => { 95 + const saved = localStorage.getItem("__MW::bookmarksSort"); 96 + return (saved as SortOption) || "date"; 97 + }); 91 98 const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 99 + 100 + useEffect(() => { 101 + localStorage.setItem("__MW::bookmarksSort", sortBy); 102 + }, [sortBy]); 92 103 93 104 // Editing modals 94 105 const editBookmarkModal = useModal("bookmark-edit-carousel"); ··· 113 124 const groupOrder = useGroupOrderStore((s) => s.groupOrder); 114 125 115 126 const items = useMemo(() => { 116 - let output: MediaItem[] = []; 127 + const output: MediaItem[] = []; 117 128 Object.entries(bookmarks).forEach((entry) => { 118 129 output.push({ 119 130 id: entry[0], 120 131 ...entry[1], 121 132 }); 122 133 }); 123 - output = output.sort((a, b) => { 124 - const bookmarkA = bookmarks[a.id]; 125 - const bookmarkB = bookmarks[b.id]; 126 - const progressA = progressItems[a.id]; 127 - const progressB = progressItems[b.id]; 128 - 129 - const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0); 130 - const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0); 131 - 132 - return dateB - dateA; 133 - }); 134 - return output; 135 - }, [bookmarks, progressItems]); 134 + return sortMediaItems(output, sortBy, bookmarks, progressItems); 135 + }, [bookmarks, progressItems, sortBy]); 136 136 137 137 const { groupedItems, regularItems } = useMemo(() => { 138 138 const grouped: Record<string, MediaItem[]> = {}; ··· 152 152 } 153 153 }); 154 154 155 - // Sort items within each group by date 155 + // Sort items within each group 156 156 Object.keys(grouped).forEach((group) => { 157 - grouped[group].sort((a, b) => { 158 - const bookmarkA = bookmarks[a.id]; 159 - const bookmarkB = bookmarks[b.id]; 160 - const progressA = progressItems[a.id]; 161 - const progressB = progressItems[b.id]; 162 - 163 - const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0); 164 - const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0); 165 - 166 - return dateB - dateA; 167 - }); 157 + grouped[group] = sortMediaItems( 158 + grouped[group], 159 + sortBy, 160 + bookmarks, 161 + progressItems, 162 + ); 168 163 }); 169 164 170 - return { groupedItems: grouped, regularItems: regular }; 171 - }, [items, bookmarks, progressItems]); 165 + // Sort regular items 166 + const sortedRegular = sortMediaItems( 167 + regular, 168 + sortBy, 169 + bookmarks, 170 + progressItems, 171 + ); 172 + 173 + return { groupedItems: grouped, regularItems: sortedRegular }; 174 + }, [items, bookmarks, progressItems, sortBy]); 172 175 173 176 const sortedSections = useMemo(() => { 174 177 const sections: Array<{ ··· 279 282 setEditingGroupName(null); 280 283 }; 281 284 285 + const sortOptions: OptionItem[] = [ 286 + { id: "date", name: t("home.bookmarks.sorting.options.date") }, 287 + { id: "title-asc", name: t("home.bookmarks.sorting.options.titleAsc") }, 288 + { id: "title-desc", name: t("home.bookmarks.sorting.options.titleDesc") }, 289 + { id: "year-asc", name: t("home.bookmarks.sorting.options.yearAsc") }, 290 + { id: "year-desc", name: t("home.bookmarks.sorting.options.yearDesc") }, 291 + ]; 292 + 293 + const selectedSortOption = 294 + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; 295 + 282 296 const categorySlug = "bookmarks"; 283 297 const SKELETON_COUNT = 10; 284 298 ··· 320 334 /> 321 335 </div> 322 336 </SectionHeading> 337 + {editing && ( 338 + <div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]"> 339 + <Dropdown 340 + selectedItem={selectedSortOption} 341 + setSelectedItem={(item) => { 342 + const newSort = item.id as SortOption; 343 + setSortBy(newSort); 344 + localStorage.setItem("__MW::bookmarksSort", newSort); 345 + }} 346 + options={sortOptions} 347 + customButton={ 348 + <button 349 + type="button" 350 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 351 + > 352 + <span>{selectedSortOption.name}</span> 353 + <Icon 354 + icon={Icons.UP_DOWN_ARROW} 355 + className="text-xs text-dropdown-secondary" 356 + /> 357 + </button> 358 + } 359 + side="left" 360 + customMenu={ 361 + <Listbox.Options static className="py-1"> 362 + {sortOptions.map((opt) => ( 363 + <Listbox.Option 364 + className={({ active }) => 365 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 366 + active 367 + ? "bg-background-secondaryHover text-type-link" 368 + : "text-type-secondary" 369 + }` 370 + } 371 + key={opt.id} 372 + value={opt} 373 + > 374 + {({ selected }) => ( 375 + <> 376 + <span 377 + className={`block ${selected ? "font-medium" : "font-normal"}`} 378 + > 379 + {opt.name} 380 + </span> 381 + {selected && ( 382 + <Icon 383 + icon={Icons.CHECKMARK} 384 + className="text-xs text-type-link" 385 + /> 386 + )} 387 + </> 388 + )} 389 + </Listbox.Option> 390 + ))} 391 + </Listbox.Options> 392 + } 393 + /> 394 + </div> 395 + )} 323 396 <div className="relative overflow-hidden carousel-container md:pb-4"> 324 397 <div 325 398 id={`carousel-${section.group}`} ··· 375 448 <SectionHeading 376 449 title={t("home.bookmarks.sectionTitle")} 377 450 icon={Icons.BOOKMARK} 378 - className="ml-4 md:ml-12 mt-2 -mb-5" 451 + className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]" 379 452 > 380 453 <div className="mr-4 lg:mr-[88px] flex items-center gap-2"> 381 454 <EditButton ··· 385 458 /> 386 459 </div> 387 460 </SectionHeading> 461 + {editing && ( 462 + <div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]"> 463 + <Dropdown 464 + selectedItem={selectedSortOption} 465 + setSelectedItem={(item) => setSortBy(item.id as SortOption)} 466 + options={sortOptions} 467 + customButton={ 468 + <button 469 + type="button" 470 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 471 + > 472 + <span>{selectedSortOption.name}</span> 473 + <Icon 474 + icon={Icons.UP_DOWN_ARROW} 475 + className="text-xs text-dropdown-secondary" 476 + /> 477 + </button> 478 + } 479 + side="left" 480 + customMenu={ 481 + <Listbox.Options static className="py-1"> 482 + {sortOptions.map((opt) => ( 483 + <Listbox.Option 484 + className={({ active }) => 485 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 486 + active 487 + ? "bg-background-secondaryHover text-type-link" 488 + : "text-type-secondary" 489 + }` 490 + } 491 + key={opt.id} 492 + value={opt} 493 + > 494 + {({ selected }) => ( 495 + <> 496 + <span 497 + className={`block ${selected ? "font-medium" : "font-normal"}`} 498 + > 499 + {opt.name} 500 + </span> 501 + {selected && ( 502 + <Icon 503 + icon={Icons.CHECKMARK} 504 + className="text-xs text-type-link" 505 + /> 506 + )} 507 + </> 508 + )} 509 + </Listbox.Option> 510 + ))} 511 + </Listbox.Options> 512 + } 513 + /> 514 + </div> 515 + )} 388 516 <div className="relative overflow-hidden carousel-container md:pb-4"> 389 517 <div 390 518 id={`carousel-${categorySlug}`}
+157 -29
src/pages/parts/home/BookmarksPart.tsx
··· 1 1 import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 + import { Listbox } from "@headlessui/react"; 2 3 import { useEffect, useMemo, useState } from "react"; 3 4 import { useTranslation } from "react-i18next"; 4 5 5 6 import { EditButton } from "@/components/buttons/EditButton"; 6 7 import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; 7 - import { Icons } from "@/components/Icon"; 8 + import { Dropdown, OptionItem } from "@/components/form/Dropdown"; 9 + import { Icon, Icons } from "@/components/Icon"; 8 10 import { SectionHeading } from "@/components/layout/SectionHeading"; 9 11 import { MediaGrid } from "@/components/media/MediaGrid"; 10 12 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; ··· 15 17 import { useBookmarkStore } from "@/stores/bookmarks"; 16 18 import { useGroupOrderStore } from "@/stores/groupOrder"; 17 19 import { useProgressStore } from "@/stores/progress"; 20 + import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; 18 21 import { MediaItem } from "@/utils/mediaTypes"; 19 22 20 23 function parseGroupString(group: string): { icon: UserIcons; name: string } { ··· 52 55 const modifyBookmarksByGroup = useBookmarkStore( 53 56 (s) => s.modifyBookmarksByGroup, 54 57 ); 58 + const [sortBy, setSortBy] = useState<SortOption>(() => { 59 + const saved = localStorage.getItem("__MW::bookmarksSort"); 60 + return (saved as SortOption) || "date"; 61 + }); 62 + 63 + useEffect(() => { 64 + localStorage.setItem("__MW::bookmarksSort", sortBy); 65 + }, [sortBy]); 55 66 56 67 const items = useMemo(() => { 57 - let output: MediaItem[] = []; 68 + const output: MediaItem[] = []; 58 69 Object.entries(bookmarks).forEach((entry) => { 59 70 output.push({ 60 71 id: entry[0], 61 72 ...entry[1], 62 73 }); 63 74 }); 64 - output = output.sort((a, b) => { 65 - const bookmarkA = bookmarks[a.id]; 66 - const bookmarkB = bookmarks[b.id]; 67 - const progressA = progressItems[a.id]; 68 - const progressB = progressItems[b.id]; 69 - 70 - const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0); 71 - const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0); 72 - 73 - return dateB - dateA; 74 - }); 75 - return output; 76 - }, [bookmarks, progressItems]); 75 + return sortMediaItems(output, sortBy, bookmarks, progressItems); 76 + }, [bookmarks, progressItems, sortBy]); 77 77 78 78 const { groupedItems, regularItems } = useMemo(() => { 79 79 const grouped: Record<string, MediaItem[]> = {}; ··· 93 93 } 94 94 }); 95 95 96 - // Sort items within each group by date 96 + // Sort items within each group 97 97 Object.keys(grouped).forEach((group) => { 98 - grouped[group].sort((a, b) => { 99 - const bookmarkA = bookmarks[a.id]; 100 - const bookmarkB = bookmarks[b.id]; 101 - const progressA = progressItems[a.id]; 102 - const progressB = progressItems[b.id]; 98 + grouped[group] = sortMediaItems( 99 + grouped[group], 100 + sortBy, 101 + bookmarks, 102 + progressItems, 103 + ); 104 + }); 103 105 104 - const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0); 105 - const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0); 106 - 107 - return dateB - dateA; 108 - }); 109 - }); 106 + // Sort regular items 107 + const sortedRegular = sortMediaItems( 108 + regular, 109 + sortBy, 110 + bookmarks, 111 + progressItems, 112 + ); 110 113 111 - return { groupedItems: grouped, regularItems: regular }; 112 - }, [items, bookmarks, progressItems]); 114 + return { groupedItems: grouped, regularItems: sortedRegular }; 115 + }, [items, bookmarks, progressItems, sortBy]); 113 116 114 117 const sortedSections = useMemo(() => { 115 118 const sections: Array<{ ··· 199 202 setEditingGroupName(null); 200 203 }; 201 204 205 + const sortOptions: OptionItem[] = [ 206 + { id: "date", name: t("home.bookmarks.sorting.options.date") }, 207 + { id: "title-asc", name: t("home.bookmarks.sorting.options.titleAsc") }, 208 + { id: "title-desc", name: t("home.bookmarks.sorting.options.titleDesc") }, 209 + { id: "year-asc", name: t("home.bookmarks.sorting.options.yearAsc") }, 210 + { id: "year-desc", name: t("home.bookmarks.sorting.options.yearDesc") }, 211 + ]; 212 + 213 + const selectedSortOption = 214 + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; 215 + 202 216 if (items.length === 0) return null; 203 217 204 218 return ( ··· 236 250 /> 237 251 </div> 238 252 </SectionHeading> 253 + {editing && ( 254 + <div className="mb-6 -mt-4"> 255 + <Dropdown 256 + selectedItem={selectedSortOption} 257 + setSelectedItem={(item) => { 258 + const newSort = item.id as SortOption; 259 + setSortBy(newSort); 260 + localStorage.setItem("__MW::bookmarksSort", newSort); 261 + }} 262 + options={sortOptions} 263 + customButton={ 264 + <button 265 + type="button" 266 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 267 + > 268 + <span>{selectedSortOption.name}</span> 269 + <Icon 270 + icon={Icons.UP_DOWN_ARROW} 271 + className="text-xs text-dropdown-secondary" 272 + /> 273 + </button> 274 + } 275 + side="left" 276 + customMenu={ 277 + <Listbox.Options static className="py-1"> 278 + {sortOptions.map((opt) => ( 279 + <Listbox.Option 280 + className={({ active }) => 281 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 282 + active 283 + ? "bg-background-secondaryHover text-type-link" 284 + : "text-type-secondary" 285 + }` 286 + } 287 + key={opt.id} 288 + value={opt} 289 + > 290 + {({ selected }) => ( 291 + <> 292 + <span 293 + className={`block ${selected ? "font-medium" : "font-normal"}`} 294 + > 295 + {opt.name} 296 + </span> 297 + {selected && ( 298 + <Icon 299 + icon={Icons.CHECKMARK} 300 + className="text-xs text-type-link" 301 + /> 302 + )} 303 + </> 304 + )} 305 + </Listbox.Option> 306 + ))} 307 + </Listbox.Options> 308 + } 309 + /> 310 + </div> 311 + )} 239 312 <MediaGrid> 240 313 {section.items.map((v) => ( 241 314 <div ··· 273 346 /> 274 347 </div> 275 348 </SectionHeading> 349 + {editing && ( 350 + <div className="mb-6 -mt-4"> 351 + <Dropdown 352 + selectedItem={selectedSortOption} 353 + setSelectedItem={(item) => setSortBy(item.id as SortOption)} 354 + options={sortOptions} 355 + customButton={ 356 + <button 357 + type="button" 358 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 359 + > 360 + <span>{selectedSortOption.name}</span> 361 + <Icon 362 + icon={Icons.UP_DOWN_ARROW} 363 + className="text-xs text-dropdown-secondary" 364 + /> 365 + </button> 366 + } 367 + side="left" 368 + customMenu={ 369 + <Listbox.Options static className="py-1"> 370 + {sortOptions.map((opt) => ( 371 + <Listbox.Option 372 + className={({ active }) => 373 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 374 + active 375 + ? "bg-background-secondaryHover text-type-link" 376 + : "text-type-secondary" 377 + }` 378 + } 379 + key={opt.id} 380 + value={opt} 381 + > 382 + {({ selected }) => ( 383 + <> 384 + <span 385 + className={`block ${selected ? "font-medium" : "font-normal"}`} 386 + > 387 + {opt.name} 388 + </span> 389 + {selected && ( 390 + <Icon 391 + icon={Icons.CHECKMARK} 392 + className="text-xs text-type-link" 393 + /> 394 + )} 395 + </> 396 + )} 397 + </Listbox.Option> 398 + ))} 399 + </Listbox.Options> 400 + } 401 + /> 402 + </div> 403 + )} 276 404 <MediaGrid ref={gridRef}> 277 405 {section.items.map((v) => ( 278 406 <div
+98 -6
src/pages/parts/home/WatchingCarousel.tsx
··· 1 - import React, { useMemo, useState } from "react"; 1 + import { Listbox } from "@headlessui/react"; 2 + import React, { useEffect, useMemo, useState } from "react"; 2 3 import { useTranslation } from "react-i18next"; 3 4 4 5 import { EditButton } from "@/components/buttons/EditButton"; 5 - import { Icons } from "@/components/Icon"; 6 + import { Dropdown, OptionItem } from "@/components/form/Dropdown"; 7 + import { Icon, Icons } from "@/components/Icon"; 6 8 import { SectionHeading } from "@/components/layout/SectionHeading"; 7 9 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 8 10 import { useIsMobile } from "@/hooks/useIsMobile"; 9 11 import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; 10 12 import { useProgressStore } from "@/stores/progress"; 11 13 import { shouldShowProgress } from "@/stores/progress/utils"; 14 + import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; 12 15 import { MediaItem } from "@/utils/mediaTypes"; 13 16 14 17 interface WatchingCarouselProps { ··· 37 40 const browser = !!window.chrome; 38 41 let isScrolling = false; 39 42 const [editing, setEditing] = useState(false); 43 + const [sortBy, setSortBy] = useState<SortOption>(() => { 44 + const saved = localStorage.getItem("__MW::watchingSort"); 45 + return (saved as SortOption) || "date"; 46 + }); 40 47 const removeItem = useProgressStore((s) => s.removeItem); 41 48 49 + useEffect(() => { 50 + localStorage.setItem("__MW::watchingSort", sortBy); 51 + }, [sortBy]); 52 + 42 53 const { isMobile } = useIsMobile(); 43 54 44 55 const itemsLength = useProgressStore((state) => { ··· 53 64 const output: MediaItem[] = []; 54 65 Object.entries(progressItems) 55 66 .filter((entry) => shouldShowProgress(entry[1]).show) 56 - .sort((a, b) => b[1].updatedAt - a[1].updatedAt) 57 67 .forEach((entry) => { 58 68 output.push({ 59 69 id: entry[0], 60 70 ...entry[1], 61 71 }); 62 72 }); 63 - return output; 64 - }, [progressItems]); 73 + return sortMediaItems(output, sortBy, undefined, progressItems); 74 + }, [progressItems, sortBy]); 65 75 66 76 const handleWheel = (e: React.WheelEvent) => { 67 77 if (isScrolling) return; ··· 81 91 } 82 92 }; 83 93 94 + const sortOptions: OptionItem[] = [ 95 + { id: "date", name: t("home.continueWatching.sorting.options.date") }, 96 + { 97 + id: "title-asc", 98 + name: t("home.continueWatching.sorting.options.titleAsc"), 99 + }, 100 + { 101 + id: "title-desc", 102 + name: t("home.continueWatching.sorting.options.titleDesc"), 103 + }, 104 + { 105 + id: "year-asc", 106 + name: t("home.continueWatching.sorting.options.yearAsc"), 107 + }, 108 + { 109 + id: "year-desc", 110 + name: t("home.continueWatching.sorting.options.yearDesc"), 111 + }, 112 + ]; 113 + 114 + const selectedSortOption = 115 + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; 116 + 84 117 const categorySlug = "continue-watching"; 85 118 const SKELETON_COUNT = 10; 86 119 ··· 93 126 icon={Icons.CLOCK} 94 127 className="ml-4 lg:ml-12 mt-2 -mb-5 lg:pl-[48px]" 95 128 > 96 - <div className="mr-4 lg:mr-[88px]"> 129 + <div className="mr-4 lg:mr-[88px] flex items-center gap-2"> 97 130 <EditButton 98 131 editing={editing} 99 132 onEdit={setEditing} ··· 101 134 /> 102 135 </div> 103 136 </SectionHeading> 137 + {editing && ( 138 + <div className="mt-4 -mb-4 ml-4 lg:ml-12 lg:pl-[48px]"> 139 + <Dropdown 140 + selectedItem={selectedSortOption} 141 + setSelectedItem={(item) => { 142 + const newSort = item.id as SortOption; 143 + setSortBy(newSort); 144 + localStorage.setItem("__MW::watchingSort", newSort); 145 + }} 146 + options={sortOptions} 147 + customButton={ 148 + <button 149 + type="button" 150 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 151 + > 152 + <span>{selectedSortOption.name}</span> 153 + <Icon 154 + icon={Icons.UP_DOWN_ARROW} 155 + className="text-xs text-dropdown-secondary" 156 + /> 157 + </button> 158 + } 159 + side="left" 160 + customMenu={ 161 + <Listbox.Options static className="py-1"> 162 + {sortOptions.map((opt) => ( 163 + <Listbox.Option 164 + className={({ active }) => 165 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 166 + active 167 + ? "bg-background-secondaryHover text-type-link" 168 + : "text-type-secondary" 169 + }` 170 + } 171 + key={opt.id} 172 + value={opt} 173 + > 174 + {({ selected }) => ( 175 + <> 176 + <span 177 + className={`block ${selected ? "font-medium" : "font-normal"}`} 178 + > 179 + {opt.name} 180 + </span> 181 + {selected && ( 182 + <Icon 183 + icon={Icons.CHECKMARK} 184 + className="text-xs text-type-link" 185 + /> 186 + )} 187 + </> 188 + )} 189 + </Listbox.Option> 190 + ))} 191 + </Listbox.Options> 192 + } 193 + /> 194 + </div> 195 + )} 104 196 <div className="relative overflow-hidden carousel-container md:pb-4"> 105 197 <div 106 198 id={`carousel-${categorySlug}`}
+96 -4
src/pages/parts/home/WatchingPart.tsx
··· 1 1 import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 + import { Listbox } from "@headlessui/react"; 2 3 import { useEffect, useMemo, useState } from "react"; 3 4 import { useTranslation } from "react-i18next"; 4 5 5 6 import { EditButton } from "@/components/buttons/EditButton"; 6 - import { Icons } from "@/components/Icon"; 7 + import { Dropdown, OptionItem } from "@/components/form/Dropdown"; 8 + import { Icon, Icons } from "@/components/Icon"; 7 9 import { SectionHeading } from "@/components/layout/SectionHeading"; 8 10 import { MediaGrid } from "@/components/media/MediaGrid"; 9 11 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 10 12 import { useProgressStore } from "@/stores/progress"; 11 13 import { shouldShowProgress } from "@/stores/progress/utils"; 14 + import { SortOption, sortMediaItems } from "@/utils/mediaSorting"; 12 15 import { MediaItem } from "@/utils/mediaTypes"; 13 16 14 17 export function WatchingPart({ ··· 22 25 const progressItems = useProgressStore((s) => s.items); 23 26 const removeItem = useProgressStore((s) => s.removeItem); 24 27 const [editing, setEditing] = useState(false); 28 + const [sortBy, setSortBy] = useState<SortOption>(() => { 29 + const saved = localStorage.getItem("__MW::watchingSort"); 30 + return (saved as SortOption) || "date"; 31 + }); 25 32 const [gridRef] = useAutoAnimate<HTMLDivElement>(); 33 + 34 + useEffect(() => { 35 + localStorage.setItem("__MW::watchingSort", sortBy); 36 + }, [sortBy]); 26 37 27 38 const sortedProgressItems = useMemo(() => { 28 39 const output: MediaItem[] = []; 29 40 Object.entries(progressItems) 30 41 .filter((entry) => shouldShowProgress(entry[1]).show) 31 - .sort((a, b) => b[1].updatedAt - a[1].updatedAt) 32 42 .forEach((entry) => { 33 43 output.push({ 34 44 id: entry[0], ··· 36 46 }); 37 47 }); 38 48 39 - return output; 40 - }, [progressItems]); 49 + return sortMediaItems(output, sortBy, undefined, progressItems); 50 + }, [progressItems, sortBy]); 41 51 42 52 useEffect(() => { 43 53 onItemsChange(sortedProgressItems.length > 0); 44 54 }, [sortedProgressItems, onItemsChange]); 45 55 56 + const sortOptions: OptionItem[] = [ 57 + { id: "date", name: t("home.continueWatching.sorting.options.date") }, 58 + { 59 + id: "title-asc", 60 + name: t("home.continueWatching.sorting.options.titleAsc"), 61 + }, 62 + { 63 + id: "title-desc", 64 + name: t("home.continueWatching.sorting.options.titleDesc"), 65 + }, 66 + { 67 + id: "year-asc", 68 + name: t("home.continueWatching.sorting.options.yearAsc"), 69 + }, 70 + { 71 + id: "year-desc", 72 + name: t("home.continueWatching.sorting.options.yearDesc"), 73 + }, 74 + ]; 75 + 76 + const selectedSortOption = 77 + sortOptions.find((opt) => opt.id === sortBy) || sortOptions[0]; 78 + 46 79 if (sortedProgressItems.length === 0) return null; 47 80 48 81 return ( ··· 57 90 id="edit-button-watching" 58 91 /> 59 92 </SectionHeading> 93 + {editing && ( 94 + <div className="mb-6 -mt-4"> 95 + <Dropdown 96 + selectedItem={selectedSortOption} 97 + setSelectedItem={(item) => { 98 + const newSort = item.id as SortOption; 99 + setSortBy(newSort); 100 + localStorage.setItem("__MW::watchingSort", newSort); 101 + }} 102 + options={sortOptions} 103 + customButton={ 104 + <button 105 + type="button" 106 + className="px-2 py-1 text-sm bg-mediaCard-hoverBackground rounded-full hover:bg-mediaCard-background transition-colors flex items-center gap-1" 107 + > 108 + <span>{selectedSortOption.name}</span> 109 + <Icon 110 + icon={Icons.UP_DOWN_ARROW} 111 + className="text-xs text-dropdown-secondary" 112 + /> 113 + </button> 114 + } 115 + side="left" 116 + customMenu={ 117 + <Listbox.Options static className="py-1"> 118 + {sortOptions.map((opt) => ( 119 + <Listbox.Option 120 + className={({ active }) => 121 + `cursor-pointer min-w-60 flex gap-4 items-center relative select-none py-2 px-4 mx-1 rounded-lg ${ 122 + active 123 + ? "bg-background-secondaryHover text-type-link" 124 + : "text-type-secondary" 125 + }` 126 + } 127 + key={opt.id} 128 + value={opt} 129 + > 130 + {({ selected }) => ( 131 + <> 132 + <span 133 + className={`block ${selected ? "font-medium" : "font-normal"}`} 134 + > 135 + {opt.name} 136 + </span> 137 + {selected && ( 138 + <Icon 139 + icon={Icons.CHECKMARK} 140 + className="text-xs text-type-link" 141 + /> 142 + )} 143 + </> 144 + )} 145 + </Listbox.Option> 146 + ))} 147 + </Listbox.Options> 148 + } 149 + /> 150 + </div> 151 + )} 60 152 <MediaGrid ref={gridRef}> 61 153 {sortedProgressItems.map((v) => ( 62 154 <div
+114
src/utils/mediaSorting.ts
··· 1 + import { BookmarkMediaItem } from "@/stores/bookmarks"; 2 + import { ProgressMediaItem } from "@/stores/progress"; 3 + import { MediaItem } from "@/utils/mediaTypes"; 4 + 5 + export type SortOption = 6 + | "date" 7 + | "title-asc" 8 + | "title-desc" 9 + | "year-asc" 10 + | "year-desc"; 11 + 12 + export function sortMediaItems( 13 + items: MediaItem[], 14 + sortBy: SortOption, 15 + bookmarks?: Record<string, BookmarkMediaItem>, 16 + progressItems?: Record<string, ProgressMediaItem>, 17 + ): MediaItem[] { 18 + const sorted = [...items]; 19 + 20 + switch (sortBy) { 21 + case "date": { 22 + sorted.sort((a, b) => { 23 + const bookmarkA = bookmarks?.[a.id]; 24 + const bookmarkB = bookmarks?.[b.id]; 25 + const progressA = progressItems?.[a.id]; 26 + const progressB = progressItems?.[b.id]; 27 + 28 + const dateA = Math.max( 29 + bookmarkA?.updatedAt ?? 0, 30 + progressA?.updatedAt ?? 0, 31 + ); 32 + const dateB = Math.max( 33 + bookmarkB?.updatedAt ?? 0, 34 + progressB?.updatedAt ?? 0, 35 + ); 36 + 37 + return dateB - dateA; // Newest first 38 + }); 39 + break; 40 + } 41 + 42 + case "title-asc": { 43 + sorted.sort((a, b) => { 44 + const titleA = a.title?.toLowerCase() ?? ""; 45 + const titleB = b.title?.toLowerCase() ?? ""; 46 + return titleA.localeCompare(titleB); 47 + }); 48 + break; 49 + } 50 + 51 + case "title-desc": { 52 + sorted.sort((a, b) => { 53 + const titleA = a.title?.toLowerCase() ?? ""; 54 + const titleB = b.title?.toLowerCase() ?? ""; 55 + return titleB.localeCompare(titleA); 56 + }); 57 + break; 58 + } 59 + 60 + case "year-asc": { 61 + sorted.sort((a, b) => { 62 + const yearA = a.year ?? Number.MAX_SAFE_INTEGER; 63 + const yearB = b.year ?? Number.MAX_SAFE_INTEGER; 64 + if (yearA === yearB) { 65 + // Secondary sort by title for same year 66 + const titleA = a.title?.toLowerCase() ?? ""; 67 + const titleB = b.title?.toLowerCase() ?? ""; 68 + return titleA.localeCompare(titleB); 69 + } 70 + return yearA - yearB; 71 + }); 72 + break; 73 + } 74 + 75 + case "year-desc": { 76 + sorted.sort((a, b) => { 77 + const yearA = a.year ?? 0; // Put undefined years at the end 78 + const yearB = b.year ?? 0; 79 + if (yearA === yearB) { 80 + // Secondary sort by title for same year 81 + const titleA = a.title?.toLowerCase() ?? ""; 82 + const titleB = b.title?.toLowerCase() ?? ""; 83 + return titleA.localeCompare(titleB); 84 + } 85 + return yearB - yearA; 86 + }); 87 + break; 88 + } 89 + 90 + default: { 91 + // Fallback to date sorting for unknown sort options 92 + sorted.sort((a, b) => { 93 + const bookmarkA = bookmarks?.[a.id]; 94 + const bookmarkB = bookmarks?.[b.id]; 95 + const progressA = progressItems?.[a.id]; 96 + const progressB = progressItems?.[b.id]; 97 + 98 + const dateA = Math.max( 99 + bookmarkA?.updatedAt ?? 0, 100 + progressA?.updatedAt ?? 0, 101 + ); 102 + const dateB = Math.max( 103 + bookmarkB?.updatedAt ?? 0, 104 + progressB?.updatedAt ?? 0, 105 + ); 106 + 107 + return dateB - dateA; // Newest first 108 + }); 109 + break; 110 + } 111 + } 112 + 113 + return sorted; 114 + }