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 order

Pas 65ea4c50 2f968bfe

+714 -162
+18 -1
src/assets/locales/en.json
··· 240 240 }, 241 241 "home": { 242 242 "bookmarks": { 243 - "sectionTitle": "Bookmarks" 243 + "sectionTitle": "Bookmarks", 244 + "groups": { 245 + "dropdown": { 246 + "placeholderButton": "Add to group", 247 + "empty": "No groups yet", 248 + "addButton": "Add", 249 + "removeFromGroup": "Remove from group", 250 + "removeAll": "Remove all" 251 + }, 252 + "reorder": { 253 + "button": "Reorder", 254 + "done": "Done", 255 + "title": "Edit Group Order", 256 + "description": "Drag and drop to reorder your bookmark groups.", 257 + "cancel": "Cancel", 258 + "save": "Save" 259 + } 260 + } 244 261 }, 245 262 "continueWatching": { 246 263 "sectionTitle": "Continue Watching..."
+29
src/backend/accounts/groupOrder.ts
··· 1 + import { ofetch } from "ofetch"; 2 + 3 + import { getAuthHeaders } from "@/backend/accounts/auth"; 4 + import { AccountWithToken } from "@/stores/auth"; 5 + 6 + export interface GroupOrderResponse { 7 + groupOrder: string[]; 8 + } 9 + 10 + export function updateGroupOrder( 11 + url: string, 12 + account: AccountWithToken, 13 + groupOrder: string[], 14 + ) { 15 + return ofetch<GroupOrderResponse>(`/users/${account.userId}/group-order`, { 16 + method: "PUT", 17 + body: groupOrder, 18 + baseURL: url, 19 + headers: getAuthHeaders(account.token), 20 + }); 21 + } 22 + 23 + export function getGroupOrder(url: string, account: AccountWithToken) { 24 + return ofetch<GroupOrderResponse>(`/users/${account.userId}/group-order`, { 25 + method: "GET", 26 + baseURL: url, 27 + headers: getAuthHeaders(account.token), 28 + }); 29 + }
+43
src/components/buttons/EditButtonWithText.tsx
··· 1 + import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 + import { useCallback, useRef } from "react"; 3 + import { useTranslation } from "react-i18next"; 4 + 5 + export interface EditButtonWithTextProps { 6 + editing: boolean; 7 + onEdit?: (editing: boolean) => void; 8 + id?: string; 9 + text: string; 10 + secondaryText?: string; 11 + } 12 + 13 + export function EditButtonWithText(props: EditButtonWithTextProps) { 14 + const { t } = useTranslation(); 15 + const [parent] = useAutoAnimate<HTMLSpanElement>(); 16 + const buttonRef = useRef<HTMLButtonElement>(null); 17 + 18 + const onClick = useCallback(() => { 19 + props.onEdit?.(!props.editing); 20 + }, [props]); 21 + 22 + return ( 23 + <button 24 + ref={buttonRef} 25 + type="button" 26 + onClick={onClick} 27 + className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105" 28 + id={props.id} // Assign id to the button 29 + > 30 + <span ref={parent}> 31 + {props.editing ? ( 32 + <span className="mx-2 sm:mx-4 whitespace-nowrap"> 33 + {props.text ?? t("home.mediaList.stopEditing")} 34 + </span> 35 + ) : ( 36 + <span className="mx-2 sm:mx-4 whitespace-nowrap"> 37 + {props.secondaryText} 38 + </span> 39 + )} 40 + </span> 41 + </button> 42 + ); 43 + }
+10 -5
src/components/form/GroupDropdown.tsx
··· 1 + import { t } from "i18next"; 1 2 import React, { useState } from "react"; 2 3 3 4 import { Icon, Icons } from "@/components/Icon"; ··· 78 79 })} 79 80 </span> 80 81 ) : ( 81 - <span className="text-white/70">Add to group</span> 82 + <span className="text-white/70"> 83 + {t("home.bookmarks.groups.dropdown.placeholderButton")} 84 + </span> 82 85 )} 83 86 <span className="ml-2 text-white/40"> 84 87 <Icon ··· 90 93 {open && ( 91 94 <div className="absolute z-[150] mt-1 end-0 bg-gray-800 border border-gray-700 rounded-lg shadow-lg py-1 pb-3 text-sm"> 92 95 {groups.length === 0 && !showInput && ( 93 - <div className="px-4 py-2 text-gray-400">No groups</div> 96 + <div className="px-4 py-2 text-gray-400"> 97 + {t("home.bookmarks.groups.dropdown.empty")} 98 + </div> 94 99 )} 95 100 {groups.map((group) => { 96 101 const { icon, name } = parseGroupString(group); ··· 137 142 disabled={!newGroup.trim()} 138 143 style={{ flexShrink: 0 }} 139 144 > 140 - Add 145 + {t("home.bookmarks.groups.dropdown.addButton")} 141 146 </button> 142 147 </div> 143 148 {newGroup.trim().length > 0 && ( ··· 167 172 {currentGroups.length > 0 && ( 168 173 <div className="border-t border-gray-700 pt-2 px-4"> 169 174 <div className="text-xs text-red-400 mb-1"> 170 - Remove from group: 175 + {t("home.bookmarks.groups.dropdown.removeFromGroup")} 171 176 </div> 172 177 <div className="flex flex-wrap gap-2"> 173 178 {currentGroups.map((group) => { ··· 190 195 className="ml-2 text-xs text-red-400 underline" 191 196 onClick={() => onRemoveGroup()} 192 197 > 193 - Remove all 198 + {t("home.bookmarks.groups.dropdown.removeAll")} 194 199 </button> 195 200 </div> 196 201 </div>
+11 -2
src/hooks/auth/useAuth.ts
··· 9 9 keysFromMnemonic, 10 10 signChallenge, 11 11 } from "@/backend/accounts/crypto"; 12 + import { getGroupOrder } from "@/backend/accounts/groupOrder"; 12 13 import { importBookmarks, importProgress } from "@/backend/accounts/import"; 13 14 import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login"; 14 15 import { progressMediaItemToInputs } from "@/backend/accounts/progress"; ··· 180 181 throw err; 181 182 } 182 183 183 - const [bookmarks, progress, settings] = await Promise.all([ 184 + const [bookmarks, progress, settings, groupOrder] = await Promise.all([ 184 185 getBookmarks(backendUrl, account), 185 186 getProgress(backendUrl, account), 186 187 getSettings(backendUrl, account), 188 + getGroupOrder(backendUrl, account), 187 189 ]); 188 190 189 - syncData(user.user, user.session, progress, bookmarks, settings); 191 + syncData( 192 + user.user, 193 + user.session, 194 + progress, 195 + bookmarks, 196 + settings, 197 + groupOrder, 198 + ); 190 199 }, 191 200 [backendUrl, syncData, logout], 192 201 );
+5
src/hooks/auth/useAuthData.ts
··· 96 96 progress: ProgressResponse[], 97 97 bookmarks: BookmarkResponse[], 98 98 settings: SettingsResponse, 99 + groupOrder: { groupOrder: string[] }, 99 100 ) => { 100 101 replaceBookmarks(bookmarkResponsesToEntries(bookmarks)); 101 102 replaceItems(progressResponsesToEntries(progress)); 103 + 104 + if (groupOrder?.groupOrder) { 105 + useBookmarkStore.getState().setGroupOrder(groupOrder.groupOrder); 106 + } 102 107 103 108 if (settings.applicationLanguage) { 104 109 setAppLanguage(settings.applicationLanguage);
+305 -102
src/pages/parts/home/BookmarksCarousel.tsx
··· 1 1 import React, { useMemo, useRef, useState } from "react"; 2 2 import { useTranslation } from "react-i18next"; 3 3 4 + import { Button } from "@/components/buttons/Button"; 4 5 import { EditButton } from "@/components/buttons/EditButton"; 6 + import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; 7 + import { Item, SortableList } from "@/components/form/SortableList"; 5 8 import { Icons } from "@/components/Icon"; 6 9 import { SectionHeading } from "@/components/layout/SectionHeading"; 7 10 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 11 + import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; 8 12 import { UserIcon, UserIcons } from "@/components/UserIcon"; 13 + import { Heading2, Paragraph } from "@/components/utils/Text"; 14 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 9 15 import { useIsMobile } from "@/hooks/useIsMobile"; 10 16 import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons"; 17 + import { useAuthStore } from "@/stores/auth"; 11 18 import { useBookmarkStore } from "@/stores/bookmarks"; 12 19 import { useProgressStore } from "@/stores/progress"; 13 20 import { MediaItem } from "@/utils/mediaTypes"; ··· 53 60 const [editing, setEditing] = useState(false); 54 61 const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 55 62 const pressTimerRef = useRef<NodeJS.Timeout | null>(null); 63 + const backendUrl = useBackendUrl(); 64 + const account = useAuthStore((s) => s.account); 65 + 66 + // Group order editing state 67 + const groupOrder = useBookmarkStore((s) => s.groupOrder); 68 + const setGroupOrder = useBookmarkStore((s) => s.setGroupOrder); 69 + const editOrderModal = useModal("bookmark-edit-order-carousel"); 70 + const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]); 56 71 57 72 const { isMobile } = useIsMobile(); 58 73 ··· 121 136 return { groupedItems: grouped, regularItems: regular }; 122 137 }, [items, bookmarks, progressItems]); 123 138 139 + // group sorting 140 + const allGroups = useMemo(() => { 141 + const groups = new Set<string>(); 142 + 143 + Object.values(bookmarks).forEach((bookmark) => { 144 + if (Array.isArray(bookmark.group)) { 145 + bookmark.group.forEach((group) => groups.add(group)); 146 + } 147 + }); 148 + 149 + groups.add("bookmarks"); 150 + 151 + return Array.from(groups); 152 + }, [bookmarks]); 153 + 154 + const sortableItems = useMemo(() => { 155 + const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder; 156 + 157 + if (currentOrder.length === 0) { 158 + return allGroups.map((group) => { 159 + const { name } = parseGroupString(group); 160 + return { 161 + id: group, 162 + name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name, 163 + } as Item; 164 + }); 165 + } 166 + 167 + const orderMap = new Map( 168 + currentOrder.map((group, index) => [group, index]), 169 + ); 170 + const sortedGroups = allGroups.sort((groupA, groupB) => { 171 + const orderA = orderMap.has(groupA) 172 + ? orderMap.get(groupA)! 173 + : Number.MAX_SAFE_INTEGER; 174 + const orderB = orderMap.has(groupB) 175 + ? orderMap.get(groupB)! 176 + : Number.MAX_SAFE_INTEGER; 177 + return orderA - orderB; 178 + }); 179 + 180 + return sortedGroups.map((group) => { 181 + const { name } = parseGroupString(group); 182 + return { 183 + id: group, 184 + name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name, 185 + } as Item; 186 + }); 187 + }, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]); 188 + 189 + // Create a unified list of sections including both grouped and regular bookmarks 190 + const sortedSections = useMemo(() => { 191 + const sections: Array<{ 192 + type: "grouped" | "regular"; 193 + group?: string; 194 + items: MediaItem[]; 195 + }> = []; 196 + 197 + // Create a combined map of all sections (grouped + regular) 198 + const allSections = new Map<string, MediaItem[]>(); 199 + 200 + // Add grouped sections 201 + Object.entries(groupedItems).forEach(([group, groupItems]) => { 202 + allSections.set(group, groupItems); 203 + }); 204 + 205 + // Add regular bookmarks as "bookmarks" group 206 + if (regularItems.length > 0) { 207 + allSections.set("bookmarks", regularItems); 208 + } 209 + 210 + // Sort sections based on group order 211 + if (groupOrder.length === 0) { 212 + // No order set, use default order 213 + allSections.forEach((sectionItems, group) => { 214 + if (group === "bookmarks") { 215 + sections.push({ type: "regular", items: sectionItems }); 216 + } else { 217 + sections.push({ type: "grouped", group, items: sectionItems }); 218 + } 219 + }); 220 + } else { 221 + // Use the saved order 222 + const orderMap = new Map( 223 + groupOrder.map((group, index) => [group, index]), 224 + ); 225 + 226 + Array.from(allSections.entries()) 227 + .sort(([groupA], [groupB]) => { 228 + const orderA = orderMap.has(groupA) 229 + ? orderMap.get(groupA)! 230 + : Number.MAX_SAFE_INTEGER; 231 + const orderB = orderMap.has(groupB) 232 + ? orderMap.get(groupB)! 233 + : Number.MAX_SAFE_INTEGER; 234 + return orderA - orderB; 235 + }) 236 + .forEach(([group, sectionItems]) => { 237 + if (group === "bookmarks") { 238 + sections.push({ type: "regular", items: sectionItems }); 239 + } else { 240 + sections.push({ type: "grouped", group, items: sectionItems }); 241 + } 242 + }); 243 + } 244 + 245 + return sections; 246 + }, [groupedItems, regularItems, groupOrder]); 247 + // kill me 248 + 124 249 const handleWheel = (e: React.WheelEvent) => { 125 250 if (isScrolling) return; 126 251 isScrolling = true; ··· 171 296 } 172 297 }; 173 298 299 + const handleEditGroupOrder = () => { 300 + // Initialize with current order or default order 301 + if (groupOrder.length === 0) { 302 + const defaultOrder = allGroups.map((group) => group); 303 + setTempGroupOrder(defaultOrder); 304 + } else { 305 + setTempGroupOrder([...groupOrder]); 306 + } 307 + editOrderModal.show(); 308 + }; 309 + 310 + const handleReorderClick = () => { 311 + handleEditGroupOrder(); 312 + // Keep editing state active by setting it to true 313 + setEditing(true); 314 + }; 315 + 316 + const handleCancelOrder = () => { 317 + editOrderModal.hide(); 318 + }; 319 + 320 + const handleSaveOrderClick = () => { 321 + setGroupOrder(tempGroupOrder); 322 + editOrderModal.hide(); 323 + 324 + // Save to backend 325 + if (backendUrl && account) { 326 + useBookmarkStore.getState().saveGroupOrderToBackend(backendUrl, account); 327 + } 328 + }; 329 + 174 330 const categorySlug = "bookmarks"; 175 331 const SKELETON_COUNT = 10; 176 332 ··· 179 335 return ( 180 336 <> 181 337 {/* Grouped Bookmarks Carousels */} 182 - {Object.entries(groupedItems).map(([group, groupItems]) => { 183 - const { icon, name } = parseGroupString(group); 338 + {sortedSections.map((section) => { 339 + if (section.type === "grouped") { 340 + const { icon, name } = parseGroupString(section.group || ""); 341 + return ( 342 + <div key={section.group || "bookmarks"}> 343 + <SectionHeading 344 + title={name} 345 + customIcon={ 346 + <span className="w-6 h-6 flex items-center justify-center"> 347 + <UserIcon icon={icon} className="w-full h-full" /> 348 + </span> 349 + } 350 + className="ml-4 md:ml-12 mt-2 -mb-5" 351 + > 352 + <div className="mr-4 md:mr-8 flex items-center gap-2"> 353 + {editing && allGroups.length > 1 && ( 354 + <EditButtonWithText 355 + editing={editing} 356 + onEdit={handleReorderClick} 357 + id="edit-group-order-button-carousel" 358 + text={t("home.bookmarks.groups.reorder.button")} 359 + secondaryText={t("home.bookmarks.groups.reorder.done")} 360 + /> 361 + )} 362 + <EditButton 363 + editing={editing} 364 + onEdit={setEditing} 365 + id={`edit-button-bookmark-${section.group}`} 366 + /> 367 + </div> 368 + </SectionHeading> 369 + <div className="relative overflow-hidden carousel-container md:pb-4"> 370 + <div 371 + id={`carousel-${section.group}`} 372 + 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" 373 + ref={(el) => { 374 + carouselRefs.current[section.group || "bookmarks"] = el; 375 + }} 376 + onWheel={handleWheel} 377 + > 378 + <div className="md:w-12" /> 379 + 380 + {section.items.map((media) => ( 381 + <div 382 + key={media.id} 383 + style={{ userSelect: "none" }} 384 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 385 + e.preventDefault() 386 + } 387 + onTouchStart={handleTouchStart} 388 + onTouchEnd={handleTouchEnd} 389 + onMouseDown={handleMouseDown} 390 + onMouseUp={handleMouseUp} 391 + 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" 392 + > 393 + <WatchedMediaCard 394 + key={media.id} 395 + media={media} 396 + onShowDetails={onShowDetails} 397 + closable={editing} 398 + onClose={() => removeBookmark(media.id)} 399 + /> 400 + </div> 401 + ))} 402 + 403 + <div className="md:w-12" /> 404 + </div> 405 + 406 + {!isMobile && ( 407 + <CarouselNavButtons 408 + categorySlug={section.group || "bookmarks"} 409 + carouselRefs={carouselRefs} 410 + /> 411 + )} 412 + </div> 413 + </div> 414 + ); 415 + } // regular items 184 416 return ( 185 - <div key={group}> 417 + <div key="regular-bookmarks"> 186 418 <SectionHeading 187 - title={name} 188 - customIcon={ 189 - <span className="w-6 h-6 flex items-center justify-center"> 190 - <UserIcon icon={icon} className="w-full h-full" /> 191 - </span> 192 - } 419 + title={t("home.bookmarks.sectionTitle") || "Bookmarks"} 420 + icon={Icons.BOOKMARK} 193 421 className="ml-4 md:ml-12 mt-2 -mb-5" 194 422 > 195 - <div className="mr-4 md:mr-8"> 423 + <div className="mr-4 md:mr-8 flex items-center gap-2"> 424 + {editing && allGroups.length > 1 && ( 425 + <EditButtonWithText 426 + editing={editing} 427 + onEdit={handleReorderClick} 428 + id="edit-group-order-button-carousel" 429 + text={t("home.bookmarks.groups.reorder.button")} 430 + secondaryText={t("home.bookmarks.groups.reorder.done")} 431 + /> 432 + )} 196 433 <EditButton 197 434 editing={editing} 198 435 onEdit={setEditing} 199 - id={`edit-button-bookmark-${group}`} 436 + id="edit-button-bookmark" 200 437 /> 201 438 </div> 202 439 </SectionHeading> 203 440 <div className="relative overflow-hidden carousel-container md:pb-4"> 204 441 <div 205 - id={`carousel-${group}`} 442 + id={`carousel-${categorySlug}`} 206 443 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" 207 444 ref={(el) => { 208 - carouselRefs.current[group] = el; 445 + carouselRefs.current[categorySlug] = el; 209 446 }} 210 447 onWheel={handleWheel} 211 448 > 212 449 <div className="md:w-12" /> 213 450 214 - {groupItems.map((media) => ( 215 - <div 216 - key={media.id} 217 - style={{ userSelect: "none" }} 218 - onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 219 - e.preventDefault() 220 - } 221 - onTouchStart={handleTouchStart} 222 - onTouchEnd={handleTouchEnd} 223 - onMouseDown={handleMouseDown} 224 - onMouseUp={handleMouseUp} 225 - 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" 226 - > 227 - <WatchedMediaCard 228 - key={media.id} 229 - media={media} 230 - onShowDetails={onShowDetails} 231 - closable={editing} 232 - onClose={() => removeBookmark(media.id)} 233 - /> 234 - </div> 235 - ))} 451 + {section.items.length > 0 452 + ? section.items.map((media) => ( 453 + <div 454 + key={media.id} 455 + style={{ userSelect: "none" }} 456 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 457 + e.preventDefault() 458 + } 459 + onTouchStart={handleTouchStart} 460 + onTouchEnd={handleTouchEnd} 461 + onMouseDown={handleMouseDown} 462 + onMouseUp={handleMouseUp} 463 + 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" 464 + > 465 + <WatchedMediaCard 466 + key={media.id} 467 + media={media} 468 + onShowDetails={onShowDetails} 469 + closable={editing} 470 + onClose={() => removeBookmark(media.id)} 471 + /> 472 + </div> 473 + )) 474 + : Array.from({ length: SKELETON_COUNT }).map(() => ( 475 + <MediaCardSkeleton 476 + key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} 477 + /> 478 + ))} 236 479 237 480 <div className="md:w-12" /> 238 481 </div> 239 482 240 483 {!isMobile && ( 241 484 <CarouselNavButtons 242 - categorySlug={group} 485 + categorySlug={categorySlug} 243 486 carouselRefs={carouselRefs} 244 487 /> 245 488 )} ··· 248 491 ); 249 492 })} 250 493 251 - {/* Regular Bookmarks Carousel */} 252 - {regularItems.length > 0 && ( 253 - <> 254 - <SectionHeading 255 - title={t("home.bookmarks.sectionTitle") || "Bookmarks"} 256 - icon={Icons.BOOKMARK} 257 - className="ml-4 md:ml-12 mt-2 -mb-5" 258 - > 259 - <div className="mr-4 md:mr-8"> 260 - <EditButton 261 - editing={editing} 262 - onEdit={setEditing} 263 - id="edit-button-bookmark" 264 - /> 265 - </div> 266 - </SectionHeading> 267 - <div className="relative overflow-hidden carousel-container md:pb-4"> 268 - <div 269 - id={`carousel-${categorySlug}`} 270 - 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" 271 - ref={(el) => { 272 - carouselRefs.current[categorySlug] = el; 494 + {/* Edit Order Modal */} 495 + <Modal id={editOrderModal.id}> 496 + <ModalCard> 497 + <Heading2 className="!mt-0"> 498 + {t("home.bookmarks.groups.reorder.title")} 499 + </Heading2> 500 + <Paragraph> 501 + {t("home.bookmarks.groups.reorder.description")} 502 + </Paragraph> 503 + <div className="mt-6"> 504 + <SortableList 505 + items={sortableItems} 506 + setItems={(newItems) => { 507 + const newOrder = newItems.map((item) => item.id); 508 + setTempGroupOrder(newOrder); 273 509 }} 274 - onWheel={handleWheel} 275 - > 276 - <div className="md:w-12" /> 277 - 278 - {regularItems.length > 0 279 - ? regularItems.map((media) => ( 280 - <div 281 - key={media.id} 282 - style={{ userSelect: "none" }} 283 - onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 284 - e.preventDefault() 285 - } 286 - onTouchStart={handleTouchStart} 287 - onTouchEnd={handleTouchEnd} 288 - onMouseDown={handleMouseDown} 289 - onMouseUp={handleMouseUp} 290 - 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" 291 - > 292 - <WatchedMediaCard 293 - key={media.id} 294 - media={media} 295 - onShowDetails={onShowDetails} 296 - closable={editing} 297 - onClose={() => removeBookmark(media.id)} 298 - /> 299 - </div> 300 - )) 301 - : Array.from({ length: SKELETON_COUNT }).map(() => ( 302 - <MediaCardSkeleton 303 - key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} 304 - /> 305 - ))} 306 - 307 - <div className="md:w-12" /> 308 - </div> 309 - 310 - {!isMobile && ( 311 - <CarouselNavButtons 312 - categorySlug={categorySlug} 313 - carouselRefs={carouselRefs} 314 - /> 315 - )} 510 + /> 511 + </div> 512 + <div className="flex gap-4 mt-6 justify-end"> 513 + <Button theme="secondary" onClick={handleCancelOrder}> 514 + {t("home.bookmarks.groups.reorder.cancel")} 515 + </Button> 516 + <Button theme="purple" onClick={handleSaveOrderClick}> 517 + {t("home.bookmarks.groups.reorder.save")} 518 + </Button> 316 519 </div> 317 - </> 318 - )} 520 + </ModalCard> 521 + </Modal> 319 522 </> 320 523 ); 321 524 }
+250 -52
src/pages/parts/home/BookmarksPart.tsx
··· 2 2 import { useEffect, useMemo, useRef, useState } from "react"; 3 3 import { useTranslation } from "react-i18next"; 4 4 5 + import { Button } from "@/components/buttons/Button"; 5 6 import { EditButton } from "@/components/buttons/EditButton"; 7 + import { EditButtonWithText } from "@/components/buttons/EditButtonWithText"; 8 + import { Item, SortableList } from "@/components/form/SortableList"; 6 9 import { Icons } from "@/components/Icon"; 7 10 import { SectionHeading } from "@/components/layout/SectionHeading"; 8 11 import { MediaGrid } from "@/components/media/MediaGrid"; 9 12 import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; 13 + import { Modal, ModalCard, useModal } from "@/components/overlays/Modal"; 10 14 import { UserIcon, UserIcons } from "@/components/UserIcon"; 15 + import { Heading2, Paragraph } from "@/components/utils/Text"; 16 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 17 + import { useAuthStore } from "@/stores/auth"; 11 18 import { useBookmarkStore } from "@/stores/bookmarks"; 12 19 import { useProgressStore } from "@/stores/progress"; 13 20 import { MediaItem } from "@/utils/mediaTypes"; ··· 35 42 const { t } = useTranslation(); 36 43 const progressItems = useProgressStore((s) => s.items); 37 44 const bookmarks = useBookmarkStore((s) => s.bookmarks); 45 + const groupOrder = useBookmarkStore((s) => s.groupOrder); 46 + const setGroupOrder = useBookmarkStore((s) => s.setGroupOrder); 38 47 const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 39 48 const [editing, setEditing] = useState(false); 40 49 const [gridRef] = useAutoAnimate<HTMLDivElement>(); 50 + const editOrderModal = useModal("bookmark-edit-order"); 51 + const [tempGroupOrder, setTempGroupOrder] = useState<string[]>([]); 52 + const backendUrl = useBackendUrl(); 53 + const account = useAuthStore((s) => s.account); 41 54 42 55 const pressTimerRef = useRef<NodeJS.Timeout | null>(null); 43 56 ··· 99 112 return { groupedItems: grouped, regularItems: regular }; 100 113 }, [items, bookmarks, progressItems]); 101 114 115 + // group sorting 116 + const allGroups = useMemo(() => { 117 + const groups = new Set<string>(); 118 + 119 + Object.values(bookmarks).forEach((bookmark) => { 120 + if (Array.isArray(bookmark.group)) { 121 + bookmark.group.forEach((group) => groups.add(group)); 122 + } 123 + }); 124 + 125 + groups.add("bookmarks"); 126 + 127 + return Array.from(groups); 128 + }, [bookmarks]); 129 + 130 + const sortableItems = useMemo(() => { 131 + const currentOrder = editOrderModal.isShown ? tempGroupOrder : groupOrder; 132 + 133 + if (currentOrder.length === 0) { 134 + return allGroups.map((group) => { 135 + const { name } = parseGroupString(group); 136 + return { 137 + id: group, 138 + name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name, 139 + } as Item; 140 + }); 141 + } 142 + 143 + const orderMap = new Map( 144 + currentOrder.map((group, index) => [group, index]), 145 + ); 146 + const sortedGroups = allGroups.sort((groupA, groupB) => { 147 + const orderA = orderMap.has(groupA) 148 + ? orderMap.get(groupA)! 149 + : Number.MAX_SAFE_INTEGER; 150 + const orderB = orderMap.has(groupB) 151 + ? orderMap.get(groupB)! 152 + : Number.MAX_SAFE_INTEGER; 153 + return orderA - orderB; 154 + }); 155 + 156 + return sortedGroups.map((group) => { 157 + const { name } = parseGroupString(group); 158 + return { 159 + id: group, 160 + name: group === "bookmarks" ? t("home.bookmarks.sectionTitle") : name, 161 + } as Item; 162 + }); 163 + }, [allGroups, t, editOrderModal.isShown, tempGroupOrder, groupOrder]); 164 + 165 + const sortedSections = useMemo(() => { 166 + const sections: Array<{ 167 + type: "grouped" | "regular"; 168 + group?: string; 169 + items: MediaItem[]; 170 + }> = []; 171 + 172 + const allSections = new Map<string, MediaItem[]>(); 173 + 174 + Object.entries(groupedItems).forEach(([group, groupItems]) => { 175 + allSections.set(group, groupItems); 176 + }); 177 + 178 + if (regularItems.length > 0) { 179 + allSections.set("bookmarks", regularItems); 180 + } 181 + 182 + if (groupOrder.length === 0) { 183 + allSections.forEach((sectionItems, group) => { 184 + if (group === "bookmarks") { 185 + sections.push({ type: "regular", items: sectionItems }); 186 + } else { 187 + sections.push({ type: "grouped", group, items: sectionItems }); 188 + } 189 + }); 190 + } else { 191 + const orderMap = new Map( 192 + groupOrder.map((group, index) => [group, index]), 193 + ); 194 + 195 + Array.from(allSections.entries()) 196 + .sort(([groupA], [groupB]) => { 197 + const orderA = orderMap.has(groupA) 198 + ? orderMap.get(groupA)! 199 + : Number.MAX_SAFE_INTEGER; 200 + const orderB = orderMap.has(groupB) 201 + ? orderMap.get(groupB)! 202 + : Number.MAX_SAFE_INTEGER; 203 + return orderA - orderB; 204 + }) 205 + .forEach(([group, sectionItems]) => { 206 + if (group === "bookmarks") { 207 + sections.push({ type: "regular", items: sectionItems }); 208 + } else { 209 + sections.push({ type: "grouped", group, items: sectionItems }); 210 + } 211 + }); 212 + } 213 + 214 + return sections; 215 + }, [groupedItems, regularItems, groupOrder]); 216 + // kill me 217 + 102 218 useEffect(() => { 103 219 onItemsChange(items.length > 0); 104 220 }, [items, onItemsChange]); ··· 135 251 } 136 252 }; 137 253 254 + const handleEditGroupOrder = () => { 255 + // Initialize with current order or default order 256 + if (groupOrder.length === 0) { 257 + const defaultOrder = allGroups.map((group) => group); 258 + setTempGroupOrder(defaultOrder); 259 + } else { 260 + setTempGroupOrder([...groupOrder]); 261 + } 262 + editOrderModal.show(); 263 + }; 264 + 265 + const handleReorderClick = () => { 266 + handleEditGroupOrder(); 267 + // Keep editing state active by setting it to true 268 + setEditing(true); 269 + }; 270 + 271 + const handleCancelOrder = () => { 272 + editOrderModal.hide(); 273 + }; 274 + 275 + const handleSaveOrderClick = () => { 276 + setGroupOrder(tempGroupOrder); 277 + editOrderModal.hide(); 278 + 279 + // Save to backend 280 + if (backendUrl && account) { 281 + useBookmarkStore.getState().saveGroupOrderToBackend(backendUrl, account); 282 + } 283 + }; 284 + 138 285 if (items.length === 0) return null; 139 286 140 287 return ( 141 288 <div className="relative"> 142 289 {/* Grouped Bookmarks */} 143 - {Object.entries(groupedItems).map(([group, groupItems]) => { 144 - const { icon, name } = parseGroupString(group); 290 + {sortedSections.map((section) => { 291 + if (section.type === "grouped") { 292 + const { icon, name } = parseGroupString(section.group || ""); 293 + return ( 294 + <div key={section.group || "bookmarks"} className="mb-6"> 295 + <SectionHeading 296 + title={name} 297 + customIcon={ 298 + <span className="w-6 h-6 flex items-center justify-center"> 299 + <UserIcon icon={icon} className="w-full h-full" /> 300 + </span> 301 + } 302 + > 303 + <div className="flex items-center gap-2"> 304 + {editing && allGroups.length > 1 && ( 305 + <EditButtonWithText 306 + editing={editing} 307 + onEdit={handleReorderClick} 308 + id="edit-group-order-button" 309 + text={t("home.bookmarks.groups.reorder.button")} 310 + secondaryText={t("home.bookmarks.groups.reorder.done")} 311 + /> 312 + )} 313 + <EditButton 314 + editing={editing} 315 + onEdit={setEditing} 316 + id={`edit-button-bookmark-${section.group}`} 317 + /> 318 + </div> 319 + </SectionHeading> 320 + <MediaGrid> 321 + {section.items.map((v) => ( 322 + <div 323 + key={v.id} 324 + style={{ userSelect: "none" }} 325 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 326 + e.preventDefault() 327 + } 328 + onTouchStart={handleTouchStart} 329 + onTouchEnd={handleTouchEnd} 330 + onMouseDown={handleMouseDown} 331 + onMouseUp={handleMouseUp} 332 + > 333 + <WatchedMediaCard 334 + media={v} 335 + closable={editing} 336 + onClose={() => removeBookmark(v.id)} 337 + onShowDetails={onShowDetails} 338 + /> 339 + </div> 340 + ))} 341 + </MediaGrid> 342 + </div> 343 + ); 344 + } // regular items 145 345 return ( 146 - <div key={group} className="mb-6"> 346 + <div key="regular-bookmarks" className="mb-6"> 147 347 <SectionHeading 148 - title={name} 149 - customIcon={ 150 - <span className="w-6 h-6 flex items-center justify-center"> 151 - <UserIcon icon={icon} className="w-full h-full" /> 152 - </span> 153 - } 348 + title={t("home.bookmarks.sectionTitle")} 349 + icon={Icons.BOOKMARK} 154 350 > 155 - <EditButton 156 - editing={editing} 157 - onEdit={setEditing} 158 - id={`edit-button-bookmark-${group}`} 159 - /> 351 + <div className="flex items-center gap-2"> 352 + {editing && allGroups.length > 1 && ( 353 + <EditButtonWithText 354 + editing={editing} 355 + onEdit={handleReorderClick} 356 + id="edit-group-order-button" 357 + text={t("home.bookmarks.groups.reorder.button")} 358 + secondaryText={t("home.bookmarks.groups.reorder.done")} 359 + /> 360 + )} 361 + <EditButton 362 + editing={editing} 363 + onEdit={setEditing} 364 + id="edit-button-bookmark" 365 + /> 366 + </div> 160 367 </SectionHeading> 161 - <MediaGrid> 162 - {groupItems.map((v) => ( 368 + <MediaGrid ref={gridRef}> 369 + {section.items.map((v) => ( 163 370 <div 164 371 key={v.id} 165 372 style={{ userSelect: "none" }} ··· 184 391 ); 185 392 })} 186 393 187 - {/* Regular Bookmarks */} 188 - {regularItems.length > 0 && ( 189 - <div> 190 - <SectionHeading 191 - title={t("home.bookmarks.sectionTitle")} 192 - icon={Icons.BOOKMARK} 193 - > 194 - <EditButton 195 - editing={editing} 196 - onEdit={setEditing} 197 - id="edit-button-bookmark" 394 + {/* Edit Order Modal */} 395 + <Modal id={editOrderModal.id}> 396 + <ModalCard> 397 + <Heading2 className="!mt-0"> 398 + {t("home.bookmarks.groups.reorder.title")} 399 + </Heading2> 400 + <Paragraph> 401 + {t("home.bookmarks.groups.reorder.description")} 402 + </Paragraph> 403 + <div className="mt-6"> 404 + <SortableList 405 + items={sortableItems} 406 + setItems={(newItems) => { 407 + const newOrder = newItems.map((item) => item.id); 408 + setTempGroupOrder(newOrder); 409 + }} 198 410 /> 199 - </SectionHeading> 200 - <MediaGrid ref={gridRef}> 201 - {regularItems.map((v) => ( 202 - <div 203 - key={v.id} 204 - style={{ userSelect: "none" }} 205 - onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 206 - e.preventDefault() 207 - } 208 - onTouchStart={handleTouchStart} 209 - onTouchEnd={handleTouchEnd} 210 - onMouseDown={handleMouseDown} 211 - onMouseUp={handleMouseUp} 212 - > 213 - <WatchedMediaCard 214 - media={v} 215 - closable={editing} 216 - onClose={() => removeBookmark(v.id)} 217 - onShowDetails={onShowDetails} 218 - /> 219 - </div> 220 - ))} 221 - </MediaGrid> 222 - </div> 223 - )} 411 + </div> 412 + <div className="flex gap-4 mt-6 justify-end"> 413 + <Button theme="secondary" onClick={handleCancelOrder}> 414 + {t("home.bookmarks.groups.reorder.cancel")} 415 + </Button> 416 + <Button theme="purple" onClick={handleSaveOrderClick}> 417 + {t("home.bookmarks.groups.reorder.save")} 418 + </Button> 419 + </div> 420 + </ModalCard> 421 + </Modal> 224 422 </div> 225 423 ); 226 424 }
+43
src/stores/bookmarks/index.ts
··· 2 2 import { persist } from "zustand/middleware"; 3 3 import { immer } from "zustand/middleware/immer"; 4 4 5 + import { getGroupOrder, updateGroupOrder } from "@/backend/accounts/groupOrder"; 6 + import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 7 + import { AccountWithToken, useAuthStore } from "@/stores/auth"; 5 8 import { PlayerMeta } from "@/stores/player/slices/source"; 6 9 7 10 export interface BookmarkMediaItem { ··· 27 30 export interface BookmarkStore { 28 31 bookmarks: Record<string, BookmarkMediaItem>; 29 32 updateQueue: BookmarkUpdateItem[]; 33 + groupOrder: string[]; 30 34 addBookmark(meta: PlayerMeta): void; 31 35 addBookmarkWithGroups(meta: PlayerMeta, groups?: string[]): void; 32 36 removeBookmark(id: string): void; 33 37 replaceBookmarks(items: Record<string, BookmarkMediaItem>): void; 38 + setGroupOrder(order: string[]): void; 39 + saveGroupOrderToBackend( 40 + backendUrl: string, 41 + account: AccountWithToken, 42 + ): Promise<void>; 43 + loadGroupOrderFromBackend( 44 + backendUrl: string, 45 + account: AccountWithToken, 46 + ): Promise<void>; 34 47 clear(): void; 35 48 clearUpdateQueue(): void; 36 49 removeUpdateItem(id: string): void; ··· 43 56 immer<BookmarkStore>((set) => ({ 44 57 bookmarks: {}, 45 58 updateQueue: [], 59 + groupOrder: [], 46 60 removeBookmark(id) { 47 61 set((s) => { 48 62 updateId += 1; ··· 119 133 removeUpdateItem(id: string) { 120 134 set((s) => { 121 135 s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)]; 136 + }); 137 + }, 138 + setGroupOrder(order: string[]) { 139 + set((s) => { 140 + s.groupOrder = order; 141 + }); 142 + }, 143 + async saveGroupOrderToBackend( 144 + backendUrl: string, 145 + account: AccountWithToken, 146 + ) { 147 + if (!account || !backendUrl) { 148 + throw new Error("No authenticated account or backend URL"); 149 + } 150 + 151 + const currentState = useBookmarkStore.getState(); 152 + await updateGroupOrder(backendUrl, account, currentState.groupOrder); 153 + }, 154 + async loadGroupOrderFromBackend( 155 + backendUrl: string, 156 + account: AccountWithToken, 157 + ) { 158 + if (!account || !backendUrl) { 159 + throw new Error("No authenticated account or backend URL"); 160 + } 161 + 162 + const response = await getGroupOrder(backendUrl, account); 163 + set((s) => { 164 + s.groupOrder = response.groupOrder; 122 165 }); 123 166 }, 124 167 })),