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 bookmark groups

Pas cc04c9e6 5a74886e

+402 -118
+1
src/backend/accounts/bookmarks.ts
··· 10 10 year: number; 11 11 poster?: string; 12 12 type: string; 13 + group?: string; 13 14 } 14 15 15 16 export interface BookmarkInput {
+12 -2
src/components/media/MediaBookmark.tsx
··· 9 9 10 10 interface MediaBookmarkProps { 11 11 media: MediaItem; 12 + group?: string; 12 13 } 13 14 14 - export function MediaBookmarkButton({ media }: MediaBookmarkProps) { 15 + export function MediaBookmarkButton({ media, group }: MediaBookmarkProps) { 15 16 const addBookmark = useBookmarkStore((s) => s.addBookmark); 17 + const addBookmarkWithGroup = useBookmarkStore((s) => s.addBookmarkWithGroup); 16 18 const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 17 19 const bookmarks = useBookmarkStore((s) => s.bookmarks); 18 20 const meta: PlayerMeta | undefined = useMemo(() => { ··· 31 33 const toggleBookmark = useCallback(() => { 32 34 if (!meta) return; 33 35 if (isBookmarked) removeBookmark(meta.tmdbId); 36 + else if (group) addBookmarkWithGroup(meta, group); 34 37 else addBookmark(meta); 35 - }, [isBookmarked, meta, addBookmark, removeBookmark]); 38 + }, [ 39 + isBookmarked, 40 + meta, 41 + addBookmark, 42 + addBookmarkWithGroup, 43 + removeBookmark, 44 + group, 45 + ]); 36 46 37 47 const buttonOpacityClass = 38 48 media.year === undefined ? "hover:opacity-100" : "hover:opacity-95";
+113 -48
src/components/overlays/details/DetailsBody.tsx
··· 10 10 import { IconPatch } from "@/components/buttons/IconPatch"; 11 11 import { Icon, Icons } from "@/components/Icon"; 12 12 import { MediaBookmarkButton } from "@/components/media/MediaBookmark"; 13 + import { useBookmarkStore } from "@/stores/bookmarks"; 13 14 14 15 import { DetailsBodyProps } from "./types"; 15 16 ··· 28 29 const [releaseInfo, setReleaseInfo] = useState<TraktReleaseResponse | null>( 29 30 null, 30 31 ); 32 + const [groupName, setGroupName] = useState(""); 33 + const addBookmarkWithGroup = useBookmarkStore((s) => s.addBookmarkWithGroup); 34 + const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 35 + const addBookmark = useBookmarkStore((s) => s.addBookmark); 36 + const bookmarks = useBookmarkStore((s) => s.bookmarks); 37 + const currentGroup = bookmarks[data.id?.toString() ?? ""]?.group; 38 + 39 + const handleGroupSubmit = (e: React.FormEvent) => { 40 + e.preventDefault(); 41 + if (!data.id) return; 42 + 43 + const meta = { 44 + tmdbId: data.id.toString(), 45 + title: data.title, 46 + type: data.type || "movie", 47 + releaseYear: data.releaseDate 48 + ? new Date(data.releaseDate).getFullYear() 49 + : 0, 50 + poster: data.posterUrl, 51 + }; 52 + 53 + if (currentGroup) { 54 + // Remove from group by removing bookmark and re-adding without group 55 + removeBookmark(data.id.toString()); 56 + addBookmark(meta); 57 + } else if (groupName.trim()) { 58 + // Add to group 59 + addBookmarkWithGroup(meta, groupName.trim()); 60 + setGroupName(""); 61 + } 62 + }; 31 63 32 64 useEffect(() => { 33 65 const fetchReleaseInfo = async () => { ··· 152 184 </div> 153 185 154 186 {/* Action Buttons */} 155 - <div className="flex items-center gap-4"> 156 - <Button 157 - onClick={onPlayClick} 158 - theme="purple" 159 - className={classNames( 160 - "flex-1 sm:flex-initial sm:w-auto", 161 - "gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100", 162 - "text-md text-white flex items-center justify-center", 163 - )} 164 - > 165 - <Icon icon={Icons.PLAY} className="text-white" /> 166 - <span className="text-white text-sm pr-1"> 167 - {data.type === "movie" 168 - ? !data.releaseDate || new Date(data.releaseDate) > new Date() 169 - ? t("media.unreleased") 187 + <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> 188 + <div className="flex items-center gap-4"> 189 + <Button 190 + onClick={onPlayClick} 191 + theme="purple" 192 + className={classNames( 193 + "flex-1 sm:flex-initial sm:w-auto", 194 + "gap-2 h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100", 195 + "text-md text-white flex items-center justify-center", 196 + )} 197 + > 198 + <Icon icon={Icons.PLAY} className="text-white" /> 199 + <span className="text-white text-sm pr-1"> 200 + {data.type === "movie" 201 + ? !data.releaseDate || new Date(data.releaseDate) > new Date() 202 + ? t("media.unreleased") 203 + : showProgress 204 + ? t("details.resume") 205 + : t("details.play") 170 206 : showProgress 171 207 ? t("details.resume") 172 - : t("details.play") 173 - : showProgress 174 - ? t("details.resume") 175 - : t("details.play")} 176 - </span> 177 - </Button> 178 - <div className="flex items-center gap-1 flex-shrink-0"> 179 - {imdbData?.trailer_url && ( 208 + : t("details.play")} 209 + </span> 210 + </Button> 211 + <div className="flex items-center gap-1 flex-shrink-0"> 212 + {imdbData?.trailer_url && ( 213 + <button 214 + type="button" 215 + onClick={onTrailerClick} 216 + className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95" 217 + title={t("details.trailer")} 218 + > 219 + <IconPatch 220 + icon={Icons.FILM} 221 + className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer" 222 + /> 223 + </button> 224 + )} 225 + <MediaBookmarkButton 226 + media={{ 227 + id: data.id?.toString() || "", 228 + title: data.title, 229 + year: data.releaseDate 230 + ? new Date(data.releaseDate).getFullYear() 231 + : undefined, 232 + poster: data.posterUrl, 233 + type: data.type || "movie", 234 + }} 235 + /> 180 236 <button 181 237 type="button" 182 - onClick={onTrailerClick} 238 + onClick={onShareClick} 183 239 className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95" 184 - title={t("details.trailer")} 240 + title="Share" 185 241 > 186 242 <IconPatch 187 - icon={Icons.FILM} 243 + icon={Icons.IOS_SHARE} 188 244 className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer" 189 245 /> 190 246 </button> 247 + </div> 248 + </div> 249 + 250 + {/* Group Input */} 251 + <form 252 + onSubmit={handleGroupSubmit} 253 + className="flex items-center gap-2 sm:ml-auto" 254 + > 255 + {currentGroup ? ( 256 + <div className="w-64 px-3 py-2 text-xs bg-gray-700/50 border border-gray-600 rounded-lg text-white"> 257 + In:{" "} 258 + <span className="font-semibold text-purple-400"> 259 + {currentGroup} 260 + </span> 261 + </div> 262 + ) : ( 263 + <input 264 + type="text" 265 + value={groupName} 266 + onChange={(e) => setGroupName(e.target.value)} 267 + placeholder="Add to group..." 268 + className="w-64 px-3 py-2 text-xs bg-gray-700/50 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500" 269 + /> 191 270 )} 192 - <MediaBookmarkButton 193 - media={{ 194 - id: data.id?.toString() || "", 195 - title: data.title, 196 - year: data.releaseDate 197 - ? new Date(data.releaseDate).getFullYear() 198 - : undefined, 199 - poster: data.backdrop, 200 - type: data.type || "movie", 201 - }} 202 - /> 203 - <button 204 - type="button" 205 - onClick={onShareClick} 206 - className="p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer hover:opacity-95" 207 - title="Share" 271 + <Button 272 + onClick={handleGroupSubmit} 273 + disabled={currentGroup ? false : !groupName.trim()} 274 + theme={currentGroup ? "danger" : "purple"} 275 + className="px-3 py-2 text-xs" 208 276 > 209 - <IconPatch 210 - icon={Icons.IOS_SHARE} 211 - className="transition-transform duration-300 hover:scale-110 hover:cursor-pointer" 212 - /> 213 - </button> 214 - </div> 277 + {currentGroup ? "Remove" : "Add"} 278 + </Button> 279 + </form> 215 280 </div> 216 281 </div> 217 282 );
+140 -38
src/pages/parts/home/BookmarksCarousel.tsx
··· 73 73 return output; 74 74 }, [bookmarks, progressItems]); 75 75 76 + const { groupedItems, regularItems } = useMemo(() => { 77 + const grouped: Record<string, MediaItem[]> = {}; 78 + const regular: MediaItem[] = []; 79 + 80 + items.forEach((item) => { 81 + const bookmark = bookmarks[item.id]; 82 + if (bookmark?.group) { 83 + if (!grouped[bookmark.group]) { 84 + grouped[bookmark.group] = []; 85 + } 86 + grouped[bookmark.group].push(item); 87 + } else { 88 + regular.push(item); 89 + } 90 + }); 91 + 92 + // Sort items within each group by date 93 + Object.keys(grouped).forEach((group) => { 94 + grouped[group].sort((a, b) => { 95 + const bookmarkA = bookmarks[a.id]; 96 + const bookmarkB = bookmarks[b.id]; 97 + const progressA = progressItems[a.id]; 98 + const progressB = progressItems[b.id]; 99 + 100 + const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0); 101 + const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0); 102 + 103 + return dateB - dateA; 104 + }); 105 + }); 106 + 107 + return { groupedItems: grouped, regularItems: regular }; 108 + }, [items, bookmarks, progressItems]); 109 + 76 110 const handleWheel = (e: React.WheelEvent) => { 77 111 if (isScrolling) return; 78 112 isScrolling = true; ··· 130 164 131 165 return ( 132 166 <> 133 - <SectionHeading 134 - title={t("home.bookmarks.sectionTitle") || "Bookmarks"} 135 - icon={Icons.BOOKMARK} 136 - className="ml-4 md:ml-12 mt-2 -mb-5" 137 - > 138 - <div className="mr-4 md:mr-8"> 139 - <EditButton 140 - editing={editing} 141 - onEdit={setEditing} 142 - id="edit-button-bookmark" 143 - /> 144 - </div> 145 - </SectionHeading> 146 - <div className="relative overflow-hidden carousel-container md:pb-4"> 147 - <div 148 - id={`carousel-${categorySlug}`} 149 - 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" 150 - ref={(el) => { 151 - carouselRefs.current[categorySlug] = el; 152 - }} 153 - onWheel={handleWheel} 154 - > 155 - <div className="md:w-12" /> 167 + {/* Grouped Bookmarks Carousels */} 168 + {Object.entries(groupedItems).map(([group, groupItems]) => ( 169 + <div key={group}> 170 + <SectionHeading 171 + title={group} 172 + icon={Icons.BOOKMARK} 173 + className="ml-4 md:ml-12 mt-2 -mb-5" 174 + > 175 + <div className="mr-4 md:mr-8"> 176 + <EditButton 177 + editing={editing} 178 + onEdit={setEditing} 179 + id={`edit-button-bookmark-${group}`} 180 + /> 181 + </div> 182 + </SectionHeading> 183 + <div className="relative overflow-hidden carousel-container md:pb-4"> 184 + <div 185 + id={`carousel-${group}`} 186 + 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" 187 + ref={(el) => { 188 + carouselRefs.current[group] = el; 189 + }} 190 + onWheel={handleWheel} 191 + > 192 + <div className="md:w-12" /> 156 193 157 - {items.length > 0 158 - ? items.map((media) => ( 194 + {groupItems.map((media) => ( 159 195 <div 160 196 key={media.id} 161 197 style={{ userSelect: "none" }} ··· 176 212 onClose={() => removeBookmark(media.id)} 177 213 /> 178 214 </div> 179 - )) 180 - : Array.from({ length: SKELETON_COUNT }).map(() => ( 181 - <MediaCardSkeleton 182 - key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} 183 - /> 184 215 ))} 185 216 186 - <div className="md:w-12" /> 217 + <div className="md:w-12" /> 218 + </div> 219 + 220 + {!isMobile && ( 221 + <CarouselNavButtons 222 + categorySlug={group} 223 + carouselRefs={carouselRefs} 224 + /> 225 + )} 226 + </div> 187 227 </div> 228 + ))} 188 229 189 - {!isMobile && ( 190 - <CarouselNavButtons 191 - categorySlug={categorySlug} 192 - carouselRefs={carouselRefs} 193 - /> 194 - )} 195 - </div> 230 + {/* Regular Bookmarks Carousel */} 231 + {regularItems.length > 0 && ( 232 + <> 233 + <SectionHeading 234 + title={t("home.bookmarks.sectionTitle") || "Bookmarks"} 235 + icon={Icons.BOOKMARK} 236 + className="ml-4 md:ml-12 mt-2 -mb-5" 237 + > 238 + <div className="mr-4 md:mr-8"> 239 + <EditButton 240 + editing={editing} 241 + onEdit={setEditing} 242 + id="edit-button-bookmark" 243 + /> 244 + </div> 245 + </SectionHeading> 246 + <div className="relative overflow-hidden carousel-container md:pb-4"> 247 + <div 248 + id={`carousel-${categorySlug}`} 249 + 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" 250 + ref={(el) => { 251 + carouselRefs.current[categorySlug] = el; 252 + }} 253 + onWheel={handleWheel} 254 + > 255 + <div className="md:w-12" /> 256 + 257 + {regularItems.length > 0 258 + ? regularItems.map((media) => ( 259 + <div 260 + key={media.id} 261 + style={{ userSelect: "none" }} 262 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 263 + e.preventDefault() 264 + } 265 + onTouchStart={handleTouchStart} 266 + onTouchEnd={handleTouchEnd} 267 + onMouseDown={handleMouseDown} 268 + onMouseUp={handleMouseUp} 269 + 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" 270 + > 271 + <WatchedMediaCard 272 + key={media.id} 273 + media={media} 274 + onShowDetails={onShowDetails} 275 + closable={editing} 276 + onClose={() => removeBookmark(media.id)} 277 + /> 278 + </div> 279 + )) 280 + : Array.from({ length: SKELETON_COUNT }).map(() => ( 281 + <MediaCardSkeleton 282 + key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`} 283 + /> 284 + ))} 285 + 286 + <div className="md:w-12" /> 287 + </div> 288 + 289 + {!isMobile && ( 290 + <CarouselNavButtons 291 + categorySlug={categorySlug} 292 + carouselRefs={carouselRefs} 293 + /> 294 + )} 295 + </div> 296 + </> 297 + )} 196 298 </> 197 299 ); 198 300 }
+108 -30
src/pages/parts/home/BookmarksPart.tsx
··· 51 51 return output; 52 52 }, [bookmarks, progressItems]); 53 53 54 + const { groupedItems, regularItems } = useMemo(() => { 55 + const grouped: Record<string, MediaItem[]> = {}; 56 + const regular: MediaItem[] = []; 57 + 58 + items.forEach((item) => { 59 + const bookmark = bookmarks[item.id]; 60 + if (bookmark?.group) { 61 + if (!grouped[bookmark.group]) { 62 + grouped[bookmark.group] = []; 63 + } 64 + grouped[bookmark.group].push(item); 65 + } else { 66 + regular.push(item); 67 + } 68 + }); 69 + 70 + // Sort items within each group by date 71 + Object.keys(grouped).forEach((group) => { 72 + grouped[group].sort((a, b) => { 73 + const bookmarkA = bookmarks[a.id]; 74 + const bookmarkB = bookmarks[b.id]; 75 + const progressA = progressItems[a.id]; 76 + const progressB = progressItems[b.id]; 77 + 78 + const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0); 79 + const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0); 80 + 81 + return dateB - dateA; 82 + }); 83 + }); 84 + 85 + return { groupedItems: grouped, regularItems: regular }; 86 + }, [items, bookmarks, progressItems]); 87 + 54 88 useEffect(() => { 55 89 onItemsChange(items.length > 0); 56 90 }, [items, onItemsChange]); ··· 91 125 92 126 return ( 93 127 <div className="relative"> 94 - <SectionHeading 95 - title={t("home.bookmarks.sectionTitle") || "Bookmarks"} 96 - icon={Icons.BOOKMARK} 97 - > 98 - <EditButton 99 - editing={editing} 100 - onEdit={setEditing} 101 - id="edit-button-bookmark" 102 - /> 103 - </SectionHeading> 104 - <MediaGrid ref={gridRef}> 105 - {items.map((v) => ( 106 - <div 107 - key={v.id} 108 - style={{ userSelect: "none" }} 109 - onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 110 - e.preventDefault() 111 - } 112 - onTouchStart={handleTouchStart} 113 - onTouchEnd={handleTouchEnd} 114 - onMouseDown={handleMouseDown} 115 - onMouseUp={handleMouseUp} 128 + {/* Grouped Bookmarks */} 129 + {Object.entries(groupedItems).map(([group, groupItems]) => ( 130 + <div key={group} className="mb-6"> 131 + <SectionHeading 132 + title={group} 133 + icon={Icons.BOOKMARK} 134 + className="mb-8" // margin? 116 135 > 117 - <WatchedMediaCard 118 - media={v} 119 - closable={editing} 120 - onClose={() => removeBookmark(v.id)} 121 - onShowDetails={onShowDetails} 136 + <EditButton 137 + editing={editing} 138 + onEdit={setEditing} 139 + id={`edit-button-bookmark-${group}`} 122 140 /> 123 - </div> 124 - ))} 125 - </MediaGrid> 141 + </SectionHeading> 142 + <MediaGrid> 143 + {groupItems.map((v) => ( 144 + <div 145 + key={v.id} 146 + style={{ userSelect: "none" }} 147 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 148 + e.preventDefault() 149 + } 150 + onTouchStart={handleTouchStart} 151 + onTouchEnd={handleTouchEnd} 152 + onMouseDown={handleMouseDown} 153 + onMouseUp={handleMouseUp} 154 + > 155 + <WatchedMediaCard 156 + media={v} 157 + closable={editing} 158 + onClose={() => removeBookmark(v.id)} 159 + onShowDetails={onShowDetails} 160 + /> 161 + </div> 162 + ))} 163 + </MediaGrid> 164 + </div> 165 + ))} 166 + 167 + {/* Regular Bookmarks */} 168 + {regularItems.length > 0 && ( 169 + <div> 170 + <SectionHeading 171 + title={t("home.bookmarks.sectionTitle")} 172 + icon={Icons.BOOKMARK} 173 + > 174 + <EditButton 175 + editing={editing} 176 + onEdit={setEditing} 177 + id="edit-button-bookmark" 178 + /> 179 + </SectionHeading> 180 + <MediaGrid ref={gridRef}> 181 + {regularItems.map((v) => ( 182 + <div 183 + key={v.id} 184 + style={{ userSelect: "none" }} 185 + onContextMenu={(e: React.MouseEvent<HTMLDivElement>) => 186 + e.preventDefault() 187 + } 188 + onTouchStart={handleTouchStart} 189 + onTouchEnd={handleTouchEnd} 190 + onMouseDown={handleMouseDown} 191 + onMouseUp={handleMouseUp} 192 + > 193 + <WatchedMediaCard 194 + media={v} 195 + closable={editing} 196 + onClose={() => removeBookmark(v.id)} 197 + onShowDetails={onShowDetails} 198 + /> 199 + </div> 200 + ))} 201 + </MediaGrid> 202 + </div> 203 + )} 126 204 </div> 127 205 ); 128 206 }
+1
src/stores/bookmarks/BookmarkSyncer.tsx
··· 32 32 title: item.title ?? "", 33 33 type: item.type ?? "", 34 34 year: item.year ?? NaN, 35 + group: item.group, 35 36 }, 36 37 tmdbId: item.tmdbId, 37 38 });
+27
src/stores/bookmarks/index.ts
··· 10 10 poster?: string; 11 11 type: "show" | "movie"; 12 12 updatedAt: number; 13 + group?: string; 13 14 } 14 15 15 16 export interface BookmarkUpdateItem { ··· 19 20 id: string; 20 21 poster?: string; 21 22 type?: "show" | "movie"; 23 + group?: string; 22 24 action: "delete" | "add"; 23 25 } 24 26 ··· 26 28 bookmarks: Record<string, BookmarkMediaItem>; 27 29 updateQueue: BookmarkUpdateItem[]; 28 30 addBookmark(meta: PlayerMeta): void; 31 + addBookmarkWithGroup(meta: PlayerMeta, group?: string): void; 29 32 removeBookmark(id: string): void; 30 33 replaceBookmarks(items: Record<string, BookmarkMediaItem>): void; 31 34 clear(): void; ··· 71 74 year: meta.releaseYear, 72 75 poster: meta.poster, 73 76 updatedAt: Date.now(), 77 + }; 78 + }); 79 + }, 80 + addBookmarkWithGroup(meta, group) { 81 + set((s) => { 82 + updateId += 1; 83 + s.updateQueue.push({ 84 + id: updateId.toString(), 85 + action: "add", 86 + tmdbId: meta.tmdbId, 87 + type: meta.type, 88 + title: meta.title, 89 + year: meta.releaseYear, 90 + poster: meta.poster, 91 + group, 92 + }); 93 + 94 + s.bookmarks[meta.tmdbId] = { 95 + type: meta.type, 96 + title: meta.title, 97 + year: meta.releaseYear, 98 + poster: meta.poster, 99 + updatedAt: Date.now(), 100 + group, 74 101 }; 75 102 }); 76 103 },