diff --git a/packages/backend/src/graphql/resolvers/playlists/queries.ts b/packages/backend/src/graphql/resolvers/playlists/queries.ts index d8c2fdd5..093ea604 100644 --- a/packages/backend/src/graphql/resolvers/playlists/queries.ts +++ b/packages/backend/src/graphql/resolvers/playlists/queries.ts @@ -71,6 +71,76 @@ function convertLitUpHoldsStringToMap(litUpHolds: string, board: BoardName): Rec } export const playlistQueries = { + /** + * Get all playlists owned by the authenticated user across all boards + */ + allUserPlaylists: async ( + _: unknown, + _args: unknown, + ctx: ConnectionContext + ): Promise => { + requireAuthenticated(ctx); + + const userId = ctx.userId!; + + // Get all user's playlists across all boards + const userPlaylists = await db + .select({ + id: dbSchema.playlists.id, + uuid: dbSchema.playlists.uuid, + boardType: dbSchema.playlists.boardType, + layoutId: dbSchema.playlists.layoutId, + name: dbSchema.playlists.name, + description: dbSchema.playlists.description, + isPublic: dbSchema.playlists.isPublic, + color: dbSchema.playlists.color, + icon: dbSchema.playlists.icon, + createdAt: dbSchema.playlists.createdAt, + updatedAt: dbSchema.playlists.updatedAt, + role: dbSchema.playlistOwnership.role, + }) + .from(dbSchema.playlists) + .innerJoin( + dbSchema.playlistOwnership, + eq(dbSchema.playlistOwnership.playlistId, dbSchema.playlists.id) + ) + .where(eq(dbSchema.playlistOwnership.userId, userId)) + .orderBy(desc(dbSchema.playlists.updatedAt)); + + // Get climb counts for each playlist + const playlistIds = userPlaylists.map(p => p.id); + + const climbCounts = + playlistIds.length > 0 + ? await db + .select({ + playlistId: dbSchema.playlistClimbs.playlistId, + count: sql`count(*)::int`, + }) + .from(dbSchema.playlistClimbs) + .where(inArray(dbSchema.playlistClimbs.playlistId, playlistIds)) + .groupBy(dbSchema.playlistClimbs.playlistId) + : []; + + const countMap = new Map(climbCounts.map(c => [c.playlistId.toString(), c.count])); + + return userPlaylists.map(p => ({ + id: p.id.toString(), + uuid: p.uuid, + boardType: p.boardType, + layoutId: p.layoutId, + name: p.name, + description: p.description, + isPublic: p.isPublic, + color: p.color, + icon: p.icon, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + climbCount: countMap.get(p.id.toString()) || 0, + userRole: p.role, + })); + }, + /** * Get all playlists owned by the authenticated user for a specific board and layout */ @@ -361,7 +431,9 @@ export const playlistQueries = { tables.climbStats, and( eq(tables.climbStats.climbUuid, dbSchema.playlistClimbs.climbUuid), - eq(tables.climbStats.angle, sql`COALESCE(${dbSchema.playlistClimbs.angle}, ${tables.climbStats.angle})`) + // Only join stats when we have a specific angle in the playlist item + // When angle is NULL (Aurora-synced), stats won't match - this prevents duplicates + eq(tables.climbStats.angle, dbSchema.playlistClimbs.angle) ) ) .leftJoin( diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 89fc62f1..dca617c0 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -472,6 +472,8 @@ export const typeDefs = /* GraphQL */ ` # Playlist Queries (require auth) # ============================================ + # Get all playlists for current user (across all boards) + allUserPlaylists: [Playlist!]! # Get current user's playlists for a board+layout userPlaylists(input: GetUserPlaylistsInput!): [Playlist!]! # Get a specific playlist by ID (checks ownership/access) diff --git a/packages/web/app/lib/__generated__/product-sizes-data.ts b/packages/web/app/lib/__generated__/product-sizes-data.ts index ff34f14d..e3d21996 100644 --- a/packages/web/app/lib/__generated__/product-sizes-data.ts +++ b/packages/web/app/lib/__generated__/product-sizes-data.ts @@ -362,6 +362,54 @@ export const getDefaultSizeForLayout = (boardName: BoardName, layoutId: number): return sizes.length > 0 ? sizes[0].id : null; }; +/** + * Get the default layout ID for a given board. + * Returns the first available layout. + */ +export const getDefaultLayoutForBoard = (boardName: BoardName): number | null => { + const layouts = getAllLayouts(boardName); + return layouts.length > 0 ? layouts[0].id : null; +}; + +/** + * Get the default set IDs for a given board, layout, and size. + * Returns all available sets for the combination. + */ +export const getDefaultSetsForLayoutSize = (boardName: BoardName, layoutId: number, sizeId: number): number[] => { + const sets = getSetsForLayoutAndSize(boardName, layoutId, sizeId); + return sets.map(s => s.id); +}; + +/** + * Get default board details for a given board type and optional layout. + * Used when we need board details but only have partial information (e.g., from a playlist). + * Returns null if the board configuration is invalid. + */ +export const getDefaultBoardDetails = (boardName: BoardName, layoutId?: number | null): BoardDetails | null => { + // Get layout, using provided or default + const resolvedLayoutId = layoutId ?? getDefaultLayoutForBoard(boardName); + if (!resolvedLayoutId) return null; + + // Get default size for this layout + const sizeId = getDefaultSizeForLayout(boardName, resolvedLayoutId); + if (!sizeId) return null; + + // Get default sets for this layout/size + const setIds = getDefaultSetsForLayoutSize(boardName, resolvedLayoutId, sizeId); + if (setIds.length === 0) return null; + + try { + return getBoardDetails({ + board_name: boardName, + layout_id: resolvedLayoutId, + size_id: sizeId, + set_ids: setIds as SetIdList, + }); + } catch { + return null; + } +}; + /** * Get all board selector options (layouts, sizes, sets) from hardcoded data. * This replaces the database query in getAllBoardSelectorOptions. diff --git a/packages/web/app/lib/graphql/operations/playlists.ts b/packages/web/app/lib/graphql/operations/playlists.ts index 88e03ce6..cb00b6b7 100644 --- a/packages/web/app/lib/graphql/operations/playlists.ts +++ b/packages/web/app/lib/graphql/operations/playlists.ts @@ -19,6 +19,16 @@ export const PLAYLIST_FIELDS = gql` } `; +// Get all user's playlists across all boards +export const GET_ALL_USER_PLAYLISTS = gql` + ${PLAYLIST_FIELDS} + query GetAllUserPlaylists { + allUserPlaylists { + ...PlaylistFields + } + } +`; + // Get user's playlists for a board+layout export const GET_USER_PLAYLISTS = gql` ${PLAYLIST_FIELDS} @@ -137,6 +147,10 @@ export interface Playlist { userRole?: string; } +export interface GetAllUserPlaylistsQueryResponse { + allUserPlaylists: Playlist[]; +} + export interface GetUserPlaylistsInput { boardType: string; layoutId: number; diff --git a/packages/web/app/playlists/[playlist_uuid]/page.tsx b/packages/web/app/playlists/[playlist_uuid]/page.tsx new file mode 100644 index 00000000..a0285b62 --- /dev/null +++ b/packages/web/app/playlists/[playlist_uuid]/page.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { getServerSession } from 'next-auth/next'; +import { Metadata } from 'next'; +import { authOptions } from '@/app/lib/auth/auth-options'; +import PlaylistViewContent from './playlist-view-content'; +import styles from './playlist-view.module.css'; + +export const metadata: Metadata = { + title: 'Playlist | Boardsesh', + description: 'View playlist details and climbs', +}; + +type PlaylistViewPageParams = { + playlist_uuid: string; +}; + +export default async function PlaylistViewPage(props: { params: Promise }) { + const params = await props.params; + const session = await getServerSession(authOptions); + + return ( +
+ +
+ ); +} diff --git a/packages/web/app/playlists/[playlist_uuid]/playlist-climbs-list.tsx b/packages/web/app/playlists/[playlist_uuid]/playlist-climbs-list.tsx new file mode 100644 index 00000000..d15109a6 --- /dev/null +++ b/packages/web/app/playlists/[playlist_uuid]/playlist-climbs-list.tsx @@ -0,0 +1,220 @@ +'use client'; + +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { Row, Col, Empty, Typography } from 'antd'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { track } from '@vercel/analytics'; +import { Climb, BoardDetails } from '@/app/lib/types'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { + GET_PLAYLIST_CLIMBS, + GetPlaylistClimbsQueryResponse, + GetPlaylistClimbsQueryVariables, +} from '@/app/lib/graphql/operations/playlists'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import ClimbCard from '@/app/components/climb-card/climb-card'; +import { ClimbCardSkeleton } from '@/app/components/board-page/board-page-skeleton'; +import styles from './playlist-view.module.css'; + +const { Text } = Typography; + +type PlaylistClimbsListProps = { + playlistUuid: string; + boardDetails: BoardDetails; +}; + +const ClimbsListSkeleton = ({ aspectRatio }: { aspectRatio: number }) => { + return ( + <> + {Array.from({ length: 6 }, (_, i) => ( + + + + ))} + + ); +}; + +export default function PlaylistClimbsList({ + playlistUuid, + boardDetails, +}: PlaylistClimbsListProps) { + const { token, isLoading: tokenLoading } = useWsAuthToken(); + const loadMoreRef = useRef(null); + const [selectedClimbUuid, setSelectedClimbUuid] = useState(null); + + const { + data, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isLoading, + error, + } = useInfiniteQuery({ + queryKey: ['playlistClimbs', playlistUuid, boardDetails.board_name, boardDetails.layout_id, boardDetails.size_id], + queryFn: async ({ pageParam = 0 }) => { + 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(','), + page: pageParam, + pageSize: 20, + }, + }, + token, + ); + return response.playlistClimbs; + }, + enabled: !tokenLoading && !!token, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (!lastPage.hasMore) return undefined; + return allPages.length; + }, + staleTime: 5 * 60 * 1000, + }); + + // Flatten all pages of climbs + const climbs: Climb[] = data?.pages.flatMap((page) => page.climbs as Climb[]) ?? []; + const totalCount = data?.pages[0]?.totalCount ?? 0; + + // Intersection Observer callback for infinite scroll + const handleObserver = useCallback( + (entries: IntersectionObserverEntry[]) => { + const [target] = entries; + if (target.isIntersecting && hasNextPage && !isFetchingNextPage) { + track('Playlist Infinite Scroll Load More', { + playlistUuid, + currentCount: climbs.length, + hasMore: hasNextPage, + }); + fetchNextPage(); + } + }, + [hasNextPage, isFetchingNextPage, fetchNextPage, climbs.length, playlistUuid], + ); + + // Set up Intersection Observer + useEffect(() => { + const element = loadMoreRef.current; + if (!element) return; + + const scrollContainer = document.getElementById('content-for-scrollable'); + + const observer = new IntersectionObserver(handleObserver, { + root: scrollContainer, + rootMargin: '100px', + threshold: 0, + }); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [handleObserver]); + + // Handle climb selection + const handleClimbClick = useCallback((climb: Climb) => { + setSelectedClimbUuid(climb.uuid); + track('Playlist Climb Card Clicked', { + climbUuid: climb.uuid, + playlistUuid, + }); + }, [playlistUuid]); + + const aspectRatio = boardDetails.boardWidth / boardDetails.boardHeight; + + // Loading state + if ((isLoading || tokenLoading) && climbs.length === 0) { + return ( +
+
+ Climbs +
+ + + +
+ ); + } + + // Error state + if (error) { + return ( +
+
+ Climbs +
+ +
+ ); + } + + // Empty state + if (climbs.length === 0 && !isFetching) { + return ( +
+
+ Climbs +
+ +
+ ); + } + + return ( +
+
+ + Climbs ({totalCount}) + +
+ + + {climbs.map((climb) => ( + + handleClimbClick(climb)} + /> + + ))} + {isFetching && climbs.length === 0 && ( + + )} + + + {/* Sentinel element for Intersection Observer */} +
+ {isFetchingNextPage && ( + + + + )} + {!hasNextPage && climbs.length > 0 && ( +
+ {climbs.length === totalCount ? `All ${totalCount} climbs loaded` : 'No more climbs'} +
+ )} +
+
+ ); +} diff --git a/packages/web/app/playlists/[playlist_uuid]/playlist-edit-drawer.tsx b/packages/web/app/playlists/[playlist_uuid]/playlist-edit-drawer.tsx new file mode 100644 index 00000000..80184090 --- /dev/null +++ b/packages/web/app/playlists/[playlist_uuid]/playlist-edit-drawer.tsx @@ -0,0 +1,184 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { Drawer, Form, Input, Switch, ColorPicker, message, Space, Typography, Button } from 'antd'; +import { GlobalOutlined, LockOutlined } from '@ant-design/icons'; +import type { Color } from 'antd/es/color-picker'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { + UPDATE_PLAYLIST, + UpdatePlaylistMutationResponse, + UpdatePlaylistMutationVariables, + Playlist, +} from '@/app/lib/graphql/operations/playlists'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { themeTokens } from '@/app/theme/theme-config'; + +const { Text } = Typography; + +// Validate hex color format +const isValidHexColor = (color: string): boolean => { + return /^#([0-9A-Fa-f]{3}){1,2}$/.test(color); +}; + +type PlaylistEditDrawerProps = { + open: boolean; + playlist: Playlist; + onClose: () => void; + onSuccess: (updatedPlaylist: Playlist) => void; +}; + +export default function PlaylistEditDrawer({ open, playlist, onClose, onSuccess }: PlaylistEditDrawerProps) { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [isPublic, setIsPublic] = useState(playlist.isPublic); + const { token } = useWsAuthToken(); + + // Reset form when drawer opens with new playlist + useEffect(() => { + if (open && playlist) { + form.setFieldsValue({ + name: playlist.name, + description: playlist.description || '', + color: playlist.color || undefined, + isPublic: playlist.isPublic, + }); + setIsPublic(playlist.isPublic); + } + }, [open, playlist, form]); + + const handleSubmit = useCallback(async () => { + try { + const values = await form.validateFields(); + setLoading(true); + + // Extract and validate hex color + let colorHex: string | undefined; + if (values.color) { + let rawColor: string | undefined; + if (typeof values.color === 'string') { + rawColor = values.color; + } else if (typeof values.color === 'object' && 'toHexString' in values.color) { + rawColor = (values.color as Color).toHexString(); + } + if (rawColor && isValidHexColor(rawColor)) { + colorHex = rawColor; + } + } + + const response = await executeGraphQL( + UPDATE_PLAYLIST, + { + input: { + playlistId: playlist.uuid, + name: values.name, + description: values.description || undefined, + color: colorHex, + isPublic: values.isPublic, + }, + }, + token, + ); + + message.success('Playlist updated successfully'); + onSuccess(response.updatePlaylist); + onClose(); + } catch (error) { + if (error instanceof Error && 'errorFields' in error) { + // Form validation error + return; + } + console.error('Error updating playlist:', error); + message.error('Failed to update playlist'); + } finally { + setLoading(false); + } + }, [form, playlist.uuid, token, onSuccess, onClose]); + + const handleCancel = useCallback(() => { + form.resetFields(); + onClose(); + }, [form, onClose]); + + const handleVisibilityChange = useCallback((checked: boolean) => { + setIsPublic(checked); + form.setFieldValue('isPublic', checked); + }, [form]); + + return ( + + + + + } + > +
+ + + + + + + + + + + + + + + } + unCheckedChildren={} + /> + + {isPublic + ? 'Public playlists can be viewed by anyone with the link' + : 'Private playlists are only visible to you'} + + + +
+
+ ); +} diff --git a/packages/web/app/playlists/[playlist_uuid]/playlist-view-actions.module.css b/packages/web/app/playlists/[playlist_uuid]/playlist-view-actions.module.css new file mode 100644 index 00000000..038e72a4 --- /dev/null +++ b/packages/web/app/playlists/[playlist_uuid]/playlist-view-actions.module.css @@ -0,0 +1,50 @@ +/* Playlist View Actions Styles */ + +.container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.actionButtons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Mobile layout */ +.mobileActions { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + gap: 8px; +} + +.mobileLeft { + flex-shrink: 0; +} + +.mobileRight { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.desktopActions { + display: none; +} + +@media (min-width: 768px) { + .desktopActions { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } + + .mobileActions { + display: none; + } +} diff --git a/packages/web/app/playlists/[playlist_uuid]/playlist-view-actions.tsx b/packages/web/app/playlists/[playlist_uuid]/playlist-view-actions.tsx new file mode 100644 index 00000000..48542cb5 --- /dev/null +++ b/packages/web/app/playlists/[playlist_uuid]/playlist-view-actions.tsx @@ -0,0 +1,54 @@ +'use client'; + +import React from 'react'; +import { Button } from 'antd'; +import { EditOutlined, ArrowLeftOutlined } from '@ant-design/icons'; +import { BoardDetails } from '@/app/lib/types'; +import styles from './playlist-view-actions.module.css'; + +type PlaylistViewActionsProps = { + boardDetails: BoardDetails; + isOwner: boolean; + onEditClick: () => void; + onBackClick: () => void; +}; + +const PlaylistViewActions = ({ boardDetails, isOwner, onEditClick, onBackClick }: PlaylistViewActionsProps) => { + return ( +
+ {/* Mobile view */} +
+
+ +
+ + {isOwner && ( +
+ +
+ )} +
+ + {/* Desktop view */} +
+ + + {isOwner && ( +
+ +
+ )} +
+
+ ); +}; + +export default PlaylistViewActions; diff --git a/packages/web/app/playlists/[playlist_uuid]/playlist-view-content.tsx b/packages/web/app/playlists/[playlist_uuid]/playlist-view-content.tsx new file mode 100644 index 00000000..c28238f5 --- /dev/null +++ b/packages/web/app/playlists/[playlist_uuid]/playlist-view-content.tsx @@ -0,0 +1,288 @@ +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { Spin, Typography, Button } from 'antd'; +import { + TagOutlined, + CalendarOutlined, + NumberOutlined, + GlobalOutlined, + LockOutlined, + FrownOutlined, + ArrowLeftOutlined, +} from '@ant-design/icons'; +import { useRouter } from 'next/navigation'; +import { BoardDetails, BoardName } from '@/app/lib/types'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { + GET_PLAYLIST, + GetPlaylistQueryResponse, + GetPlaylistQueryVariables, + Playlist, +} from '@/app/lib/graphql/operations/playlists'; +import { getDefaultBoardDetails } from '@/app/lib/__generated__/product-sizes-data'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { themeTokens } from '@/app/theme/theme-config'; +import PlaylistViewActions from './playlist-view-actions'; +import PlaylistEditDrawer from './playlist-edit-drawer'; +import PlaylistClimbsList from './playlist-climbs-list'; +import styles from './playlist-view.module.css'; + +const { Title, Text } = Typography; + +// Validate hex color format +const isValidHexColor = (color: string): boolean => { + return /^#([0-9A-Fa-f]{3}){1,2}$/.test(color); +}; + +type PlaylistViewContentProps = { + playlistUuid: string; + currentUserId?: string; +}; + +export default function PlaylistViewContent({ + playlistUuid, + currentUserId, +}: PlaylistViewContentProps) { + const router = useRouter(); + const [playlist, setPlaylist] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editDrawerOpen, setEditDrawerOpen] = useState(false); + const { token, isLoading: tokenLoading } = useWsAuthToken(); + + // Get board details based on playlist's board type and layout + const boardDetails: BoardDetails | null = useMemo(() => { + if (!playlist) return null; + + const boardName = playlist.boardType as BoardName; + if (!boardName || (boardName !== 'kilter' && boardName !== 'tension')) { + return null; + } + + return getDefaultBoardDetails(boardName, playlist.layoutId); + }, [playlist]); + + const fetchPlaylist = useCallback(async () => { + if (tokenLoading) return; + + try { + setLoading(true); + setError(null); + + const response = await executeGraphQL( + GET_PLAYLIST, + { playlistId: playlistUuid }, + token, + ); + + if (!response.playlist) { + setError('Playlist not found'); + return; + } + + setPlaylist(response.playlist); + } catch (err) { + console.error('Error fetching playlist:', err); + setError('Failed to load playlist'); + } finally { + setLoading(false); + } + }, [playlistUuid, token, tokenLoading]); + + useEffect(() => { + fetchPlaylist(); + }, [fetchPlaylist]); + + const handleEditSuccess = useCallback((updatedPlaylist: Playlist) => { + setPlaylist(updatedPlaylist); + }, []); + + // Check if current user is the owner + const isOwner = playlist?.userRole === 'owner'; + + // Format date + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + // Get color for indicator + const getPlaylistColor = () => { + if (playlist?.color && isValidHexColor(playlist.color)) { + return playlist.color; + } + return themeTokens.colors.primary; + }; + + if (loading || tokenLoading) { + return ( +
+ +
+ ); + } + + if (error || !playlist) { + return ( + <> +
+ +
+
+ +
+ {error === 'Playlist not found' ? 'Playlist Not Found' : 'Unable to Load Playlist'} +
+
+ {error === 'Playlist not found' + ? 'This playlist may have been deleted or you may not have permission to view it.' + : 'There was an error loading this playlist. Please try again.'} +
+ +
+ + ); + } + + if (!boardDetails) { + return ( + <> +
+ +
+
+ +
Unable to Load Board Configuration
+
+ Could not load board configuration for this playlist. The board type may not be supported. +
+
+ + ); + } + + return ( + <> + {/* Actions Section */} +
+ setEditDrawerOpen(true)} + onBackClick={() => router.push('/playlists')} + /> +
+ + {/* Main Content */} +
+
+ {/* Header with color indicator and name */} +
+
+ +
+
+ + {playlist.name} + +
+ + + {playlist.climbCount} {playlist.climbCount === 1 ? 'climb' : 'climbs'} + + + + Created {formatDate(playlist.createdAt)} + + + {playlist.isPublic ? ( + <> + Public + + ) : ( + <> + Private + + )} + +
+
+
+ + {/* Description */} + {playlist.description ? ( +
+
Description
+ {playlist.description} +
+ ) : isOwner ? ( +
+
Description
+ + No description yet. Click Edit to add one. + +
+ ) : null} + + {/* Stats */} +
+
+ {playlist.climbCount} + + {playlist.climbCount === 1 ? 'Climb' : 'Climbs'} + +
+ {playlist.updatedAt !== playlist.createdAt && ( +
+ + {formatDate(playlist.updatedAt)} + + Last Updated +
+ )} +
+
+ + {/* Climbs List */} + +
+ + {/* Edit Drawer */} + {playlist && ( + setEditDrawerOpen(false)} + onSuccess={handleEditSuccess} + /> + )} + + ); +} diff --git a/packages/web/app/playlists/[playlist_uuid]/playlist-view.module.css b/packages/web/app/playlists/[playlist_uuid]/playlist-view.module.css new file mode 100644 index 00000000..38497d98 --- /dev/null +++ b/packages/web/app/playlists/[playlist_uuid]/playlist-view.module.css @@ -0,0 +1,315 @@ +/* Playlist View Page Styles */ + +.pageContainer { + padding: 16px; + min-height: 100vh; + background-color: #F9FAFB; + max-width: 1400px; + margin: 0 auto; +} + +/* Actions section - white card container */ +.actionsSection { + background-color: #FFFFFF; + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +/* Main content wrapper */ +.contentWrapper { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Playlist details section */ +.detailsSection { + background-color: #FFFFFF; + border-radius: 12px; + padding: 20px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +/* Header with color indicator */ +.playlistHeader { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 16px; +} + +.colorIndicator { + width: 48px; + height: 48px; + border-radius: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.colorIndicatorIcon { + font-size: 24px; + color: #FFFFFF; +} + +.headerContent { + flex: 1; + min-width: 0; +} + +.playlistName { + margin: 0 0 4px 0 !important; + font-size: 24px !important; + font-weight: 600 !important; + word-break: break-word; +} + +.playlistMeta { + display: flex; + flex-wrap: wrap; + gap: 12px; + color: #6B7280; + font-size: 14px; +} + +.metaItem { + display: flex; + align-items: center; + gap: 4px; +} + +/* Description section */ +.descriptionSection { + padding-top: 16px; + border-top: 1px solid #E5E7EB; +} + +.descriptionLabel { + font-size: 12px; + color: #9CA3AF; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.descriptionText { + color: #374151; + line-height: 1.6; + white-space: pre-wrap; +} + +.noDescription { + color: #9CA3AF; + font-style: italic; +} + +/* Stats section */ +.statsSection { + display: flex; + gap: 24px; + padding-top: 16px; + border-top: 1px solid #E5E7EB; + margin-top: 16px; +} + +.statItem { + display: flex; + flex-direction: column; +} + +.statValue { + font-size: 24px; + font-weight: 600; + color: #111827; +} + +.statLabel { + font-size: 12px; + color: #6B7280; +} + +/* Loading and error states */ +.loadingContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +.errorContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + background-color: #FFFFFF; + border-radius: 12px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +.errorIcon { + font-size: 48px; + color: #9CA3AF; + margin-bottom: 16px; +} + +.errorTitle { + font-size: 18px; + font-weight: 600; + color: #111827; + margin-bottom: 8px; +} + +.errorMessage { + color: #6B7280; + margin-bottom: 20px; +} + +/* Visibility badge */ +.visibilityBadge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.publicBadge { + background-color: #ECFDF5; + color: #059669; +} + +.privateBadge { + background-color: #F3F4F6; + color: #6B7280; +} + +/* Mobile adjustments */ +@media (max-width: 767px) { + .pageContainer { + padding: 12px; + } + + .actionsSection { + padding: 12px; + border-radius: 8px; + margin-bottom: 12px; + } + + .detailsSection { + padding: 16px; + border-radius: 8px; + } + + .contentWrapper { + gap: 12px; + } + + .playlistHeader { + gap: 12px; + } + + .colorIndicator { + width: 40px; + height: 40px; + } + + .colorIndicatorIcon { + font-size: 20px; + } + + .playlistName { + font-size: 20px !important; + } + + .playlistMeta { + gap: 8px; + font-size: 13px; + } + + .statsSection { + gap: 16px; + } + + .statValue { + font-size: 20px; + } +} + +/* Climbs section */ +.climbsSection { + background-color: #FFFFFF; + border-radius: 12px; + padding: 20px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +.climbsSectionHeader { + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #E5E7EB; +} + +.climbsSectionTitle { + font-size: 16px; + color: #111827; +} + +/* Desktop layout */ +@media (min-width: 992px) { + .pageContainer { + padding: 24px; + } + + .actionsSection { + padding: 20px; + } + + .detailsSection { + padding: 24px; + } + + .contentWrapper { + gap: 24px; + } + + .colorIndicator { + width: 56px; + height: 56px; + } + + .colorIndicatorIcon { + font-size: 28px; + } + + .playlistName { + font-size: 28px !important; + } + + .climbsSection { + padding: 24px; + } +} + +/* Mobile adjustments for climbs section */ +@media (max-width: 767px) { + .climbsSection { + padding: 16px; + border-radius: 8px; + } + + .climbsSectionHeader { + margin-bottom: 12px; + padding-bottom: 8px; + } + + .climbsSectionTitle { + font-size: 14px; + } +} diff --git a/packages/web/app/playlists/page.tsx b/packages/web/app/playlists/page.tsx new file mode 100644 index 00000000..4bd4f4c3 --- /dev/null +++ b/packages/web/app/playlists/page.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { getServerSession } from 'next-auth/next'; +import { Metadata } from 'next'; +import { authOptions } from '@/app/lib/auth/auth-options'; +import PlaylistsListContent from './playlists-list-content'; +import styles from './playlists.module.css'; + +export const metadata: Metadata = { + title: 'My Playlists | Boardsesh', + description: 'View and manage your climb playlists', +}; + +export default async function PlaylistsPage() { + const session = await getServerSession(authOptions); + + return ( +
+ +
+ ); +} diff --git a/packages/web/app/playlists/playlists-list-content.tsx b/packages/web/app/playlists/playlists-list-content.tsx new file mode 100644 index 00000000..65e6e923 --- /dev/null +++ b/packages/web/app/playlists/playlists-list-content.tsx @@ -0,0 +1,311 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { Spin, Typography, Button, List, Empty, Segmented } from 'antd'; +import { + TagOutlined, + RightOutlined, + GlobalOutlined, + LockOutlined, + FrownOutlined, + LoginOutlined, + ArrowLeftOutlined, +} from '@ant-design/icons'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useSession } from 'next-auth/react'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { + GET_ALL_USER_PLAYLISTS, + GetAllUserPlaylistsQueryResponse, + Playlist, +} from '@/app/lib/graphql/operations/playlists'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { themeTokens } from '@/app/theme/theme-config'; +import AuthModal from '@/app/components/auth/auth-modal'; +import styles from './playlists.module.css'; + +const { Title, Text } = Typography; + +// Validate hex color format +const isValidHexColor = (color: string): boolean => { + return /^#([0-9A-Fa-f]{3}){1,2}$/.test(color); +}; + +// Board display names +const BOARD_DISPLAY_NAMES: Record = { + kilter: 'Kilter', + tension: 'Tension', +}; + +type PlaylistsListContentProps = { + currentUserId?: string; +}; + +export default function PlaylistsListContent({ + currentUserId, +}: PlaylistsListContentProps) { + const router = useRouter(); + const { data: session, status: sessionStatus } = useSession(); + const [playlists, setPlaylists] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showAuthModal, setShowAuthModal] = useState(false); + const [selectedBoard, setSelectedBoard] = useState('all'); + const { token, isLoading: tokenLoading } = useWsAuthToken(); + + const isAuthenticated = sessionStatus === 'authenticated'; + + const fetchPlaylists = useCallback(async () => { + if (tokenLoading || !isAuthenticated) { + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const response = await executeGraphQL( + GET_ALL_USER_PLAYLISTS, + {}, + token, + ); + + setPlaylists(response.allUserPlaylists); + } catch (err) { + console.error('Error fetching playlists:', err); + setError('Failed to load playlists'); + } finally { + setLoading(false); + } + }, [token, tokenLoading, isAuthenticated]); + + useEffect(() => { + fetchPlaylists(); + }, [fetchPlaylists]); + + const getPlaylistColor = (playlist: Playlist) => { + if (playlist.color && isValidHexColor(playlist.color)) { + return playlist.color; + } + return themeTokens.colors.primary; + }; + + // Get unique board types from playlists + const boardTypes = [...new Set(playlists.map(p => p.boardType))]; + + // Filter playlists by selected board + const filteredPlaylists = selectedBoard === 'all' + ? playlists + : playlists.filter(p => p.boardType === selectedBoard); + + // Group playlists by board type for display + const groupedPlaylists = filteredPlaylists.reduce((acc, playlist) => { + const boardType = playlist.boardType; + if (!acc[boardType]) { + acc[boardType] = []; + } + acc[boardType].push(playlist); + return acc; + }, {} as Record); + + // Not authenticated + if (!isAuthenticated && sessionStatus !== 'loading') { + return ( + <> +
+ +
+
+ + Sign in to view playlists + + Create and manage your own climb playlists by signing in. + + +
+ setShowAuthModal(false)} + title="Sign in to Boardsesh" + description="Sign in to create and manage your climb playlists." + /> + + ); + } + + if (loading || tokenLoading || sessionStatus === 'loading') { + return ( +
+ +
+ ); + } + + if (error) { + return ( + <> +
+ +
+
+ +
Unable to Load Playlists
+
+ There was an error loading your playlists. Please try again. +
+ +
+ + ); + } + + // Build board filter options + const boardOptions = [ + { label: 'All', value: 'all' }, + ...boardTypes.map(boardType => ({ + label: BOARD_DISPLAY_NAMES[boardType] || boardType, + value: boardType, + })), + ]; + + return ( + <> + {/* Actions Section */} +
+
+ + My Playlists +
+ {boardTypes.length > 1 && ( + setSelectedBoard(value as string)} + style={{ marginTop: 12 }} + /> + )} +
+ + {/* Content */} +
+ {filteredPlaylists.length === 0 ? ( +
+ + No playlists yet + + Create your first playlist by adding climbs from the climb list. + +
+ ) : selectedBoard === 'all' && boardTypes.length > 1 ? ( + // Show grouped by board when "All" is selected and multiple boards exist + Object.entries(groupedPlaylists).map(([boardType, boardPlaylists]) => ( +
+
+ {BOARD_DISPLAY_NAMES[boardType] || boardType} +
+ ( + + +
+
+ +
+
+
{playlist.name}
+
+ {playlist.climbCount} {playlist.climbCount === 1 ? 'climb' : 'climbs'} + · + {playlist.isPublic ? ( + + Public + + ) : ( + + Private + + )} +
+
+
+ +
+ + )} + /> +
+ )) + ) : ( + // Show flat list for single board or when a specific board is selected +
+ ( + + +
+
+ +
+
+
{playlist.name}
+
+ {playlist.climbCount} {playlist.climbCount === 1 ? 'climb' : 'climbs'} + · + {playlist.isPublic ? ( + + Public + + ) : ( + + Private + + )} +
+
+
+ +
+ + )} + /> +
+ )} +
+ + ); +} diff --git a/packages/web/app/playlists/playlists.module.css b/packages/web/app/playlists/playlists.module.css new file mode 100644 index 00000000..b3789803 --- /dev/null +++ b/packages/web/app/playlists/playlists.module.css @@ -0,0 +1,264 @@ +/* Playlists List Page Styles */ + +.pageContainer { + padding: 16px; + min-height: 100vh; + background-color: #F9FAFB; + max-width: 800px; + margin: 0 auto; +} + +/* Actions section */ +.actionsSection { + background-color: #FFFFFF; + border-radius: 12px; + padding: 16px; + margin-bottom: 16px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +.actionsContainer { + display: flex; + align-items: center; + gap: 12px; +} + +/* Content wrapper */ +.contentWrapper { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* List section */ +.listSection { + background-color: #FFFFFF; + border-radius: 12px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +/* Board header for grouped display */ +.boardHeader { + padding: 12px 16px; + background-color: #F9FAFB; + border-bottom: 1px solid #E5E7EB; +} + +/* Playlist item styles */ +.playlistLink { + text-decoration: none; + color: inherit; + display: block; +} + +.playlistLink:hover { + text-decoration: none; +} + +.playlistItem { + padding: 16px !important; + cursor: pointer; + transition: background-color 0.15s ease; + border-bottom: 1px solid #E5E7EB; + display: flex !important; + justify-content: space-between !important; + align-items: center !important; +} + +.playlistItem:last-child { + border-bottom: none; +} + +.playlistItem:hover { + background-color: #F9FAFB; +} + +.playlistItemContent { + display: flex; + align-items: center; + gap: 12px; + flex: 1; + min-width: 0; +} + +.playlistColor { + width: 40px; + height: 40px; + border-radius: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.playlistColorIcon { + font-size: 18px; + color: #FFFFFF; +} + +.playlistInfo { + flex: 1; + min-width: 0; +} + +.playlistName { + font-size: 16px; + font-weight: 500; + color: #111827; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.playlistMeta { + font-size: 13px; + color: #6B7280; + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; +} + +.metaDot { + color: #D1D5DB; +} + +.visibilityText { + display: flex; + align-items: center; + gap: 4px; +} + +.playlistArrow { + color: #9CA3AF; + font-size: 12px; + flex-shrink: 0; +} + +/* Empty state */ +.emptyContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; + background-color: #FFFFFF; + border-radius: 12px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +.emptyIcon { + font-size: 48px; + color: #D1D5DB; + margin-bottom: 16px; +} + +.emptyTitle { + margin-bottom: 8px !important; +} + +.emptyText { + max-width: 300px; +} + +/* Loading and error states */ +.loadingContainer { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +.errorContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; + background-color: #FFFFFF; + border-radius: 12px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1); +} + +.errorIcon { + font-size: 48px; + color: #9CA3AF; + margin-bottom: 16px; +} + +.errorTitle { + font-size: 18px; + font-weight: 600; + color: #111827; + margin-bottom: 8px; +} + +.errorMessage { + color: #6B7280; + margin-bottom: 20px; +} + +/* Mobile adjustments */ +@media (max-width: 767px) { + .pageContainer { + padding: 12px; + } + + .actionsSection { + padding: 12px; + border-radius: 8px; + margin-bottom: 12px; + } + + .listSection { + border-radius: 8px; + } + + .playlistItem { + padding: 12px !important; + } + + .playlistColor { + width: 36px; + height: 36px; + } + + .playlistColorIcon { + font-size: 16px; + } + + .playlistName { + font-size: 15px; + } + + .playlistMeta { + font-size: 12px; + } +} + +/* Desktop layout */ +@media (min-width: 992px) { + .pageContainer { + padding: 24px; + } + + .actionsSection { + padding: 20px; + } + + .playlistItem { + padding: 20px !important; + } + + .playlistColor { + width: 44px; + height: 44px; + } + + .playlistColorIcon { + font-size: 20px; + } +}