diff --git a/packages/backend/src/graphql/resolvers/playlists/queries.ts b/packages/backend/src/graphql/resolvers/playlists/queries.ts index 84f3ba5a..4848858b 100644 --- a/packages/backend/src/graphql/resolvers/playlists/queries.ts +++ b/packages/backend/src/graphql/resolvers/playlists/queries.ts @@ -7,6 +7,8 @@ import { GetUserPlaylistsInputSchema, GetPlaylistsForClimbInputSchema, GetPlaylistClimbsInputSchema, + DiscoverPlaylistsInputSchema, + GetPlaylistCreatorsInputSchema, } from '../../../validation/schemas'; import { getBoardTables, isValidBoardName } from '../../../db/queries/util/table-select'; import { getSizeEdges } from '../../../db/queries/util/product-sizes-data'; @@ -407,4 +409,203 @@ export const playlistQueries = { hasMore, }; }, + + /** + * Discover public playlists with at least 1 climb for a specific board and layout + * No authentication required + */ + discoverPlaylists: async ( + _: unknown, + { input }: { input: { + boardType: string; + layoutId: number; + name?: string; + creatorIds?: string[]; + page?: number; + pageSize?: number; + } }, + _ctx: ConnectionContext + ): Promise<{ playlists: unknown[]; totalCount: number; hasMore: boolean }> => { + validateInput(DiscoverPlaylistsInputSchema, input, 'input'); + + const page = input.page ?? 0; + const pageSize = input.pageSize ?? 20; + + // Build conditions for filtering + const conditions = [ + eq(dbSchema.playlists.isPublic, true), + eq(dbSchema.playlists.boardType, input.boardType), + // Include playlists with matching layoutId OR null layoutId (Aurora-synced circuits) + or( + eq(dbSchema.playlists.layoutId, input.layoutId), + isNull(dbSchema.playlists.layoutId) + ), + ]; + + // Add name filter if provided (case-insensitive partial match) + if (input.name) { + conditions.push(sql`LOWER(${dbSchema.playlists.name}) LIKE LOWER(${'%' + input.name + '%'})`); + } + + // Add creator filter if provided + if (input.creatorIds && input.creatorIds.length > 0) { + conditions.push(inArray(dbSchema.playlistOwnership.userId, input.creatorIds)); + } + + // First get total count of matching playlists with at least 1 climb + const countResult = await db + .select({ count: sql`count(DISTINCT ${dbSchema.playlists.id})::int` }) + .from(dbSchema.playlists) + .innerJoin( + dbSchema.playlistOwnership, + eq(dbSchema.playlistOwnership.playlistId, dbSchema.playlists.id) + ) + .innerJoin( + dbSchema.playlistClimbs, + eq(dbSchema.playlistClimbs.playlistId, dbSchema.playlists.id) + ) + .innerJoin( + dbSchema.users, + eq(dbSchema.users.id, dbSchema.playlistOwnership.userId) + ) + .where(and(...conditions, eq(dbSchema.playlistOwnership.role, 'owner'))); + + const totalCount = countResult[0]?.count || 0; + + // Get playlists with creator info and climb counts + const results = await db + .selectDistinctOn([dbSchema.playlists.id], { + id: dbSchema.playlists.id, + uuid: dbSchema.playlists.uuid, + boardType: dbSchema.playlists.boardType, + layoutId: dbSchema.playlists.layoutId, + name: dbSchema.playlists.name, + description: dbSchema.playlists.description, + color: dbSchema.playlists.color, + icon: dbSchema.playlists.icon, + createdAt: dbSchema.playlists.createdAt, + updatedAt: dbSchema.playlists.updatedAt, + creatorId: dbSchema.playlistOwnership.userId, + creatorName: sql`COALESCE(${dbSchema.users.name}, 'Anonymous')`, + }) + .from(dbSchema.playlists) + .innerJoin( + dbSchema.playlistOwnership, + eq(dbSchema.playlistOwnership.playlistId, dbSchema.playlists.id) + ) + .innerJoin( + dbSchema.playlistClimbs, + eq(dbSchema.playlistClimbs.playlistId, dbSchema.playlists.id) + ) + .innerJoin( + dbSchema.users, + eq(dbSchema.users.id, dbSchema.playlistOwnership.userId) + ) + .where(and(...conditions, eq(dbSchema.playlistOwnership.role, 'owner'))) + .orderBy(dbSchema.playlists.id, desc(dbSchema.playlists.updatedAt)) + .limit(pageSize + 1) + .offset(page * pageSize); + + // Check if there are more results + const hasMore = results.length > pageSize; + const trimmedResults = hasMore ? results.slice(0, pageSize) : results; + + // Get climb counts for each playlist + const playlistIds = trimmedResults.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])); + + const playlists = trimmedResults.map(p => ({ + id: p.id.toString(), + uuid: p.uuid, + boardType: p.boardType, + layoutId: p.layoutId, + name: p.name, + description: p.description, + color: p.color, + icon: p.icon, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + climbCount: countMap.get(p.id.toString()) || 0, + creatorId: p.creatorId, + creatorName: p.creatorName, + })); + + return { + playlists, + totalCount, + hasMore, + }; + }, + + /** + * Get playlist creators for autocomplete + * Returns users who have created public playlists with at least 1 climb + */ + playlistCreators: async ( + _: unknown, + { input }: { input: { + boardType: string; + layoutId: number; + searchQuery?: string; + } }, + _ctx: ConnectionContext + ): Promise => { + validateInput(GetPlaylistCreatorsInputSchema, input, 'input'); + + // Build base conditions + const conditions = [ + eq(dbSchema.playlists.isPublic, true), + eq(dbSchema.playlists.boardType, input.boardType), + or( + eq(dbSchema.playlists.layoutId, input.layoutId), + isNull(dbSchema.playlists.layoutId) + ), + eq(dbSchema.playlistOwnership.role, 'owner'), + ]; + + // Add search query filter if provided (only search by name, not email) + if (input.searchQuery) { + conditions.push( + sql`LOWER(${dbSchema.users.name}) LIKE LOWER(${'%' + input.searchQuery + '%'})` + ); + } + + // Get creators with their playlist counts + const results = await db + .select({ + userId: dbSchema.playlistOwnership.userId, + displayName: sql`COALESCE(${dbSchema.users.name}, 'Anonymous')`, + playlistCount: sql`count(DISTINCT ${dbSchema.playlists.id})::int`, + }) + .from(dbSchema.playlists) + .innerJoin( + dbSchema.playlistOwnership, + eq(dbSchema.playlistOwnership.playlistId, dbSchema.playlists.id) + ) + .innerJoin( + dbSchema.playlistClimbs, + eq(dbSchema.playlistClimbs.playlistId, dbSchema.playlists.id) + ) + .innerJoin( + dbSchema.users, + eq(dbSchema.users.id, dbSchema.playlistOwnership.userId) + ) + .where(and(...conditions)) + .groupBy(dbSchema.playlistOwnership.userId, dbSchema.users.name) + .orderBy(desc(sql`count(DISTINCT ${dbSchema.playlists.id})`)) + .limit(20); + + return results; + }, }; diff --git a/packages/backend/src/validation/schemas.ts b/packages/backend/src/validation/schemas.ts index 59fade7a..6bf10c87 100644 --- a/packages/backend/src/validation/schemas.ts +++ b/packages/backend/src/validation/schemas.ts @@ -370,3 +370,26 @@ export const GetPlaylistClimbsInputSchema = z.object({ page: z.number().int().min(0).optional(), pageSize: z.number().int().min(1).max(100).optional(), }); + +/** + * Discover playlists input validation schema + */ +export const DiscoverPlaylistsInputSchema = z.object({ + boardType: BoardNameSchema, + layoutId: z.number().int().positive(), + // Optional filters + name: z.string().max(100).optional(), + creatorIds: z.array(z.string().min(1)).optional(), + // Pagination + page: z.number().int().min(0).optional(), + pageSize: z.number().int().min(1).max(100).optional(), +}); + +/** + * Get playlist creators input validation schema + */ +export const GetPlaylistCreatorsInputSchema = z.object({ + boardType: BoardNameSchema, + layoutId: z.number().int().positive(), + searchQuery: z.string().max(100).optional(), +}); diff --git a/packages/shared-schema/src/schema.ts b/packages/shared-schema/src/schema.ts index 766a51fe..6d5d325b 100644 --- a/packages/shared-schema/src/schema.ts +++ b/packages/shared-schema/src/schema.ts @@ -407,6 +407,53 @@ export const typeDefs = /* GraphQL */ ` hasMore: Boolean! } + # Playlist creator info for discover playlists + type PlaylistCreator { + userId: ID! + displayName: String! + playlistCount: Int! + } + + # Discoverable playlist with creator info + type DiscoverablePlaylist { + id: ID! + uuid: ID! + boardType: String! + layoutId: Int + name: String! + description: String + color: String + icon: String + createdAt: String! + updatedAt: String! + climbCount: Int! + creatorId: ID! + creatorName: String! + } + + input DiscoverPlaylistsInput { + boardType: String! + layoutId: Int! + # Optional filters + name: String + creatorIds: [ID!] + # Pagination + page: Int + pageSize: Int + } + + type DiscoverPlaylistsResult { + playlists: [DiscoverablePlaylist!]! + totalCount: Int! + hasMore: Boolean! + } + + input GetPlaylistCreatorsInput { + boardType: String! + layoutId: Int! + searchQuery: String + } + type Query { session(sessionId: ID!): Session # Get buffered events since a sequence number for delta sync (Phase 2) @@ -482,6 +529,15 @@ export const typeDefs = /* GraphQL */ ` playlistsForClimb(input: GetPlaylistsForClimbInput!): [ID!]! # Get climbs in a playlist with full climb data playlistClimbs(input: GetPlaylistClimbsInput!): PlaylistClimbsResult! + + # ============================================ + # Playlist Discovery Queries (no auth required) + # ============================================ + + # Discover public playlists with at least 1 climb + discoverPlaylists(input: DiscoverPlaylistsInput!): DiscoverPlaylistsResult! + # Get playlist creators for autocomplete + playlistCreators(input: GetPlaylistCreatorsInput!): [PlaylistCreator!]! } type Mutation { diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/creator-name-select.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/creator-name-select.tsx new file mode 100644 index 00000000..302b2ecc --- /dev/null +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/creator-name-select.tsx @@ -0,0 +1,102 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Select } from 'antd'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { + GET_PLAYLIST_CREATORS, + GetPlaylistCreatorsQueryResponse, + GetPlaylistCreatorsInput, + PlaylistCreator, +} from '@/app/lib/graphql/operations/playlists'; +import useSWR from 'swr'; + +interface CreatorNameSelectProps { + boardType: string; + layoutId: number; + value: string[]; + onChange: (value: string[]) => void; +} + +const MIN_SEARCH_LENGTH = 2; + +const CreatorNameSelect = ({ + boardType, + layoutId, + value, + onChange, +}: CreatorNameSelectProps) => { + const [searchValue, setSearchValue] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + // Fetch creators when dropdown is open OR when user is searching + const shouldFetch = isOpen || searchValue.length >= MIN_SEARCH_LENGTH; + const isSearching = searchValue.length >= MIN_SEARCH_LENGTH; + + // Fetcher function for SWR + const fetcher = async (input: GetPlaylistCreatorsInput): Promise => { + const response = await executeGraphQL( + GET_PLAYLIST_CREATORS, + { input }, + undefined // No auth token needed for public discovery + ); + return response.playlistCreators; + }; + + // Build the input for the query + const queryInput: GetPlaylistCreatorsInput | null = shouldFetch + ? { + boardType, + layoutId, + searchQuery: isSearching ? searchValue : undefined, + } + : null; + + // Fetch creators + const { data: creators, isLoading } = useSWR( + queryInput ? ['playlistCreators', queryInput] : null, + () => fetcher(queryInput!), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + keepPreviousData: true, + } + ); + + // Map creators to Select options + const options = useMemo(() => { + if (!creators) return []; + + return creators.map((creator) => ({ + value: creator.userId, + label: `${creator.displayName} (${creator.playlistCount})`, + count: creator.playlistCount, + })); + }, [creators]); + + return ( + } + value={searchName} + onChange={(e) => setSearchName(e.target.value)} + allowClear + /> + + + + + + + + {/* Content */} + {loading ? ( +
+ +
+ ) : playlists.length === 0 ? ( +
+ + No public playlists found + + {debouncedSearchName || selectedCreators.length > 0 + ? 'Try adjusting your search filters.' + : 'Be the first to share a playlist for this board!'} + +
+ ) : ( +
+ + +
+ ) : null + } + renderItem={(playlist) => ( + + +
+
+ +
+
+
{playlist.name}
+
+ {playlist.climbCount} {playlist.climbCount === 1 ? 'climb' : 'climbs'} + · + + {playlist.creatorName} + +
+
+
+ +
+ + )} + /> + + )} + + ); +} diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists-list-content.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists-list-content.tsx index e67de763..fdeb3fc7 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists-list-content.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists-list-content.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; -import { Spin, Typography, Button, List, Empty } from 'antd'; +import { Spin, Typography, Button, List, Tabs } from 'antd'; import { TagOutlined, PlusOutlined, @@ -10,6 +10,7 @@ import { LockOutlined, FrownOutlined, LoginOutlined, + CompassOutlined, } from '@ant-design/icons'; import Link from 'next/link'; import { useSession } from 'next-auth/react'; @@ -26,6 +27,7 @@ import { constructClimbListWithSlugs, generateLayoutSlug, generateSizeSlug, gene import { themeTokens } from '@/app/theme/theme-config'; import BackButton from '@/app/components/back-button'; import AuthModal from '@/app/components/auth/auth-modal'; +import DiscoverPlaylistsContent from './discover-playlists-content'; import styles from './playlists.module.css'; const { Title, Text } = Typography; @@ -117,16 +119,32 @@ export default function PlaylistsListContent({ return themeTokens.colors.primary; }; - // Not authenticated - if (!isAuthenticated && sessionStatus !== 'loading') { + if (error) { return ( <>
+
+ +
Unable to Load Playlists
+
+ There was an error loading your playlists. Please try again. +
+ +
+ + ); + } + + // Render the "Your playlists" content + const renderYourPlaylists = () => { + // Not authenticated + if (!isAuthenticated && sessionStatus !== 'loading') { + return (
- Sign in to view playlists + Sign in to view your playlists Create and manage your own climb playlists by signing in. @@ -139,41 +157,109 @@ export default function PlaylistsListContent({ Sign 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 (playlists.length === 0) { + return ( +
+ + No playlists yet + + Create your first playlist by adding climbs from the climb list. + + + + +
+ ); + } - if (loading || tokenLoading || sessionStatus === 'loading') { return ( -
- +
+ ( + + +
+
+ +
+
+
{playlist.name}
+
+ {playlist.climbCount} {playlist.climbCount === 1 ? 'climb' : 'climbs'} + · + {playlist.isPublic ? ( + + Public + + ) : ( + + Private + + )} +
+
+
+ +
+ + )} + />
); - } + }; - if (error) { - return ( - <> -
- + const tabItems = [ + { + key: 'your', + label: ( + + + Your playlists + + ), + children: ( +
+ {renderYourPlaylists()}
-
- -
Unable to Load Playlists
-
- There was an error loading your playlists. Please try again. -
- -
- - ); - } + ), + }, + { + key: 'discover', + label: ( + + + Discover + + ), + children: ( + + ), + }, + ]; return ( <> @@ -181,68 +267,25 @@ export default function PlaylistsListContent({
- My Playlists + Playlists
- {/* Content */} -
- {playlists.length === 0 ? ( -
- - No playlists yet - - Create your first playlist by adding climbs from the climb list. - - - - -
- ) : ( -
- ( - - -
-
- -
-
-
{playlist.name}
-
- {playlist.climbCount} {playlist.climbCount === 1 ? 'climb' : 'climbs'} - · - {playlist.isPublic ? ( - - Public - - ) : ( - - Private - - )} -
-
-
- -
- - )} - /> -
- )} + {/* Tabs */} +
+
+ + setShowAuthModal(false)} + title="Sign in to Boardsesh" + description="Sign in to create and manage your climb playlists." + /> ); } diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists.module.css b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists.module.css index ac8632d2..a2561a44 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists.module.css +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/playlists.module.css @@ -123,6 +123,12 @@ gap: 4px; } +.creatorText { + display: flex; + align-items: center; + gap: 4px; +} + .playlistArrow { color: #9CA3AF; font-size: 12px; @@ -232,6 +238,53 @@ } } +/* Tabs container */ +.tabsContainer { + 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; +} + +.playlistTabs { + padding: 0; +} + +.playlistTabs :global(.ant-tabs-nav) { + margin-bottom: 0; + padding: 0 16px; +} + +.playlistTabs :global(.ant-tabs-content-holder) { + padding: 16px; +} + +.playlistTabs :global(.ant-tabs-tab) { + padding: 12px 0; +} + +.playlistTabs :global(.ant-tabs-tab span) { + display: flex; + align-items: center; + gap: 8px; +} + +/* Search filters */ +.searchFilters { + 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); +} + +/* Load more button container */ +.loadMoreContainer { + display: flex; + justify-content: center; + padding: 16px; +} + /* Desktop layout */ @media (min-width: 992px) { .pageContainer { @@ -254,4 +307,41 @@ .playlistColorIcon { font-size: 20px; } + + .tabsContainer { + border-radius: 16px; + } + + .playlistTabs :global(.ant-tabs-nav) { + padding: 0 20px; + } + + .playlistTabs :global(.ant-tabs-content-holder) { + padding: 20px; + } + + .searchFilters { + padding: 20px; + } +} + +/* Mobile adjustments for tabs */ +@media (max-width: 767px) { + .tabsContainer { + border-radius: 8px; + } + + .playlistTabs :global(.ant-tabs-nav) { + padding: 0 12px; + } + + .playlistTabs :global(.ant-tabs-content-holder) { + padding: 12px; + } + + .searchFilters { + padding: 12px; + border-radius: 8px; + margin-bottom: 12px; + } } diff --git a/packages/web/app/lib/graphql/operations/playlists.ts b/packages/web/app/lib/graphql/operations/playlists.ts index 712bf02a..bc619a7f 100644 --- a/packages/web/app/lib/graphql/operations/playlists.ts +++ b/packages/web/app/lib/graphql/operations/playlists.ts @@ -288,3 +288,104 @@ export interface PlaylistClimbsResult { export interface GetPlaylistClimbsQueryResponse { playlistClimbs: PlaylistClimbsResult; } + +// ============================================ +// Discover Playlists Types and Operations +// ============================================ + +// Playlist creator info for autocomplete +export interface PlaylistCreator { + userId: string; + displayName: string; + playlistCount: number; +} + +// Discoverable playlist with creator info +export interface DiscoverablePlaylist { + id: string; + uuid: string; + boardType: string; + layoutId?: number | null; + name: string; + description?: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; + climbCount: number; + creatorId: string; + creatorName: string; +} + +export interface DiscoverPlaylistsInput { + boardType: string; + layoutId: number; + name?: string; + creatorIds?: string[]; + page?: number; + pageSize?: number; +} + +export interface DiscoverPlaylistsResult { + playlists: DiscoverablePlaylist[]; + totalCount: number; + hasMore: boolean; +} + +export interface DiscoverPlaylistsQueryVariables { + input: DiscoverPlaylistsInput; +} + +export interface DiscoverPlaylistsQueryResponse { + discoverPlaylists: DiscoverPlaylistsResult; +} + +export interface GetPlaylistCreatorsInput { + boardType: string; + layoutId: number; + searchQuery?: string; +} + +export interface GetPlaylistCreatorsQueryVariables { + input: GetPlaylistCreatorsInput; +} + +export interface GetPlaylistCreatorsQueryResponse { + playlistCreators: PlaylistCreator[]; +} + +// Discover public playlists +export const DISCOVER_PLAYLISTS = gql` + query DiscoverPlaylists($input: DiscoverPlaylistsInput!) { + discoverPlaylists(input: $input) { + playlists { + id + uuid + boardType + layoutId + name + description + color + icon + createdAt + updatedAt + climbCount + creatorId + creatorName + } + totalCount + hasMore + } + } +`; + +// Get playlist creators for autocomplete +export const GET_PLAYLIST_CREATORS = gql` + query GetPlaylistCreators($input: GetPlaylistCreatorsInput!) { + playlistCreators(input: $input) { + userId + displayName + playlistCount + } + } +`;