diff --git a/src/assets/UI/libraryBgWeb.png b/src/assets/UI/libraryBgWeb.png new file mode 100644 index 0000000..e679c67 Binary files /dev/null and b/src/assets/UI/libraryBgWeb.png differ diff --git a/src/assets/UI/profileBgWeb.png b/src/assets/UI/profileBgWeb.png new file mode 100644 index 0000000..f31fc59 Binary files /dev/null and b/src/assets/UI/profileBgWeb.png differ diff --git a/src/assets/index.js b/src/assets/index.js index eba13fb..bebb459 100644 --- a/src/assets/index.js +++ b/src/assets/index.js @@ -218,8 +218,10 @@ export const background = { playbackBG, playbackBG2, libraryBG, + libraryBgWeb: require("./UI/libraryBgWeb.png"), libraryBG2, profileBG, + profileBgWeb: require("./UI/profileBgWeb.png"), hitParadeBG, homeBG, homeBGWeb: require("./UI/homeBGWeb.png"), diff --git a/src/components/SearchBar.js b/src/components/SearchBar.js index 0868a9e..dffb099 100644 --- a/src/components/SearchBar.js +++ b/src/components/SearchBar.js @@ -1,4 +1,4 @@ -import { View, Text, TextInput, Image, Platform } from "react-native"; +import { View, TextInput, Image } from "react-native"; import React from "react"; import { BlurView } from "expo-blur"; import { icons } from "../assets"; @@ -20,9 +20,6 @@ const SearchBar = ({ gap: 10, ...Style.containerRow, }} - // experimentalBlurMethod={ - // Platform.OS !== "ios" ? "dimezisBlurView" : "none" - // } > null, + onError = null, }) { const [loading, setLoading] = useState(true); const [endReached, setEndReached] = useState(false); const [data, setData] = useState(initialState); const [lastVisible, setLastVisible] = useState(null); + const [error, setError] = useState(null); useEffect(() => { if (condition) { + setError(null); if (listener && !usePagination) { const subData = getListenerData(); return () => subData?.(); @@ -57,12 +60,14 @@ export default function useDataFromRef({ } setEndReached(false); setLastVisible(null); + setError(null); setData(initialState); }; const getData = async ({ paginate = false } = {}) => { try { setLoading(true); + setError(null); let newData = simpleRef ? null : []; let initialDoc = null; @@ -115,6 +120,10 @@ export default function useDataFromRef({ } else { console.log(e); } + setError(e); + if (onError) { + onError(e); + } await updateData([]); } finally { setLoading(false); @@ -124,6 +133,7 @@ export default function useDataFromRef({ const getListenerData = () => { return ref?.onSnapshot( async (dataSnap) => { + setError(null); let newData; if (simpleRef) { @@ -145,6 +155,10 @@ export default function useDataFromRef({ } else { console.log(e); } + setError(e); + if (onError) { + onError(e); + } await updateData([]); setLoading(false); }, @@ -160,5 +174,5 @@ export default function useDataFromRef({ } }; - return { data, setData, loading, loadMore }; + return { data, setData, loading, loadMore, hasMore: !endReached, error }; } diff --git a/src/hooks/useDebounced.js b/src/hooks/useDebounced.js new file mode 100644 index 0000000..c10657c --- /dev/null +++ b/src/hooks/useDebounced.js @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export default function useDebounced(value, delay = 250) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useSearch.js b/src/hooks/useSearch.js new file mode 100644 index 0000000..6767356 --- /dev/null +++ b/src/hooks/useSearch.js @@ -0,0 +1,232 @@ +import { useEffect, useMemo, useState } from "react"; +import { useGlobal } from "reactn"; +import { usersRef, projectsRef } from "../config/firebase"; +import useDataFromRef from "./useDataFromRef"; + +const batchSizes = { + users: 5, + projects: 6, +}; + +const useSearch = () => { + const [selected, setSelected] = useState(null); + const [search, setSearch] = useState(""); + const queryText = useMemo(() => search.trim(), [search]); + const queryLower = useMemo( + () => (search || "").toLowerCase().trim(), + [search], + ); + const [currentUID] = useGlobal("currentUID"); + const [autoLoads, setAutoLoads] = useState(0); + + const usersQueryRef = useMemo(() => { + try { + const lower = queryLower; + if (lower) { + return usersRef + .orderBy("userNameLower") + .startAt(lower) + .endAt(lower + "\uf8ff"); + } + return usersRef.orderBy("userNameLower"); + } catch (_error) { + return null; + } + }, [queryLower]); + + const projectsQueryRefLower = useMemo(() => { + try { + const lower = queryText ? queryText.toLowerCase() : ""; + if (lower) { + return projectsRef + .orderBy("titleLower") + .startAt(lower) + .endAt(lower + "\uf8ff"); + } + return null; + } catch (_error) { + return null; + } + }, [queryText]); + + const projectsQueryRefCase = useMemo(() => { + try { + const raw = queryText || ""; + if (raw) { + return projectsRef + .orderBy("title") + .startAt(raw) + .endAt(raw + "\uf8ff"); + } + return null; + } catch (_error) { + return null; + } + }, [queryText]); + + const { + data: userResults = [], + loading: usersLoading, + loadMore: loadMoreUsers, + } = useDataFromRef({ + ref: usersQueryRef, + simpleRef: false, + listener: false, + condition: !!usersQueryRef, + refreshArray: [queryLower], + usePagination: true, + batchSize: batchSizes.users, + }); + + const filteredUsers = useMemo(() => { + const arr = Array.isArray(userResults) ? userResults : []; + const matchingUsers = queryLower + ? arr.filter((user) => { + const nameLower = ( + user?.userNameLower || + user?.userName || + "" + ).toLowerCase(); + return nameLower.includes(queryLower); + }) + : arr; + const seen = new Set(); + const unique = []; + for (const user of matchingUsers) { + const id = user?.id; + if (!id || seen.has(id)) continue; + if (id === currentUID || user?.userID === currentUID) continue; + seen.add(id); + unique.push(user); + } + return unique; + }, [userResults, queryLower, currentUID]); + + const { + data: projectResultsLower = [], + loading: projectsLoadingLower, + loadMore: loadMoreProjectsLower, + } = useDataFromRef({ + ref: projectsQueryRefLower, + simpleRef: false, + listener: false, + condition: !!projectsQueryRefLower, + refreshArray: [queryText], + usePagination: true, + batchSize: batchSizes.projects, + }); + + const { + data: projectResultsCase = [], + loading: projectsLoadingCase, + loadMore: loadMoreProjectsCase, + } = useDataFromRef({ + ref: projectsQueryRefCase, + simpleRef: false, + listener: false, + condition: !!projectsQueryRefCase, + refreshArray: [queryText], + usePagination: true, + batchSize: batchSizes.projects, + }); + + const { + data: projectResultsDefault = [], + loading: projectsLoadingDefault, + loadMore: loadMoreProjectsDefault, + } = useDataFromRef({ + ref: !queryText ? projectsRef.orderBy("updatedAt", "desc") : null, + simpleRef: false, + listener: false, + condition: !queryText, + refreshArray: [queryText], + usePagination: true, + batchSize: batchSizes.projects, + }); + + const projectsLoading = queryText + ? projectsLoadingLower || projectsLoadingCase + : projectsLoadingDefault; + + const loadMoreProjects = () => { + if (queryText) { + loadMoreProjectsLower?.(); + loadMoreProjectsCase?.(); + } else { + loadMoreProjectsDefault?.(); + } + }; + + const filteredProjects = useMemo(() => { + const lowerMatches = Array.isArray(projectResultsLower) + ? projectResultsLower + : []; + const caseMatches = Array.isArray(projectResultsCase) + ? projectResultsCase + : []; + const defaultProjects = Array.isArray(projectResultsDefault) + ? projectResultsDefault + : []; + + const arr = queryText ? [...lowerMatches, ...caseMatches] : defaultProjects; + const seen = new Set(); + const unique = []; + for (const project of arr) { + const id = project?.id; + if (!id || seen.has(id)) continue; + seen.add(id); + unique.push(project); + } + return unique; + }, [ + queryText, + projectResultsLower, + projectResultsCase, + projectResultsDefault, + ]); + + useEffect(() => { + setAutoLoads(0); + }, [queryText]); + + useEffect(() => { + if ( + filteredUsers.length < batchSizes.users && + usersQueryRef && + !usersLoading + ) { + if (autoLoads < 3) { + setAutoLoads((previous) => previous + 1); + loadMoreUsers?.(); + } + } + }, [ + filteredUsers.length, + usersLoading, + usersQueryRef, + autoLoads, + loadMoreUsers, + ]); + + const toggleSelected = (item) => { + setSelected((prev) => (prev === item ? null : item)); + }; + + return { + search, + setSearch, + queryText, + queryLower, + selected, + setSelected, + toggleSelected, + filteredUsers, + usersLoading, + loadMoreUsers, + filteredProjects, + projectsLoading, + loadMoreProjects, + }; +}; + +export default useSearch; diff --git a/src/screens/HitParade/HitParade.js b/src/screens/HitParade/HitParade.js index 0761d2d..54f85f0 100644 --- a/src/screens/HitParade/HitParade.js +++ b/src/screens/HitParade/HitParade.js @@ -183,7 +183,10 @@ const HitParade = () => { const isWeb = Platform.OS === "web"; return ( - + diff --git a/src/screens/Library/Library.web.js b/src/screens/Library/Library.web.js index 86469c4..6ba43b3 100644 --- a/src/screens/Library/Library.web.js +++ b/src/screens/Library/Library.web.js @@ -1,18 +1,10 @@ -import React from "react"; -import { - Image, - Platform, - Pressable, - ScrollView, - Text, - View, -} from "react-native"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { Image, Platform, ScrollView, Text, View } from "react-native"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { background, icons } from "../../assets"; import SearchBar from "../../components/SearchBar"; +import useSearch from "../../hooks/useSearch"; import Page from "../../layouts/Page"; -import { Routes } from "../../navigation"; -import { navigate } from "../../navigation/NavigationService"; import { useUser } from "../../providers/UserDataProvider"; import { Palette, Style } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; @@ -21,6 +13,8 @@ import LikedMusic from "./components/LikedMusic"; import LikedPlayback from "./components/LikedPlayback"; import MyMusic from "./components/MyMusic"; import MyPlaylist from "./components/MyPlaylist"; +import ResearchHeader from "./components/ResearchHeader"; +import SearchResultsList from "./components/SearchResultsList"; const Library = () => { const { userProjects = [], @@ -51,18 +45,126 @@ const Library = () => { hasMyClips || hasLikedClips; + const { + search, + setSearch, + selected, + setSelected, + toggleSelected, + filteredProjects, + projectsLoading, + loadMoreProjects, + filteredUsers, + usersLoading, + loadMoreUsers, + } = useSearch(); + + const [dropdownVisible, setDropdownVisible] = useState(false); + const searchWrapperRef = useRef(null); + + const closeDropdown = useCallback(() => { + setDropdownVisible(false); + setSelected(null); + setSearch(""); + }, [setSelected, setSearch]); + + const handleFocus = () => { + setDropdownVisible(true); + }; + + const shouldShowResults = dropdownVisible; + + const handleChangeText = (value) => { + setSearch(value); + setDropdownVisible(true); + }; + + useEffect(() => { + if (!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); + }; + }, [dropdownVisible, closeDropdown]); + return ( - + - navigate(Routes.Research)}> + navigate(Routes.Research), + value: search, + onChangeText: handleChangeText, + autoFocus: false, + onFocus: handleFocus, }} /> - + {shouldShowResults && ( + + + + + + + )} + { - const [selected, setSelected] = useState(null); - const [search, setSearch] = useState(""); - const queryText = useMemo(() => search.trim(), [search]); - const queryLower = useMemo( - () => (search || "").toLowerCase().trim(), - [search], - ); - const [currentUID] = useGlobal("currentUID"); - const [autoLoads, setAutoLoads] = useState(0); - - const usersQueryRef = useMemo(() => { - try { - const lower = queryLower; - if (lower) { - return usersRef - .orderBy("userNameLower") - .startAt(lower) - .endAt(lower + "\uf8ff"); - } - return usersRef.orderBy("userNameLower"); - } catch (e) { - return null; - } - }, [queryLower]); - - const projectsQueryRefLower = useMemo(() => { - try { - const lower = queryText ? queryText.toLowerCase() : ""; - if (lower) { - return projectsRef - .orderBy("titleLower") - .startAt(lower) - .endAt(lower + "\uf8ff"); - } - return null; - } catch (e) { - return null; - } - }, [queryText]); - - const projectsQueryRefCase = useMemo(() => { - try { - const raw = queryText || ""; - if (raw) { - return projectsRef - .orderBy("title") - .startAt(raw) - .endAt(raw + "\uf8ff"); - } - return null; - } catch (e) { - return null; - } - }, [queryText]); - const { - data: userResults = [], - loading: usersLoading, - loadMore: loadMoreUsers, - } = useDataFromRef({ - ref: usersQueryRef, - simpleRef: false, - listener: false, - condition: !!usersQueryRef, - refreshArray: [queryLower], - usePagination: true, - batchSize: 5, - }); - const filteredUsers = useMemo(() => { - let arr = Array.isArray(userResults) ? userResults : []; - // Client-side safety filter to guarantee match on pseudo - if (queryLower) { - arr = arr.filter((u) => { - const nameLower = (u?.userNameLower || u?.userName || "").toLowerCase(); - return nameLower.includes(queryLower); - }); - } - const seen = new Set(); - const unique = []; - for (const u of arr) { - const id = u?.id; - if (!id || seen.has(id)) continue; - if (id === currentUID || u?.userID === currentUID) continue; - seen.add(id); - unique.push(u); - } - return unique; - }, [JSON.stringify(userResults), queryLower, currentUID]); - - const { - data: projectResultsLower = [], - loading: projectsLoadingLower, - loadMore: loadMoreProjectsLower, - } = useDataFromRef({ - ref: projectsQueryRefLower, - simpleRef: false, - listener: false, - condition: !!projectsQueryRefLower, - refreshArray: [queryText], - usePagination: true, - batchSize: 6, - }); - const { - data: projectResultsCase = [], - loading: projectsLoadingCase, - loadMore: loadMoreProjectsCase, - } = useDataFromRef({ - ref: projectsQueryRefCase, - simpleRef: false, - listener: false, - condition: !!projectsQueryRefCase, - refreshArray: [queryText], - usePagination: true, - batchSize: 6, - }); - const { - data: projectResultsDefault = [], - loading: projectsLoadingDefault, - loadMore: loadMoreProjectsDefault, - } = useDataFromRef({ - ref: !queryText ? projectsRef.orderBy("updatedAt", "desc") : null, - simpleRef: false, - listener: false, - condition: !queryText, - refreshArray: [queryText], - usePagination: true, - batchSize: 6, - }); - - const projectsLoading = queryText - ? projectsLoadingLower || projectsLoadingCase - : projectsLoadingDefault; - const loadMoreProjects = () => { - if (queryText) { - loadMoreProjectsLower?.(); - loadMoreProjectsCase?.(); - } else { - loadMoreProjectsDefault?.(); - } - }; - const filteredProjects = useMemo(() => { - const arr = queryText - ? [ - ...(Array.isArray(projectResultsLower) ? projectResultsLower : []), - ...(Array.isArray(projectResultsCase) ? projectResultsCase : []), - ] - : Array.isArray(projectResultsDefault) - ? projectResultsDefault - : []; - const seen = new Set(); - const unique = []; - for (const p of arr) { - const id = p?.id; - if (!id || seen.has(id)) continue; - seen.add(id); - unique.push(p); - } - return unique; - }, [ - queryText, - JSON.stringify(projectResultsLower), - JSON.stringify(projectResultsCase), - JSON.stringify(projectResultsDefault), - ]); - - useEffect(() => { - setAutoLoads(0); - }, [queryText]); - - useEffect(() => { - if (filteredUsers.length < 5 && usersQueryRef && !usersLoading) { - if (autoLoads < 3) { - setAutoLoads((n) => n + 1); - loadMoreUsers(); - } - } - }, [filteredUsers.length, usersLoading, usersQueryRef, autoLoads]); - const [menuPosition, setMenuPosition] = useState(null); - const [showMenu, setShowMenu] = useState(false); - const [selectedProjectId, setSelectedProjectId] = useState(null); - - const onPressMenu = (item) => { - if (selected === item) { - setSelected(null); - } else { - setSelected(item); - } - }; - - const EmptyText = ({ text }) => ( - - - {text} - - - ); + search, + setSearch, + selected, + toggleSelected, + filteredProjects, + projectsLoading, + loadMoreProjects, + filteredUsers, + usersLoading, + loadMoreUsers, + } = useSearch(); return ( { > onPressMenu(item)} + onPress={toggleSelected} selected={selected} searchValue={search} onChangeSearch={setSearch} /> - - - {((!selected && (filteredProjects.length > 0 || projectsLoading)) || - selected === "Musiques") && ( - - - {filteredProjects.length > 0 ? ( - - {Array.isArray(filteredProjects) && - filteredProjects.slice(0, 6).map((p) => ( - - navigate(Routes.MusicDetails, { projectId: p.id }) - } - onPressMore={(posTop) => { - setSelectedProjectId(p.id); - setMenuPosition(posTop); - setShowMenu( - (prev) => - !prev || - posTop?.top !== (menuPosition?.top ?? null), - ); - }} - /> - ))} - {(filteredProjects.length >= 6 || projectsLoading) && ( - - - {projectsLoading ? "Chargement…" : "Charger plus"} - - - )} - - ) : ( - !projectsLoading && ( - - ) - )} - - - )} - {((!selected && (filteredUsers.length > 0 || usersLoading)) || - selected === "Profils") && ( - - - {filteredUsers.length > 0 ? ( - - {Array.isArray(filteredUsers) && - filteredUsers.slice(0, 5).map((u) => ( - - navigate(Routes.SingerProfile, { userId: u.id }) - } - > - - - {u?.userName || "Utilisateur"} - - - ))} - {(filteredUsers.length >= 5 || usersLoading) && ( - - - {usersLoading ? "Chargement…" : "Charger plus"} - - - )} - - ) : ( - !usersLoading && - )} - - - )} - {selected === "Playbacks" && ( - - - - - - )} - {selected === "Clips" && ( - - - - - - )} - setShowMenu(false)} - inPlaylist={false} - projectId={selectedProjectId} - /> - - + ); diff --git a/src/screens/Library/components/ResearchHeader.js b/src/screens/Library/components/ResearchHeader.js index 56f0c16..10111e1 100644 --- a/src/screens/Library/components/ResearchHeader.js +++ b/src/screens/Library/components/ResearchHeader.js @@ -11,23 +11,27 @@ const ResearchHeader = ({ selected, searchValue = "", onChangeSearch = () => {}, + showSearchBar = true, + autoFocusSearch = true, }) => { return ( - + {showSearchBar && ( + + )} - {["Playbacks", "Clips", "Musiques", "Profils"].map((item, index) => ( + {["Musiques", "Playbacks", "Profils"].map((item, index) => ( onPress(item)}> ( + + + {text} + + +); + +const SearchResultsList = ({ + selected, + filteredProjects, + projectsLoading, + loadMoreProjects, + filteredUsers, + usersLoading, + loadMoreUsers, + onResultSelected, + contentContainerStyle, + style, +}) => { + const [menuPosition, setMenuPosition] = useState(null); + const [showMenu, setShowMenu] = useState(false); + const [selectedProjectId, setSelectedProjectId] = useState(null); + + const handleProjectPress = (projectId) => { + navigate(Routes.MusicDetails, { projectId }); + onResultSelected?.(); + }; + + const handleProfilePress = (userId) => { + navigate(Routes.SingerProfile, { userId }); + onResultSelected?.(); + }; + + return ( + + + {((!selected && (filteredProjects.length > 0 || projectsLoading)) || + selected === "Musiques") && ( + + + {filteredProjects.length > 0 && ( + + {Array.isArray(filteredProjects) && + filteredProjects.slice(0, 6).map((project) => ( + handleProjectPress(project.id)} + onPressMore={(positionTop) => { + setSelectedProjectId(project.id); + setMenuPosition(positionTop); + setShowMenu( + (previous) => + !previous || + positionTop?.top !== (menuPosition?.top ?? null), + ); + }} + /> + ))} + {(filteredProjects.length >= 6 || projectsLoading) && ( + + + {projectsLoading ? "Chargement…" : "Charger plus"} + + + )} + + )} + {filteredProjects.length === 0 && !projectsLoading && ( + + )} + + + )} + {((!selected && (filteredUsers.length > 0 || usersLoading)) || + selected === "Profils") && ( + + + {filteredUsers.length > 0 && ( + + {Array.isArray(filteredUsers) && + filteredUsers.slice(0, 5).map((user) => ( + handleProfilePress(user.id)} + > + + + {user?.userName || "Utilisateur"} + + + ))} + {(filteredUsers.length >= 5 || usersLoading) && ( + + + {usersLoading ? "Chargement…" : "Charger plus"} + + + )} + + )} + {filteredUsers.length === 0 && !usersLoading && ( + + )} + + + )} + {selected === "Playbacks" && ( + + + + + + )} + {selected === "Clips" && ( + + + + + + )} + + setShowMenu(false)} + inPlaylist={false} + projectId={selectedProjectId} + /> + + ); +}; + +export default SearchResultsList; diff --git a/src/screens/Profile/Profile.js b/src/screens/Profile/Profile.js index ddf2d3e..35d74ac 100644 --- a/src/screens/Profile/Profile.js +++ b/src/screens/Profile/Profile.js @@ -65,7 +65,7 @@ const Profile = () => { setFollowers( Array.isArray(currentUserData?.followedBy) ? currentUserData.followedBy.length - : 0 + : 0, ); return; } @@ -139,7 +139,8 @@ const Profile = () => { setSelectedProjectId(item.id); setMenuPosition(posTop); setShowMenu( - (prev) => !prev || posTop?.top !== (menuPosition?.top ?? null) + (prev) => + !prev || posTop?.top !== (menuPosition?.top ?? null), ); }} /> @@ -231,7 +232,7 @@ const Profile = () => { return ( + s + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim();