add support for multiple groups

This commit is contained in:
Pas 2025-07-22 11:10:18 -06:00
parent 3a6b0fb2f2
commit fe8533251c
7 changed files with 113 additions and 77 deletions

View file

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

View file

@ -5,10 +5,10 @@ import { UserIcon, UserIcons } from "@/components/UserIcon";
interface GroupDropdownProps {
groups: string[];
currentGroup?: string;
onSelectGroup: (group: string) => void;
currentGroups: string[];
onSelectGroups: (groups: string[]) => void;
onCreateGroup: (group: string, icon: UserIcons) => void;
onRemoveGroup: () => void;
onRemoveGroup: (groupToRemove?: string) => void;
}
const userIconList = Object.values(UserIcons);
@ -26,8 +26,8 @@ function parseGroupString(group: string): { icon: UserIcons; name: string } {
export function GroupDropdown({
groups,
currentGroup,
onSelectGroup,
currentGroups,
onSelectGroups,
onCreateGroup,
onRemoveGroup,
}: GroupDropdownProps) {
@ -36,11 +36,14 @@ export function GroupDropdown({
const [showInput, setShowInput] = useState(false);
const [selectedIcon, setSelectedIcon] = useState<UserIcons>(userIconList[0]);
const handleSelect = (group: string) => {
setOpen(false);
setShowInput(false);
setNewGroup("");
onSelectGroup(group);
const handleToggleGroup = (group: string) => {
let newGroups;
if (currentGroups.includes(group)) {
newGroups = currentGroups.filter((g) => g !== group);
} else {
newGroups = [...currentGroups, group];
}
onSelectGroups(newGroups);
};
const handleCreate = (group: string, icon: UserIcons) => {
@ -59,18 +62,21 @@ export function GroupDropdown({
className="w-full px-3 py-2 text-xs bg-gray-700/50 border border-gray-600 rounded-lg text-white flex justify-between items-center"
onClick={() => setOpen((v) => !v)}
>
{currentGroup ? (
(() => {
const { icon, name } = parseGroupString(currentGroup);
return (
<span className="flex items-center gap-2 font-semibold text-purple-400">
<span className="w-6 h-6 flex items-center justify-center">
<UserIcon icon={icon} className="inline-block" />
{currentGroups.length > 0 ? (
<span className="flex flex-wrap gap-1 items-center">
{currentGroups.map((group) => {
const { icon, name } = parseGroupString(group);
return (
<span
key={group}
className="flex items-center gap-1 bg-purple-900/30 px-2 py-1 rounded text-purple-300 text-xs"
>
<UserIcon icon={icon} className="inline-block w-4 h-4" />
{name}
</span>
{name}
</span>
);
})()
);
})}
</span>
) : (
<span className="text-white/70">Add to group</span>
)}
@ -82,24 +88,23 @@ export function GroupDropdown({
</span>
</button>
{open && (
<div className="absolute z-50 mt-1 w-full bg-gray-800 border border-gray-700 rounded-lg shadow-lg py-1 text-xs">
<div className="absolute z-[150] mt-1 w-full bg-gray-800 border border-gray-700 rounded-lg shadow-lg py-1 text-xs">
{groups.length === 0 && !showInput && (
<div className="px-4 py-2 text-gray-400">No groups</div>
)}
{groups.map((group) => {
const { icon, name } = parseGroupString(group);
return (
<button
type="button"
<label
key={group}
className={`w-full text-left px-4 py-2 hover:bg-purple-700/30 rounded-md flex items-center gap-2 ${
currentGroup === group
? "text-purple-400 font-semibold"
: "text-white"
}`}
onClick={() => handleSelect(group)}
disabled={currentGroup === group}
className="flex items-center gap-2 px-4 py-2 hover:bg-purple-700/30 rounded-md cursor-pointer"
>
<input
type="checkbox"
checked={currentGroups.includes(group)}
onChange={() => handleToggleGroup(group)}
className="accent-purple-400"
/>
<span className="w-5 h-5 flex items-center justify-center mr-2">
<UserIcon
icon={icon}
@ -107,7 +112,7 @@ export function GroupDropdown({
/>
</span>
{name}
</button>
</label>
);
})}
<div className="flex flex-col gap-2 px-4 py-2">
@ -159,17 +164,36 @@ export function GroupDropdown({
</div>
)}
</div>
{currentGroup && (
<button
type="button"
className="w-full text-left px-4 pt-3 pb-2 text-red-400 hover:bg-red-700/30 border-t border-gray-700"
onClick={() => {
setOpen(false);
onRemoveGroup();
}}
>
Remove from group
</button>
{currentGroups.length > 0 && (
<div className="border-t border-gray-700 pt-2 px-4">
<div className="text-xs text-red-400 mb-1">
Remove from group:
</div>
<div className="flex flex-wrap gap-2">
{currentGroups.map((group) => {
const { icon, name } = parseGroupString(group);
return (
<button
key={group}
type="button"
className="flex items-center gap-1 px-2 py-1 rounded bg-red-900/30 text-red-300 text-xs hover:bg-red-700/30"
onClick={() => onRemoveGroup(group)}
>
<UserIcon icon={icon} className="inline-block w-4 h-4" />
{name}
<span className="ml-1">&times;</span>
</button>
);
})}
<button
type="button"
className="ml-2 text-xs text-red-400 underline"
onClick={() => onRemoveGroup()}
>
Remove all
</button>
</div>
</div>
)}
</div>
)}

View file

@ -9,12 +9,14 @@ import { IconPatch } from "../buttons/IconPatch";
interface MediaBookmarkProps {
media: MediaItem;
group?: string;
group?: string[];
}
export function MediaBookmarkButton({ media, group }: MediaBookmarkProps) {
const addBookmark = useBookmarkStore((s) => s.addBookmark);
const addBookmarkWithGroup = useBookmarkStore((s) => s.addBookmarkWithGroup);
const addBookmarkWithGroups = useBookmarkStore(
(s) => s.addBookmarkWithGroups,
);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const meta: PlayerMeta | undefined = useMemo(() => {
@ -33,13 +35,13 @@ export function MediaBookmarkButton({ media, group }: MediaBookmarkProps) {
const toggleBookmark = useCallback(() => {
if (!meta) return;
if (isBookmarked) removeBookmark(meta.tmdbId);
else if (group) addBookmarkWithGroup(meta, group);
else if (group && group.length > 0) addBookmarkWithGroups(meta, group);
else addBookmark(meta);
}, [
isBookmarked,
meta,
addBookmark,
addBookmarkWithGroup,
addBookmarkWithGroups,
removeBookmark,
group,
]);

View file

@ -30,21 +30,22 @@ export function DetailsBody({
const [releaseInfo, setReleaseInfo] = useState<TraktReleaseResponse | null>(
null,
);
const addBookmarkWithGroup = useBookmarkStore((s) => s.addBookmarkWithGroup);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const addBookmark = useBookmarkStore((s) => s.addBookmark);
const addBookmarkWithGroups = useBookmarkStore(
(s) => s.addBookmarkWithGroups,
);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const currentGroup = bookmarks[data.id?.toString() ?? ""]?.group;
const currentGroups = bookmarks[data.id?.toString() ?? ""]?.group || [];
const allGroups = Array.from(
new Set(
Object.values(bookmarks)
.map((b) => b.group)
.flatMap((b) => b.group || [])
.filter(Boolean),
),
) as string[];
const handleSelectGroup = (group: string) => {
const handleSelectGroups = (groups: string[]) => {
if (!data.id) return;
const meta = {
tmdbId: data.id.toString(),
@ -55,14 +56,14 @@ export function DetailsBody({
: 0,
poster: data.posterUrl,
};
addBookmarkWithGroup(meta, group);
addBookmarkWithGroups(meta, groups);
};
const handleCreateGroup = (group: string) => {
handleSelectGroup(group);
handleSelectGroups([...currentGroups, group]);
};
const handleRemoveGroup = () => {
const handleRemoveGroup = (groupToRemove?: string) => {
if (!data.id) return;
const meta = {
tmdbId: data.id.toString(),
@ -73,8 +74,13 @@ export function DetailsBody({
: 0,
poster: data.posterUrl,
};
removeBookmark(data.id.toString());
addBookmark(meta);
if (groupToRemove) {
const newGroups = currentGroups.filter((g) => g !== groupToRemove);
addBookmarkWithGroups(meta, newGroups);
} else {
// Remove all groups
addBookmarkWithGroups(meta, []);
}
};
useEffect(() => {
@ -266,8 +272,8 @@ export function DetailsBody({
{/* Group Dropdown */}
<GroupDropdown
groups={allGroups}
currentGroup={currentGroup}
onSelectGroup={handleSelectGroup}
currentGroups={currentGroups}
onSelectGroups={handleSelectGroups}
onCreateGroup={handleCreateGroup}
onRemoveGroup={handleRemoveGroup}
/>

View file

@ -91,11 +91,13 @@ export function BookmarksCarousel({
items.forEach((item) => {
const bookmark = bookmarks[item.id];
if (bookmark?.group) {
if (!grouped[bookmark.group]) {
grouped[bookmark.group] = [];
}
grouped[bookmark.group].push(item);
if (Array.isArray(bookmark?.group)) {
bookmark.group.forEach((groupName) => {
if (!grouped[groupName]) {
grouped[groupName] = [];
}
grouped[groupName].push(item);
});
} else {
regular.push(item);
}

View file

@ -69,11 +69,13 @@ export function BookmarksPart({
items.forEach((item) => {
const bookmark = bookmarks[item.id];
if (bookmark?.group) {
if (!grouped[bookmark.group]) {
grouped[bookmark.group] = [];
}
grouped[bookmark.group].push(item);
if (Array.isArray(bookmark?.group)) {
bookmark.group.forEach((groupName) => {
if (!grouped[groupName]) {
grouped[groupName] = [];
}
grouped[groupName].push(item);
});
} else {
regular.push(item);
}

View file

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