pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

add edit group and edit bookmarks modals

Pas 1b073006 2ad6b8b9

+1029
+20
src/assets/locales/en.json
··· 297 297 "description": "Drag and drop to reorder your bookmark groups", 298 298 "cancel": "Cancel", 299 299 "save": "Save" 300 + }, 301 + "editGroup": { 302 + "title": "Edit Group", 303 + "description": "Edit the name and icon of your bookmark group", 304 + "cancel": "Cancel", 305 + "save": "Save", 306 + "affectsBookmarks": "This will affect {{count}} bookmark(s)", 307 + "nameLabel": "Group name", 308 + "namePlaceholder": "Enter a name for your group" 300 309 } 310 + }, 311 + "edit": { 312 + "title": "Edit Bookmark", 313 + "description": "Edit the details for this bookmark", 314 + "cancel": "Cancel", 315 + "save": "Save", 316 + "groupsLabel": "Groups", 317 + "titleLabel": "Title", 318 + "titlePlaceholder": "Enter a title for your bookmark", 319 + "yearLabel": "Year", 320 + "yearPlaceholder": "Enter a year for your bookmark" 301 321 } 302 322 }, 303 323 "continueWatching": {
+22
src/components/media/MediaCard.tsx
··· 94 94 onClose?: () => void; 95 95 onShowDetails?: (media: MediaItem) => void; 96 96 forceSkeleton?: boolean; 97 + editable?: boolean; 98 + onEdit?: () => void; 97 99 } 98 100 99 101 function checkReleased(media: MediaItem): boolean { ··· 119 121 onClose, 120 122 onShowDetails, 121 123 forceSkeleton, 124 + editable, 125 + onEdit, 122 126 }: MediaCardProps) { 123 127 const { t } = useTranslation(); 124 128 const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`; ··· 284 288 <Icon 285 289 className="text-xs font-semibold text-type-secondary" 286 290 icon={Icons.ELLIPSIS} 291 + /> 292 + </button> 293 + </div> 294 + )} 295 + {editable && closable && ( 296 + <div className="absolute bottom-0 translate-y-1 right-1"> 297 + <button 298 + className="media-more-button p-2" 299 + type="button" 300 + onClick={(e) => { 301 + e.preventDefault(); 302 + e.stopPropagation(); 303 + onEdit?.(); 304 + }} 305 + > 306 + <Icon 307 + className="text-xs font-semibold text-type-secondary" 308 + icon={Icons.EDIT} 287 309 /> 288 310 </button> 289 311 </div>
+4
src/components/media/WatchedMediaCard.tsx
··· 24 24 closable?: boolean; 25 25 onClose?: () => void; 26 26 onShowDetails?: (media: MediaItem) => void; 27 + editable?: boolean; 28 + onEdit?: () => void; 27 29 } 28 30 29 31 export function WatchedMediaCard(props: WatchedMediaCardProps) { ··· 51 53 onClose={props.onClose} 52 54 closable={props.closable} 53 55 onShowDetails={props.onShowDetails} 56 + editable={props.editable} 57 + onEdit={props.onEdit} 54 58 /> 55 59 ); 56 60 }
+167
src/components/overlays/EditBookmarkModal.tsx
··· 1 + import { useEffect, useMemo, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import { Button } from "@/components/buttons/Button"; 5 + import { GroupDropdown } from "@/components/form/GroupDropdown"; 6 + import { Modal, ModalCard } from "@/components/overlays/Modal"; 7 + import { UserIcons } from "@/components/UserIcon"; 8 + import { Heading2, Paragraph } from "@/components/utils/Text"; 9 + import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks"; 10 + 11 + interface EditBookmarkModalProps { 12 + id: string; 13 + isShown: boolean; 14 + bookmarkId: string | null; 15 + onCancel: () => void; 16 + onSave: (bookmarkId: string, changes: Partial<BookmarkMediaItem>) => void; 17 + } 18 + 19 + export function EditBookmarkModal({ 20 + id, 21 + isShown, 22 + bookmarkId, 23 + onCancel, 24 + onSave, 25 + }: EditBookmarkModalProps) { 26 + const { t } = useTranslation(); 27 + const bookmarks = useBookmarkStore((s) => s.bookmarks); 28 + 29 + const [title, setTitle] = useState(""); 30 + const [year, setYear] = useState<number | undefined>(); 31 + const [groups, setGroups] = useState<string[]>([]); 32 + 33 + // Get all available groups from all bookmarks 34 + const allGroups = useMemo(() => { 35 + const groupSet = new Set<string>(); 36 + Object.values(bookmarks).forEach((bookmark) => { 37 + if (bookmark.group) { 38 + bookmark.group.forEach((group) => groupSet.add(group)); 39 + } 40 + }); 41 + return Array.from(groupSet); 42 + }, [bookmarks]); 43 + 44 + useEffect(() => { 45 + if (bookmarkId && bookmarks[bookmarkId]) { 46 + const bookmark = bookmarks[bookmarkId]; 47 + setTitle(bookmark.title); 48 + setYear(bookmark.year); 49 + setGroups(bookmark.group || []); 50 + } else { 51 + setTitle(""); 52 + setYear(undefined); 53 + setGroups([]); 54 + } 55 + }, [bookmarkId, bookmarks]); 56 + 57 + const handleSave = () => { 58 + if (!bookmarkId) return; 59 + 60 + const changes: Partial<BookmarkMediaItem> = {}; 61 + 62 + if (title !== bookmarks[bookmarkId]?.title) { 63 + changes.title = title; 64 + } 65 + 66 + if (year !== bookmarks[bookmarkId]?.year) { 67 + changes.year = year; 68 + } 69 + 70 + const currentGroups = bookmarks[bookmarkId]?.group || []; 71 + if ( 72 + JSON.stringify(groups.sort()) !== JSON.stringify(currentGroups.sort()) 73 + ) { 74 + changes.group = groups; 75 + } 76 + 77 + if (Object.keys(changes).length > 0) { 78 + onSave(bookmarkId, changes); 79 + } 80 + 81 + onCancel(); 82 + }; 83 + 84 + const handleCreateGroup = (groupString: string, _icon: UserIcons) => { 85 + if (!groups.includes(groupString)) { 86 + setGroups([...groups, groupString]); 87 + } 88 + }; 89 + 90 + const handleRemoveGroup = (groupToRemove?: string) => { 91 + if (groupToRemove) { 92 + setGroups(groups.filter((group) => group !== groupToRemove)); 93 + } else { 94 + setGroups([]); 95 + } 96 + }; 97 + 98 + if (!isShown || !bookmarkId) return null; 99 + 100 + return ( 101 + <Modal id={id}> 102 + <ModalCard> 103 + <Heading2 className="!my-0">{t("home.bookmarks.edit.title")}</Heading2> 104 + <Paragraph className="mt-4"> 105 + {t("home.bookmarks.edit.description")} 106 + </Paragraph> 107 + 108 + <div className="space-y-4 mt-6"> 109 + {/* Title */} 110 + <div> 111 + <label className="block text-sm font-medium mb-2"> 112 + {t("home.bookmarks.edit.titleLabel")} 113 + </label> 114 + <input 115 + type="text" 116 + value={title} 117 + onChange={(e) => setTitle(e.target.value)} 118 + placeholder={t("home.bookmarks.edit.titlePlaceholder")} 119 + className="w-full px-3 py-2 bg-background-main border border-background-secondary rounded-lg text-white text-sm placeholder:text-type-secondary" 120 + /> 121 + </div> 122 + 123 + {/* Year */} 124 + <div> 125 + <label className="block text-sm font-medium mb-2"> 126 + {t("home.bookmarks.edit.yearLabel")} 127 + </label> 128 + <input 129 + type="number" 130 + value={year || ""} 131 + onChange={(e) => 132 + setYear( 133 + e.target.value ? parseInt(e.target.value, 10) : undefined, 134 + ) 135 + } 136 + placeholder={t("home.bookmarks.edit.yearPlaceholder")} 137 + className="w-full px-3 py-2 bg-background-main border border-background-secondary rounded-lg text-white text-sm placeholder:text-type-secondary" 138 + /> 139 + </div> 140 + 141 + {/* Groups */} 142 + <div> 143 + <label className="block text-sm font-medium mb-2"> 144 + {t("home.bookmarks.edit.groupsLabel")} 145 + </label> 146 + <GroupDropdown 147 + groups={allGroups} 148 + currentGroups={groups} 149 + onSelectGroups={setGroups} 150 + onCreateGroup={handleCreateGroup} 151 + onRemoveGroup={handleRemoveGroup} 152 + /> 153 + </div> 154 + </div> 155 + 156 + <div className="flex gap-4 mt-6 justify-end"> 157 + <Button theme="secondary" onClick={onCancel}> 158 + {t("home.bookmarks.edit.cancel")} 159 + </Button> 160 + <Button theme="purple" onClick={handleSave}> 161 + {t("home.bookmarks.edit.save")} 162 + </Button> 163 + </div> 164 + </ModalCard> 165 + </Modal> 166 + ); 167 + }
+175
src/components/overlays/EditGroupModal.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { useTranslation } from "react-i18next"; 3 + 4 + import { Button } from "@/components/buttons/Button"; 5 + import { Modal, ModalCard } from "@/components/overlays/Modal"; 6 + import { UserIcon, UserIcons } from "@/components/UserIcon"; 7 + import { Heading2, Paragraph } from "@/components/utils/Text"; 8 + import { useBookmarkStore } from "@/stores/bookmarks"; 9 + import { 10 + createGroupString, 11 + findBookmarksByGroup, 12 + parseGroupString, 13 + } from "@/utils/bookmarkModifications"; 14 + 15 + const userIconList = Object.values(UserIcons); 16 + 17 + interface EditGroupModalProps { 18 + id: string; 19 + isShown: boolean; 20 + groupName: string | null; 21 + onCancel: () => void; 22 + onSave: (oldGroupName: string, newGroupName: string) => void; 23 + } 24 + 25 + export function EditGroupModal({ 26 + id, 27 + isShown, 28 + groupName, 29 + onCancel, 30 + onSave, 31 + }: EditGroupModalProps) { 32 + const { t } = useTranslation(); 33 + const bookmarks = useBookmarkStore((s) => s.bookmarks); 34 + 35 + const [newGroupName, setNewGroupName] = useState(""); 36 + const [newGroupIcon, setNewGroupIcon] = useState<UserIcons>( 37 + UserIcons.BOOKMARK, 38 + ); 39 + const [affectedBookmarks, setAffectedBookmarks] = useState<string[]>([]); 40 + 41 + const getIconFromKey = (iconKey: string): UserIcons => { 42 + const key = iconKey.toUpperCase() as keyof typeof UserIcons; 43 + return UserIcons[key] || UserIcons.BOOKMARK; 44 + }; 45 + 46 + const getIconKey = (icon: UserIcons): string => { 47 + const entry = Object.entries(UserIcons).find(([, value]) => value === icon); 48 + return entry ? entry[0] : "BOOKMARK"; 49 + }; 50 + 51 + useEffect(() => { 52 + if (groupName) { 53 + const { icon, name } = parseGroupString(groupName); 54 + setNewGroupName(name); 55 + setNewGroupIcon(getIconFromKey(icon || "BOOKMARK")); 56 + setAffectedBookmarks(findBookmarksByGroup(bookmarks, groupName)); 57 + } else { 58 + setNewGroupName(""); 59 + setNewGroupIcon(UserIcons.BOOKMARK); 60 + setAffectedBookmarks([]); 61 + } 62 + }, [groupName, bookmarks]); 63 + 64 + const handleSave = () => { 65 + if (!groupName || !newGroupName.trim()) return; 66 + 67 + const iconKey = getIconKey(newGroupIcon); 68 + const newGroupString = createGroupString(iconKey, newGroupName.trim()); 69 + 70 + if (newGroupString !== groupName) { 71 + onSave(groupName, newGroupString); 72 + } 73 + 74 + onCancel(); 75 + }; 76 + 77 + if (!isShown || !groupName) return null; 78 + 79 + const { icon: currentIcon, name: currentName } = parseGroupString(groupName); 80 + const currentIconKey = currentIcon.toUpperCase() as keyof typeof UserIcons; 81 + const currentIconComponent = UserIcons[currentIconKey] || UserIcons.BOOKMARK; 82 + 83 + return ( 84 + <Modal id={id}> 85 + <ModalCard> 86 + <Heading2 className="!my-0"> 87 + {t("home.bookmarks.groups.editGroup.title")} 88 + </Heading2> 89 + <Paragraph className="mt-4"> 90 + {t("home.bookmarks.groups.editGroup.description")} 91 + </Paragraph> 92 + 93 + {/* Current Group Info */} 94 + <div className="mt-4 p-3 bg-background-main rounded"> 95 + <div className="flex items-center gap-2 mb-2"> 96 + <UserIcon icon={currentIconComponent} className="w-5 h-5" /> 97 + <span className="font-medium">{currentName}</span> 98 + </div> 99 + <p className="text-sm text-type-secondary"> 100 + {t("home.bookmarks.groups.editGroup.affectsBookmarks", { 101 + count: affectedBookmarks.length, 102 + })} 103 + </p> 104 + </div> 105 + 106 + <div className="space-y-4 mt-6"> 107 + {/* New Group Name */} 108 + <div> 109 + <label className="block text-sm font-medium mb-1"> 110 + {t("home.bookmarks.groups.editGroup.nameLabel")} 111 + </label> 112 + <input 113 + type="text" 114 + value={newGroupName} 115 + onChange={(e) => setNewGroupName(e.target.value)} 116 + placeholder={t("home.bookmarks.groups.editGroup.namePlaceholder")} 117 + onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => { 118 + if (e.key === "Enter") { 119 + e.preventDefault(); 120 + handleSave(); 121 + } 122 + }} 123 + className="w-full px-3 py-2 bg-background-main border border-border rounded text-sm" 124 + autoFocus 125 + /> 126 + {newGroupName.trim().length > 0 && ( 127 + <div className="flex items-center gap-2 flex-wrap pt-4 w-full justify-center"> 128 + {userIconList.map((icon) => ( 129 + <button 130 + type="button" 131 + key={icon} 132 + className={`rounded p-1 border-2 ${ 133 + newGroupIcon === icon 134 + ? "border-type-link bg-mediaCard-hoverBackground" 135 + : "border-transparent hover:border-background-secondary" 136 + }`} 137 + onClick={() => setNewGroupIcon(icon)} 138 + > 139 + <span className="w-5 h-5 flex items-center justify-center"> 140 + <UserIcon 141 + icon={icon} 142 + className={`w-full h-full ${ 143 + newGroupIcon === icon ? "text-type-link" : "" 144 + }`} 145 + /> 146 + </span> 147 + </button> 148 + ))} 149 + </div> 150 + )} 151 + </div> 152 + </div> 153 + 154 + <div className="flex gap-4 mt-6 justify-end"> 155 + <Button theme="secondary" onClick={onCancel}> 156 + {t("home.bookmarks.groups.editGroup.cancel")} 157 + </Button> 158 + <Button 159 + theme="purple" 160 + onClick={handleSave} 161 + disabled={ 162 + !newGroupName.trim() || 163 + createGroupString( 164 + getIconKey(newGroupIcon), 165 + newGroupName.trim(), 166 + ) === groupName 167 + } 168 + > 169 + {t("home.bookmarks.groups.editGroup.save")} 170 + </Button> 171 + </div> 172 + </ModalCard> 173 + </Modal> 174 + ); 175 + }
+79
src/pages/parts/home/BookmarksCarousel.tsx
··· 8 8 import { Icon, Icons } from "@/components/Icon"; 9 9 import { SectionHeading } from "@/components/layout/SectionHeading"; 10 10 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 11 + import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; 12 + import { EditGroupModal } from "@/components/overlays/EditGroupModal"; 11 13 import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; 12 14 import { useModal } from "@/components/overlays/Modal"; 13 15 import { UserIcon, UserIcons } from "@/components/UserIcon"; ··· 93 95 const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 94 96 const backendUrl = useBackendUrl(); 95 97 const account = useAuthStore((s) => s.account); 98 + 99 + // Editing modals 100 + const editBookmarkModal = useModal("bookmark-edit-carousel"); 101 + const editGroupModal = useModal("bookmark-edit-group-carousel"); 102 + const [editingBookmarkId, setEditingBookmarkId] = useState<string | null>( 103 + null, 104 + ); 105 + const [editingGroupName, setEditingGroupName] = useState<string | null>(null); 106 + const modifyBookmarks = useBookmarkStore((s) => s.modifyBookmarks); 107 + const modifyBookmarksByGroup = useBookmarkStore( 108 + (s) => s.modifyBookmarksByGroup, 109 + ); 96 110 97 111 // Group order editing state 98 112 const groupOrder = useGroupOrderStore((s) => s.groupOrder); ··· 328 342 } 329 343 }; 330 344 345 + const handleEditBookmark = (bookmarkId: string) => { 346 + setEditingBookmarkId(bookmarkId); 347 + editBookmarkModal.show(); 348 + }; 349 + 350 + const handleSaveBookmark = (bookmarkId: string, changes: any) => { 351 + modifyBookmarks([bookmarkId], changes); 352 + editBookmarkModal.hide(); 353 + setEditingBookmarkId(null); 354 + }; 355 + 356 + const handleEditGroup = (groupName: string) => { 357 + setEditingGroupName(groupName); 358 + editGroupModal.show(); 359 + }; 360 + 361 + const handleSaveGroup = (oldGroupName: string, newGroupName: string) => { 362 + modifyBookmarksByGroup({ oldGroupName, newGroupName }); 363 + editGroupModal.hide(); 364 + setEditingGroupName(null); 365 + }; 366 + 367 + const handleCancelEditBookmark = () => { 368 + editBookmarkModal.hide(); 369 + setEditingBookmarkId(null); 370 + }; 371 + 372 + const handleCancelEditGroup = () => { 373 + editGroupModal.hide(); 374 + setEditingGroupName(null); 375 + }; 376 + 331 377 const categorySlug = "bookmarks"; 332 378 const SKELETON_COUNT = 10; 333 379 ··· 360 406 secondaryText={t("home.bookmarks.groups.reorder.done")} 361 407 /> 362 408 )} 409 + {editing && section.group && ( 410 + <EditButtonWithText 411 + editing={editing} 412 + onEdit={() => handleEditGroup(section.group!)} 413 + id="edit-group-button" 414 + text={t("home.bookmarks.groups.editGroup.title")} 415 + secondaryText={t( 416 + "home.bookmarks.groups.editGroup.cancel", 417 + )} 418 + /> 419 + )} 363 420 <EditButton 364 421 editing={editing} 365 422 onEdit={setEditing} ··· 394 451 onShowDetails={onShowDetails} 395 452 closable={editing} 396 453 onClose={() => removeBookmark(media.id)} 454 + editable={editing} 455 + onEdit={() => handleEditBookmark(media.id)} 397 456 /> 398 457 </div> 399 458 ))} ··· 467 526 onShowDetails={onShowDetails} 468 527 closable={editing} 469 528 onClose={() => removeBookmark(media.id)} 529 + editable={editing} 530 + onEdit={() => handleEditBookmark(media.id)} 470 531 /> 471 532 </div> 472 533 )) ··· 505 566 const newOrder = newItems.map((item) => item.id); 506 567 setTempGroupOrder(newOrder); 507 568 }} 569 + /> 570 + 571 + {/* Edit Bookmark Modal */} 572 + <EditBookmarkModal 573 + id={editBookmarkModal.id} 574 + isShown={editBookmarkModal.isShown} 575 + bookmarkId={editingBookmarkId} 576 + onCancel={handleCancelEditBookmark} 577 + onSave={handleSaveBookmark} 578 + /> 579 + 580 + {/* Edit Group Modal */} 581 + <EditGroupModal 582 + id={editGroupModal.id} 583 + isShown={editGroupModal.isShown} 584 + groupName={editingGroupName} 585 + onCancel={handleCancelEditGroup} 586 + onSave={handleSaveGroup} 508 587 /> 509 588 </> 510 589 );
+79
src/pages/parts/home/BookmarksPart.tsx
··· 9 9 import { SectionHeading } from "@/components/layout/SectionHeading"; 10 10 import { MediaGrid } from "@/components/media/MediaGrid"; 11 11 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 12 + import { EditBookmarkModal } from "@/components/overlays/EditBookmarkModal"; 13 + import { EditGroupModal } from "@/components/overlays/EditGroupModal"; 12 14 import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal"; 13 15 import { useModal } from "@/components/overlays/Modal"; 14 16 import { UserIcon, UserIcons } from "@/components/UserIcon"; ··· 46 48 const [editing, setEditing] = useState(false); 47 49 const [gridRef] = useAutoAnimate<HTMLDivElement>(); 48 50 const editOrderModal = useModal("bookmark-edit-order"); 51 + const editBookmarkModal = useModal("bookmark-edit"); 52 + const editGroupModal = useModal("bookmark-edit-group"); 49 53 const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]); 54 + const [editingBookmarkId, setEditingBookmarkId] = useState<string | null>( 55 + null, 56 + ); 57 + const [editingGroupName, setEditingGroupName] = useState<string | null>(null); 50 58 const backendUrl = useBackendUrl(); 51 59 const account = useAuthStore((s) => s.account); 60 + const modifyBookmarks = useBookmarkStore((s) => s.modifyBookmarks); 61 + const modifyBookmarksByGroup = useBookmarkStore( 62 + (s) => s.modifyBookmarksByGroup, 63 + ); 52 64 53 65 const items = useMemo(() => { 54 66 let output: MediaItem[] = []; ··· 248 260 } 249 261 }; 250 262 263 + const handleEditBookmark = (bookmarkId: string) => { 264 + setEditingBookmarkId(bookmarkId); 265 + editBookmarkModal.show(); 266 + }; 267 + 268 + const handleSaveBookmark = (bookmarkId: string, changes: any) => { 269 + modifyBookmarks([bookmarkId], changes); 270 + editBookmarkModal.hide(); 271 + setEditingBookmarkId(null); 272 + }; 273 + 274 + const handleEditGroup = (groupName: string) => { 275 + setEditingGroupName(groupName); 276 + editGroupModal.show(); 277 + }; 278 + 279 + const handleSaveGroup = (oldGroupName: string, newGroupName: string) => { 280 + modifyBookmarksByGroup({ oldGroupName, newGroupName }); 281 + editGroupModal.hide(); 282 + setEditingGroupName(null); 283 + }; 284 + 285 + const handleCancelEditBookmark = () => { 286 + editBookmarkModal.hide(); 287 + setEditingBookmarkId(null); 288 + }; 289 + 290 + const handleCancelEditGroup = () => { 291 + editGroupModal.hide(); 292 + setEditingGroupName(null); 293 + }; 294 + 251 295 if (items.length === 0) return null; 252 296 253 297 return ( ··· 276 320 secondaryText={t("home.bookmarks.groups.reorder.done")} 277 321 /> 278 322 )} 323 + {editing && section.group && ( 324 + <EditButtonWithText 325 + editing={editing} 326 + onEdit={() => handleEditGroup(section.group!)} 327 + id="edit-group-button" 328 + text={t("home.bookmarks.groups.editGroup.title")} 329 + secondaryText={t( 330 + "home.bookmarks.groups.editGroup.cancel", 331 + )} 332 + /> 333 + )} 279 334 <EditButton 280 335 editing={editing} 281 336 onEdit={setEditing} ··· 290 345 onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 291 346 e.preventDefault() 292 347 } 348 + className="relative group" 293 349 > 294 350 <WatchedMediaCard 295 351 media={v} 296 352 closable={editing} 297 353 onClose={() => removeBookmark(v.id)} 298 354 onShowDetails={onShowDetails} 355 + editable={editing} 356 + onEdit={() => handleEditBookmark(v.id)} 299 357 /> 300 358 </div> 301 359 ))} ··· 333 391 onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 334 392 e.preventDefault() 335 393 } 394 + className="relative group" 336 395 > 337 396 <WatchedMediaCard 338 397 media={v} 339 398 closable={editing} 340 399 onClose={() => removeBookmark(v.id)} 341 400 onShowDetails={onShowDetails} 401 + editable={editing} 402 + onEdit={() => handleEditBookmark(v.id)} 342 403 /> 343 404 </div> 344 405 ))} ··· 358 419 const newOrder = newItems.map((item) => item.id); 359 420 setTempGroupOrder(newOrder); 360 421 }} 422 + /> 423 + 424 + {/* Edit Bookmark Modal */} 425 + <EditBookmarkModal 426 + id={editBookmarkModal.id} 427 + isShown={editBookmarkModal.isShown} 428 + bookmarkId={editingBookmarkId} 429 + onCancel={handleCancelEditBookmark} 430 + onSave={handleSaveBookmark} 431 + /> 432 + 433 + {/* Edit Group Modal */} 434 + <EditGroupModal 435 + id={editGroupModal.id} 436 + isShown={editGroupModal.isShown} 437 + groupName={editingGroupName} 438 + onCancel={handleCancelEditGroup} 439 + onSave={handleSaveGroup} 361 440 /> 362 441 </div> 363 442 );
+91
src/stores/bookmarks/index.ts
··· 3 3 import { immer } from "zustand/middleware/immer"; 4 4 5 5 import { PlayerMeta } from "@/stores/player/slices/source"; 6 + import { 7 + BookmarkModificationOptions, 8 + BookmarkModificationResult, 9 + BulkGroupModificationOptions, 10 + modifyBookmarks, 11 + modifyBookmarksByGroup, 12 + } from "@/utils/bookmarkModifications"; 6 13 7 14 export interface BookmarkMediaItem { 8 15 title: string; ··· 40 47 ): void; 41 48 isEpisodeFavorited(showId: string, episodeId: string): boolean; 42 49 getFavoriteEpisodes(showId: string): string[]; 50 + modifyBookmarks( 51 + bookmarkIds: string[], 52 + options: BookmarkModificationOptions, 53 + ): BookmarkModificationResult; 54 + modifyBookmarksByGroup( 55 + options: BulkGroupModificationOptions, 56 + ): BookmarkModificationResult; 43 57 clear(): void; 44 58 clearUpdateQueue(): void; 45 59 removeUpdateItem(id: string): void; ··· 185 199 getFavoriteEpisodes(showId: string): string[] { 186 200 const bookmark = useBookmarkStore.getState().bookmarks[showId]; 187 201 return bookmark?.favoriteEpisodes ?? []; 202 + }, 203 + modifyBookmarks( 204 + bookmarkIds: string[], 205 + options: BookmarkModificationOptions, 206 + ): BookmarkModificationResult { 207 + let result: BookmarkModificationResult = { 208 + modifiedIds: [], 209 + hasChanges: false, 210 + }; 211 + 212 + set((s) => { 213 + const { modifiedBookmarks, result: modificationResult } = 214 + modifyBookmarks(s.bookmarks, bookmarkIds, options); 215 + s.bookmarks = modifiedBookmarks; 216 + result = modificationResult; 217 + 218 + // Add to update queue for modified bookmarks 219 + if (result.hasChanges) { 220 + result.modifiedIds.forEach((bookmarkId) => { 221 + const bookmark = s.bookmarks[bookmarkId]; 222 + if (bookmark) { 223 + updateId += 1; 224 + s.updateQueue.push({ 225 + id: updateId.toString(), 226 + action: "add", 227 + tmdbId: bookmarkId, 228 + title: bookmark.title, 229 + year: bookmark.year, 230 + poster: bookmark.poster, 231 + type: bookmark.type, 232 + group: bookmark.group, 233 + favoriteEpisodes: bookmark.favoriteEpisodes, 234 + }); 235 + } 236 + }); 237 + } 238 + }); 239 + 240 + return result; 241 + }, 242 + modifyBookmarksByGroup( 243 + options: BulkGroupModificationOptions, 244 + ): BookmarkModificationResult { 245 + let result: BookmarkModificationResult = { 246 + modifiedIds: [], 247 + hasChanges: false, 248 + }; 249 + 250 + set((s) => { 251 + const { modifiedBookmarks, result: modificationResult } = 252 + modifyBookmarksByGroup(s.bookmarks, options); 253 + s.bookmarks = modifiedBookmarks; 254 + result = modificationResult; 255 + 256 + // Add to update queue for modified bookmarks 257 + if (result.hasChanges) { 258 + result.modifiedIds.forEach((bookmarkId) => { 259 + const bookmark = s.bookmarks[bookmarkId]; 260 + if (bookmark) { 261 + updateId += 1; 262 + s.updateQueue.push({ 263 + id: updateId.toString(), 264 + action: "add", 265 + tmdbId: bookmarkId, 266 + title: bookmark.title, 267 + year: bookmark.year, 268 + poster: bookmark.poster, 269 + type: bookmark.type, 270 + group: bookmark.group, 271 + favoriteEpisodes: bookmark.favoriteEpisodes, 272 + }); 273 + } 274 + }); 275 + } 276 + }); 277 + 278 + return result; 188 279 }, 189 280 })), 190 281 {
+47
src/stores/progress/index.ts
··· 3 3 import { immer } from "zustand/middleware/immer"; 4 4 5 5 import { PlayerMeta } from "@/stores/player/slices/source"; 6 + import { 7 + ProgressModificationOptions, 8 + ProgressModificationResult, 9 + modifyProgressItems, 10 + } from "@/utils/progressModifications"; 6 11 7 12 export { getProgressPercentage } from "./utils"; 8 13 ··· 63 68 updateItem(ops: UpdateItemOptions): void; 64 69 removeItem(id: string): void; 65 70 replaceItems(items: Record<string, ProgressMediaItem>): void; 71 + modifyProgressItems( 72 + progressIds: string[], 73 + options: ProgressModificationOptions, 74 + ): ProgressModificationResult; 66 75 clear(): void; 67 76 clearUpdateQueue(): void; 68 77 removeUpdateItem(id: string): void; ··· 174 183 set((s) => { 175 184 s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)]; 176 185 }); 186 + }, 187 + modifyProgressItems( 188 + progressIds: string[], 189 + options: ProgressModificationOptions, 190 + ): ProgressModificationResult { 191 + let result: ProgressModificationResult = { 192 + modifiedIds: [], 193 + hasChanges: false, 194 + }; 195 + 196 + set((s) => { 197 + const { modifiedProgressItems, result: modificationResult } = 198 + modifyProgressItems(s.items, progressIds, options); 199 + s.items = modifiedProgressItems; 200 + result = modificationResult; 201 + 202 + // Add to update queue for modified progress items 203 + if (result.hasChanges) { 204 + result.modifiedIds.forEach((progressId) => { 205 + const progressItem = s.items[progressId]; 206 + if (progressItem) { 207 + updateId += 1; 208 + s.updateQueue.push({ 209 + id: updateId.toString(), 210 + action: "upsert", 211 + tmdbId: progressId, 212 + title: progressItem.title, 213 + year: progressItem.year, 214 + poster: progressItem.poster, 215 + type: progressItem.type, 216 + progress: progressItem.progress, 217 + }); 218 + } 219 + }); 220 + } 221 + }); 222 + 223 + return result; 177 224 }, 178 225 })), 179 226 {
+242
src/utils/bookmarkModifications.ts
··· 1 + import { BookmarkMediaItem } from "@/stores/bookmarks"; 2 + 3 + /** 4 + * Options for modifying bookmark properties 5 + */ 6 + export interface BookmarkModificationOptions { 7 + /** Update the title of the bookmark */ 8 + title?: string; 9 + /** Update the year of the bookmark */ 10 + year?: number; 11 + /** Update the poster URL of the bookmark */ 12 + poster?: string; 13 + /** Update the groups array (replaces existing groups) */ 14 + groups?: string[]; 15 + /** Add groups to existing groups (doesn't remove existing ones) */ 16 + addGroups?: string[]; 17 + /** Remove specific groups from the bookmark */ 18 + removeGroups?: string[]; 19 + /** Update favorite episodes */ 20 + favoriteEpisodes?: string[]; 21 + } 22 + 23 + /** 24 + * Result of a bookmark modification operation 25 + */ 26 + export interface BookmarkModificationResult { 27 + /** IDs of bookmarks that were modified */ 28 + modifiedIds: string[]; 29 + /** Whether any bookmarks were actually changed */ 30 + hasChanges: boolean; 31 + } 32 + 33 + /** 34 + * Modifies a single bookmark item with the provided options 35 + */ 36 + export function modifyBookmark( 37 + bookmark: BookmarkMediaItem, 38 + options: BookmarkModificationOptions, 39 + ): BookmarkMediaItem { 40 + const modified = { ...bookmark, updatedAt: Date.now() }; 41 + 42 + if (options.title !== undefined) { 43 + modified.title = options.title; 44 + } 45 + 46 + if (options.year !== undefined) { 47 + modified.year = options.year; 48 + } 49 + 50 + if (options.poster !== undefined) { 51 + modified.poster = options.poster; 52 + } 53 + 54 + if (options.groups !== undefined) { 55 + modified.group = options.groups; 56 + } 57 + 58 + if (options.addGroups && options.addGroups.length > 0) { 59 + const currentGroups = modified.group || []; 60 + const newGroups = [...currentGroups]; 61 + options.addGroups.forEach((group) => { 62 + if (!newGroups.includes(group)) { 63 + newGroups.push(group); 64 + } 65 + }); 66 + modified.group = newGroups; 67 + } 68 + 69 + if (options.removeGroups && options.removeGroups.length > 0) { 70 + const currentGroups = modified.group || []; 71 + modified.group = currentGroups.filter( 72 + (group) => !options.removeGroups!.includes(group), 73 + ); 74 + } 75 + 76 + if (options.favoriteEpisodes !== undefined) { 77 + modified.favoriteEpisodes = options.favoriteEpisodes; 78 + } 79 + 80 + return modified; 81 + } 82 + 83 + /** 84 + * Modifies multiple bookmarks by their IDs 85 + */ 86 + export function modifyBookmarks( 87 + bookmarks: Record<string, BookmarkMediaItem>, 88 + bookmarkIds: string[], 89 + options: BookmarkModificationOptions, 90 + ): { 91 + modifiedBookmarks: Record<string, BookmarkMediaItem>; 92 + result: BookmarkModificationResult; 93 + } { 94 + const modifiedBookmarks = { ...bookmarks }; 95 + const modifiedIds: string[] = []; 96 + let hasChanges = false; 97 + 98 + bookmarkIds.forEach((id) => { 99 + const original = modifiedBookmarks[id]; 100 + if (original) { 101 + const modified = modifyBookmark(original, options); 102 + modifiedBookmarks[id] = modified; 103 + modifiedIds.push(id); 104 + 105 + // Check if anything actually changed 106 + if (!hasChanges) { 107 + hasChanges = Object.keys(options).some((key) => { 108 + const optionKey = key as keyof BookmarkModificationOptions; 109 + if (optionKey === "addGroups" || optionKey === "removeGroups") 110 + return true; 111 + 112 + const optionValue = options[optionKey]; 113 + const currentValue = modified[optionKey as keyof BookmarkMediaItem]; 114 + 115 + if (Array.isArray(optionValue) && Array.isArray(currentValue)) { 116 + return ( 117 + optionValue.length !== currentValue.length || 118 + !optionValue.every((val) => currentValue.includes(val)) 119 + ); 120 + } 121 + 122 + return optionValue !== currentValue; 123 + }); 124 + } 125 + } 126 + }); 127 + 128 + return { 129 + modifiedBookmarks, 130 + result: { modifiedIds, hasChanges: hasChanges && modifiedIds.length > 0 }, 131 + }; 132 + } 133 + 134 + /** 135 + * Options for bulk group modifications 136 + */ 137 + export interface BulkGroupModificationOptions { 138 + /** The old group name to replace */ 139 + oldGroupName: string; 140 + /** The new group name */ 141 + newGroupName: string; 142 + /** Whether to only modify bookmarks that have this as their only group */ 143 + onlyIfExclusive?: boolean; 144 + } 145 + 146 + /** 147 + * Modifies all bookmarks that contain a specific group name 148 + */ 149 + export function modifyBookmarksByGroup( 150 + bookmarks: Record<string, BookmarkMediaItem>, 151 + options: BulkGroupModificationOptions, 152 + ): { 153 + modifiedBookmarks: Record<string, BookmarkMediaItem>; 154 + result: BookmarkModificationResult; 155 + } { 156 + const modifiedBookmarks = { ...bookmarks }; 157 + const modifiedIds: string[] = []; 158 + 159 + Object.entries(bookmarks).forEach(([id, bookmark]) => { 160 + if (bookmark.group && bookmark.group.includes(options.oldGroupName)) { 161 + // Check if we should only modify exclusive groups 162 + if (options.onlyIfExclusive && bookmark.group.length > 1) { 163 + return; 164 + } 165 + 166 + const newGroups = bookmark.group.map((group) => 167 + group === options.oldGroupName ? options.newGroupName : group, 168 + ); 169 + 170 + modifiedBookmarks[id] = { 171 + ...bookmark, 172 + group: newGroups, 173 + updatedAt: Date.now(), 174 + }; 175 + modifiedIds.push(id); 176 + } 177 + }); 178 + 179 + return { 180 + modifiedBookmarks, 181 + result: { modifiedIds, hasChanges: modifiedIds.length > 0 }, 182 + }; 183 + } 184 + 185 + /** 186 + * Finds all bookmarks that belong to a specific group 187 + */ 188 + export function findBookmarksByGroup( 189 + bookmarks: Record<string, BookmarkMediaItem>, 190 + groupName: string, 191 + ): string[] { 192 + return Object.entries(bookmarks) 193 + .filter(([, bookmark]) => bookmark.group?.includes(groupName)) 194 + .map(([id]) => id); 195 + } 196 + 197 + /** 198 + * Gets all unique group names from bookmarks 199 + */ 200 + export function getAllGroupNames( 201 + bookmarks: Record<string, BookmarkMediaItem>, 202 + ): string[] { 203 + const groups = new Set<string>(); 204 + Object.values(bookmarks).forEach((bookmark) => { 205 + if (bookmark.group) { 206 + bookmark.group.forEach((group) => groups.add(group)); 207 + } 208 + }); 209 + return Array.from(groups); 210 + } 211 + 212 + /** 213 + * Validates a group name format 214 + */ 215 + export function isValidGroupName(groupName: string): boolean { 216 + // Group names should be non-empty and not contain only whitespace 217 + return groupName.trim().length > 0; 218 + } 219 + 220 + /** 221 + * Parses a group string to extract icon and name components 222 + */ 223 + export function parseGroupString(group: string): { 224 + icon: string; 225 + name: string; 226 + } { 227 + const match = group.match(/^\[([a-zA-Z0-9_]+)\](.*)$/); 228 + if (match) { 229 + return { icon: match[1], name: match[2].trim() }; 230 + } 231 + return { icon: "", name: group }; 232 + } 233 + 234 + /** 235 + * Creates a formatted group string from icon and name 236 + */ 237 + export function createGroupString(icon: string, name: string): string { 238 + if (icon && name) { 239 + return `[${icon}]${name}`; 240 + } 241 + return name; 242 + }
+103
src/utils/progressModifications.ts
··· 1 + import { ProgressItem, ProgressMediaItem } from "@/stores/progress"; 2 + 3 + /** 4 + * Options for modifying progress item properties 5 + */ 6 + export interface ProgressModificationOptions { 7 + /** Update the title of the progress item */ 8 + title?: string; 9 + /** Update the year of the progress item */ 10 + year?: number; 11 + /** Update the poster URL of the progress item */ 12 + poster?: string; 13 + /** Update the overall progress for movies or shows */ 14 + progress?: ProgressItem; 15 + } 16 + 17 + /** 18 + * Result of a progress modification operation 19 + */ 20 + export interface ProgressModificationResult { 21 + /** IDs of progress items that were modified */ 22 + modifiedIds: string[]; 23 + /** Whether any progress items were actually changed */ 24 + hasChanges: boolean; 25 + } 26 + 27 + /** 28 + * Modifies a single progress item with the provided options 29 + */ 30 + export function modifyProgressItem( 31 + progressItem: ProgressMediaItem, 32 + options: ProgressModificationOptions, 33 + ): ProgressMediaItem { 34 + const modified = { ...progressItem, updatedAt: Date.now() }; 35 + 36 + if (options.title !== undefined) { 37 + modified.title = options.title; 38 + } 39 + 40 + if (options.year !== undefined) { 41 + modified.year = options.year; 42 + } 43 + 44 + if (options.poster !== undefined) { 45 + modified.poster = options.poster; 46 + } 47 + 48 + if (options.progress !== undefined) { 49 + modified.progress = { ...options.progress }; 50 + } 51 + 52 + return modified; 53 + } 54 + 55 + /** 56 + * Modifies multiple progress items by their IDs 57 + */ 58 + export function modifyProgressItems( 59 + progressItems: Record<string, ProgressMediaItem>, 60 + progressIds: string[], 61 + options: ProgressModificationOptions, 62 + ): { 63 + modifiedProgressItems: Record<string, ProgressMediaItem>; 64 + result: ProgressModificationResult; 65 + } { 66 + const modifiedProgressItems = { ...progressItems }; 67 + const modifiedIds: string[] = []; 68 + let hasChanges = false; 69 + 70 + progressIds.forEach((id) => { 71 + const original = modifiedProgressItems[id]; 72 + if (original) { 73 + const modified = modifyProgressItem(original, options); 74 + modifiedProgressItems[id] = modified; 75 + modifiedIds.push(id); 76 + 77 + // Check if anything actually changed 78 + if (!hasChanges) { 79 + hasChanges = Object.keys(options).some((key) => { 80 + const optionKey = key as keyof ProgressModificationOptions; 81 + const optionValue = options[optionKey]; 82 + const currentValue = modified[optionKey as keyof ProgressMediaItem]; 83 + 84 + if (optionKey === "progress" && optionValue && currentValue) { 85 + return ( 86 + (optionValue as ProgressItem).watched !== 87 + (currentValue as ProgressItem).watched || 88 + (optionValue as ProgressItem).duration !== 89 + (currentValue as ProgressItem).duration 90 + ); 91 + } 92 + 93 + return optionValue !== currentValue; 94 + }); 95 + } 96 + } 97 + }); 98 + 99 + return { 100 + modifiedProgressItems, 101 + result: { modifiedIds, hasChanges: hasChanges && modifiedIds.length > 0 }, 102 + }; 103 + }