diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index c905b464..cfd73521 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -242,6 +242,7 @@ "home": { "bookmarks": { "sectionTitle": "Bookmarks", + "showAll": "Show all", "groups": { "dropdown": { "placeholderButton": "Add to group", diff --git a/src/pages/bookmarks/AllBookmarks.tsx b/src/pages/bookmarks/AllBookmarks.tsx new file mode 100644 index 00000000..9ccd0fa6 --- /dev/null +++ b/src/pages/bookmarks/AllBookmarks.tsx @@ -0,0 +1,454 @@ +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { Button } from "@/components/buttons/Button"; +import { EditButton } from "@/components/buttons/EditButton"; +import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; +import { Item, SortableList } from "@/components/form/SortableList"; +import { Icon, Icons } from "@/components/Icon"; +import { SectionHeading } from "@/components/layout/SectionHeading"; +import { WideContainer } from "@/components/layout/WideContainer"; +import { MediaGrid } from "@/components/media/MediaGrid"; +import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; +import { DetailsModal } from "@/components/overlays/details/DetailsModal"; +import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; +import { UserIcon, UserIcons } from "@/components/UserIcon"; +import { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { useRandomTranslation } from "@/hooks/useRandomTranslation"; +import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; +import { useAuthStore } from "@/stores/auth"; +import { useBookmarkStore } from "@/stores/bookmarks"; +import { useGroupOrderStore } from "@/stores/groupOrder"; +import { useOverlayStack } from "@/stores/interface/overlayStack"; +import { useProgressStore } from "@/stores/progress"; +import { MediaItem } from "@/utils/mediaTypes"; + +function parseGroupString(group: string): { icon: UserIcons; name: string } { + const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/); + if (match) { + const iconKey = match[1].toUpperCase() as keyof typeof UserIcons; + const icon = UserIcons[iconKey] || UserIcons.BOOKMARK; + const name = match[2].trim(); + return { icon, name }; + } + return { icon: UserIcons.BOOKMARK, name: group }; +} + +interface AllBookmarksProps { + onShowDetails?: (media: MediaItem) => void; +} + +export function AllBookmarks({ onShowDetails }: AllBookmarksProps) { + const { t } = useTranslation(); + const { t: randomT } = useRandomTranslation(); + const emptyText = randomT(`home.search.empty`); + const navigate = useNavigate(); + const progressItems = useProgressStore((s) => s.items); + const bookmarks = useBookmarkStore((s) => s.bookmarks); + const groupOrder = useGroupOrderStore((s) => s.groupOrder); + const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder); + const removeBookmark = useBookmarkStore((s) => s.removeBookmark); + const [editing, setEditing] = useState(false); + const [gridRef] = useAutoAnimate(); + const editOrderModal = useModal("bookmark-edit-order-all"); + const [tempGroupOrder, setTempGroupOrder] = useState([]); + const backendUrl = useBackendUrl(); + const account = useAuthStore((s) => s.account); + const [detailsData, setDetailsData] = useState(); + const { showModal } = useOverlayStack(); + + const handleShowDetails = async (media: MediaItem) => { + if (onShowDetails) { + onShowDetails(media); + } else { + setDetailsData({ + id: Number(media.id), + type: media.type === "movie" ? "movie" : "show", + }); + showModal("details"); + } + }; + + const items = useMemo(() => { + let 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]); + + const { groupedItems, regularItems } = useMemo(() => { + const grouped: Record = {}; + const regular: MediaItem[] = []; + + items.forEach((item) => { + const bookmark = bookmarks[item.id]; + if (Array.isArray(bookmark?.group)) { + bookmark.group.forEach((groupName) => { + if (!grouped[groupName]) { + grouped[groupName] = []; + } + grouped[groupName].push(item); + }); + } else { + regular.push(item); + } + }); + + // Sort items within each group by date + 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; + }); + }); + + return { groupedItems: grouped, regularItems: regular }; + }, [items, bookmarks, progressItems]); + + // group sorting + const allGroups = useMemo(() => { + const groups = new Set(); + + Object.values(bookmarks).forEach((bookmark) => { + if (Array.isArray(bookmark.group)) { + bookmark.group.forEach((group) => groups.add(group)); + } + }); + + groups.add("bookmarks"); + + return Array.from(groups); + }, [bookmarks]); + + const sortableItems = useMemo(() => { + const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder; + + if (currentOrder.length === 0) { + return allGroups.map((group) => { + const { name } = parseGroupString(group); + return { + id: group, + name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name, + } as Item; + }); + } + + const orderMap = new Map( + currentOrder.map((group, index) => [group, index]), + ); + const sortedGroups = allGroups.sort((groupA, groupB) => { + const orderA = orderMap.has(groupA) + ? orderMap.get(groupA)! + : Number.MAX_SAFE_INTEGER; + const orderB = orderMap.has(groupB) + ? orderMap.get(groupB)! + : Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + return sortedGroups.map((group) => { + const { name } = parseGroupString(group); + return { + id: group, + name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name, + } as Item; + }); + }, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]); + + const sortedSections = useMemo(() => { + const sections: Array<{ + type: "grouped" | "regular"; + group?: string; + items: MediaItem[]; + }> = []; + + const allSections = new Map(); + + Object.entries(groupedItems).forEach(([group, groupItems]) => { + allSections.set(group, groupItems); + }); + + if (regularItems.length > 0) { + allSections.set("bookmarks", regularItems); + } + + if (groupOrder.length === 0) { + allSections.forEach((sectionItems, group) => { + if (group === "bookmarks") { + sections.push({ type: "regular", items: sectionItems }); + } else { + sections.push({ type: "grouped", group, items: sectionItems }); + } + }); + } else { + const orderMap = new Map( + groupOrder.map((group, index) => [group, index]), + ); + + Array.from(allSections.entries()) + .sort(([groupA], [groupB]) => { + const orderA = orderMap.has(groupA) + ? orderMap.get(groupA)! + : Number.MAX_SAFE_INTEGER; + const orderB = orderMap.has(groupB) + ? orderMap.get(groupB)! + : Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }) + .forEach(([group, sectionItems]) => { + if (group === "bookmarks") { + sections.push({ type: "regular", items: sectionItems }); + } else { + sections.push({ type: "grouped", group, items: sectionItems }); + } + }); + } + + return sections; + }, [groupedItems, regularItems, groupOrder]); + + const handleEditGroupOrder = () => { + // Initialize with current order or default order + if (groupOrder.length === 0) { + const defaultOrder = allGroups.map((group) => group); + setTempGroupOrder(defaultOrder); + } else { + setTempGroupOrder([...groupOrder]); + } + editOrderModal.show(); + }; + + const handleReorderClick = () => { + handleEditGroupOrder(); + // Keep editing state active by setting it to true + setEditing(true); + }; + + const handleCancelOrder = () => { + editOrderModal.hide(); + }; + + const handleSaveOrderClick = () => { + setGroupOrder(tempGroupOrder); + editOrderModal.hide(); + + // Save to backend + if (backendUrl && account) { + useGroupOrderStore + .getState() + .saveGroupOrderToBackend(backendUrl, account); + } + }; + + if (items.length === 0) { + return ( + + +
+

{emptyText}

+ +
+
+
+ ); + } + + return ( + + +
+ + {t("home.bookmarks.sectionTitle")} + +
+ {editing && allGroups.length > 1 && ( + + )} +
+
+ +
+ +
+ +
+ {/* Grouped Bookmarks */} + {sortedSections.map((section) => { + if (section.type === "grouped") { + const { icon, name } = parseGroupString(section.group || ""); + return ( +
+ + + + } + > +
+ {editing && allGroups.length > 1 && ( + + )} + +
+
+ + {section.items.map((v) => ( +
) => + e.preventDefault() + } + > + removeBookmark(v.id)} + onShowDetails={handleShowDetails} + /> +
+ ))} +
+
+ ); + } // regular items + return ( +
+ +
+ {editing && allGroups.length > 1 && ( + + )} + +
+
+ + {section.items.map((v) => ( +
) => + e.preventDefault() + } + > + removeBookmark(v.id)} + onShowDetails={handleShowDetails} + /> +
+ ))} +
+
+ ); + })} +
+ + {/* Edit Order Modal */} + + + + {t("home.bookmarks.groups.reorder.title")} + + + {t("home.bookmarks.groups.reorder.description")} + +
+ { + const newOrder = newItems.map((item) => item.id); + setTempGroupOrder(newOrder); + }} + /> +
+
+ + +
+
+
+ + {detailsData && } +
+
+ ); +} diff --git a/src/pages/parts/home/BookmarksCarousel.tsx b/src/pages/parts/home/BookmarksCarousel.tsx index d489b057..18611469 100644 --- a/src/pages/parts/home/BookmarksCarousel.tsx +++ b/src/pages/parts/home/BookmarksCarousel.tsx @@ -1,15 +1,17 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; import { Button } from "@/components/buttons/Button"; import { EditButton } from "@/components/buttons/EditButton"; import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; import { Item, SortableList } from "@/components/form/SortableList"; -import { Icons } from "@/components/Icon"; +import { Icon, Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; import { UserIcon, UserIcons } from "@/components/UserIcon"; +import { Flare } from "@/components/utils/Flare"; import { Heading2, Paragraph } from "@/components/utils/Text"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useIsMobile } from "@/hooks/useIsMobile"; @@ -39,6 +41,7 @@ interface BookmarksCarouselProps { } const LONG_PRESS_DURATION = 500; // 0.5 seconds +const MAX_ITEMS_PER_SECTION = 20; // Limit items per section function MediaCardSkeleton() { return ( @@ -51,6 +54,36 @@ function MediaCardSkeleton() { ); } +function MoreBookmarksCard() { + const { t } = useTranslation(); + + return ( +
+ + + + +
+ + + {t("home.bookmarks.showAll")} + +
+
+
+ +
+ ); +} + export function BookmarksCarousel({ carouselRefs, onShowDetails, @@ -396,7 +429,7 @@ export function BookmarksCarousel({ if (section.type === "grouped") { const { icon, name } = parseGroupString(section.group || ""); return ( -
+
- {section.items.map((media) => ( -
) => - e.preventDefault() - } - onTouchStart={handleTouchStart} - onTouchEnd={handleTouchEnd} - onMouseDown={handleMouseDown} - onMouseUp={handleMouseUp} - className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" - > - ( +
removeBookmark(media.id)} - /> -
- ))} + style={{ userSelect: "none" }} + onContextMenu={(e: React.MouseEvent) => + e.preventDefault() + } + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} + className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" + > + removeBookmark(media.id)} + /> +
+ ))} + + {section.items.length > MAX_ITEMS_PER_SECTION && ( + + )}
@@ -472,7 +511,7 @@ export function BookmarksCarousel({ return (
@@ -503,34 +542,40 @@ export function BookmarksCarousel({
{section.items.length > 0 - ? section.items.map((media) => ( -
) => - e.preventDefault() - } - onTouchStart={handleTouchStart} - onTouchEnd={handleTouchEnd} - onMouseDown={handleMouseDown} - onMouseUp={handleMouseUp} - className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" - > - ( +
removeBookmark(media.id)} - /> -
- )) + style={{ userSelect: "none" }} + onContextMenu={( + e: React.MouseEvent, + ) => e.preventDefault()} + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} + className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto" + > + removeBookmark(media.id)} + /> +
+ )) : Array.from({ length: SKELETON_COUNT }).map(() => ( ))} + {section.items.length > MAX_ITEMS_PER_SECTION && ( + + )} +
diff --git a/src/setup/App.tsx b/src/setup/App.tsx index de5c3b86..c602eb80 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -14,6 +14,7 @@ import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; import { useOnlineListener } from "@/hooks/usePing"; import { AboutPage } from "@/pages/About"; import { AdminPage } from "@/pages/admin/AdminPage"; +import { AllBookmarks } from "@/pages/bookmarks/AllBookmarks"; import VideoTesterView from "@/pages/developer/VideoTesterView"; import { DiscoverMore } from "@/pages/discover/AllMovieLists"; import { Discover } from "@/pages/discover/Discover"; @@ -182,6 +183,8 @@ function App() { /> } /> } /> + {/* Bookmarks page */} + } /> {/* Settings page */}