diff --git a/apps/frontend/src/app/(content)/page.tsx b/apps/frontend/src/app/(content)/page.tsx index 6447f2ea..a251e34c 100644 --- a/apps/frontend/src/app/(content)/page.tsx +++ b/apps/frontend/src/app/(content)/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from 'next'; +import { BROWSER_SONGS } from '@nbw/config'; import type { FeaturedSongsDto, PageDto, SongPreviewDto } from '@nbw/database'; import axiosInstance from '@web/lib/axios'; import { HomePageProvider } from '@web/modules/browse/components/client/context/HomePage.context'; @@ -9,8 +10,8 @@ async function fetchRecentSongs() { try { const response = await axiosInstance.get>('/song', { params: { - page: 1, // TODO: fix constants - limit: 11, // TODO: change 'limit' parameter to 'skip' and load 12 songs initially, then load 8 more songs on each pagination + page: 1, + limit: BROWSER_SONGS.recentFetchCount, sort: 'recent', order: 'desc', }, diff --git a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx index 5abfb33e..534098e1 100644 --- a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx +++ b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx @@ -19,21 +19,19 @@ import { WelcomeBanner } from '../WelcomeBanner'; import { CategoryButtonGroup } from './client/CategoryButton'; import { useFeaturedSongsProvider } from './client/context/FeaturedSongs.context'; -import { - useRecentSongsProvider, - useRecentSongsCategoriesLoader, -} from './client/context/RecentSongs.context'; +import { useRecentSongsStore } from './client/context/RecentSongs.context'; import LoadMoreButton from './client/LoadMoreButton'; import { TimespanButtonGroup } from './client/TimespanButton'; import SongCard from './SongCard'; import SongCardGroup from './SongCardGroup'; export const HomePageComponent = () => { - // Initialize sync hooks for proper effect handling - useRecentSongsCategoriesLoader(); - const { featuredSongsPage, timespan } = useFeaturedSongsProvider(); - const { recentSongs, increasePageRecent, hasMore } = useRecentSongsProvider(); + const recentItems = useRecentSongsStore((state) => state.recentItems); + const increasePageRecent = useRecentSongsStore( + (state) => state.increasePageRecent, + ); + const hasMore = useRecentSongsStore((state) => state.hasMore); return ( <> {/* Welcome banner/Hero */} @@ -85,14 +83,17 @@ export const HomePageComponent = () => {
- {recentSongs.map((song, i) => - // TODO: currently null = skeleton, undefined = ad slot. There must be a more robust system to indicate this. - song === undefined ? ( - - ) : ( - - ), - )} + {recentItems.map((item, i) => { + if (item.type === 'ad') { + return ; + } + + if (item.type === 'loading') { + return ; + } + + return ; + })}
{hasMore ? ( diff --git a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx index b91a5572..7a7999e8 100644 --- a/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx +++ b/apps/frontend/src/modules/browse/components/client/CategoryButton.tsx @@ -1,4 +1,6 @@ 'use client'; +import { useEffect } from 'react'; + import { UPLOAD_CONSTANTS } from '@nbw/config'; import type { CategoryType } from '@nbw/database'; import { @@ -9,7 +11,7 @@ import { CarouselPreviousSmall, } from '@web/modules/shared/components/client/Carousel'; -import { useRecentSongsProvider } from './context/RecentSongs.context'; +import { useRecentSongsStore } from './context/RecentSongs.context'; type CategoryButtonProps = { children: React.ReactNode; @@ -20,8 +22,21 @@ type CategoryButtonProps = { }; export const CategoryButtonGroup = () => { - const { categories, setSelectedCategory, selectedCategory } = - useRecentSongsProvider(); + const categories = useRecentSongsStore((state) => state.categories); + const fetchCategories = useRecentSongsStore((state) => state.fetchCategories); + const setSelectedCategory = useRecentSongsStore( + (state) => state.setSelectedCategory, + ); + const selectedCategory = useRecentSongsStore( + (state) => state.selectedCategory, + ); + const categoryCount = Object.keys(categories).length; + + useEffect(() => { + if (categoryCount === 0) { + void fetchCategories(); + } + }, [categoryCount, fetchCategories]); return ( ); } - -// Legacy hook name for backward compatibility -export function useHomePageProvider() { - return useHomePageStore(); -} diff --git a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx index 84fd2547..4636c8cb 100644 --- a/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx +++ b/apps/frontend/src/modules/browse/components/client/context/RecentSongs.context.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useEffect } from 'react'; -import { create } from 'zustand'; +import { createContext, useContext, useRef } from 'react'; +import { createStore, useStore } from 'zustand'; +import { BROWSER_SONGS } from '@nbw/config'; import type { PageDto, SongPreviewDtoType } from '@nbw/database'; import axiosInstance from '@web/lib/axios'; interface RecentSongsState { - recentSongs: (SongPreviewDtoType | null | undefined)[]; + recentItems: FeedItem[]; recentError: string; isLoading: boolean; hasMore: boolean; @@ -17,154 +18,184 @@ interface RecentSongsState { } interface RecentSongsActions { - initialize: (initialRecentSongs: SongPreviewDtoType[]) => void; setSelectedCategory: (category: string) => void; increasePageRecent: () => Promise; - fetchRecentSongs: () => Promise; + fetchRecentSongs: (targetPage?: number) => Promise; fetchCategories: () => Promise; } -type RecentSongsStore = RecentSongsState & RecentSongsActions; +export type RecentSongsStore = RecentSongsState & RecentSongsActions; -const adCount = 1; -const pageSize = 12; -const fetchCount = pageSize - adCount; +export const AD_COUNT = BROWSER_SONGS.recentAdCount; +export const PAGE_SIZE = BROWSER_SONGS.recentPageSize; +export const FETCH_COUNT = BROWSER_SONGS.recentFetchCount; -function injectAdSlots( - songs: SongPreviewDtoType[], -): Array { - const songsWithAds: Array = [...songs]; +export type FeedItem = + | { type: 'song'; data: SongPreviewDtoType } + | { type: 'ad' } + | { type: 'loading' }; - for (let i = 0; i < adCount; i++) { - const adPosition = Math.floor(Math.random() * (songsWithAds.length + 1)); - songsWithAds.splice(adPosition, 0, undefined); +function createLoadingItems(count: number): FeedItem[] { + return Array.from({ length: count }, () => ({ type: 'loading' as const })); +} + +function injectAdSlots(songs: SongPreviewDtoType[], page: number): FeedItem[] { + const songItems: FeedItem[] = songs.map((song) => ({ + type: 'song', + data: song, + })); + + if (AD_COUNT <= 0) { + return songItems; } - return songsWithAds; + const result: FeedItem[] = [...songItems]; + + for (let i = 0; i < AD_COUNT; i++) { + const insertionIndex = (page * 7 + i * 13) % (result.length + 1); + result.splice(insertionIndex, 0, { type: 'ad' }); + } + + return result; } +const createRecentSongsStore = (initialRecentSongs: SongPreviewDtoType[]) => { + let fetchController: AbortController | null = null; -export const useRecentSongsStore = create((set, get) => { - const fetchRecentSongs = async () => { - const { page, selectedCategory } = get(); - set({ isLoading: true }); - - try { - const params: Record = { - page, - limit: fetchCount, // TODO: fix constants - sort: 'recent', - order: 'desc', - }; - - if (selectedCategory) { - params.category = selectedCategory; + return createStore((set, get) => { + const fetchRecentSongs = async (targetPage?: number) => { + if (fetchController) { + fetchController.abort(); } - const response = await axiosInstance.get>( - '/song', - { params }, - ); - - const fetchedSongs = response.data.content; - const newSongs = injectAdSlots(fetchedSongs); - - set((state) => ({ - recentSongs: [ - ...state.recentSongs.filter((song) => song !== null), - ...newSongs, - ], - hasMore: fetchedSongs.length >= fetchCount, - recentError: '', - })); - } catch (error) { - set((state) => ({ - recentSongs: state.recentSongs.filter((song) => song !== null), - recentError: 'Error loading recent songs', - })); - } finally { - set({ isLoading: false }); - } - }; - - return { - // Initial state - recentSongs: [], - recentError: '', - isLoading: false, - hasMore: true, - selectedCategory: '', - categories: {}, - page: 1, // Start from page 1 since it's loaded server-side - - // Actions - initialize: (initialRecentSongs) => { - set({ - recentSongs: injectAdSlots(initialRecentSongs), - page: 1, - hasMore: true, - recentError: '', - }); - }, - - fetchCategories: async () => { + const controller = new AbortController(); + fetchController = controller; + const pageToFetch = targetPage ?? get().page; + const { selectedCategory } = get(); + set({ isLoading: true }); + try { - const response = await axiosInstance.get>( - '/song/categories', + const params: Record = { + page: pageToFetch, + limit: FETCH_COUNT, + sort: 'recent', + order: 'desc', + }; + + if (selectedCategory) { + params.category = selectedCategory; + } + + const response = await axiosInstance.get>( + '/song', + { params, signal: controller.signal }, ); - set({ categories: response.data }); + const fetchedSongs = response.data.content; + const newSongs = injectAdSlots(fetchedSongs, pageToFetch); + + set((state) => ({ + recentItems: [ + ...state.recentItems.filter((item) => item.type !== 'loading'), + ...newSongs, + ], + hasMore: fetchedSongs.length >= FETCH_COUNT, + recentError: '', + page: pageToFetch, + })); + + return true; } catch (error) { - set({ categories: {} }); + if (controller.signal.aborted) { + return false; + } + + set((state) => ({ + recentItems: state.recentItems.filter( + (item) => item.type !== 'loading', + ), + recentError: 'Error loading recent songs', + })); + return false; + } finally { + if (fetchController === controller) { + fetchController = null; + set({ isLoading: false }); + } } - }, - - fetchRecentSongs: fetchRecentSongs, - - setSelectedCategory: (category) => { - set({ - selectedCategory: category, - page: 1, // Fetch from the first page when category changes - recentSongs: Array(pageSize).fill(null), - hasMore: true, - }); - fetchRecentSongs(); - }, - - increasePageRecent: async () => { - const { isLoading, recentError, hasMore, recentSongs } = get(); - - if (isLoading || recentError || !hasMore) { - return; - } - - set({ - recentSongs: [...recentSongs, ...Array(pageSize).fill(null)], - page: get().page + 1, - }); - fetchRecentSongs(); - }, - }; -}); - -// Hook to fetch categories on mount -export const useRecentSongsCategoriesLoader = () => { - const fetchCategories = useRecentSongsStore((state) => state.fetchCategories); - - useEffect(() => { - fetchCategories(); - }, [fetchCategories]); + }; + + return { + recentItems: injectAdSlots(initialRecentSongs, 1), + recentError: '', + isLoading: false, + hasMore: initialRecentSongs.length >= FETCH_COUNT, + selectedCategory: '', + categories: {}, + page: 1, + + fetchCategories: async () => { + try { + const response = await axiosInstance.get>( + '/song/categories', + ); + set({ categories: response.data }); + } catch (error) { + set({ categories: {} }); + } + }, + + fetchRecentSongs, + + setSelectedCategory: (category) => { + set({ + selectedCategory: category, + page: 1, + recentItems: createLoadingItems(FETCH_COUNT), + hasMore: true, + recentError: '', + }); + void fetchRecentSongs(1); + }, + + increasePageRecent: async () => { + const { isLoading, recentError, hasMore, recentItems, page } = get(); + + if (isLoading || recentError || !hasMore) { + return; + } + + const nextPage = page + 1; + set({ + recentItems: [...recentItems, ...createLoadingItems(FETCH_COUNT)], + }); + + const didLoad = await fetchRecentSongs(nextPage); + if (!didLoad) { + set((state) => ({ + recentItems: state.recentItems.filter( + (item) => item.type !== 'loading', + ), + })); + } + }, + }; + }); }; -// Legacy hook name for backward compatibility -export const useRecentSongsProvider = () => { - const store = useRecentSongsStore(); - // Ensure recentSongs is always an array - return { - ...store, - recentSongs: store.recentSongs || [], - }; -}; +type RecentSongsStoreApi = ReturnType; +const RecentSongsStoreContext = createContext(null); + +export function useRecentSongsStore( + selector: (state: RecentSongsStore) => T, +): T { + const store = useContext(RecentSongsStoreContext); + if (!store) { + throw new Error( + 'useRecentSongsStore must be used within RecentSongsProvider', + ); + } + return useStore(store, selector); +} -// Provider component for initialization (now just a wrapper) type RecentSongsProviderProps = { children: React.ReactNode; initialRecentSongs: SongPreviewDtoType[]; @@ -174,11 +205,15 @@ export function RecentSongsProvider({ children, initialRecentSongs, }: RecentSongsProviderProps) { - const initialize = useRecentSongsStore((state) => state.initialize); + const storeRef = useRef(null); - useEffect(() => { - initialize(initialRecentSongs); - }, [initialRecentSongs, initialize]); + if (!storeRef.current) { + storeRef.current = createRecentSongsStore(initialRecentSongs); + } - return <>{children}; + return ( + + {children} + + ); } diff --git a/docker-compose.yml b/docker-compose.yml index 2161a172..a5d8990f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,32 @@ services: - MONGO_INITDB_ROOT_PASSWORD=noteblockworldpassword - MONGO_INITDB_DATABASE=noteblockworld - MONGO_INITDB_ROOT_USERNAME=noteblockworlduser + healthcheck: + test: + [ + 'CMD', + 'mongosh', + '-u', + 'noteblockworlduser', + '-p', + 'noteblockworldpassword', + '--authenticationDatabase', + 'admin', + '--eval', + "db.adminCommand('ping').ok", + ] + interval: 5s + timeout: 5s + retries: 10 + start_period: 15s maildev: container_name: noteblockworld-maildev-dev image: maildev/maildev + # Image HEALTHCHECK can report unhealthy and block `compose up --wait`, so + # `docker:minio-init` never runs and MinIO buckets are never created. + healthcheck: + disable: true ports: - '1080:1080' # Web Interface - '1025:1025' # SMTP Server @@ -35,15 +57,25 @@ services: - MINIO_ROOT_PASSWORD=minioadmin volumes: - minio_data:/data + healthcheck: + test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live'] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s # You can access the MinIO web interface at http://localhost:9000 # You can access the MinIO console at http://localhost:9001 + # One-shot: exits 0 after buckets/CORS. Not part of `up --wait` (that waits for long-running services). mc: container_name: minio-client + profiles: + - minio-init image: minio/mc entrypoint: ['/bin/sh', '-c'] depends_on: - - minio + minio: + condition: service_healthy environment: - MINIO_ROOT_USER=minioadmin - MINIO_ROOT_PASSWORD=minioadmin diff --git a/package.json b/package.json index 3f66ddb0..5d4e2be9 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,11 @@ "build:web": "bun run --filter '@nbw/frontend' build", "build:all": "bun run build:packages && bun run build:apps", "docker": "docker-compose -f docker-compose-dev.yml up -d && bun run dev && docker-compose down", + "docker:minio-init": "docker compose --profile minio-init run --rm mc", + "docker:up": "docker compose up -d --wait && bun run docker:minio-init", + "docker:down": "docker compose down", + "docker:reset": "docker compose down && docker compose up -d --wait && bun run docker:minio-init", + "docker:reset:fresh": "docker compose down -v && docker compose up -d --wait && bun run docker:minio-init", "start:apps": "bun run --filter './apps/*' start", "start:server": "bun run --filter '@nbw/backend' start", "start:web": "bun run --filter '@nbw/frontend' start", diff --git a/packages/configs/src/song.ts b/packages/configs/src/song.ts index 613125f7..2a1adec7 100644 --- a/packages/configs/src/song.ts +++ b/packages/configs/src/song.ts @@ -128,6 +128,9 @@ export const MY_SONGS = { export const BROWSER_SONGS = { featuredPageSize: 10, paddedFeaturedPageSize: 5, + recentAdCount: 2, + recentPageSize: 12, + recentFetchCount: 10, } as const; export const SEARCH_FEATURES: Record = {