Add bookmark groups

This commit is contained in:
Pas 2025-07-17 12:39:50 -06:00
parent 5a74886e7d
commit cc04c9e6c0
7 changed files with 404 additions and 120 deletions

View file

@ -10,6 +10,7 @@ export interface BookmarkMetaInput {
year: number;
poster?: string;
type: string;
group?: string;
}
export interface BookmarkInput {

View file

@ -9,10 +9,12 @@ import { IconPatch } from "../buttons/IconPatch";
interface MediaBookmarkProps {
media: MediaItem;
group?: string;
}
export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
export function MediaBookmarkButton({ media, group }: MediaBookmarkProps) {
const addBookmark = useBookmarkStore((s) => s.addBookmark);
const addBookmarkWithGroup = useBookmarkStore((s) => s.addBookmarkWithGroup);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const meta: PlayerMeta | undefined = useMemo(() => {
@ -31,8 +33,16 @@ export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
const toggleBookmark = useCallback(() => {
if (!meta) return;
if (isBookmarked) removeBookmark(meta.tmdbId);
else if (group) addBookmarkWithGroup(meta, group);
else addBookmark(meta);
}, [isBookmarked, meta, addBookmark, removeBookmark]);
}, [
isBookmarked,
meta,
addBookmark,
addBookmarkWithGroup,
removeBookmark,
group,
]);
const buttonOpacityClass =
media.year === undefined ? "hover:opacity-100" : "hover:opacity-95";

View file

@ -10,6 +10,7 @@ import { Button } from "@/components/buttons/Button";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icon, Icons } from "@/components/Icon";
import { MediaBookmarkButton } from "@/components/media/MediaBookmark";
import { useBookmarkStore } from "@/stores/bookmarks";
import { DetailsBodyProps } from "./types";
@ -28,6 +29,37 @@ export function DetailsBody({
const [releaseInfo, setReleaseInfo] = useState<TraktReleaseResponse | null>(
null,
);
const [groupName, setGroupName] = useState("");
const addBookmarkWithGroup = useBookmarkStore((s) => s.addBookmarkWithGroup);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const addBookmark = useBookmarkStore((s) => s.addBookmark);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const currentGroup = bookmarks[data.id?.toString() ?? ""]?.group;
const handleGroupSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!data.id) return;
const meta = {
tmdbId: data.id.toString(),
title: data.title,
type: data.type || "movie",
releaseYear: data.releaseDate
? new Date(data.releaseDate).getFullYear()
: 0,
poster: data.posterUrl,
};
if (currentGroup) {
// Remove from group by removing bookmark and re-adding without group
removeBookmark(data.id.toString());
addBookmark(meta);
} else if (groupName.trim()) {
// Add to group
addBookmarkWithGroup(meta, groupName.trim());
setGroupName("");
}
};
useEffect(() => {
const fetchReleaseInfo = async () => {
@ -152,66 +184,99 @@ export function DetailsBody({
</div>
{/* Action Buttons */}
<div className="flex items-center gap-4">
<Button
onClick={onPlayClick}
theme="purple"
className={classNames(
"flex-1 sm:flex-initial sm:w-auto",
"gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
)}
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white text-sm pr-1">
{data.type === "movie"
? !data.releaseDate || new Date(data.releaseDate) > new Date()
? t("media.unreleased")
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Button
onClick={onPlayClick}
theme="purple"
className={classNames(
"flex-1 sm:flex-initial sm:w-auto",
"gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
"text-md text-white flex items-center justify-center",
)}
>
<Icon icon={Icons.PLAY} className="text-white" />
<span className="text-white text-sm pr-1">
{data.type === "movie"
? !data.releaseDate || new Date(data.releaseDate) > new Date()
? t("media.unreleased")
: showProgress
? t("details.resume")
: t("details.play")
: showProgress
? t("details.resume")
: t("details.play")
: showProgress
? t("details.resume")
: t("details.play")}
</span>
</Button>
<div className="flex items-center gap-1 flex-shrink-0">
{imdbData?.trailer_url && (
: t("details.play")}
</span>
</Button>
<div className="flex items-center gap-1 flex-shrink-0">
{imdbData?.trailer_url && (
<button
type="button"
onClick={onTrailerClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title={t("details.trailer")}
>
<IconPatch
icon={Icons.FILM}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
)}
<MediaBookmarkButton
media={{
id: data.id?.toString() || "",
title: data.title,
year: data.releaseDate
? new Date(data.releaseDate).getFullYear()
: undefined,
poster: data.posterUrl,
type: data.type || "movie",
}}
/>
<button
type="button"
onClick={onTrailerClick}
onClick={onShareClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title={t("details.trailer")}
title="Share"
>
<IconPatch
icon={Icons.FILM}
icon={Icons.IOS_SHARE}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
)}
<MediaBookmarkButton
media={{
id: data.id?.toString() || "",
title: data.title,
year: data.releaseDate
? new Date(data.releaseDate).getFullYear()
: undefined,
poster: data.backdrop,
type: data.type || "movie",
}}
/>
<button
type="button"
onClick={onShareClick}
className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95"
title="Share"
>
<IconPatch
icon={Icons.IOS_SHARE}
className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer"
/>
</button>
</div>
</div>
{/* Group Input */}
<form
onSubmit={handleGroupSubmit}
className="flex items-center gap-2 sm:ml-auto"
>
{currentGroup ? (
<div className="w-64 px-3 py-2 text-xs bg-gray-700/50 border border-gray-600 rounded-lg text-white">
In:{" "}
<span className="font-semibold text-purple-400">
{currentGroup}
</span>
</div>
) : (
<input
type="text"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
placeholder="Add to group..."
className="w-64 px-3 py-2 text-xs bg-gray-700/50 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500"
/>
)}
<Button
onClick={handleGroupSubmit}
disabled={currentGroup ? false : !groupName.trim()}
theme={currentGroup ? "danger" : "purple"}
className="px-3 py-2 text-xs"
>
{currentGroup ? "Remove" : "Add"}
</Button>
</form>
</div>
</div>
);

View file

@ -73,6 +73,40 @@ export function BookmarksCarousel({
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 (bookmark?.group) {
if (!grouped[bookmark.group]) {
grouped[bookmark.group] = [];
}
grouped[bookmark.group].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]);
const handleWheel = (e: React.WheelEvent) => {
if (isScrolling) return;
isScrolling = true;
@ -130,32 +164,34 @@ export function BookmarksCarousel({
return (
<>
<SectionHeading
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
icon={Icons.BOOKMARK}
className="ml-4 md:ml-12 mt-2 -mb-5"
>
<div className="mr-4 md:mr-8">
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-bookmark"
/>
</div>
</SectionHeading>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
ref={(el) => {
carouselRefs.current[categorySlug] = el;
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
{/* Grouped Bookmarks Carousels */}
{Object.entries(groupedItems).map(([group, groupItems]) => (
<div key={group}>
<SectionHeading
title={group}
icon={Icons.BOOKMARK}
className="ml-4 md:ml-12 mt-2 -mb-5"
>
<div className="mr-4 md:mr-8">
<EditButton
editing={editing}
onEdit={setEditing}
id={`edit-button-bookmark-${group}`}
/>
</div>
</SectionHeading>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${group}`}
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
ref={(el) => {
carouselRefs.current[group] = el;
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
{items.length > 0
? items.map((media) => (
{groupItems.map((media) => (
<div
key={media.id}
style={{ userSelect: "none" }}
@ -176,23 +212,89 @@ export function BookmarksCarousel({
onClose={() => removeBookmark(media.id)}
/>
</div>
))
: Array.from({ length: SKELETON_COUNT }).map(() => (
<MediaCardSkeleton
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
/>
))}
<div className="md:w-12" />
</div>
<div className="md:w-12" />
</div>
{!isMobile && (
<CarouselNavButtons
categorySlug={categorySlug}
carouselRefs={carouselRefs}
/>
)}
</div>
{!isMobile && (
<CarouselNavButtons
categorySlug={group}
carouselRefs={carouselRefs}
/>
)}
</div>
</div>
))}
{/* Regular Bookmarks Carousel */}
{regularItems.length > 0 && (
<>
<SectionHeading
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
icon={Icons.BOOKMARK}
className="ml-4 md:ml-12 mt-2 -mb-5"
>
<div className="mr-4 md:mr-8">
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-bookmark"
/>
</div>
</SectionHeading>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
ref={(el) => {
carouselRefs.current[categorySlug] = el;
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
{regularItems.length > 0
? regularItems.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
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)}`}
/>
))}
<div className="md:w-12" />
</div>
{!isMobile && (
<CarouselNavButtons
categorySlug={categorySlug}
carouselRefs={carouselRefs}
/>
)}
</div>
</>
)}
</>
);
}

View file

@ -51,6 +51,40 @@ export function BookmarksPart({
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 (bookmark?.group) {
if (!grouped[bookmark.group]) {
grouped[bookmark.group] = [];
}
grouped[bookmark.group].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]);
useEffect(() => {
onItemsChange(items.length > 0);
}, [items, onItemsChange]);
@ -91,38 +125,82 @@ export function BookmarksPart({
return (
<div className="relative">
<SectionHeading
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
icon={Icons.BOOKMARK}
>
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-bookmark"
/>
</SectionHeading>
<MediaGrid ref={gridRef}>
{items.map((v) => (
<div
key={v.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
{/* Grouped Bookmarks */}
{Object.entries(groupedItems).map(([group, groupItems]) => (
<div key={group} className="mb-6">
<SectionHeading
title={group}
icon={Icons.BOOKMARK}
className="mb-8" // margin?
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
<EditButton
editing={editing}
onEdit={setEditing}
id={`edit-button-bookmark-${group}`}
/>
</div>
))}
</MediaGrid>
</SectionHeading>
<MediaGrid>
{groupItems.map((v) => (
<div
key={v.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
/>
</div>
))}
</MediaGrid>
</div>
))}
{/* Regular Bookmarks */}
{regularItems.length > 0 && (
<div>
<SectionHeading
title={t("home.bookmarks.sectionTitle")}
icon={Icons.BOOKMARK}
>
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-bookmark"
/>
</SectionHeading>
<MediaGrid ref={gridRef}>
{regularItems.map((v) => (
<div
key={v.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
/>
</div>
))}
</MediaGrid>
</div>
)}
</div>
);
}

View file

@ -32,6 +32,7 @@ async function syncBookmarks(
title: item.title ?? "",
type: item.type ?? "",
year: item.year ?? NaN,
group: item.group,
},
tmdbId: item.tmdbId,
});

View file

@ -10,6 +10,7 @@ export interface BookmarkMediaItem {
poster?: string;
type: "show" | "movie";
updatedAt: number;
group?: string;
}
export interface BookmarkUpdateItem {
@ -19,6 +20,7 @@ export interface BookmarkUpdateItem {
id: string;
poster?: string;
type?: "show" | "movie";
group?: string;
action: "delete" | "add";
}
@ -26,6 +28,7 @@ export interface BookmarkStore {
bookmarks: Record<string, BookmarkMediaItem>;
updateQueue: BookmarkUpdateItem[];
addBookmark(meta: PlayerMeta): void;
addBookmarkWithGroup(meta: PlayerMeta, group?: string): void;
removeBookmark(id: string): void;
replaceBookmarks(items: Record<string, BookmarkMediaItem>): void;
clear(): void;
@ -74,6 +77,30 @@ export const useBookmarkStore = create(
};
});
},
addBookmarkWithGroup(meta, group) {
set((s) => {
updateId += 1;
s.updateQueue.push({
id: updateId.toString(),
action: "add",
tmdbId: meta.tmdbId,
type: meta.type,
title: meta.title,
year: meta.releaseYear,
poster: meta.poster,
group,
});
s.bookmarks[meta.tmdbId] = {
type: meta.type,
title: meta.title,
year: meta.releaseYear,
poster: meta.poster,
updatedAt: Date.now(),
group,
};
});
},
replaceBookmarks(items: Record<string, BookmarkMediaItem>) {
set((s) => {
s.bookmarks = items;