diff --git a/src/components/UserIcon.tsx b/src/components/UserIcon.tsx index 64d54a4f..8ee734c7 100644 --- a/src/components/UserIcon.tsx +++ b/src/components/UserIcon.tsx @@ -38,14 +38,14 @@ const iconList: Record = { saturn: ``, headphones: ``, tv: ``, - ghost: ``, + ghost: ``, coffee: ``, - fire: ``, - megaphone: ``, + fire: ``, + megaphone: ``, dragon: ``, rising_star: ``, cloud_arrow_up: ``, - wand: ``, + wand: ``, clapperBoard: ``, }; diff --git a/src/components/form/GroupDropdown.tsx b/src/components/form/GroupDropdown.tsx new file mode 100644 index 00000000..3ad50d25 --- /dev/null +++ b/src/components/form/GroupDropdown.tsx @@ -0,0 +1,178 @@ +import React, { useState } from "react"; + +import { Icon, Icons } from "@/components/Icon"; +import { UserIcon, UserIcons } from "@/components/UserIcon"; + +interface GroupDropdownProps { + groups: string[]; + currentGroup?: string; + onSelectGroup: (group: string) => void; + onCreateGroup: (group: string, icon: UserIcons) => void; + onRemoveGroup: () => void; +} + +const userIconList = Object.values(UserIcons); + +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] || userIconList[0]; + const name = match[2].trim(); + return { icon, name }; + } + return { icon: userIconList[0], name: group }; +} + +export function GroupDropdown({ + groups, + currentGroup, + onSelectGroup, + onCreateGroup, + onRemoveGroup, +}: GroupDropdownProps) { + const [open, setOpen] = useState(false); + const [newGroup, setNewGroup] = useState(""); + const [showInput, setShowInput] = useState(false); + const [selectedIcon, setSelectedIcon] = useState(userIconList[0]); + + const handleSelect = (group: string) => { + setOpen(false); + setShowInput(false); + setNewGroup(""); + onSelectGroup(group); + }; + + const handleCreate = (group: string, icon: UserIcons) => { + const groupString = `[${icon}]${group}`; + onCreateGroup(groupString, icon); + setOpen(false); + setShowInput(false); + setNewGroup(""); + setSelectedIcon(userIconList[0]); + }; + + return ( +
+ + {open && ( +
+ {groups.length === 0 && !showInput && ( +
No groups
+ )} + {groups.map((group) => { + const { icon, name } = parseGroupString(group); + return ( + + ); + })} +
+
+ setNewGroup(e.target.value)} + className="flex-1 px-2 py-1 rounded bg-gray-700 text-white border border-gray-600 text-xs min-w-0" + placeholder="Group name" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(newGroup, selectedIcon); + if (e.key === "Escape") setShowInput(false); + }} + style={{ minWidth: 0 }} + /> + +
+ {newGroup.trim().length > 0 && ( +
+ {userIconList.map((icon) => ( + + ))} +
+ )} +
+ {currentGroup && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/layout/SectionHeading.tsx b/src/components/layout/SectionHeading.tsx index 68f9d33a..526a57f7 100644 --- a/src/components/layout/SectionHeading.tsx +++ b/src/components/layout/SectionHeading.tsx @@ -7,6 +7,7 @@ interface SectionHeadingProps { title: string; children?: ReactNode; className?: string; + customIcon?: ReactNode; } export function SectionHeading(props: SectionHeadingProps) { @@ -14,7 +15,11 @@ export function SectionHeading(props: SectionHeadingProps) {

- {props.icon ? ( + {props.customIcon ? ( + + {props.customIcon} + + ) : props.icon ? ( diff --git a/src/components/overlays/details/DetailsBody.tsx b/src/components/overlays/details/DetailsBody.tsx index b5b1f03f..80e7dbf2 100644 --- a/src/components/overlays/details/DetailsBody.tsx +++ b/src/components/overlays/details/DetailsBody.tsx @@ -8,6 +8,7 @@ import { } from "@/backend/metadata/traktApi"; import { Button } from "@/components/buttons/Button"; import { IconPatch } from "@/components/buttons/IconPatch"; +import { GroupDropdown } from "@/components/form/GroupDropdown"; import { Icon, Icons } from "@/components/Icon"; import { MediaBookmarkButton } from "@/components/media/MediaBookmark"; import { useBookmarkStore } from "@/stores/bookmarks"; @@ -29,17 +30,22 @@ export function DetailsBody({ const [releaseInfo, setReleaseInfo] = useState( 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 allGroups = Array.from( + new Set( + Object.values(bookmarks) + .map((b) => b.group) + .filter(Boolean), + ), + ) as string[]; + const handleSelectGroup = (group: string) => { + if (!data.id) return; const meta = { tmdbId: data.id.toString(), title: data.title, @@ -49,16 +55,26 @@ export function DetailsBody({ : 0, poster: data.posterUrl, }; + addBookmarkWithGroup(meta, group); + }; - 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(""); - } + const handleCreateGroup = (group: string) => { + handleSelectGroup(group); + }; + + const handleRemoveGroup = () => { + 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, + }; + removeBookmark(data.id.toString()); + addBookmark(meta); }; useEffect(() => { @@ -247,36 +263,14 @@ export function DetailsBody({

- {/* Group Input */} -
- {currentGroup ? ( -
- In:{" "} - - {currentGroup} - -
- ) : ( - 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" - /> - )} - -
+ {/* Group Dropdown */} + ); diff --git a/src/pages/parts/home/BookmarksCarousel.tsx b/src/pages/parts/home/BookmarksCarousel.tsx index 47113795..8a2d3f30 100644 --- a/src/pages/parts/home/BookmarksCarousel.tsx +++ b/src/pages/parts/home/BookmarksCarousel.tsx @@ -5,12 +5,24 @@ import { EditButton } from "@/components/buttons/EditButton"; import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; +import { UserIcon, UserIcons } from "@/components/UserIcon"; import { useIsMobile } from "@/hooks/useIsMobile"; import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; import { useBookmarkStore } from "@/stores/bookmarks"; 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.CAT; + const name = match[2].trim(); + return { icon, name }; + } + return { icon: UserIcons.CAT, name: group }; +} + interface BookmarksCarouselProps { carouselRefs: React.MutableRefObject<{ [key: string]: HTMLDivElement | null; @@ -165,67 +177,74 @@ export function BookmarksCarousel({ return ( <> {/* Grouped Bookmarks Carousels */} - {Object.entries(groupedItems).map(([group, groupItems]) => ( -
- -
- -
-
-
-
{ - carouselRefs.current[group] = el; - }} - onWheel={handleWheel} + {Object.entries(groupedItems).map(([group, groupItems]) => { + const { icon, name } = parseGroupString(group); + return ( +
+ + + + } + className="ml-4 md:ml-12 mt-2 -mb-5" > -
+
+ +
+ +
+
{ + carouselRefs.current[group] = el; + }} + onWheel={handleWheel} + > +
- {groupItems.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)} + /> +
+ ))} -
+
+
+ + {!isMobile && ( + + )}
- - {!isMobile && ( - - )}
-
- ))} + ); + })} {/* Regular Bookmarks Carousel */} {regularItems.length > 0 && ( diff --git a/src/pages/parts/home/BookmarksPart.tsx b/src/pages/parts/home/BookmarksPart.tsx index b1eff792..601ee458 100644 --- a/src/pages/parts/home/BookmarksPart.tsx +++ b/src/pages/parts/home/BookmarksPart.tsx @@ -7,10 +7,22 @@ import { Icons } from "@/components/Icon"; import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; +import { UserIcon, UserIcons } from "@/components/UserIcon"; import { useBookmarkStore } from "@/stores/bookmarks"; 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.CAT; + const name = match[2].trim(); + return { icon, name }; + } + return { icon: UserIcons.CAT, name: group }; +} + const LONG_PRESS_DURATION = 700; // 0.7 seconds export function BookmarksPart({ @@ -126,43 +138,49 @@ export function BookmarksPart({ return (
{/* Grouped Bookmarks */} - {Object.entries(groupedItems).map(([group, groupItems]) => ( -
- - - - - {groupItems.map((v) => ( -
) => - e.preventDefault() - } - onTouchStart={handleTouchStart} - onTouchEnd={handleTouchEnd} - onMouseDown={handleMouseDown} - onMouseUp={handleMouseUp} - > - removeBookmark(v.id)} - onShowDetails={onShowDetails} - /> -
- ))} -
-
- ))} + {Object.entries(groupedItems).map(([group, groupItems]) => { + const { icon, name } = parseGroupString(group); + return ( +
+ + + + } + > + + + + {groupItems.map((v) => ( +
) => + e.preventDefault() + } + onTouchStart={handleTouchStart} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} + > + removeBookmark(v.id)} + onShowDetails={onShowDetails} + /> +
+ ))} +
+
+ ); + })} {/* Regular Bookmarks */} {regularItems.length > 0 && (