From 3f08a8b795c68a13df0fe00c9316a1a1148d1970 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 12:57:46 +0000 Subject: [PATCH] feat: Add 'Queue All' button to playlist view Adds a button to the playlist view that allows users to add all climbs from a playlist to their queue at once. The feature: - Fetches all pages of playlist climbs (handling pagination) - Filters out cross-layout climbs that can't be displayed - Shows loading state while fetching - Displays success/error messages via toast notifications - Tracks the action via Vercel Analytics --- .../[playlist_uuid]/playlist-view-actions.tsx | 78 ++++++++---- .../[playlist_uuid]/playlist-view-content.tsx | 119 +++++++++++++++++- 2 files changed, 167 insertions(+), 30 deletions(-) diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx index a4c4f609..2c40b1e4 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Button } from 'antd'; -import { EditOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { EditOutlined, ThunderboltOutlined, PlusOutlined } from '@ant-design/icons'; import { BoardDetails } from '@/app/lib/types'; import { constructClimbListWithSlugs } from '@/app/lib/url-utils'; import BackButton from '@/app/components/back-button'; @@ -16,6 +16,9 @@ type PlaylistViewActionsProps = { playlistUuid: string; onEditClick: () => void; onPlaylistUpdated?: () => void; + onAddAllToQueue: () => void; + isAddingToQueue: boolean; + climbCount: number; }; const PlaylistViewActions = ({ @@ -25,8 +28,12 @@ const PlaylistViewActions = ({ playlistUuid, onEditClick, onPlaylistUpdated, + onAddAllToQueue, + isAddingToQueue, + climbCount, }: PlaylistViewActionsProps) => { const [generatorOpen, setGeneratorOpen] = useState(false); + const getBackToListUrl = () => { const { board_name, layout_name, size_name, size_description, set_names } = boardDetails; @@ -43,6 +50,17 @@ const PlaylistViewActions = ({ onPlaylistUpdated?.(); }; + const addAllButton = ( + + ); + return ( <>
@@ -52,38 +70,44 @@ const PlaylistViewActions = ({
- {isOwner && ( -
- - -
- )} +
+ {addAllButton} + {isOwner && ( + <> + + + + )} +
{/* Desktop view */}
- {isOwner && ( -
- - -
- )} +
+ {addAllButton} + {isOwner && ( + <> + + + + )} +
diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx index 73d57200..2b6d5017 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { useState, useEffect, useCallback } from 'react'; -import { Spin, Typography, Button } from 'antd'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Spin, Typography, Button, message } from 'antd'; import { TagOutlined, CalendarOutlined, @@ -10,15 +10,21 @@ import { LockOutlined, FrownOutlined, } from '@ant-design/icons'; -import { BoardDetails } from '@/app/lib/types'; +import { track } from '@vercel/analytics'; +import { BoardDetails, Climb } from '@/app/lib/types'; import { executeGraphQL } from '@/app/lib/graphql/client'; import { GET_PLAYLIST, + GET_PLAYLIST_CLIMBS, GetPlaylistQueryResponse, GetPlaylistQueryVariables, + GetPlaylistClimbsQueryResponse, + GetPlaylistClimbsQueryVariables, + PlaylistClimbsResult, Playlist, } from '@/app/lib/graphql/operations/playlists'; import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { useQueueContext } from '@/app/components/graphql-queue'; import { themeTokens } from '@/app/theme/theme-config'; import PlaylistViewActions from './playlist-view-actions'; import PlaylistEditDrawer from './playlist-edit-drawer'; @@ -50,7 +56,11 @@ export default function PlaylistViewContent({ const [error, setError] = useState(null); const [editDrawerOpen, setEditDrawerOpen] = useState(false); const [listRefreshKey, setListRefreshKey] = useState(0); + const [isAddingToQueue, setIsAddingToQueue] = useState(false); + const addingToQueueRef = useRef(false); + const abortControllerRef = useRef(null); const { token, isLoading: tokenLoading } = useWsAuthToken(); + const { addToQueue } = useQueueContext(); const fetchPlaylist = useCallback(async () => { if (tokenLoading) return; @@ -83,6 +93,13 @@ export default function PlaylistViewContent({ fetchPlaylist(); }, [fetchPlaylist]); + // Cleanup abort controller on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + const handleEditSuccess = useCallback((updatedPlaylist: Playlist) => { setPlaylist(updatedPlaylist); }, []); @@ -94,6 +111,99 @@ export default function PlaylistViewContent({ fetchPlaylist(); }, [fetchPlaylist]); + const handleAddAllToQueue = useCallback(async () => { + // Use ref to prevent race conditions from rapid clicks + if (!token || addingToQueueRef.current) return; + + // Cancel any in-flight request + abortControllerRef.current?.abort(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + addingToQueueRef.current = true; + setIsAddingToQueue(true); + + try { + // Fetch all climbs from the playlist (paginate through all pages) + type PlaylistClimb = PlaylistClimbsResult['climbs'][number]; + const allClimbs: Array = []; + let page = 0; + let hasMore = true; + const pageSize = 100; // Larger page size for efficiency + + while (hasMore) { + // Check if aborted before each fetch + if (abortController.signal.aborted) { + return; + } + + const response = await executeGraphQL< + GetPlaylistClimbsQueryResponse, + GetPlaylistClimbsQueryVariables + >( + GET_PLAYLIST_CLIMBS, + { + input: { + playlistId: playlistUuid, + boardName: boardDetails.board_name, + layoutId: boardDetails.layout_id, + sizeId: boardDetails.size_id, + setIds: boardDetails.set_ids.join(','), + angle, + page, + pageSize, + }, + }, + token, + ); + + // Check if aborted after fetch completes + if (abortController.signal.aborted) { + return; + } + + const climbs = response.playlistClimbs.climbs; + + // Filter out cross-layout climbs and update angle to route angle + for (const climb of climbs) { + const isCrossLayout = climb.layoutId != null && climb.layoutId !== boardDetails.layout_id; + if (!isCrossLayout) { + allClimbs.push({ ...climb, angle }); + } + } + hasMore = response.playlistClimbs.hasMore; + page++; + } + + if (allClimbs.length === 0) { + message.info('No climbs to add from this playlist'); + return; + } + + // Add all climbs to queue (cast to Climb - the queue only uses fields we have) + for (const climb of allClimbs) { + addToQueue(climb as Climb); + } + + track('Playlist Add All To Queue', { + playlistUuid, + climbCount: allClimbs.length, + }); + + message.success(`Added ${allClimbs.length} ${allClimbs.length === 1 ? 'climb' : 'climbs'} to queue`); + } catch (err) { + // Don't show error if aborted + if (abortController.signal.aborted) { + return; + } + console.error('Error adding climbs to queue:', err); + message.error('Failed to add climbs to queue'); + } finally { + addingToQueueRef.current = false; + setIsAddingToQueue(false); + } + }, [token, playlistUuid, boardDetails, angle, addToQueue]); + // Check if current user is the owner const isOwner = playlist?.userRole === 'owner'; @@ -151,6 +261,9 @@ export default function PlaylistViewContent({ playlistUuid={playlistUuid} onEditClick={() => setEditDrawerOpen(true)} onPlaylistUpdated={handlePlaylistUpdated} + onAddAllToQueue={handleAddAllToQueue} + isAddingToQueue={isAddingToQueue} + climbCount={playlist.climbCount} />