add dedicated bookmarks page

This commit is contained in:
Pas 2025-08-01 17:09:47 -06:00
parent 2aece97408
commit 9cccb2637c
4 changed files with 548 additions and 45 deletions

View file

@ -242,6 +242,7 @@
"home": {
"bookmarks": {
"sectionTitle": "Bookmarks",
"showAll": "Show all",
"groups": {
"dropdown": {
"placeholderButton": "Add to group",

View file

@ -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<HTMLDivElement>();
const editOrderModal = useModal("bookmark-edit-order-all");
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
const backendUrl = useBackendUrl();
const account = useAuthStore((s) => s.account);
const [detailsData, setDetailsData] = useState<any>();
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<string, MediaItem[]> = {};
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<string>();
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<string, MediaItem[]>();
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 (
<SubPageLayout>
<WideContainer>
<div className="flex flex-col items-center justify-center translate-y-1/2">
<p className="text-[18.5px] pb-3">{emptyText}</p>
<Button
theme="purple"
onClick={() => navigate("/")}
className="mt-4"
>
{t("notFound.goHome")}
</Button>
</div>
</WideContainer>
</SubPageLayout>
);
}
return (
<SubPageLayout>
<WideContainer>
<div className="flex items-center justify-between gap-8">
<Heading1 className="text-2xl font-bold text-white">
{t("home.bookmarks.sectionTitle")}
</Heading1>
<div className="flex items-center gap-2">
{editing && allGroups.length > 1 && (
<EditButtonWithText
editing={editing}
onEdit={handleReorderClick}
id="edit-group-order-button-all"
text={t("home.bookmarks.groups.reorder.button")}
secondaryText={t("home.bookmarks.groups.reorder.done")}
/>
)}
</div>
</div>
<div className="flex items-center gap-4 pb-8">
<button
type="button"
onClick={() => navigate("/")}
className="flex items-center text-white hover:text-gray-300 transition-colors"
>
<Icon icon={Icons.ARROW_LEFT} className="text-xl" />
<span className="ml-2">{t("discover.page.back")}</span>
</button>
</div>
<div
className={`relative ${editOrderModal.isShown ? "pointer-events-none" : ""}`}
>
{/* Grouped Bookmarks */}
{sortedSections.map((section) => {
if (section.type === "grouped") {
const { icon, name } = parseGroupString(section.group || "");
return (
<div key={section.group || "bookmarks"} className="mb-6">
<SectionHeading
title={name}
customIcon={
<span className="w-6 h-6 flex items-center justify-center">
<UserIcon icon={icon} className="w-full h-full" />
</span>
}
>
<div className="flex items-center gap-2">
{editing && allGroups.length > 1 && (
<EditButtonWithText
editing={editing}
onEdit={handleReorderClick}
id="edit-group-order-button"
text={t("home.bookmarks.groups.reorder.button")}
secondaryText={t(
"home.bookmarks.groups.reorder.done",
)}
/>
)}
<EditButton
editing={editing}
onEdit={setEditing}
id={`edit-button-bookmark-${section.group}`}
/>
</div>
</SectionHeading>
<MediaGrid>
{section.items.map((v) => (
<div
key={v.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={handleShowDetails}
/>
</div>
))}
</MediaGrid>
</div>
);
} // regular items
return (
<div key="regular-bookmarks" className="mb-6">
<SectionHeading
title={t("home.bookmarks.sectionTitle")}
icon={Icons.BOOKMARK}
>
<div className="flex items-center gap-2">
{editing && allGroups.length > 1 && (
<EditButtonWithText
editing={editing}
onEdit={handleReorderClick}
id="edit-group-order-button"
text={t("home.bookmarks.groups.reorder.button")}
secondaryText={t("home.bookmarks.groups.reorder.done")}
/>
)}
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-bookmark"
/>
</div>
</SectionHeading>
<MediaGrid ref={gridRef}>
{section.items.map((v) => (
<div
key={v.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={handleShowDetails}
/>
</div>
))}
</MediaGrid>
</div>
);
})}
</div>
{/* Edit Order Modal */}
<Modal id={editOrderModal.id}>
<ModalCard>
<Heading2 className="!mt-0">
{t("home.bookmarks.groups.reorder.title")}
</Heading2>
<Paragraph>
{t("home.bookmarks.groups.reorder.description")}
</Paragraph>
<div className="mt-6">
<SortableList
items={sortableItems}
setItems={(newItems) => {
const newOrder = newItems.map((item) => item.id);
setTempGroupOrder(newOrder);
}}
/>
</div>
<div className="flex gap-4 mt-6 justify-end">
<Button theme="secondary" onClick={handleCancelOrder}>
{t("home.bookmarks.groups.reorder.cancel")}
</Button>
<Button theme="purple" onClick={handleSaveOrderClick}>
{t("home.bookmarks.groups.reorder.save")}
</Button>
</div>
</ModalCard>
</Modal>
{detailsData && <DetailsModal id="details" data={detailsData} />}
</WideContainer>
</SubPageLayout>
);
}

View file

@ -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 (
<div 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">
<Link to="/bookmarks" className="block">
<Flare.Base className="group -m-[0.705em] h-[20rem] hover:scale-95 transition-all rounded-xl bg-background-main duration-300 hover:bg-mediaCard-hoverBackground tabbable">
<Flare.Light
flareSize={300}
cssColorVar="--colors-mediaCard-hoverAccent"
backgroundClass="bg-mediaCard-hoverBackground duration-100"
className="rounded-xl bg-background-main group-hover:opacity-100"
/>
<Flare.Child className="pointer-events-auto h-[20rem] relative mb-2 p-[0.4em] transition-transform duration-300">
<div className="flex absolute inset-0 flex-col items-center justify-center">
<Icon
icon={Icons.ARROW_RIGHT}
className="text-4xl mb-2 transition-transform duration-300"
/>
<span className="text-sm text-center px-2">
{t("home.bookmarks.showAll")}
</span>
</div>
</Flare.Child>
</Flare.Base>
</Link>
</div>
);
}
export function BookmarksCarousel({
carouselRefs,
onShowDetails,
@ -396,7 +429,7 @@ export function BookmarksCarousel({
if (section.type === "grouped") {
const { icon, name } = parseGroupString(section.group || "");
return (
<div key={section.group || "bookmarks"}>
<div key={section.group}>
<SectionHeading
title={name}
customIcon={
@ -432,28 +465,34 @@ export function BookmarksCarousel({
>
<div className="md:w-12" />
{section.items.map((media) => (
<div
key={media.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
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"
>
<WatchedMediaCard
{section.items
.slice(0, MAX_ITEMS_PER_SECTION)
.map((media) => (
<div
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
/>
</div>
))}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
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"
>
<WatchedMediaCard
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
/>
</div>
))}
{section.items.length > MAX_ITEMS_PER_SECTION && (
<MoreBookmarksCard />
)}
<div className="md:w-12" />
</div>
@ -472,7 +511,7 @@ export function BookmarksCarousel({
return (
<div key="regular-bookmarks">
<SectionHeading
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
title={t("home.bookmarks.sectionTitle")}
icon={Icons.BOOKMARK}
className="ml-4 md:ml-12 mt-2 -mb-5"
>
@ -503,34 +542,40 @@ export function BookmarksCarousel({
<div className="md:w-12" />
{section.items.length > 0
? section.items.map((media) => (
<div
key={media.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
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"
>
<WatchedMediaCard
? section.items
.slice(0, MAX_ITEMS_PER_SECTION)
.map((media) => (
<div
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
/>
</div>
))
style={{ userSelect: "none" }}
onContextMenu={(
e: React.MouseEvent<HTMLDivElement>,
) => 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"
>
<WatchedMediaCard
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
/>
</div>
))
: Array.from({ length: SKELETON_COUNT }).map(() => (
<MediaCardSkeleton
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
/>
))}
{section.items.length > MAX_ITEMS_PER_SECTION && (
<MoreBookmarksCard />
)}
<div className="md:w-12" />
</div>

View file

@ -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() {
/>
<Route path="/discover/more/:category" element={<MoreContent />} />
<Route path="/discover/all" element={<DiscoverMore />} />
{/* Bookmarks page */}
<Route path="/bookmarks" element={<AllBookmarks />} />
{/* Settings page */}
<Route
path="/settings"