From 795acd35d4d4443c67fca89de5d81b459e2ac6f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 01:43:11 +0000 Subject: [PATCH 1/3] feat: Add Discover playlists tab with public playlist browsing Add a new "Discover" tab to the playlists view that allows users to browse public playlists shared by other users. The feature includes: - Two tabs: "Your playlists" (existing functionality) and "Discover" - Search by playlist name with debounced input - Multi-select autocomplete to filter by playlist creators - Backend GraphQL queries for discovering public playlists with filtering - Only shows playlists with at least 1 climb for the current board/layout - Creator display showing who created each public playlist --- .../graphql/resolvers/playlists/queries.ts | 204 ++++++++++++++++ packages/backend/src/validation/schemas.ts | 23 ++ packages/shared-schema/src/schema.ts | 56 +++++ .../[angle]/playlists/creator-name-select.tsx | 102 ++++++++ .../playlists/discover-playlists-content.tsx | 192 +++++++++++++++ .../playlists/playlists-list-content.tsx | 225 +++++++++++------- .../[angle]/playlists/playlists.module.css | 83 +++++++ .../app/lib/graphql/operations/playlists.ts | 101 ++++++++ 8 files changed, 895 insertions(+), 91 deletions(-) create mode 100644 packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/creator-name-select.tsx create mode 100644 packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/discover-playlists-content.tsx diff --git a/packages/backend/src/graphql/resolvers/playlists/queries.ts b/packages/backend/src/graphql/resolvers/playlists/queries.ts index 84f3ba5a..415c91cd 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,206 @@ 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}, ${dbSchema.users.email})`, + }) + .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 + if (input.searchQuery) { + conditions.push( + or( + sql`LOWER(${dbSchema.users.name}) LIKE LOWER(${'%' + input.searchQuery + '%'})`, + sql`LOWER(${dbSchema.users.email}) 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}, ${dbSchema.users.email})`, + 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, dbSchema.users.email) + .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!'} + +
+ ) : ( +
+ ( + + +
+
+ +
+
+
{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..f9a99af4 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,46 @@ } } +/* 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); +} + /* Desktop layout */ @media (min-width: 992px) { .pageContainer { @@ -254,4 +300,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 + } + } +`; From d384c2701ce171347810b3fd1d390ea32692e6aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 01:44:32 +0000 Subject: [PATCH 2/3] fix: Never expose email in playlist creator display Use 'Anonymous' as fallback instead of email when user has no display name set. Also update search to only search by name, not email. --- .../src/graphql/resolvers/playlists/queries.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/graphql/resolvers/playlists/queries.ts b/packages/backend/src/graphql/resolvers/playlists/queries.ts index 415c91cd..4848858b 100644 --- a/packages/backend/src/graphql/resolvers/playlists/queries.ts +++ b/packages/backend/src/graphql/resolvers/playlists/queries.ts @@ -486,7 +486,7 @@ export const playlistQueries = { createdAt: dbSchema.playlists.createdAt, updatedAt: dbSchema.playlists.updatedAt, creatorId: dbSchema.playlistOwnership.userId, - creatorName: sql`COALESCE(${dbSchema.users.name}, ${dbSchema.users.email})`, + creatorName: sql`COALESCE(${dbSchema.users.name}, 'Anonymous')`, }) .from(dbSchema.playlists) .innerJoin( @@ -574,13 +574,10 @@ export const playlistQueries = { eq(dbSchema.playlistOwnership.role, 'owner'), ]; - // Add search query filter if provided + // Add search query filter if provided (only search by name, not email) if (input.searchQuery) { conditions.push( - or( - sql`LOWER(${dbSchema.users.name}) LIKE LOWER(${'%' + input.searchQuery + '%'})`, - sql`LOWER(${dbSchema.users.email}) LIKE LOWER(${'%' + input.searchQuery + '%'})` - ) + sql`LOWER(${dbSchema.users.name}) LIKE LOWER(${'%' + input.searchQuery + '%'})` ); } @@ -588,7 +585,7 @@ export const playlistQueries = { const results = await db .select({ userId: dbSchema.playlistOwnership.userId, - displayName: sql`COALESCE(${dbSchema.users.name}, ${dbSchema.users.email})`, + displayName: sql`COALESCE(${dbSchema.users.name}, 'Anonymous')`, playlistCount: sql`count(DISTINCT ${dbSchema.playlists.id})::int`, }) .from(dbSchema.playlists) @@ -605,7 +602,7 @@ export const playlistQueries = { eq(dbSchema.users.id, dbSchema.playlistOwnership.userId) ) .where(and(...conditions)) - .groupBy(dbSchema.playlistOwnership.userId, dbSchema.users.name, dbSchema.users.email) + .groupBy(dbSchema.playlistOwnership.userId, dbSchema.users.name) .orderBy(desc(sql`count(DISTINCT ${dbSchema.playlists.id})`)) .limit(20); From af5aedb8f7f40bbf06027c01c61758ebd2ca3dad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Jan 2026 06:10:24 +0000 Subject: [PATCH 3/3] fix: Address code review feedback for discover playlists - Remove unused Empty import from discover-playlists-content.tsx - Add "Load more" pagination button for discover playlists - Extract debounce timeout to named constant (SEARCH_DEBOUNCE_MS) - Add PAGE_SIZE constant for pagination - Reset pagination when search filters change --- .../playlists/discover-playlists-content.tsx | 65 ++++++++++++++++--- .../[angle]/playlists/playlists.module.css | 7 ++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/discover-playlists-content.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/discover-playlists-content.tsx index dc2075fe..ac1722af 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/discover-playlists-content.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlists/discover-playlists-content.tsx @@ -1,7 +1,7 @@ 'use client'; -import React, { useState, useCallback, useEffect } from 'react'; -import { Spin, Typography, List, Empty, Input, Form } from 'antd'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { Spin, Typography, List, Input, Form, Button } from 'antd'; import { TagOutlined, SearchOutlined, @@ -24,6 +24,9 @@ import styles from './playlists.module.css'; const { Title, Text } = Typography; +const SEARCH_DEBOUNCE_MS = 300; +const PAGE_SIZE = 20; + // Validate hex color format const isValidHexColor = (color: string): boolean => { return /^#([0-9A-Fa-f]{3}){1,2}$/.test(color); @@ -40,22 +43,39 @@ export default function DiscoverPlaylistsContent({ }: DiscoverPlaylistsContentProps) { const [playlists, setPlaylists] = useState([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const [searchName, setSearchName] = useState(''); const [selectedCreators, setSelectedCreators] = useState([]); const [debouncedSearchName, setDebouncedSearchName] = useState(''); + const [hasMore, setHasMore] = useState(false); + const [page, setPage] = useState(0); + const isInitialLoad = useRef(true); // Debounce search name useEffect(() => { const timer = setTimeout(() => { setDebouncedSearchName(searchName); - }, 300); + }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(timer); }, [searchName]); - const fetchPlaylists = useCallback(async () => { + // Reset pagination when filters change + useEffect(() => { + if (!isInitialLoad.current) { + setPage(0); + setPlaylists([]); + } + isInitialLoad.current = false; + }, [debouncedSearchName, selectedCreators]); + + const fetchPlaylists = useCallback(async (pageNum: number, append: boolean = false) => { try { - setLoading(true); + if (append) { + setLoadingMore(true); + } else { + setLoading(true); + } setError(null); const input: DiscoverPlaylistsInput = { @@ -63,6 +83,8 @@ export default function DiscoverPlaylistsContent({ layoutId: boardDetails.layout_id, name: debouncedSearchName || undefined, creatorIds: selectedCreators.length > 0 ? selectedCreators : undefined, + page: pageNum, + pageSize: PAGE_SIZE, }; const response = await executeGraphQL( @@ -71,18 +93,33 @@ export default function DiscoverPlaylistsContent({ undefined // No auth token needed for public discovery ); - setPlaylists(response.discoverPlaylists.playlists); + const newPlaylists = response.discoverPlaylists.playlists; + setHasMore(response.discoverPlaylists.hasMore); + + if (append) { + setPlaylists(prev => [...prev, ...newPlaylists]); + } else { + setPlaylists(newPlaylists); + } } catch (err) { console.error('Error fetching playlists:', err); setError('Failed to load playlists'); } finally { setLoading(false); + setLoadingMore(false); } }, [boardDetails.board_name, boardDetails.layout_id, debouncedSearchName, selectedCreators]); + // Fetch when filters change (page 0) useEffect(() => { - fetchPlaylists(); - }, [fetchPlaylists]); + fetchPlaylists(0, false); + }, [debouncedSearchName, selectedCreators, boardDetails.board_name, boardDetails.layout_id]); + + const handleLoadMore = () => { + const nextPage = page + 1; + setPage(nextPage); + fetchPlaylists(nextPage, true); + }; const getPlaylistUrl = (playlistUuid: string) => { const { board_name, layout_name, size_name, size_description, set_names } = boardDetails; @@ -159,6 +196,18 @@ export default function DiscoverPlaylistsContent({
+ +
+ ) : null + } renderItem={(playlist) => ( 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 f9a99af4..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 @@ -278,6 +278,13 @@ 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 {