623 lines
17 KiB
JavaScript
623 lines
17 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
FlatList,
|
|
Image,
|
|
Platform,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from 'react-native'
|
|
import { BlurView } from 'expo-blur'
|
|
import { responsiveHeight } from 'react-native-responsive-dimensions'
|
|
import { background, icons } from '../../assets'
|
|
import BorderGradientButton from '../../components/BorderGradientButton'
|
|
import SearchBar from '../../components/SearchBar'
|
|
import ShareBtn from '../../components/ShareBtn/ShareBtn'
|
|
import firebase, { projectsRef, usersRef } from '../../config/firebase'
|
|
import useDataFromRef from '../../hooks/useDataFromRef'
|
|
import useNavigateToMusicDetails from '../../hooks/useNavigateToMusicDetails'
|
|
import useSearch from '../../hooks/useSearch'
|
|
import Page from '../../layouts/Page'
|
|
import { Palette, Style } from '../../styles'
|
|
import { FONT_FAMILY } from '../../styles/Fonts'
|
|
import CreateLyricsHeader from '../Writing/components/CreateLyricsHeader'
|
|
import MobileCoinBadge from '../../components/MobileCoinBadge'
|
|
import ResearchHeader from '../Library/components/ResearchHeader'
|
|
import SearchResultsList from '../Library/components/SearchResultsList'
|
|
import SongCard from './components/SongCard'
|
|
|
|
const allowedPremiumLevels = new Set(['starter', 'pro', 'premium'])
|
|
|
|
const normalizePremiumLevel = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return null
|
|
}
|
|
const normalized = value.trim().toLowerCase()
|
|
return allowedPremiumLevels.has(normalized) ? normalized : null
|
|
}
|
|
|
|
const chunkArray = (items = [], size = 10) => {
|
|
const chunks = []
|
|
for (let i = 0; i < items.length; i += size) {
|
|
chunks.push(items.slice(i, i + size))
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
const HitParade = () => {
|
|
const [selectedCategory, setSelectedCategory] = useState('Chansons')
|
|
const navigateToMusicDetails = useNavigateToMusicDetails()
|
|
const { data: allSongs } = useDataFromRef({
|
|
ref: projectsRef.orderBy('updatedAt', 'desc'),
|
|
format: (docs) => docs.filter((item) => item?.hasPlayback !== true),
|
|
simpleRef: false,
|
|
listener: true,
|
|
condition: true,
|
|
})
|
|
|
|
const previousMonthKey = useMemo(() => {
|
|
const now = new Date()
|
|
const target = new Date(now.getFullYear(), now.getMonth(), 1)
|
|
target.setMonth(target.getMonth() - 1)
|
|
const month = String(target.getMonth() + 1).padStart(2, '0')
|
|
return `${target.getFullYear()}-${month}`
|
|
}, [])
|
|
|
|
const { data: monthlyTopDoc } = useDataFromRef({
|
|
ref: firebase.firestore().collection('monthlyTopSongs').doc(previousMonthKey),
|
|
initialState: null,
|
|
simpleRef: true,
|
|
listener: false,
|
|
condition: true,
|
|
})
|
|
|
|
const songsList = useMemo(() => (Array.isArray(allSongs) ? allSongs : []), [allSongs])
|
|
|
|
const topMonthList = useMemo(() => {
|
|
const items = monthlyTopDoc?.topProjects
|
|
return Array.isArray(items) ? items : []
|
|
}, [monthlyTopDoc])
|
|
|
|
// === RENDUS PAR PLATEFORME ===
|
|
const renderSongsMobile = () => (
|
|
<FlatList
|
|
contentContainerStyle={{
|
|
gap: 10,
|
|
paddingBottom: responsiveHeight(20),
|
|
}}
|
|
data={songsList}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={({ item }) => (
|
|
<SongCard
|
|
rank={null}
|
|
title={item?.title || 'Sans titre'}
|
|
artist={item?.userName}
|
|
coverUrl={item?.coverUrl || null}
|
|
subscriptionLevel={getCreatorLevel(item)}
|
|
onPress={() =>
|
|
navigateToMusicDetails({
|
|
projectId: item.id,
|
|
songUrl: item?.songUrl,
|
|
project: item,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
|
|
const renderTopMonthMobile = () => (
|
|
<FlatList
|
|
contentContainerStyle={{
|
|
gap: 10,
|
|
paddingBottom: responsiveHeight(20),
|
|
}}
|
|
data={topMonthList}
|
|
keyExtractor={(item) => item.projectId}
|
|
renderItem={({ item }) => (
|
|
<SongCard
|
|
rank={item?.rank}
|
|
title={item?.title || 'Sans titre'}
|
|
artist={item?.userName}
|
|
coverUrl={item?.coverUrl || null}
|
|
subscriptionLevel={getCreatorLevel(item)}
|
|
onPress={() =>
|
|
navigateToMusicDetails({
|
|
projectId: item?.projectId,
|
|
songUrl: item?.songUrl,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
|
|
const renderSongList = () => (
|
|
<View style={{ gap: 10 }}>
|
|
{songsList.map((item) => (
|
|
<SongCard
|
|
key={item.id}
|
|
rank={null}
|
|
title={item?.title || 'Sans titre'}
|
|
artist={item?.userName}
|
|
coverUrl={item?.coverUrl || null}
|
|
subscriptionLevel={getCreatorLevel(item)}
|
|
onPress={() =>
|
|
navigateToMusicDetails({
|
|
projectId: item.id,
|
|
songUrl: item?.songUrl,
|
|
project: item,
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
)
|
|
|
|
const renderTopMonthColumns = () => (
|
|
<View style={{ gap: 10 }}>
|
|
{topMonthList.map((item) => (
|
|
<SongCard
|
|
key={item.projectId}
|
|
rank={item?.rank}
|
|
title={item?.title || 'Sans titre'}
|
|
artist={item?.userName}
|
|
coverUrl={item?.coverUrl || null}
|
|
subscriptionLevel={getCreatorLevel(item)}
|
|
onPress={() =>
|
|
navigateToMusicDetails({
|
|
projectId: item?.projectId,
|
|
songUrl: item?.songUrl,
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
)
|
|
|
|
const isWeb = Platform.OS === 'web'
|
|
const {
|
|
search,
|
|
setSearch,
|
|
selected: searchSelected,
|
|
setSelected: setSearchSelected,
|
|
users = [],
|
|
musics = [],
|
|
playbacks = [],
|
|
loading: searchLoading,
|
|
} = useSearch()
|
|
|
|
const [dropdownVisible, setDropdownVisible] = useState(false)
|
|
const searchWrapperRef = useRef(null)
|
|
const hasSearchQuery = search.trim().length > 0
|
|
|
|
const toggleSearchSelected = useCallback(
|
|
(value) => {
|
|
setSearchSelected((previous) => (previous === value ? null : value))
|
|
},
|
|
[setSearchSelected]
|
|
)
|
|
|
|
const musicResults = useMemo(() => (Array.isArray(musics) ? musics : []), [musics])
|
|
|
|
const [creatorsById, setCreatorsById] = useState({})
|
|
|
|
const requiredUserIds = useMemo(() => {
|
|
const collected = new Set()
|
|
const collectUserId = (project) => {
|
|
const uid = project && typeof project.userId === 'string' ? project.userId.trim() : null
|
|
if (uid) {
|
|
collected.add(uid)
|
|
}
|
|
}
|
|
songsList.forEach(collectUserId)
|
|
topMonthList.forEach(collectUserId)
|
|
return Array.from(collected)
|
|
}, [songsList, topMonthList])
|
|
|
|
const fetchCreatorsByIds = useCallback(
|
|
async (userIds) => {
|
|
if (!Array.isArray(userIds) || userIds.length === 0) {
|
|
return
|
|
}
|
|
|
|
const normalizedIds = userIds
|
|
.map((id) => (typeof id === 'string' ? id.trim() : null))
|
|
.filter(Boolean)
|
|
|
|
if (normalizedIds.length === 0) {
|
|
return
|
|
}
|
|
|
|
const pendingIds = new Set(normalizedIds)
|
|
const nextCreators = {}
|
|
|
|
const idChunks = chunkArray(normalizedIds, 10)
|
|
|
|
await Promise.all(
|
|
idChunks.map(async (chunk) => {
|
|
try {
|
|
const snapshot = await usersRef
|
|
.where(firebase.firestore.FieldPath.documentId(), 'in', chunk)
|
|
.get()
|
|
|
|
snapshot.docs.forEach((doc) => {
|
|
const data = doc.data() || {}
|
|
nextCreators[doc.id] = {
|
|
premiumLevel: normalizePremiumLevel(data.premiumLevel),
|
|
}
|
|
pendingIds.delete(doc.id)
|
|
})
|
|
} catch (error) {
|
|
console.log('HitParade: unable to fetch creators', error?.message || error)
|
|
}
|
|
})
|
|
)
|
|
|
|
pendingIds.forEach((userId) => {
|
|
nextCreators[userId] = { premiumLevel: null }
|
|
})
|
|
|
|
if (Object.keys(nextCreators).length > 0) {
|
|
setCreatorsById((previous) => ({ ...previous, ...nextCreators }))
|
|
}
|
|
},
|
|
[setCreatorsById]
|
|
)
|
|
|
|
useEffect(() => {
|
|
const missingIds = requiredUserIds.filter((id) => !creatorsById[id])
|
|
if (missingIds.length === 0) {
|
|
return
|
|
}
|
|
fetchCreatorsByIds(missingIds)
|
|
}, [creatorsById, fetchCreatorsByIds, requiredUserIds])
|
|
|
|
const getCreatorLevel = useCallback(
|
|
(project) => {
|
|
const uid = project && typeof project.userId === 'string' ? project.userId.trim() : null
|
|
if (!uid) {
|
|
return null
|
|
}
|
|
return creatorsById?.[uid]?.premiumLevel || null
|
|
},
|
|
[creatorsById]
|
|
)
|
|
|
|
const playbackResults = useMemo(() => (Array.isArray(playbacks) ? playbacks : []), [playbacks])
|
|
|
|
const userResults = useMemo(() => (Array.isArray(users) ? users : []), [users])
|
|
|
|
const musicsLoading = searchLoading
|
|
const playbacksLoading = searchLoading
|
|
const usersLoading = searchLoading
|
|
|
|
const closeDropdown = useCallback(() => {
|
|
setDropdownVisible(false)
|
|
setSearchSelected(null)
|
|
setSearch('')
|
|
}, [setSearch, setSearchSelected])
|
|
|
|
const handleSearchFocus = useCallback(() => {
|
|
if (isWeb) {
|
|
setDropdownVisible(true)
|
|
}
|
|
}, [isWeb])
|
|
|
|
const handleSearchChange = useCallback(
|
|
(value) => {
|
|
setSearch(value)
|
|
if (isWeb) {
|
|
setDropdownVisible(true)
|
|
}
|
|
},
|
|
[isWeb, setSearch]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!isWeb || !dropdownVisible) {
|
|
return undefined
|
|
}
|
|
|
|
const handleClickOutside = (event) => {
|
|
if (searchWrapperRef.current?.contains?.(event.target)) {
|
|
return
|
|
}
|
|
closeDropdown()
|
|
}
|
|
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
document.addEventListener('touchstart', handleClickOutside)
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
document.removeEventListener('touchstart', handleClickOutside)
|
|
}
|
|
}, [closeDropdown, dropdownVisible, isWeb])
|
|
|
|
const shouldShowResults = isWeb && dropdownVisible
|
|
const shouldBlurContent = shouldShowResults
|
|
|
|
const handlePlayRandomSong = useCallback(() => {
|
|
const arr = songsList
|
|
|
|
if (!arr.length) {
|
|
console.warn('Aucune chanson disponible pour le moment.')
|
|
return
|
|
}
|
|
|
|
const randomSong = arr[Math.floor(Math.random() * arr.length)]
|
|
|
|
navigateToMusicDetails({
|
|
projectId: randomSong.id,
|
|
songUrl: randomSong?.songUrl,
|
|
project: randomSong,
|
|
})
|
|
}, [navigateToMusicDetails, songsList])
|
|
|
|
const heroTitle = isWeb
|
|
? 'Coups de coeur des utilisateurs'
|
|
: `Coups de coeur des utilisateurs catégorie ${selectedCategory}`
|
|
|
|
const heroSubtitle = 'Les 3 musiques les plus likées du mois reçoivent des tokens.'
|
|
|
|
const renderHero = () => (
|
|
<View style={{ gap: 11, maxWidth: 800, width: '100%', alignSelf: 'center' }}>
|
|
{/* <Pressable onPress={handlePlayRandomSong}>
|
|
<Image
|
|
source={icons.play}
|
|
style={{ alignSelf: "center", marginVertical: 10 }}
|
|
/>
|
|
</Pressable> */}
|
|
<CreateLyricsHeader gradientProps={{ colors: ['#F94697', '#7023F7'] }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 16,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
|
}}
|
|
>
|
|
{heroTitle}
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
}}
|
|
>
|
|
{heroSubtitle}
|
|
</Text>
|
|
</CreateLyricsHeader>
|
|
</View>
|
|
)
|
|
|
|
const renderWebContent = () => (
|
|
<ScrollView
|
|
contentContainerStyle={{
|
|
flexGrow: 1,
|
|
gap: 24,
|
|
paddingBottom: responsiveHeight(20),
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
style={{ flex: 1, position: 'relative', zIndex: 5 }}
|
|
>
|
|
{renderHero()}
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
gap: 16,
|
|
alignItems: 'flex-start',
|
|
}}
|
|
>
|
|
<View style={{ flex: 1, gap: 12 }}>
|
|
<BlurView
|
|
intensity={50}
|
|
tint="dark"
|
|
style={{
|
|
alignSelf: 'flex-start',
|
|
borderRadius: 10,
|
|
overflow: 'hidden',
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 10,
|
|
}}
|
|
>
|
|
{/* <BorderGradient> */}
|
|
<Text
|
|
style={{
|
|
fontSize: 20,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
|
}}
|
|
>
|
|
Chansons
|
|
</Text>
|
|
{/* </BorderGradient> */}
|
|
</BlurView>
|
|
{renderSongList()}
|
|
</View>
|
|
<View style={{ flex: 1, gap: 12 }}>
|
|
<BlurView
|
|
intensity={50}
|
|
tint="dark"
|
|
style={{
|
|
alignSelf: 'flex-start',
|
|
borderRadius: 10,
|
|
overflow: 'hidden',
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 10,
|
|
}}
|
|
>
|
|
{/* <BorderGradient> */}
|
|
<Text
|
|
style={{
|
|
fontSize: 20,
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
|
}}
|
|
>
|
|
Top du mois
|
|
</Text>
|
|
{/* </BorderGradient> */}
|
|
</BlurView>
|
|
{renderTopMonthColumns()}
|
|
</View>
|
|
</View>
|
|
</ScrollView>
|
|
)
|
|
|
|
const renderMobileContent = () => (
|
|
<View style={{ flex: 1, gap: 24 }}>
|
|
{renderHero()}
|
|
<View
|
|
style={{
|
|
...Style.containerRow,
|
|
justifyContent: 'space-between',
|
|
width: 'auto',
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{['Chansons', 'Top du mois'].map((item, index) => (
|
|
<BorderGradientButton
|
|
key={index}
|
|
title={item}
|
|
onPress={() => setSelectedCategory(item)}
|
|
tint={selectedCategory === item ? 'light' : 'dark'}
|
|
containerStyle={{ flex: 1 }}
|
|
titleStyle={{
|
|
fontSize: 16,
|
|
fontFamily: FONT_FAMILY.HelveticaNeueBold,
|
|
}}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
{selectedCategory === 'Chansons' && renderSongsMobile()}
|
|
{selectedCategory === 'Top du mois' && renderTopMonthMobile()}
|
|
{/* "Clips" pourra reprendre la même logique si tu le réactives */}
|
|
</View>
|
|
)
|
|
|
|
return (
|
|
<Page
|
|
backgroundImg={isWeb ? background.hitParadeBG2 : background.hitParadeBG}
|
|
headerType="NONE"
|
|
width="100%"
|
|
maxWidth={1200}
|
|
blurIntensity={isWeb ? 0 : 0}
|
|
showCoin={false}
|
|
>
|
|
{!isWeb ? (
|
|
<View
|
|
style={{
|
|
width: '100%',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 12,
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
<MobileCoinBadge style={{ flexShrink: 1 }} />
|
|
<ShareBtn label="Partager" style={{ flexShrink: 0 }} />
|
|
</View>
|
|
) : null}
|
|
<View style={{ gap: 12, flex: 1 }}>
|
|
<Image
|
|
source={icons.musicLandLogo}
|
|
style={{ alignSelf: 'center', height: 150, resizeMode: 'contain' }}
|
|
/>
|
|
|
|
{isWeb && (
|
|
<View
|
|
ref={searchWrapperRef}
|
|
style={{
|
|
position: 'relative',
|
|
alignSelf: 'center',
|
|
width: '65%',
|
|
maxWidth: 520,
|
|
minWidth: 360,
|
|
zIndex: dropdownVisible ? 40 : 1,
|
|
overflow: 'visible',
|
|
}}
|
|
>
|
|
<SearchBar
|
|
textInputProps={{
|
|
value: search,
|
|
onChangeText: handleSearchChange,
|
|
autoFocus: false,
|
|
onFocus: handleSearchFocus,
|
|
}}
|
|
/>
|
|
{shouldShowResults && (
|
|
<View
|
|
style={{
|
|
position: 'absolute',
|
|
top: 60,
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 50,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
gap: 14,
|
|
borderRadius: 18,
|
|
padding: 16,
|
|
backgroundColor: Palette.ultraLightWhite,
|
|
...Style.defaultBorder,
|
|
width: '100%',
|
|
zIndex: 50,
|
|
elevation: 12,
|
|
}}
|
|
>
|
|
<ResearchHeader
|
|
onPress={toggleSearchSelected}
|
|
selected={searchSelected}
|
|
showSearchBar={false}
|
|
/>
|
|
<SearchResultsList
|
|
selected={searchSelected}
|
|
musics={musicResults}
|
|
musicsLoading={musicsLoading}
|
|
playbacks={playbackResults}
|
|
playbacksLoading={playbacksLoading}
|
|
users={userResults}
|
|
usersLoading={usersLoading}
|
|
onResultSelected={closeDropdown}
|
|
style={{ flex: 0, maxHeight: 420 }}
|
|
/>
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<View style={{ flex: 1, position: 'relative' }}>
|
|
{shouldBlurContent && (
|
|
<BlurView
|
|
intensity={35}
|
|
tint="dark"
|
|
style={[
|
|
StyleSheet.absoluteFillObject,
|
|
{
|
|
zIndex: 10,
|
|
borderRadius: 18,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.25)',
|
|
overflow: 'hidden',
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
|
|
{isWeb ? renderWebContent() : renderMobileContent()}
|
|
</View>
|
|
</View>
|
|
</Page>
|
|
)
|
|
}
|
|
|
|
export default HitParade
|