add edit group and edit bookmarks modals

This commit is contained in:
Pas 2025-11-05 23:57:56 -07:00
parent 2ad6b8b942
commit 1b073006f4
11 changed files with 1029 additions and 0 deletions

View file

@ -297,7 +297,27 @@
"description": "Drag and drop to reorder your bookmark groups",
"cancel": "Cancel",
"save": "Save"
},
"editGroup": {
"title": "Edit Group",
"description": "Edit the name and icon of your bookmark group",
"cancel": "Cancel",
"save": "Save",
"affectsBookmarks": "This will affect {{count}} bookmark(s)",
"nameLabel": "Group name",
"namePlaceholder": "Enter a name for your group"
}
},
"edit": {
"title": "Edit Bookmark",
"description": "Edit the details for this bookmark",
"cancel": "Cancel",
"save": "Save",
"groupsLabel": "Groups",
"titleLabel": "Title",
"titlePlaceholder": "Enter a title for your bookmark",
"yearLabel": "Year",
"yearPlaceholder": "Enter a year for your bookmark"
}
},
"continueWatching": {

View file

@ -94,6 +94,8 @@ export interface MediaCardProps {
onClose?: () => void;
onShowDetails?: (media: MediaItem) => void;
forceSkeleton?: boolean;
editable?: boolean;
onEdit?: () => void;
}
function checkReleased(media: MediaItem): boolean {
@ -119,6 +121,8 @@ function MediaCardContent({
onClose,
onShowDetails,
forceSkeleton,
editable,
onEdit,
}: MediaCardProps) {
const { t } = useTranslation();
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
@ -288,6 +292,24 @@ function MediaCardContent({
</button>
</div>
)}
{editable && closable && (
<div className="absolute bottom-0 translate-y-1 right-1">
<button
className="media-more-button p-2"
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit?.();
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.EDIT}
/>
</button>
</div>
)}
</Flare.Child>
</Flare.Base>
);

View file

@ -24,6 +24,8 @@ export interface WatchedMediaCardProps {
closable?: boolean;
onClose?: () => void;
onShowDetails?: (media: MediaItem) => void;
editable?: boolean;
onEdit?: () => void;
}
export function WatchedMediaCard(props: WatchedMediaCardProps) {
@ -51,6 +53,8 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
onClose={props.onClose}
closable={props.closable}
onShowDetails={props.onShowDetails}
editable={props.editable}
onEdit={props.onEdit}
/>
);
}

View file

@ -0,0 +1,167 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { GroupDropdown } from "@/components/form/GroupDropdown";
import { Modal, ModalCard } from "@/components/overlays/Modal";
import { UserIcons } from "@/components/UserIcon";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
interface EditBookmarkModalProps {
id: string;
isShown: boolean;
bookmarkId: string | null;
onCancel: () => void;
onSave: (bookmarkId: string, changes: Partial<BookmarkMediaItem>) => void;
}
export function EditBookmarkModal({
id,
isShown,
bookmarkId,
onCancel,
onSave,
}: EditBookmarkModalProps) {
const { t } = useTranslation();
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const [title, setTitle] = useState("");
const [year, setYear] = useState<number | undefined>();
const [groups, setGroups] = useState<string[]>([]);
// Get all available groups from all bookmarks
const allGroups = useMemo(() => {
const groupSet = new Set<string>();
Object.values(bookmarks).forEach((bookmark) => {
if (bookmark.group) {
bookmark.group.forEach((group) => groupSet.add(group));
}
});
return Array.from(groupSet);
}, [bookmarks]);
useEffect(() => {
if (bookmarkId && bookmarks[bookmarkId]) {
const bookmark = bookmarks[bookmarkId];
setTitle(bookmark.title);
setYear(bookmark.year);
setGroups(bookmark.group || []);
} else {
setTitle("");
setYear(undefined);
setGroups([]);
}
}, [bookmarkId, bookmarks]);
const handleSave = () => {
if (!bookmarkId) return;
const changes: Partial<BookmarkMediaItem> = {};
if (title !== bookmarks[bookmarkId]?.title) {
changes.title = title;
}
if (year !== bookmarks[bookmarkId]?.year) {
changes.year = year;
}
const currentGroups = bookmarks[bookmarkId]?.group || [];
if (
JSON.stringify(groups.sort()) !== JSON.stringify(currentGroups.sort())
) {
changes.group = groups;
}
if (Object.keys(changes).length > 0) {
onSave(bookmarkId, changes);
}
onCancel();
};
const handleCreateGroup = (groupString: string, _icon: UserIcons) => {
if (!groups.includes(groupString)) {
setGroups([...groups, groupString]);
}
};
const handleRemoveGroup = (groupToRemove?: string) => {
if (groupToRemove) {
setGroups(groups.filter((group) => group !== groupToRemove));
} else {
setGroups([]);
}
};
if (!isShown || !bookmarkId) return null;
return (
<Modal id={id}>
<ModalCard>
<Heading2 className="!my-0">{t("home.bookmarks.edit.title")}</Heading2>
<Paragraph className="mt-4">
{t("home.bookmarks.edit.description")}
</Paragraph>
<div className="space-y-4 mt-6">
{/* Title */}
<div>
<label className="block text-sm font-medium mb-2">
{t("home.bookmarks.edit.titleLabel")}
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("home.bookmarks.edit.titlePlaceholder")}
className="w-full px-3 py-2 bg-background-main border border-background-secondary rounded-lg text-white text-sm placeholder:text-type-secondary"
/>
</div>
{/* Year */}
<div>
<label className="block text-sm font-medium mb-2">
{t("home.bookmarks.edit.yearLabel")}
</label>
<input
type="number"
value={year || ""}
onChange={(e) =>
setYear(
e.target.value ? parseInt(e.target.value, 10) : undefined,
)
}
placeholder={t("home.bookmarks.edit.yearPlaceholder")}
className="w-full px-3 py-2 bg-background-main border border-background-secondary rounded-lg text-white text-sm placeholder:text-type-secondary"
/>
</div>
{/* Groups */}
<div>
<label className="block text-sm font-medium mb-2">
{t("home.bookmarks.edit.groupsLabel")}
</label>
<GroupDropdown
groups={allGroups}
currentGroups={groups}
onSelectGroups={setGroups}
onCreateGroup={handleCreateGroup}
onRemoveGroup={handleRemoveGroup}
/>
</div>
</div>
<div className="flex gap-4 mt-6 justify-end">
<Button theme="secondary" onClick={onCancel}>
{t("home.bookmarks.edit.cancel")}
</Button>
<Button theme="purple" onClick={handleSave}>
{t("home.bookmarks.edit.save")}
</Button>
</div>
</ModalCard>
</Modal>
);
}

View file

@ -0,0 +1,175 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { Modal, ModalCard } from "@/components/overlays/Modal";
import { UserIcon, UserIcons } from "@/components/UserIcon";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { useBookmarkStore } from "@/stores/bookmarks";
import {
createGroupString,
findBookmarksByGroup,
parseGroupString,
} from "@/utils/bookmarkModifications";
const userIconList = Object.values(UserIcons);
interface EditGroupModalProps {
id: string;
isShown: boolean;
groupName: string | null;
onCancel: () => void;
onSave: (oldGroupName: string, newGroupName: string) => void;
}
export function EditGroupModal({
id,
isShown,
groupName,
onCancel,
onSave,
}: EditGroupModalProps) {
const { t } = useTranslation();
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const [newGroupName, setNewGroupName] = useState("");
const [newGroupIcon, setNewGroupIcon] = useState<UserIcons>(
UserIcons.BOOKMARK,
);
const [affectedBookmarks, setAffectedBookmarks] = useState<string[]>([]);
const getIconFromKey = (iconKey: string): UserIcons => {
const key = iconKey.toUpperCase() as keyof typeof UserIcons;
return UserIcons[key] || UserIcons.BOOKMARK;
};
const getIconKey = (icon: UserIcons): string => {
const entry = Object.entries(UserIcons).find(([, value]) => value === icon);
return entry ? entry[0] : "BOOKMARK";
};
useEffect(() => {
if (groupName) {
const { icon, name } = parseGroupString(groupName);
setNewGroupName(name);
setNewGroupIcon(getIconFromKey(icon || "BOOKMARK"));
setAffectedBookmarks(findBookmarksByGroup(bookmarks, groupName));
} else {
setNewGroupName("");
setNewGroupIcon(UserIcons.BOOKMARK);
setAffectedBookmarks([]);
}
}, [groupName, bookmarks]);
const handleSave = () => {
if (!groupName || !newGroupName.trim()) return;
const iconKey = getIconKey(newGroupIcon);
const newGroupString = createGroupString(iconKey, newGroupName.trim());
if (newGroupString !== groupName) {
onSave(groupName, newGroupString);
}
onCancel();
};
if (!isShown || !groupName) return null;
const { icon: currentIcon, name: currentName } = parseGroupString(groupName);
const currentIconKey = currentIcon.toUpperCase() as keyof typeof UserIcons;
const currentIconComponent = UserIcons[currentIconKey] || UserIcons.BOOKMARK;
return (
<Modal id={id}>
<ModalCard>
<Heading2 className="!my-0">
{t("home.bookmarks.groups.editGroup.title")}
</Heading2>
<Paragraph className="mt-4">
{t("home.bookmarks.groups.editGroup.description")}
</Paragraph>
{/* Current Group Info */}
<div className="mt-4 p-3 bg-background-main rounded">
<div className="flex items-center gap-2 mb-2">
<UserIcon icon={currentIconComponent} className="w-5 h-5" />
<span className="font-medium">{currentName}</span>
</div>
<p className="text-sm text-type-secondary">
{t("home.bookmarks.groups.editGroup.affectsBookmarks", {
count: affectedBookmarks.length,
})}
</p>
</div>
<div className="space-y-4 mt-6">
{/* New Group Name */}
<div>
<label className="block text-sm font-medium mb-1">
{t("home.bookmarks.groups.editGroup.nameLabel")}
</label>
<input
type="text"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t("home.bookmarks.groups.editGroup.namePlaceholder")}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
}
}}
className="w-full px-3 py-2 bg-background-main border border-border rounded text-sm"
autoFocus
/>
{newGroupName.trim().length > 0 && (
<div className="flex items-center gap-2 flex-wrap pt-4 w-full justify-center">
{userIconList.map((icon) => (
<button
type="button"
key={icon}
className={`rounded p-1 border-2 ${
newGroupIcon === icon
? "border-type-link bg-mediaCard-hoverBackground"
: "border-transparent hover:border-background-secondary"
}`}
onClick={() => setNewGroupIcon(icon)}
>
<span className="w-5 h-5 flex items-center justify-center">
<UserIcon
icon={icon}
className={`w-full h-full ${
newGroupIcon === icon ? "text-type-link" : ""
}`}
/>
</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="flex gap-4 mt-6 justify-end">
<Button theme="secondary" onClick={onCancel}>
{t("home.bookmarks.groups.editGroup.cancel")}
</Button>
<Button
theme="purple"
onClick={handleSave}
disabled={
!newGroupName.trim() ||
createGroupString(
getIconKey(newGroupIcon),
newGroupName.trim(),
) === groupName
}
>
{t("home.bookmarks.groups.editGroup.save")}
</Button>
</div>
</ModalCard>
</Modal>
);
}

View file

@ -8,6 +8,8 @@ import { Item } from "@/components/form/SortableList";
import { Icon, Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal";
import { EditGroupModal } from "@/components/overlays/EditGroupModal";
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
import { useModal } from "@/components/overlays/Modal";
import { UserIcon, UserIcons } from "@/components/UserIcon";
@ -94,6 +96,18 @@ export function BookmarksCarousel({
const backendUrl = useBackendUrl();
const account = useAuthStore((s) => s.account);
// Editing modals
const editBookmarkModal = useModal("bookmark-edit-carousel");
const editGroupModal = useModal("bookmark-edit-group-carousel");
const [editingBookmarkId, setEditingBookmarkId] = useState<string | null>(
null,
);
const [editingGroupName, setEditingGroupName] = useState<string | null>(null);
const modifyBookmarks = useBookmarkStore((s) => s.modifyBookmarks);
const modifyBookmarksByGroup = useBookmarkStore(
(s) => s.modifyBookmarksByGroup,
);
// Group order editing state
const groupOrder = useGroupOrderStore((s) => s.groupOrder);
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
@ -328,6 +342,38 @@ export function BookmarksCarousel({
}
};
const handleEditBookmark = (bookmarkId: string) => {
setEditingBookmarkId(bookmarkId);
editBookmarkModal.show();
};
const handleSaveBookmark = (bookmarkId: string, changes: any) => {
modifyBookmarks([bookmarkId], changes);
editBookmarkModal.hide();
setEditingBookmarkId(null);
};
const handleEditGroup = (groupName: string) => {
setEditingGroupName(groupName);
editGroupModal.show();
};
const handleSaveGroup = (oldGroupName: string, newGroupName: string) => {
modifyBookmarksByGroup({ oldGroupName, newGroupName });
editGroupModal.hide();
setEditingGroupName(null);
};
const handleCancelEditBookmark = () => {
editBookmarkModal.hide();
setEditingBookmarkId(null);
};
const handleCancelEditGroup = () => {
editGroupModal.hide();
setEditingGroupName(null);
};
const categorySlug = "bookmarks";
const SKELETON_COUNT = 10;
@ -360,6 +406,17 @@ export function BookmarksCarousel({
secondaryText={t("home.bookmarks.groups.reorder.done")}
/>
)}
{editing && section.group && (
<EditButtonWithText
editing={editing}
onEdit={() => handleEditGroup(section.group!)}
id="edit-group-button"
text={t("home.bookmarks.groups.editGroup.title")}
secondaryText={t(
"home.bookmarks.groups.editGroup.cancel",
)}
/>
)}
<EditButton
editing={editing}
onEdit={setEditing}
@ -394,6 +451,8 @@ export function BookmarksCarousel({
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
editable={editing}
onEdit={() => handleEditBookmark(media.id)}
/>
</div>
))}
@ -467,6 +526,8 @@ export function BookmarksCarousel({
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
editable={editing}
onEdit={() => handleEditBookmark(media.id)}
/>
</div>
))
@ -506,6 +567,24 @@ export function BookmarksCarousel({
setTempGroupOrder(newOrder);
}}
/>
{/* Edit Bookmark Modal */}
<EditBookmarkModal
id={editBookmarkModal.id}
isShown={editBookmarkModal.isShown}
bookmarkId={editingBookmarkId}
onCancel={handleCancelEditBookmark}
onSave={handleSaveBookmark}
/>
{/* Edit Group Modal */}
<EditGroupModal
id={editGroupModal.id}
isShown={editGroupModal.isShown}
groupName={editingGroupName}
onCancel={handleCancelEditGroup}
onSave={handleSaveGroup}
/>
</>
);
}

View file

@ -9,6 +9,8 @@ import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal";
import { EditGroupModal } from "@/components/overlays/EditGroupModal";
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
import { useModal } from "@/components/overlays/Modal";
import { UserIcon, UserIcons } from "@/components/UserIcon";
@ -46,9 +48,19 @@ export function BookmarksPart({
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
const editOrderModal = useModal("bookmark-edit-order");
const editBookmarkModal = useModal("bookmark-edit");
const editGroupModal = useModal("bookmark-edit-group");
const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]);
const [editingBookmarkId, setEditingBookmarkId] = useState<string | null>(
null,
);
const [editingGroupName, setEditingGroupName] = useState<string | null>(null);
const backendUrl = useBackendUrl();
const account = useAuthStore((s) => s.account);
const modifyBookmarks = useBookmarkStore((s) => s.modifyBookmarks);
const modifyBookmarksByGroup = useBookmarkStore(
(s) => s.modifyBookmarksByGroup,
);
const items = useMemo(() => {
let output: MediaItem[] = [];
@ -248,6 +260,38 @@ export function BookmarksPart({
}
};
const handleEditBookmark = (bookmarkId: string) => {
setEditingBookmarkId(bookmarkId);
editBookmarkModal.show();
};
const handleSaveBookmark = (bookmarkId: string, changes: any) => {
modifyBookmarks([bookmarkId], changes);
editBookmarkModal.hide();
setEditingBookmarkId(null);
};
const handleEditGroup = (groupName: string) => {
setEditingGroupName(groupName);
editGroupModal.show();
};
const handleSaveGroup = (oldGroupName: string, newGroupName: string) => {
modifyBookmarksByGroup({ oldGroupName, newGroupName });
editGroupModal.hide();
setEditingGroupName(null);
};
const handleCancelEditBookmark = () => {
editBookmarkModal.hide();
setEditingBookmarkId(null);
};
const handleCancelEditGroup = () => {
editGroupModal.hide();
setEditingGroupName(null);
};
if (items.length === 0) return null;
return (
@ -276,6 +320,17 @@ export function BookmarksPart({
secondaryText={t("home.bookmarks.groups.reorder.done")}
/>
)}
{editing && section.group && (
<EditButtonWithText
editing={editing}
onEdit={() => handleEditGroup(section.group!)}
id="edit-group-button"
text={t("home.bookmarks.groups.editGroup.title")}
secondaryText={t(
"home.bookmarks.groups.editGroup.cancel",
)}
/>
)}
<EditButton
editing={editing}
onEdit={setEditing}
@ -290,12 +345,15 @@ export function BookmarksPart({
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
className="relative group"
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
editable={editing}
onEdit={() => handleEditBookmark(v.id)}
/>
</div>
))}
@ -333,12 +391,15 @@ export function BookmarksPart({
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
className="relative group"
>
<WatchedMediaCard
media={v}
closable={editing}
onClose={() => removeBookmark(v.id)}
onShowDetails={onShowDetails}
editable={editing}
onEdit={() => handleEditBookmark(v.id)}
/>
</div>
))}
@ -359,6 +420,24 @@ export function BookmarksPart({
setTempGroupOrder(newOrder);
}}
/>
{/* Edit Bookmark Modal */}
<EditBookmarkModal
id={editBookmarkModal.id}
isShown={editBookmarkModal.isShown}
bookmarkId={editingBookmarkId}
onCancel={handleCancelEditBookmark}
onSave={handleSaveBookmark}
/>
{/* Edit Group Modal */}
<EditGroupModal
id={editGroupModal.id}
isShown={editGroupModal.isShown}
groupName={editingGroupName}
onCancel={handleCancelEditGroup}
onSave={handleSaveGroup}
/>
</div>
);
}

View file

@ -3,6 +3,13 @@ import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { PlayerMeta } from "@/stores/player/slices/source";
import {
BookmarkModificationOptions,
BookmarkModificationResult,
BulkGroupModificationOptions,
modifyBookmarks,
modifyBookmarksByGroup,
} from "@/utils/bookmarkModifications";
export interface BookmarkMediaItem {
title: string;
@ -40,6 +47,13 @@ export interface BookmarkStore {
): void;
isEpisodeFavorited(showId: string, episodeId: string): boolean;
getFavoriteEpisodes(showId: string): string[];
modifyBookmarks(
bookmarkIds: string[],
options: BookmarkModificationOptions,
): BookmarkModificationResult;
modifyBookmarksByGroup(
options: BulkGroupModificationOptions,
): BookmarkModificationResult;
clear(): void;
clearUpdateQueue(): void;
removeUpdateItem(id: string): void;
@ -186,6 +200,83 @@ export const useBookmarkStore = create(
const bookmark = useBookmarkStore.getState().bookmarks[showId];
return bookmark?.favoriteEpisodes ?? [];
},
modifyBookmarks(
bookmarkIds: string[],
options: BookmarkModificationOptions,
): BookmarkModificationResult {
let result: BookmarkModificationResult = {
modifiedIds: [],
hasChanges: false,
};
set((s) => {
const { modifiedBookmarks, result: modificationResult } =
modifyBookmarks(s.bookmarks, bookmarkIds, options);
s.bookmarks = modifiedBookmarks;
result = modificationResult;
// Add to update queue for modified bookmarks
if (result.hasChanges) {
result.modifiedIds.forEach((bookmarkId) => {
const bookmark = s.bookmarks[bookmarkId];
if (bookmark) {
updateId += 1;
s.updateQueue.push({
id: updateId.toString(),
action: "add",
tmdbId: bookmarkId,
title: bookmark.title,
year: bookmark.year,
poster: bookmark.poster,
type: bookmark.type,
group: bookmark.group,
favoriteEpisodes: bookmark.favoriteEpisodes,
});
}
});
}
});
return result;
},
modifyBookmarksByGroup(
options: BulkGroupModificationOptions,
): BookmarkModificationResult {
let result: BookmarkModificationResult = {
modifiedIds: [],
hasChanges: false,
};
set((s) => {
const { modifiedBookmarks, result: modificationResult } =
modifyBookmarksByGroup(s.bookmarks, options);
s.bookmarks = modifiedBookmarks;
result = modificationResult;
// Add to update queue for modified bookmarks
if (result.hasChanges) {
result.modifiedIds.forEach((bookmarkId) => {
const bookmark = s.bookmarks[bookmarkId];
if (bookmark) {
updateId += 1;
s.updateQueue.push({
id: updateId.toString(),
action: "add",
tmdbId: bookmarkId,
title: bookmark.title,
year: bookmark.year,
poster: bookmark.poster,
type: bookmark.type,
group: bookmark.group,
favoriteEpisodes: bookmark.favoriteEpisodes,
});
}
});
}
});
return result;
},
})),
{
name: "__MW::bookmarks",

View file

@ -3,6 +3,11 @@ import { persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { PlayerMeta } from "@/stores/player/slices/source";
import {
ProgressModificationOptions,
ProgressModificationResult,
modifyProgressItems,
} from "@/utils/progressModifications";
export { getProgressPercentage } from "./utils";
@ -63,6 +68,10 @@ export interface ProgressStore {
updateItem(ops: UpdateItemOptions): void;
removeItem(id: string): void;
replaceItems(items: Record<string, ProgressMediaItem>): void;
modifyProgressItems(
progressIds: string[],
options: ProgressModificationOptions,
): ProgressModificationResult;
clear(): void;
clearUpdateQueue(): void;
removeUpdateItem(id: string): void;
@ -175,6 +184,44 @@ export const useProgressStore = create(
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
});
},
modifyProgressItems(
progressIds: string[],
options: ProgressModificationOptions,
): ProgressModificationResult {
let result: ProgressModificationResult = {
modifiedIds: [],
hasChanges: false,
};
set((s) => {
const { modifiedProgressItems, result: modificationResult } =
modifyProgressItems(s.items, progressIds, options);
s.items = modifiedProgressItems;
result = modificationResult;
// Add to update queue for modified progress items
if (result.hasChanges) {
result.modifiedIds.forEach((progressId) => {
const progressItem = s.items[progressId];
if (progressItem) {
updateId += 1;
s.updateQueue.push({
id: updateId.toString(),
action: "upsert",
tmdbId: progressId,
title: progressItem.title,
year: progressItem.year,
poster: progressItem.poster,
type: progressItem.type,
progress: progressItem.progress,
});
}
});
}
});
return result;
},
})),
{
name: "__MW::progress",

View file

@ -0,0 +1,242 @@
import { BookmarkMediaItem } from "@/stores/bookmarks";
/**
* Options for modifying bookmark properties
*/
export interface BookmarkModificationOptions {
/** Update the title of the bookmark */
title?: string;
/** Update the year of the bookmark */
year?: number;
/** Update the poster URL of the bookmark */
poster?: string;
/** Update the groups array (replaces existing groups) */
groups?: string[];
/** Add groups to existing groups (doesn't remove existing ones) */
addGroups?: string[];
/** Remove specific groups from the bookmark */
removeGroups?: string[];
/** Update favorite episodes */
favoriteEpisodes?: string[];
}
/**
* Result of a bookmark modification operation
*/
export interface BookmarkModificationResult {
/** IDs of bookmarks that were modified */
modifiedIds: string[];
/** Whether any bookmarks were actually changed */
hasChanges: boolean;
}
/**
* Modifies a single bookmark item with the provided options
*/
export function modifyBookmark(
bookmark: BookmarkMediaItem,
options: BookmarkModificationOptions,
): BookmarkMediaItem {
const modified = { ...bookmark, updatedAt: Date.now() };
if (options.title !== undefined) {
modified.title = options.title;
}
if (options.year !== undefined) {
modified.year = options.year;
}
if (options.poster !== undefined) {
modified.poster = options.poster;
}
if (options.groups !== undefined) {
modified.group = options.groups;
}
if (options.addGroups && options.addGroups.length > 0) {
const currentGroups = modified.group || [];
const newGroups = [...currentGroups];
options.addGroups.forEach((group) => {
if (!newGroups.includes(group)) {
newGroups.push(group);
}
});
modified.group = newGroups;
}
if (options.removeGroups && options.removeGroups.length > 0) {
const currentGroups = modified.group || [];
modified.group = currentGroups.filter(
(group) => !options.removeGroups!.includes(group),
);
}
if (options.favoriteEpisodes !== undefined) {
modified.favoriteEpisodes = options.favoriteEpisodes;
}
return modified;
}
/**
* Modifies multiple bookmarks by their IDs
*/
export function modifyBookmarks(
bookmarks: Record<string, BookmarkMediaItem>,
bookmarkIds: string[],
options: BookmarkModificationOptions,
): {
modifiedBookmarks: Record<string, BookmarkMediaItem>;
result: BookmarkModificationResult;
} {
const modifiedBookmarks = { ...bookmarks };
const modifiedIds: string[] = [];
let hasChanges = false;
bookmarkIds.forEach((id) => {
const original = modifiedBookmarks[id];
if (original) {
const modified = modifyBookmark(original, options);
modifiedBookmarks[id] = modified;
modifiedIds.push(id);
// Check if anything actually changed
if (!hasChanges) {
hasChanges = Object.keys(options).some((key) => {
const optionKey = key as keyof BookmarkModificationOptions;
if (optionKey === "addGroups" || optionKey === "removeGroups")
return true;
const optionValue = options[optionKey];
const currentValue = modified[optionKey as keyof BookmarkMediaItem];
if (Array.isArray(optionValue) && Array.isArray(currentValue)) {
return (
optionValue.length !== currentValue.length ||
!optionValue.every((val) => currentValue.includes(val))
);
}
return optionValue !== currentValue;
});
}
}
});
return {
modifiedBookmarks,
result: { modifiedIds, hasChanges: hasChanges && modifiedIds.length > 0 },
};
}
/**
* Options for bulk group modifications
*/
export interface BulkGroupModificationOptions {
/** The old group name to replace */
oldGroupName: string;
/** The new group name */
newGroupName: string;
/** Whether to only modify bookmarks that have this as their only group */
onlyIfExclusive?: boolean;
}
/**
* Modifies all bookmarks that contain a specific group name
*/
export function modifyBookmarksByGroup(
bookmarks: Record<string, BookmarkMediaItem>,
options: BulkGroupModificationOptions,
): {
modifiedBookmarks: Record<string, BookmarkMediaItem>;
result: BookmarkModificationResult;
} {
const modifiedBookmarks = { ...bookmarks };
const modifiedIds: string[] = [];
Object.entries(bookmarks).forEach(([id, bookmark]) => {
if (bookmark.group && bookmark.group.includes(options.oldGroupName)) {
// Check if we should only modify exclusive groups
if (options.onlyIfExclusive && bookmark.group.length > 1) {
return;
}
const newGroups = bookmark.group.map((group) =>
group === options.oldGroupName ? options.newGroupName : group,
);
modifiedBookmarks[id] = {
...bookmark,
group: newGroups,
updatedAt: Date.now(),
};
modifiedIds.push(id);
}
});
return {
modifiedBookmarks,
result: { modifiedIds, hasChanges: modifiedIds.length > 0 },
};
}
/**
* Finds all bookmarks that belong to a specific group
*/
export function findBookmarksByGroup(
bookmarks: Record<string, BookmarkMediaItem>,
groupName: string,
): string[] {
return Object.entries(bookmarks)
.filter(([, bookmark]) => bookmark.group?.includes(groupName))
.map(([id]) => id);
}
/**
* Gets all unique group names from bookmarks
*/
export function getAllGroupNames(
bookmarks: Record<string, BookmarkMediaItem>,
): string[] {
const groups = new Set<string>();
Object.values(bookmarks).forEach((bookmark) => {
if (bookmark.group) {
bookmark.group.forEach((group) => groups.add(group));
}
});
return Array.from(groups);
}
/**
* Validates a group name format
*/
export function isValidGroupName(groupName: string): boolean {
// Group names should be non-empty and not contain only whitespace
return groupName.trim().length > 0;
}
/**
* Parses a group string to extract icon and name components
*/
export function parseGroupString(group: string): {
icon: string;
name: string;
} {
const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/);
if (match) {
return { icon: match[1], name: match[2].trim() };
}
return { icon: "", name: group };
}
/**
* Creates a formatted group string from icon and name
*/
export function createGroupString(icon: string, name: string): string {
if (icon && name) {
return `[${icon}]${name}`;
}
return name;
}

View file

@ -0,0 +1,103 @@
import { ProgressItem, ProgressMediaItem } from "@/stores/progress";
/**
* Options for modifying progress item properties
*/
export interface ProgressModificationOptions {
/** Update the title of the progress item */
title?: string;
/** Update the year of the progress item */
year?: number;
/** Update the poster URL of the progress item */
poster?: string;
/** Update the overall progress for movies or shows */
progress?: ProgressItem;
}
/**
* Result of a progress modification operation
*/
export interface ProgressModificationResult {
/** IDs of progress items that were modified */
modifiedIds: string[];
/** Whether any progress items were actually changed */
hasChanges: boolean;
}
/**
* Modifies a single progress item with the provided options
*/
export function modifyProgressItem(
progressItem: ProgressMediaItem,
options: ProgressModificationOptions,
): ProgressMediaItem {
const modified = { ...progressItem, updatedAt: Date.now() };
if (options.title !== undefined) {
modified.title = options.title;
}
if (options.year !== undefined) {
modified.year = options.year;
}
if (options.poster !== undefined) {
modified.poster = options.poster;
}
if (options.progress !== undefined) {
modified.progress = { ...options.progress };
}
return modified;
}
/**
* Modifies multiple progress items by their IDs
*/
export function modifyProgressItems(
progressItems: Record<string, ProgressMediaItem>,
progressIds: string[],
options: ProgressModificationOptions,
): {
modifiedProgressItems: Record<string, ProgressMediaItem>;
result: ProgressModificationResult;
} {
const modifiedProgressItems = { ...progressItems };
const modifiedIds: string[] = [];
let hasChanges = false;
progressIds.forEach((id) => {
const original = modifiedProgressItems[id];
if (original) {
const modified = modifyProgressItem(original, options);
modifiedProgressItems[id] = modified;
modifiedIds.push(id);
// Check if anything actually changed
if (!hasChanges) {
hasChanges = Object.keys(options).some((key) => {
const optionKey = key as keyof ProgressModificationOptions;
const optionValue = options[optionKey];
const currentValue = modified[optionKey as keyof ProgressMediaItem];
if (optionKey === "progress" && optionValue && currentValue) {
return (
(optionValue as ProgressItem).watched !==
(currentValue as ProgressItem).watched ||
(optionValue as ProgressItem).duration !==
(currentValue as ProgressItem).duration
);
}
return optionValue !== currentValue;
});
}
}
});
return {
modifiedProgressItems,
result: { modifiedIds, hasChanges: hasChanges && modifiedIds.length > 0 },
};
}