diff --git a/functions/package-lock.json b/functions/package-lock.json index 33c787a..0e8567f 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -2734,9 +2734,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", diff --git a/src/assets/UI/homeBGWeb.png b/src/assets/UI/homeBGWeb.png new file mode 100644 index 0000000..569c477 Binary files /dev/null and b/src/assets/UI/homeBGWeb.png differ diff --git a/src/assets/index.js b/src/assets/index.js index 119c57d..b56e3b8 100644 --- a/src/assets/index.js +++ b/src/assets/index.js @@ -111,6 +111,7 @@ import placeholder2 from "./UI/placeholder2.jpg"; import placeholder3 from "./UI/placeholder3.png"; import placeholder4 from "./UI/placeholder4.jpg"; import profile from "./UI/profile.jpg"; +import { Platform } from "react-native"; export const tabs = { home, @@ -221,6 +222,7 @@ export const background = { profileBG, hitParadeBG, homeBG, + homeBGWeb: require("./UI/homeBGWeb.png"), }; export const ai = { @@ -231,7 +233,10 @@ export const ai = { }; export const videos = { - test: require("./video/testVideo.mp4"), + test: + Platform.OS === "web" + ? require("./video/testVideoWeb.mp4") + : require("./video/testVideo.mp4"), }; export const img = { diff --git a/src/assets/video/testVideoWeb.mp4 b/src/assets/video/testVideoWeb.mp4 new file mode 100644 index 0000000..9c913ee Binary files /dev/null and b/src/assets/video/testVideoWeb.mp4 differ diff --git a/src/components/FullscreenIntroVideo.js b/src/components/FullscreenIntroVideo.native.js similarity index 100% rename from src/components/FullscreenIntroVideo.js rename to src/components/FullscreenIntroVideo.native.js diff --git a/src/components/FullscreenIntroVideo.web.js b/src/components/FullscreenIntroVideo.web.js new file mode 100644 index 0000000..ba7417c --- /dev/null +++ b/src/components/FullscreenIntroVideo.web.js @@ -0,0 +1,172 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Pressable, Text, View } from "react-native"; +import { Asset } from "expo-asset"; + +import { videos } from "../assets"; + +const overlayStyle = { + position: "fixed", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: "black", + justifyContent: "center", + alignItems: "center", + zIndex: 9999, +}; + +const videoStyle = { + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + objectFit: "cover", +}; + +const closeButtonStyle = { + position: "absolute", + top: 50, + right: 20, + backgroundColor: "#00000080", + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 20, + borderWidth: 1, + borderColor: "#FFFFFF55", + cursor: "pointer", +}; + +const closeTextStyle = { + color: "#FFF", + fontSize: 14, +}; + +const resolveModuleUri = async (module) => { + const asset = Asset.fromModule(module); + + if (!asset.localUri && !asset.uri) { + await asset.downloadAsync(); + } + + return asset.localUri ?? asset.uri ?? null; +}; + +const FullscreenIntroVideo = ({ url, visible = true, onClose }) => { + const videoRef = useRef(null); + const [uri, setUri] = useState(null); + const [muted, setMuted] = useState(false); + + const resolvedSource = useMemo(() => url ?? videos.test, [url]); + + useEffect(() => { + let isMounted = true; + + const assignUri = (nextUri) => { + if (isMounted) { + setMuted(false); + setUri(nextUri); + } + }; + + if (typeof resolvedSource === "string") { + assignUri(resolvedSource); + return () => { + isMounted = false; + }; + } + + const load = async () => { + try { + const nextUri = await resolveModuleUri(resolvedSource); + assignUri(nextUri); + } catch { + if (isMounted) { + setUri(null); + } + } + }; + + load(); + + return () => { + isMounted = false; + }; + }, [resolvedSource]); + + useEffect(() => { + if (!visible) { + return undefined; + } + + const video = videoRef.current; + + if (!video || !uri) { + return undefined; + } + + const handleEnded = () => onClose?.(); + video.addEventListener("ended", handleEnded); + + video.currentTime = 0; + const attemptPlay = () => { + const result = video.play(); + + if (result?.catch) { + result.catch((error) => { + if (error?.name === "NotAllowedError" && !muted) { + setMuted(true); + } + }); + } + }; + + attemptPlay(); + + return () => { + video.pause(); + video.removeEventListener("ended", handleEnded); + }; + }, [muted, onClose, uri, visible]); + + useEffect(() => { + const video = videoRef.current; + + if (!video || !muted) { + return; + } + + const result = video.play(); + + if (result?.catch) { + result.catch(() => {}); + } + }, [muted]); + + if (!visible) { + return null; + } + + return ( + + {uri ? ( + + ); +}; + +export default FullscreenIntroVideo; diff --git a/src/components/GradientButton.js b/src/components/GradientButton.js index dbd5545..aefc304 100644 --- a/src/components/GradientButton.js +++ b/src/components/GradientButton.js @@ -1,4 +1,4 @@ -import { View, Text, Pressable, Image } from "react-native"; +import { View, Text, Pressable, Image, FlatList } from "react-native"; import React from "react"; import { LinearGradient } from "./LinearGradient/LinearGradient"; import { Palette, Style } from "../styles"; @@ -15,7 +15,11 @@ const GradientButton = ({ disabled = false, }) => { return ( - + { const [isFocused, setIsFocused] = useState(false); const [showPassword, setShowPassword] = useState(false); - const [showCountryPicker, setShowCountryPicker] = useState(false); - const isDefaultLayout = layout === 'default'; + const isDefaultLayout = layout === "default"; const mainColor = - theme === 'radioactiv' ? Palette.radioactivGreen : Palette.primary; + theme === "radioactiv" ? Palette.radioactivGreen : Palette.primary; const isRoundedRectangle = - ['textarea', 'coinAmount'].includes(type) || layout === 'default'; + ["textarea", "coinAmount"].includes(type) || layout === "default"; - const inputAccessoryViewID = 'uniqueID'; + const inputAccessoryViewID = "uniqueID"; const { places } = usePlaceApi({ - query: type === 'autoCompleteAddress' ? value : '', + query: type === "autoCompleteAddress" ? value : "", apiKey: GOOGLE_API_KEY, // Your Google API Key - queryFields: 'formatted_address,geometry,name,address_components', - queryCountries: ['fr'], - language: 'fr-FR', + queryFields: "formatted_address,geometry,name,address_components", + queryCountries: ["fr"], + language: "fr-FR", minChars: 2, }); @@ -72,23 +71,25 @@ const Input = ({ <> + }} + > {label?.length > 0 && ( + }} + > {label} )} - {type === 'search' ? ( + width: "100%", + }} + > + {type === "search" ? ( ) : null} - {/* {type === "countryPicker" ? ( - { - console.log(country); - setValue(country.cca2); - }} - visible={showCountryPicker} - theme={{ - ...DARK_THEME, - primaryColor: Palette.primary, - backgroundColor: Palette.darkPurple, - ...fontTypeList.default, - }} - closeButtonImage={icons.arrowRight} - closeButtonStyle={[Style.mirrorHorizontal]} - withEmoji={true} - withFilter={true} - withAlphaFilter={false} - withCountryNameButton={true} - containerButtonStyle={{ padding: 0, margin: 0 }} - translation="fra" - placeholder={"Sélectionnez un pays"} - countryName={value} - preferredCountries={["FR", "BE", "CH", "LU", "CA", "US", "GB"]} - filterProps={{ - placeholder: "Rechercher un pays", - style: { - ...Fonts({}), - }, - }} - /> - ) : */} - {type !== 'countryPicker' && ( + {type !== "countryPicker" && ( setIsFocused(true)} onBlur={() => setIsFocused(false)} style={{ - width: '100%', + width: "100%", ...(isRoundedRectangle ? { - height: '100%', + height: "100%", flex: 1, - textAlignVertical: type === 'textarea' ? 'top' : 'center', + textAlignVertical: type === "textarea" ? "top" : "center", } - : { textAlign: 'center' }), + : { textAlign: "center" }), fontSize: 14, color: Palette.white, fontFamily: FONT_FAMILY.InterRegular, ...(isWeb ? { - lineHeight: 'auto', + lineHeight: "auto", } : {}), ...textInputStyle, }} - {...(type === 'password' + {...(type === "password" ? { secureTextEntry: !showPassword, - autoCapitalize: 'none', - autoCompleteType: 'password', - textContentType: 'password', + autoCapitalize: "none", + autoCompleteType: "password", + textContentType: "password", } : {})} - {...(type === 'email' + {...(type === "email" ? { - keyboardType: 'email-address', - autoCapitalize: 'none', - autoCompleteType: 'email', - textContentType: 'emailAddress', + keyboardType: "email-address", + autoCapitalize: "none", + autoCompleteType: "email", + textContentType: "emailAddress", } : {})} - keyboardType={isNumeric ? 'numeric' : 'default'} - keyboardAppearance='dark' + keyboardType={isNumeric ? "numeric" : "default"} + keyboardAppearance="dark" inputAccessoryViewID={inputAccessoryViewID} {...textInputProps} /> )} - {type === 'password' ? ( + {type === "password" ? ( setShowPassword(!showPassword)}> - ) : type === 'coinAmount' ? ( + ) : type === "coinAmount" ? ( ) : null} - {type === 'autoCompleteAddress' && + {type === "autoCompleteAddress" && places?.[0]?.description && places?.[0]?.description !== value && places.map((place, index) => ( @@ -247,32 +216,36 @@ const Input = ({ style={{ ...Style.containerSpaceBetween, marginBottom: index !== places.length - 1 ? gutters / 2 : 0, - }}> + }} + > - {place?.description || '-'} + }} + > + {place?.description || "-"} ))} - {(type === 'textarea' || isNumeric) && !isWeb && ( + {(type === "textarea" || isNumeric) && !isWeb && ( Keyboard.dismiss()} style={{ ...Style.containerRow, - justifyContent: 'flex-end', + justifyContent: "flex-end", backgroundColor: Palette.transparentPrimary, padding: gutters, paddingVertical: gutters / 2, - }}> + }} + > + }} + > Fermer diff --git a/src/components/ItemContainer/ItemContainer.js b/src/components/ItemContainer/ItemContainer.js index 15b77c0..4a1066c 100644 --- a/src/components/ItemContainer/ItemContainer.js +++ b/src/components/ItemContainer/ItemContainer.js @@ -1,10 +1,9 @@ -import { View, Text, StyleSheet, Platform } from 'react-native'; -import React from 'react'; -import BorderGradient from '../BorderGradient/BorderGradient'; -import { BlurView } from 'expo-blur'; -import { responsiveHeight } from 'react-native-responsive-dimensions'; -import { useKeyboard } from '@react-native-community/hooks'; -import { isWeb } from '../../hooks/useLayoutType'; +import { View, StyleSheet, Platform } from "react-native"; +import React from "react"; +import BorderGradient from "../BorderGradient/BorderGradient"; +import { BlurView } from "expo-blur"; +import { responsiveHeight } from "react-native-responsive-dimensions"; +import { useKeyboard } from "@react-native-community/hooks"; const ItemContainer = ({ height = responsiveHeight(40), @@ -18,7 +17,7 @@ const ItemContainer = ({ return ( + }} + > {children} @@ -55,7 +56,7 @@ const styles = StyleSheet.create({ borderGradientStyle: { borderWidth: 1, borderRadius: 20, - shadowColor: '#000', + shadowColor: "#000", shadowOffset: { width: 0, height: 2, @@ -67,7 +68,7 @@ const styles = StyleSheet.create({ blurContainer: { flex: 1, borderRadius: 20, - overflow: 'hidden', + overflow: "hidden", zIndex: 1, }, }); diff --git a/src/components/ItemContainer/ItemContainer.web.js b/src/components/ItemContainer/ItemContainer.web.js index ac29d31..e818e0c 100644 --- a/src/components/ItemContainer/ItemContainer.web.js +++ b/src/components/ItemContainer/ItemContainer.web.js @@ -1,4 +1,4 @@ -import { View, Text, StyleSheet, Platform } from "react-native"; +import { View, StyleSheet } from "react-native"; import React, { useState } from "react"; import BorderGradient from "../BorderGradient/BorderGradient"; import { responsiveHeight } from "react-native-responsive-dimensions"; @@ -46,7 +46,7 @@ const ItemContainer = ({ }} > {children} diff --git a/src/components/ProjectDropDown/ProjectDropDown.js b/src/components/ProjectDropDown/ProjectDropDown.js new file mode 100644 index 0000000..a8c4f3a --- /dev/null +++ b/src/components/ProjectDropDown/ProjectDropDown.js @@ -0,0 +1,375 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + FlatList, + Image, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { BlurView } from "expo-blur"; +import { icons, img } from "../../assets"; +import { Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; +import GradientButton from "../GradientButton"; + +const BUTTON_BLUR_INTENSITY = 20; +const ROW_BLUR_INTENSITY = 5; +const ROW_BLUR_SELECTED_INTENSITY = 45; + +const defaultFormatDate = (timestamp) => { + try { + const value = timestamp?.toDate ? timestamp.toDate() : timestamp; + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) { + return ""; + } + const day = date.getDate().toString().padStart(2, "0"); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + return `${day}/${month}`; + } catch (error) { + console.warn("ProjectDropDown: unable to format date", error); + return ""; + } +}; + +const ProjectDropDown = ({ + style, + projects = [], + selectedProject, + onSelectProject, + onModifyProject, + onCreateProject, + formatDate = defaultFormatDate, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [triggerLayout, setTriggerLayout] = useState(null); + + const normalizedProjects = Array.isArray(projects) ? projects : []; + const isDisabled = normalizedProjects.length === 0; + + const currentProject = selectedProject?.id + ? selectedProject + : normalizedProjects[0] || null; + + const dropdownProjects = currentProject + ? normalizedProjects.filter((project) => { + if (!project) return false; + if (currentProject.id && project.id) { + return project.id !== currentProject.id; + } + return project !== currentProject; + }) + : normalizedProjects; + + const toggleDropdown = () => { + if (isDisabled) { + return; + } + setIsOpen((prev) => !prev); + }; + + const closeDropdown = () => setIsOpen(false); + + const handleSelect = (project) => { + if (!project?.id) return; + onSelectProject?.(project); + closeDropdown(); + }; + + const handleModify = (project, topPosition) => { + if (!project?.id) return; + onModifyProject?.(project, topPosition); + }; + + const handleCreate = () => { + onCreateProject?.(); + closeDropdown(); + }; + + const dropdownWidth = useMemo( + () => triggerLayout?.width || 0, + [triggerLayout], + ); + + return ( + + {isOpen ? ( + + ) : null} + + setTriggerLayout(event.nativeEvent.layout)} + > + + + + + {isOpen && ( + + + + + + project?.id || project?.title || `project-${index}` + } + ItemSeparatorComponent={() => } + renderItem={({ item: project }) => ( + + )} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.dropdownListContent} + style={styles.dropdownList} + /> + + + + )} + + + ); +}; + +export default ProjectDropDown; + +const ProjectRow = ({ + project, + formatDate, + isSelected, + onSelect, + onModify, + isTitle = false, + isOpen, +}) => { + const rowRef = useRef(null); + const [layout, setLayout] = useState(null); + const [menuPos, setMenuPos] = useState(0); + + useEffect(() => { + if (!layout) return; + const top = (layout?.y || 0) + 45; + setMenuPos(top); + }, [layout]); + + const onPressMenu = () => { + if (!project?.id) return; + + if (rowRef?.current?.measureInWindow) { + try { + rowRef.current.measureInWindow((x, y) => { + const top = (y || 0) + 45; + setMenuPos(top); + onModify?.(project, top); + }); + return; + } catch (error) { + // Fallback to layout-derived position + } + } + + onModify?.(project, menuPos); + }; + const coverUri = + project?.coverUrl || + project?.coverUri || + (typeof project?.cover === "string" ? project.cover : project?.cover?.uri); + const title = project?.title || "Sans titre"; + const formattedDate = formatDate(project?.updatedAt || project?.createdAt); + + return ( + onSelect(project)} + onLayout={(event) => setLayout(event.nativeEvent.layout)} + style={styles.projectRowPressable} + > + + + + + {title} + + + {formattedDate ? `Modifié le ${formattedDate}` : "MusicLand"} + + + {isTitle ? ( + + ) : ( + { + event.stopPropagation?.(); + onPressMenu(); + }} + > + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + maxWidth: 450, + position: "relative", + }, + dropdownBlur: { + borderRadius: 15, + padding: 8, + position: "relative", + }, + dropdownBlurDisabled: { + opacity: 0.6, + }, + dropdownBlurHidden: { + opacity: 0, + pointerEvents: "none", + }, + triggerWrapper: { + position: "relative", + zIndex: 3, + }, + dropdownOverlayBlur: { + position: "absolute", + top: 0, + left: 0, + borderRadius: 16, + paddingHorizontal: 8, + paddingVertical: 8, + gap: 9, + maxHeight: 400, + zIndex: 4, + elevation: 4, + }, + dropdownOverlayContent: { + maxHeight: 260, + width: "100%", + gap: 12, + }, + dropdownList: { + maxHeight: 220, + width: "100%", + }, + dropdownListContent: { + paddingBottom: 4, + }, + createButtonContainer: { + width: 200, + alignSelf: "center", + }, + chevron: { + width: 18, + height: 18, + tintColor: Palette.white, + transform: [{ rotate: "0deg" }], + }, + chevronOpen: { + transform: [{ rotate: "180deg" }], + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + zIndex: 1, + backgroundColor: "transparent", + }, + projectRowPressable: { + flexDirection: "row", + alignItems: "center", + gap: 5, + width: "100%", + }, + projectInfo: { + flex: 1, + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 18, + paddingVertical: 12, + borderRadius: 10, + }, + projectImage: { + width: 60, + height: 60, + borderRadius: 8, + }, + projectTexts: { + flex: 1, + gap: 4, + }, + projectTitle: { + fontSize: 15, + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + }, + projectSubtitle: { + fontSize: 13, + color: "rgba(255, 255, 255, 0.7)", + fontFamily: FONT_FAMILY.InterRegular, + }, + moreIcon: { + width: 18, + height: 18, + tintColor: Palette.white, + transform: [{ rotate: "90deg" }], + }, +}); diff --git a/src/components/ShareBtnWeb.js b/src/components/ShareBtnWeb.js new file mode 100644 index 0000000..365d1ff --- /dev/null +++ b/src/components/ShareBtnWeb.js @@ -0,0 +1,68 @@ +import React from "react"; +import { Alert, Pressable, Platform, Text } from "react-native"; +import { BlurView } from "expo-blur"; +import { Palette } from "../styles"; +import { FONT_FAMILY } from "../styles/Fonts"; +import { icons } from "../assets"; +import { Image } from "expo-image"; + +export default function ShareBtnWeb({ style }) { + const handleShare = () => { + const shareUrl = + typeof window !== "undefined" ? window.location.href : undefined; + if (typeof navigator !== "undefined" && navigator.share) { + navigator + .share({ + title: "MusicLand", + text: "Découvre mon expérience sur MusicLand", + ...(shareUrl ? { url: shareUrl } : {}), + }) + .catch(() => { + Alert.alert("Partage", "Le partage a été annulé."); + }); + return; + } + Alert.alert("Partage", "Fonctionnalité disponible prochainement."); + }; + + return ( + + + + Partager l'expérience + + + + + ); +} diff --git a/src/layouts/Page.js b/src/layouts/Page.js index 00d29e6..0d94adc 100644 --- a/src/layouts/Page.js +++ b/src/layouts/Page.js @@ -1,19 +1,18 @@ /* eslint-disable react/display-name */ -import { Image, View } from "react-native"; +import { Alert, Image, Pressable, Text, View, Platform } from "react-native"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; import { SafeAreaView } from "react-native-safe-area-context"; -import React, { setGlobal, useEffect, useState } from "reactn"; - +import React, { setGlobal, useEffect } from "reactn"; import { responsiveHeight } from "../actions/responsiveSizes.js"; - -import { gutters } from "../styles"; - +import { gutters, Palette } from "../styles"; import BaseHeader from "../components/BaseHeader"; import NavigateHeader from "../components/NavigateHeader"; - -import { background } from "../assets/index.js"; +import { background, icons } from "../assets"; import { isDesktop, isWeb } from "../hooks/useLayoutType.js"; import { useUserData } from "../providers/UserDataProvider.js"; +import { BlurView } from "expo-blur"; +import { FONT_FAMILY } from "../styles/Fonts"; +import ShareBtnWeb from "../components/ShareBtnWeb"; export default ({ children, @@ -37,21 +36,15 @@ export default ({ backgroundImg = background.writingBG, headerTitleStyle = {}, hideBackButton = false, + shareBtn = false, }) => { const { currentUID } = useUserData(); - const [scrollPosition, setScrollPosition] = useState(0); - const PageContainer = containerType === "SAFE_AREA_VIEW" ? SafeAreaView : View; const ContentContainer = scrollEnabled ? KeyboardAwareScrollView : View; - const handleScroll = (event) => { - const position = event.nativeEvent.contentOffset.y; - setScrollPosition(position); - }; - // On web, push the background image to a global so AppLayout can render // a full-screen background behind the phone frame. We avoid rendering // the per-screen background inside the phone on web to prevent duplication. @@ -104,7 +97,6 @@ export default ({ style={{ flex: 1, ...contentContainerStyle }} {...(scrollEnabled ? { - onScroll: handleScroll, scrollEventThrottle: 80, showsVerticalScrollIndicator: false, contentContainerStyle: { paddingBottom: responsiveHeight(55) }, @@ -117,6 +109,9 @@ export default ({ {bottomStickyContent?.()} + {shareBtn && ( + + )} ); }; diff --git a/src/navigation/HomeStack.js b/src/navigation/HomeStack.js index 71b82cb..cbf05b5 100644 --- a/src/navigation/HomeStack.js +++ b/src/navigation/HomeStack.js @@ -5,8 +5,8 @@ import { createStackNavigator } from "@react-navigation/stack"; import { Palette } from "../styles"; import { Routes } from "./Routes"; -import Home from "../screens/Home"; import CreateSongs from "../screens/CreateSongs"; +import Home from "../screens/Home/Home"; const isWeb = Platform.OS === "web"; @@ -46,7 +46,7 @@ export default function HomeStackScreen() { component={Home} options={{ headerShown: false }} /> - { useEffect(() => { const handleDeepLink = async (event) => { - console.log("event", event); + // console.log("event", event); const { path = "", queryParams } = Linking.parse(event.url); - console.log("path", path); - console.log("queryParams", queryParams); + // console.log("path", path); + // console.log("queryParams", queryParams); const appSchemeURL = `${ appJson.expo.scheme @@ -66,7 +64,7 @@ const UniversalLinkProvider = ({ children }) => { const initDeepLinkHandling = async () => { const initialUrl = await Linking.getInitialURL(); if (initialUrl) { - console.log("Initial URL:", initialUrl); + // console.log("Initial URL:", initialUrl); handleDeepLink({ url: initialUrl }); } @@ -82,14 +80,14 @@ const UniversalLinkProvider = ({ children }) => { useEffect(() => { if (tempTaskData && currentUID && isFullyLoaded) { - console.log("try to navigate to task"); + // console.log("try to navigate to task"); navigateToTask(tempTaskData); setTempTaskData(null); } }, [tempTaskData, currentUID, isFullyLoaded]); const handleParams = (path) => { - console.log("path", path); + // console.log("path", path); cleanURL(); }; @@ -98,7 +96,7 @@ const UniversalLinkProvider = ({ children }) => { if (isWeb) { const url = new URL(window.location); - console.log(url); + // console.log(url); url.search = ""; window.history.replaceState({}, document.title, url.origin.toString()); diff --git a/src/providers/UserDataProvider.js b/src/providers/UserDataProvider.js index b3ac8c2..5ad6957 100644 --- a/src/providers/UserDataProvider.js +++ b/src/providers/UserDataProvider.js @@ -3,7 +3,7 @@ import { useDataFromRef } from "react-native-minuit/src/hooks"; import useMinuit from "react-native-minuit/src/hooks/useMinuit"; import { createContext, useContext, useGlobal } from "reactn"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { checkIfEmailIsValid } from "../actions/signupActions"; import firebase, { arrayRemove, @@ -104,11 +104,49 @@ export default ({ children }) => { refreshArray: [selectedProjectId], }); - const resetSelectedProject = () => { - setSelectedProjectId(null); - setSelectedProject(null); - }; - const selectProject = (projectId) => setSelectedProjectId(projectId || null); + const persistSelectedProjectId = useCallback( + async (projectId) => { + if (!currentUID) return; + try { + await usersRef.doc(currentUID).set( + { + selectedProjectId: projectId || null, + }, + { merge: true }, + ); + } catch (error) { + console.log( + "UserDataProvider: unable to persist selectedProjectId", + error?.message || error, + ); + } + }, + [currentUID], + ); + + const selectProject = useCallback( + (projectId) => { + const safeId = projectId || null; + setSelectedProjectId(safeId); + if (!safeId) { + setSelectedProject(null); + } + + if (currentUID) { + persistSelectedProjectId(safeId).catch((error) => { + console.log( + "UserDataProvider: persist selectedProjectId failed", + error?.message || error, + ); + }); + } + }, + [currentUID, persistSelectedProjectId, setSelectedProject], + ); + + const resetSelectedProject = useCallback(() => { + selectProject(null); + }, [selectProject]); // Ancienne variante de création de projet supprimée pour éviter les doublons. @@ -139,7 +177,7 @@ export default ({ children }) => { updatedAt: firebase.firestore.FieldValue.serverTimestamp(), }; const { id } = await projectsRef.add(payload); - setSelectedProjectId(id); + selectProject(id); return id; } catch (e) { console.log("createNewProject error", e?.message); @@ -294,6 +332,37 @@ export default ({ children }) => { const isSuperAdmin = currentUserRoles.some((role) => role === "SUPERADMIN"); + useEffect(() => { + const remoteSelectedId = currentUserDoc?.selectedProjectId; + if (!remoteSelectedId) { + if (remoteSelectedId === null && selectedProjectId !== null) { + setSelectedProjectId(null); + setSelectedProject(null); + } + return; + } + + if (remoteSelectedId !== selectedProjectId) { + setSelectedProjectId(remoteSelectedId); + } + }, [currentUserDoc?.selectedProjectId, selectedProjectId, setSelectedProject]); + + useEffect(() => { + if (!Array.isArray(userProjects) || userProjects.length === 0) return; + if (selectedProjectId) return; + if (currentUserDoc?.selectedProjectId) return; + + const fallbackProjectId = userProjects[0]?.id; + if (!fallbackProjectId) return; + + selectProject(fallbackProjectId); + }, [ + currentUserDoc?.selectedProjectId, + selectProject, + selectedProjectId, + userProjects, + ]); + return ( { unfollowUser, resetSelectedProject, selectProject, - setSelectedProjectId, + setSelectedProjectId: selectProject, updateProjectData, createNewProject, }} diff --git a/src/screens/Home.js b/src/screens/Home/Home.js similarity index 88% rename from src/screens/Home.js rename to src/screens/Home/Home.js index e771329..a2abc35 100644 --- a/src/screens/Home.js +++ b/src/screens/Home/Home.js @@ -1,26 +1,26 @@ import { View, Text, Image, Platform } from "react-native"; import React, { useMemo, useState } from "react"; -import Page from "../layouts/Page"; -import { background, img } from "../assets"; +import Page from "../../layouts/Page"; +import { background, img } from "../../assets"; import { responsiveHeight } from "react-native-responsive-dimensions"; -import { Palette } from "../styles"; -import { FONT_FAMILY } from "../styles/Fonts"; -import { navigate } from "../navigation/NavigationService"; -import { Routes } from "../navigation"; +import { Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; +import { navigate } from "../../navigation/NavigationService"; +import { Routes } from "../../navigation"; import { FlatList, Alert } from "react-native"; import { BlurView } from "expo-blur"; -import { useUser } from "../providers/UserDataProvider"; -import MusicCard from "./Library/components/MusicCard"; -import GradientButton from "../components/GradientButton"; -import MoreMenu from "../components/MoreMenu"; -import { projectsRef } from "../config/firebase"; +import { useUser } from "../../providers/UserDataProvider"; +import MusicCard from "../Library/components/MusicCard"; +import GradientButton from "../../components/GradientButton"; +import MoreMenu from "../../components/MoreMenu"; +import { projectsRef } from "../../config/firebase"; import { useGlobal } from "reactn"; const Home = () => { const { userProjects = [], resetSelectedProject, selectProject } = useUser(); const projects = useMemo( () => (Array.isArray(userProjects) ? userProjects : []), - [userProjects] + [userProjects], ); const [, setTooltip] = useGlobal("_tooltip"); const [menuTop, setMenuTop] = useState(0); @@ -124,7 +124,7 @@ const Home = () => { }, }, ], - { cancelable: true } + { cancelable: true }, ), }, ]} diff --git a/src/screens/Home/Home.web.js b/src/screens/Home/Home.web.js new file mode 100644 index 0000000..6841bca --- /dev/null +++ b/src/screens/Home/Home.web.js @@ -0,0 +1,333 @@ +import React, { useCallback, useMemo } from "react"; +import { + FlatList, + Image, + StyleSheet, + Text, + View, + useWindowDimensions, +} from "react-native"; +import { BlurView } from "expo-blur"; +import Page from "../../layouts/Page"; +import { background, img } from "../../assets"; +import { Palette } from "../../styles"; +import { FONT_FAMILY } from "../../styles/Fonts"; +import { navigate } from "../../navigation/NavigationService"; +import { Routes } from "../../navigation"; +import { useUser } from "../../providers/UserDataProvider"; +import ProjectDropDown from "../../components/ProjectDropDown/ProjectDropDown"; + +const ITEM_SPACING = 24; + +const Home = () => { + const { + userProjects = [], + resetSelectedProject, + selectProject, + selectedProject, + selectedProjectId, + } = useUser(); + + const projects = useMemo( + () => (Array.isArray(userProjects) ? userProjects : []), + [userProjects], + ); + + const { width: windowWidth } = useWindowDimensions(); + + const carouselItems = useMemo( + () => [ + { + id: "project-vision", + title: "Composez sans limites", + description: + "Créez instantanément des maquettes professionnelles et explorez de nouveaux genres.", + image: img.placeholder, + }, + { + id: "project-community", + title: "Collaborez en équipe", + description: + "Partagez vos projets, échangez des idées et co-créez en temps réel.", + image: img.placeholder2, + }, + { + id: "project-ai", + title: "Optimisé par l'IA", + description: + "Accédez à des suggestions intelligentes pour les paroles, arrangements et mixages.", + image: img.placeholder3, + }, + { + id: "project-stage", + title: "Prêt pour la scène", + description: + "Finalisez vos titres et exportez-les facilement pour le live ou le streaming.", + image: img.placeholder4, + }, + ], + [], + ); + + const carouselItemWidth = useMemo(() => { + const baseWidth = Math.min(windowWidth * 0.9, 640); + return Math.max(baseWidth, 320); + }, [windowWidth]); + + const carouselItemHeight = useMemo(() => { + const baseHeight = Math.min(windowWidth * 0.6, 360); + return Math.max(baseHeight, 240); + }, [windowWidth]); + + const snapInterval = useMemo( + () => carouselItemHeight + ITEM_SPACING, + [carouselItemHeight], + ); + + const currentProject = useMemo(() => { + if (!projects.length) return null; + const activeId = selectedProject?.id || selectedProjectId; + if (!activeId) { + return projects[0]; + } + return projects.find((project) => project?.id === activeId) || projects[0]; + }, [projects, selectedProject?.id, selectedProjectId]); + + const formatDate = useCallback((timestamp) => { + try { + const value = timestamp?.toDate ? timestamp.toDate() : timestamp; + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) { + return ""; + } + const day = date.getDate().toString().padStart(2, "0"); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + return `${day}/${month}`; + } catch (error) { + console.warn("Home.web: formatDate error", error); + return ""; + } + }, []); + + const handleSelectProject = (project) => { + if (!project?.id) return; + selectProject(project.id); + }; + + const handleModifyProject = (project) => { + if (!project?.id) return; + selectProject(project.id); + navigate(Routes.FlowSelection); + }; + + const handleCreateNew = () => { + resetSelectedProject(); + navigate(Routes.FlowSelection); + }; + + const renderCarouselItem = useCallback( + ({ item, index }) => { + const isImageOnRight = index % 2 === 1; + + let imageSize = Math.min(carouselItemHeight - 24, 280); + let availableWidth = carouselItemWidth - imageSize - 48; + + if (availableWidth < 140) { + const minImageSize = Math.max(carouselItemWidth - 140 - 48, 140); + imageSize = Math.min(imageSize, minImageSize); + availableWidth = carouselItemWidth - imageSize - 48; + } + + const blurWidth = Math.min(Math.max(availableWidth, 120), 260); + const blurHeight = Math.max( + Math.min(imageSize * 0.75, carouselItemHeight - 48), + 140, + ); + + return ( + + + + + {item.title} + {item.description} + + + + ); + }, + [carouselItemHeight, carouselItemWidth], + ); + + const keyExtractor = useCallback((item) => item.id, []); + + return ( + + + + + + + ( + + )} + /> + + + + ); +}; + +export default Home; + +const styles = StyleSheet.create({ + root: { + flex: 1, + paddingHorizontal: 24, + position: "relative", + justifyContent: "flex-start", + alignItems: "center", + width: "100%", + }, + heroImage: { + position: "absolute", + top: -60, + alignSelf: "center", + width: "80%", + maxWidth: 920, + height: 420, + opacity: 0.9, + }, + dropdownArea: { + width: "100%", + alignItems: "center", + justifyContent: "center", + paddingTop: 72, + zIndex: 2, + }, + dropdownContainer: { + width: "100%", + }, + shareIcon: { + width: 18, + height: 18, + tintColor: Palette.white, + }, + carouselSection: { + width: "100%", + marginTop: 48, + }, + carouselList: { + width: "100%", + }, + carouselItem: { + borderRadius: 18, + backgroundColor: "rgba(0, 0, 0, 0.18)", + borderWidth: 1, + borderColor: "rgba(255, 255, 255, 0.1)", + paddingVertical: 24, + paddingHorizontal: 16, + alignItems: "center", + justifyContent: "center", + }, + carouselItemRight: { + flexDirection: "row-reverse", + alignItems: "center", + }, + carouselItemLeft: { + flexDirection: "row", + alignItems: "center", + }, + carouselImage: { + borderRadius: 28, + shadowColor: "#000", + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.25, + shadowRadius: 20, + }, + carouselImageRight: { + marginLeft: 20, + }, + carouselImageLeft: { + marginRight: 20, + }, + carouselBlur: { + borderRadius: 20, + overflow: "hidden", + paddingHorizontal: 18, + paddingVertical: 16, + justifyContent: "center", + alignItems: "flex-start", + backgroundColor: "rgba(0, 0, 0, 0.25)", + gap: 8, + }, + carouselBlurRight: { + marginRight: 12, + }, + carouselBlurLeft: { + marginLeft: 12, + }, + carouselTextContainer: { + width: "100%", + }, + carouselTitle: { + fontSize: 20, + color: Palette.white, + fontFamily: FONT_FAMILY.InterSemiBold, + marginBottom: 8, + }, + carouselDescription: { + fontSize: 14, + lineHeight: 20, + color: "rgba(255, 255, 255, 0.7)", + fontFamily: FONT_FAMILY.InterRegular, + }, +}); diff --git a/src/screens/Library/components/MusicCard.js b/src/screens/Library/components/MusicCard.js index 4d2e664..6527fb4 100644 --- a/src/screens/Library/components/MusicCard.js +++ b/src/screens/Library/components/MusicCard.js @@ -1,7 +1,6 @@ import { BlurView } from "expo-blur"; import React, { useEffect, useRef, useState } from "react"; import { - Platform, Pressable, StyleSheet, Text, diff --git a/src/screens/Login.js b/src/screens/Login.js index 37a0567..745697d 100644 --- a/src/screens/Login.js +++ b/src/screens/Login.js @@ -1,34 +1,32 @@ -/* eslint-disable react/display-name */ -import { GoogleSigninButton } from "@react-native-google-signin/google-signin"; -import * as AuthSession from "expo-auth-session"; -import * as GoogleAuth from "expo-auth-session/providers/google"; -import * as WebBrowser from "expo-web-browser"; -import React, { useEffect, useState } from "react"; -import { Pressable, Text, View } from "react-native"; -import { useGlobal } from "reactn"; -import { background } from "../assets"; -import GradientButton from "../components/GradientButton.js"; -import { Input } from "../components/Input.js"; -import ItemContainer from "../components/ItemContainer/ItemContainer.js"; -import firebase, { usersRef } from "../config/firebase"; - +import { GoogleSigninButton } from '@react-native-google-signin/google-signin'; +import * as AuthSession from 'expo-auth-session'; +import * as GoogleAuth from 'expo-auth-session/providers/google'; +import * as WebBrowser from 'expo-web-browser'; +import React, { useEffect, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { useGlobal } from 'reactn'; +import { background } from '../assets'; +import GradientButton from '../components/GradientButton.js'; +import { Input } from '../components/Input.js'; +import ItemContainer from '../components/ItemContainer/ItemContainer.js'; +import firebase, { usersRef } from '../config/firebase'; import { GOOGLE_ANDROID_CLIENT_ID, GOOGLE_IOS_CLIENT_ID, GOOGLE_WEB_CLIENT_ID, -} from "../data/keys"; -import { isWeb } from "../hooks/useLayoutType"; -import Page from "../layouts/Page.js"; -import { Routes } from "../navigation"; -import { navigate } from "../navigation/NavigationService.js"; -import { FONT_FAMILY } from "../styles/Fonts.js"; -import Palette from "../styles/Palette.js"; +} from '../data/keys'; +import { isWeb } from '../hooks/useLayoutType'; +import Page from '../layouts/Page.js'; +import { Routes } from '../navigation'; +import { navigate } from '../navigation/NavigationService.js'; +import { FONT_FAMILY } from '../styles/Fonts.js'; +import Palette from '../styles/Palette.js'; export default ({ navigation }) => { WebBrowser.maybeCompleteAuthSession(); - const [, setTooltip] = useGlobal("_tooltip"); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); + const [, setTooltip] = useGlobal('_tooltip'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); // Let the provider compute a compliant redirect URI for native (com.googleusercontent.apps.:/oauth2redirect) // Avoid forcing a custom scheme like musicland:// which Google can reject for native apps. @@ -41,12 +39,12 @@ export default ({ navigation }) => { // Use Authorization Code + PKCE to comply with Google OAuth for native apps responseType: AuthSession.ResponseType.Code, usePKCE: true, - scopes: ["openid", "profile", "email"], + scopes: ['openid', 'profile', 'email'], }); const afterLoginNavigate = async () => { const uid = firebase.auth().currentUser?.uid; - if (!uid) throw new Error("Aucun utilisateur après connexion"); + if (!uid) throw new Error('Aucun utilisateur après connexion'); const snap = await usersRef.doc(uid).get(); const hasUserName = !!snap.data()?.userName; if (hasUserName) { @@ -62,8 +60,8 @@ export default ({ navigation }) => { await firebase.auth().signInWithEmailAndPassword(email.trim(), password); await afterLoginNavigate(); } catch (e) { - console.log("Login error", e?.message); - setTooltip({ text: e?.message || "Connexion impossible", type: "error" }); + console.log('Login error', e?.message); + setTooltip({ text: e?.message || 'Connexion impossible', type: 'error' }); } finally { setLoading(false); } @@ -74,24 +72,24 @@ export default ({ navigation }) => { setLoading(true); if (isWeb) { const provider = new firebase.auth.GoogleAuthProvider(); - provider.addScope("profile"); - provider.addScope("email"); + provider.addScope('profile'); + provider.addScope('email'); await firebase.auth().signInWithPopup(provider); await afterLoginNavigate(); setLoading(false); } else { // Use defaults from the request; don't override with proxy here const result = await promptAsync(); - if (result?.type !== "success") { + if (result?.type !== 'success') { // Cancelled or errored during the browser flow setLoading(false); } } } catch (e) { - console.log("Google Login error", e?.message); + console.log('Google Login error', e?.message); setTooltip({ - text: e?.message || "Connexion Google impossible. Merci de réessayer.", - type: "error", + text: e?.message || 'Connexion Google impossible. Merci de réessayer.', + type: 'error', }); setLoading(false); } @@ -101,7 +99,7 @@ export default ({ navigation }) => { useEffect(() => { const handleNativeGoogleResponse = async () => { try { - if (response?.type === "success") { + if (response?.type === 'success') { // If using Expo proxy, tokens can be present already. let idToken = response?.authentication?.idToken || response?.params?.id_token; @@ -116,13 +114,13 @@ export default ({ navigation }) => { const clientId = request?.clientId; const discovery = { authorizationEndpoint: - "https://accounts.google.com/o/oauth2/v2/auth", - tokenEndpoint: "https://oauth2.googleapis.com/token", - revocationEndpoint: "https://oauth2.googleapis.com/revoke", + 'https://accounts.google.com/o/oauth2/v2/auth', + tokenEndpoint: 'https://oauth2.googleapis.com/token', + revocationEndpoint: 'https://oauth2.googleapis.com/revoke', }; // Light debug: helps identify redirect/client mismatches in dev. - console.log("Google token exchange", { + console.log('Google token exchange', { clientId, redirectUri: request?.redirectUri, hasCodeVerifier: !!request?.codeVerifier, @@ -135,13 +133,13 @@ export default ({ navigation }) => { redirectUri: request?.redirectUri, extraParams: { code_verifier: request?.codeVerifier }, }, - discovery + discovery, ); idToken = tokenResponse?.id_token; } if (!idToken) - throw new Error("Jeton Google manquant (échange/retour)"); + throw new Error('Jeton Google manquant (échange/retour)'); const credential = firebase.auth.GoogleAuthProvider.credential(idToken); @@ -149,10 +147,10 @@ export default ({ navigation }) => { await afterLoginNavigate(); } } catch (e) { - console.log("Google native sign-in error", e?.message); + console.log('Google native sign-in error', e?.message); setTooltip({ - text: e?.message || "Connexion Google impossible", - type: "error", + text: e?.message || 'Connexion Google impossible', + type: 'error', }); } finally { setLoading(false); @@ -230,8 +228,8 @@ export default ({ navigation }) => { { onPress={onLoginWithGoogle} disabled={loading} /> */} - + {/**/} @@ -266,7 +264,7 @@ export default ({ navigation }) => { fontFamily: FONT_FAMILY.HelveticaNeueRegular, }} > - Pas encore de compte?{" "} + Pas encore de compte?{' '} { title: result?.title || "", titleLower: (result?.title || "").toLowerCase(), lyrics: Array.isArray(result?.lyrics) ? result.lyrics : [], + description: result?.lyricsDescription, config: config || null, selections: selections || null, hasLyrics: false,