diff --git a/src/components/AnimatedPaginationDot/AnimatedPaginationDot.js b/src/components/AnimatedPaginationDot/AnimatedPaginationDot.js new file mode 100644 index 0000000..cca6259 --- /dev/null +++ b/src/components/AnimatedPaginationDot/AnimatedPaginationDot.js @@ -0,0 +1,136 @@ +import React, { useMemo } from "react"; +import { Animated, StyleSheet, useWindowDimensions, View } from "react-native"; +import { Palette } from "../../styles"; + +const ORIENTATION = { + HORIZONTAL: "horizontal", + VERTICAL: "vertical", +}; + +const AnimatedPaginationDot = ({ + data = [], + scrollValue, + itemDimension, + containerStyle, + dotStyle, + orientation = ORIENTATION.HORIZONTAL, + expandingDotSize = 20, + inactiveDotOpacity = 0.4, + inactiveDotColor = "rgba(255,255,255,0.4)", + activeDotColor = Palette.white, + baseDotSize = 10, +}) => { + const animatedValue = useMemo( + () => scrollValue || new Animated.Value(0), + [scrollValue], + ); + + const { width, height } = useWindowDimensions(); + const distance = useMemo(() => { + if (typeof itemDimension === "number" && itemDimension > 0) { + return itemDimension; + } + if (orientation === ORIENTATION.VERTICAL) { + return Math.max(height, 1); + } + return Math.max(width, 1); + }, [height, itemDimension, orientation, width]); + + const resolvedDotStyle = useMemo(() => StyleSheet.flatten(dotStyle) || {}, [dotStyle]); + const defaultSize = Math.max(baseDotSize, 1); + const baseWidth = + typeof resolvedDotStyle?.width === "number" + ? resolvedDotStyle.width + : defaultSize; + const baseHeight = + typeof resolvedDotStyle?.height === "number" + ? resolvedDotStyle.height + : defaultSize; + + const containerOrientationStyle = + orientation === ORIENTATION.VERTICAL + ? styles.containerVertical + : styles.containerHorizontal; + + return ( + + {data.map((_, index) => { + const inputRange = [ + (index - 1) * distance, + index * distance, + (index + 1) * distance, + ]; + + const staticSizeStyle = { + width: baseWidth, + height: baseHeight, + }; + + const color = animatedValue.interpolate({ + inputRange, + outputRange: [inactiveDotColor, activeDotColor, inactiveDotColor], + extrapolate: "clamp", + }); + + const opacity = animatedValue.interpolate({ + inputRange, + outputRange: [inactiveDotOpacity, 1, inactiveDotOpacity], + extrapolate: "clamp", + }); + + const primarySize = animatedValue.interpolate({ + inputRange, + outputRange: [ + orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth, + expandingDotSize, + orientation === ORIENTATION.VERTICAL ? baseHeight : baseWidth, + ], + extrapolate: "clamp", + }); + + const animatedSizeStyle = + orientation === ORIENTATION.VERTICAL + ? { height: primarySize } + : { width: primarySize }; + + return ( + + ); + })} + + ); +}; + +AnimatedPaginationDot.orientation = ORIENTATION; + +export default AnimatedPaginationDot; + +const styles = StyleSheet.create({ + containerBase: { + alignItems: "center", + justifyContent: "center", + }, + containerHorizontal: { + flexDirection: "row", + }, + containerVertical: { + flexDirection: "column", + }, + dotBase: { + borderRadius: 999, + marginHorizontal: 4, + marginVertical: 4, + }, +}); diff --git a/src/components/FeatureCarousel/FeatureCarousel.js b/src/components/FeatureCarousel/FeatureCarousel.js index 4124d16..1cce3be 100644 --- a/src/components/FeatureCarousel/FeatureCarousel.js +++ b/src/components/FeatureCarousel/FeatureCarousel.js @@ -6,6 +6,7 @@ import React, { useState, } from "react"; import { + Animated, FlatList, Platform, StyleSheet, @@ -15,6 +16,8 @@ import { import { ai } from "../../assets"; import { getCreationStageStates } from "../../utils/projectStages"; import PersonaCard from "../cards/PersonaCard/PersonaCard"; +import AnimatedPaginationDot from "../AnimatedPaginationDot/AnimatedPaginationDot"; +import { BlurView } from "expo-blur"; const STAGE_CARD_CONTENT = [ { @@ -65,14 +68,14 @@ const FeatureCarousel = ({ ...item, isLocked: stageStates[index]?.isLocked ?? true, })), - [stageStates] + [stageStates], ); const { height: windowHeight } = useWindowDimensions(); const isWeb = Platform.OS === "web"; const [viewportHeight, setViewportHeight] = useState(() => - Math.max(windowHeight, 1) + Math.max(windowHeight, 1), ); const updateSnapHeight = useCallback((height) => { @@ -97,9 +100,10 @@ const FeatureCarousel = ({ const pendingScrollRef = useRef(false); const alignTimeoutRef = useRef(null); const activeIndexRef = useRef( - typeof activeIndex === "number" ? activeIndex : 0 + typeof activeIndex === "number" ? activeIndex : 0, ); const onActiveIndexChangeRef = useRef(onActiveIndexChange); + const scrollY = useRef(new Animated.Value(0)).current; useEffect(() => { onActiveIndexChangeRef.current = onActiveIndexChange; @@ -124,7 +128,7 @@ const FeatureCarousel = ({ } return index; }, - [carouselItems.length] + [carouselItems.length], ); const clearPendingAlignment = useCallback(() => { @@ -169,7 +173,7 @@ const FeatureCarousel = ({ pendingScrollRef.current = false; } }, - [clampIndex, isWeb, itemHeight, updateSnapHeight] + [clampIndex, isWeb, itemHeight, updateSnapHeight], ); useEffect(() => { @@ -234,7 +238,7 @@ const FeatureCarousel = ({ scrollToIndex(nextIndex, shouldAnimate, height); } }, - [clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight] + [clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight], ); const handleScrollEnd = useCallback( @@ -244,7 +248,7 @@ const FeatureCarousel = ({ const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height; alignToOffset(offsetY, layoutHeight); }, - [alignToOffset, clearPendingAlignment] + [alignToOffset, clearPendingAlignment], ); const handleScroll = useCallback( @@ -260,7 +264,16 @@ const FeatureCarousel = ({ alignTimeoutRef.current = null; }, 80); }, - [alignToOffset, clearPendingAlignment, isWeb] + [alignToOffset, clearPendingAlignment, isWeb], + ); + + const animatedScrollHandler = useMemo( + () => + Animated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { + useNativeDriver: false, + listener: isWeb ? handleScroll : undefined, + }), + [handleScroll, isWeb, scrollY], ); const handleLayout = useCallback( @@ -270,7 +283,7 @@ const FeatureCarousel = ({ updateSnapHeight(layoutHeight); } }, - [updateSnapHeight] + [updateSnapHeight], ); const keyExtractor = useCallback((item) => item.key, []); @@ -284,7 +297,7 @@ const FeatureCarousel = ({ height={itemHeight} /> ), - [itemHeight] + [itemHeight], ); const getItemLayout = useCallback( @@ -293,7 +306,7 @@ const FeatureCarousel = ({ offset: itemHeight * index, index, }), - [itemHeight] + [itemHeight], ); const viewabilityConfig = useRef({ viewAreaCoveragePercentThreshold: 60 }); @@ -324,8 +337,20 @@ const FeatureCarousel = ({ return carouselItems.map((_, index) => index * itemHeight); }, [carouselItems, isWeb, itemHeight]); + const blurIntensity = isWeb ? 80 : 30; + const dotsWrapperStyle = useMemo( + () => [ + styles.dotsWrapperBase, + isWeb ? styles.dotsWrapperWeb : styles.dotsWrapperNative, + ], + [isWeb], + ); + return ( - + + + + ); }; @@ -362,8 +405,44 @@ const styles = StyleSheet.create({ container: { flex: 1, width: "100%", + flexDirection: "row", + }, + containerWeb: { + paddingRight: 56, }, list: { flex: 1, }, + dotsWrapperBase: { + justifyContent: "center", + alignItems: "center", + borderRadius: 16, + paddingVertical: 12, + paddingHorizontal: 8, + overflow: "hidden", + backgroundColor: "rgba(18, 18, 18, 0.2)", + }, + dotsWrapperWeb: { + position: "absolute", + right: 12, + top: 0, + bottom: 0, + maxHeight: "75%", + alignSelf: "center", + }, + dotsWrapperNative: { + marginLeft: 12, + alignSelf: "center", + }, + dotsContainer: { + flexDirection: "column", + }, + dot: { + width: 8, + height: 8, + marginHorizontal: 0, + marginVertical: 6, + borderRadius: 999, + backgroundColor: "rgba(255,255,255,0.4)", + }, }); diff --git a/src/components/MoreMenu.js b/src/components/MoreMenu.js index be2d4e0..b7e96b3 100644 --- a/src/components/MoreMenu.js +++ b/src/components/MoreMenu.js @@ -9,6 +9,24 @@ import { Palette } from "../styles"; import { FONT_FAMILY } from "../styles/Fonts"; import { Portal } from "@gorhom/portal"; +const MoreBtn = ({ onPress, children }) => ( + + + {children} + + +); + const MoreMenu = ({ visible = false, top = 0, @@ -26,8 +44,8 @@ const MoreMenu = ({ typeof position?.top === "number" ? position.top : typeof top === "number" - ? top - : 0; + ? top + : 0; const resolvedRight = typeof position?.right === "number" ? Math.max(position.right, 0) : null; const resolvedLeft = @@ -108,47 +126,7 @@ const MoreMenu = ({ > {inPlaylist && ( - - - - Supprimer de la playlist - - - - )} - - - + - Ajouter à une playlist + Supprimer de la playlist - - + + )} + + + + Ajouter à une playlist + + {Array.isArray(extraItems) && extraItems.map((item, idx) => ( - { try { @@ -173,30 +163,16 @@ const MoreMenu = ({ } }} > - - - {item?.label || "Action"} - - - + {item?.label || "Action"} + + ))} diff --git a/src/components/ProjectDropDown/ProjectDropDown.js b/src/components/ProjectDropDown/ProjectDropDown.js index a43fdce..391fa7c 100644 --- a/src/components/ProjectDropDown/ProjectDropDown.js +++ b/src/components/ProjectDropDown/ProjectDropDown.js @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Animated, + Dimensions, Easing, FlatList, Image, @@ -88,9 +89,9 @@ const ProjectDropDown = ({ ); const handleModify = useCallback( - (project, topPosition) => { + (project, anchor) => { if (!project?.id) return; - onModifyProject?.(project, topPosition); + onModifyProject?.(project, anchor); }, [onModifyProject], ); @@ -261,23 +262,37 @@ const ProjectRow = ({ }) => { const rowRef = useRef(null); const [layout, setLayout] = useState(null); - const [menuPos, setMenuPos] = useState(0); + const [menuPos, setMenuPos] = useState({ + top: 0, + right: 0, + left: 0, + width: 0, + height: 0, + }); useEffect(() => { if (!layout) return; - const top = (layout?.y || 0) + 45; - setMenuPos(top); + const top = (layout?.y || 0) + (layout?.height || 45); + setMenuPos((prev) => ({ ...prev, top })); }, [layout]); const onPressMenu = () => { if (!project?.id) return; + const updateAnchor = (x = 0, y = 0, width = 0, height = 0) => { + const windowWidth = Dimensions.get("window").width || 0; + const top = (y || 0) + (height || 0) + 8; + const left = Math.max(0, x || 0); + const right = Math.max(0, windowWidth - (left + (width || 0))); + const anchor = { top, right, left, width, height }; + setMenuPos(anchor); + onModify?.(project, anchor); + }; + if (rowRef?.current?.measureInWindow) { try { - rowRef.current.measureInWindow((x, y) => { - const top = (y || 0) + 45; - setMenuPos(top); - onModify?.(project, top); + rowRef.current.measureInWindow((x, y, width, height) => { + updateAnchor(x, y, width, height); }); return; } catch (_error) { diff --git a/src/components/cards/PersonaCard/PersonaCard.js b/src/components/cards/PersonaCard/PersonaCard.js index 436fa64..149e955 100644 --- a/src/components/cards/PersonaCard/PersonaCard.js +++ b/src/components/cards/PersonaCard/PersonaCard.js @@ -1,5 +1,5 @@ import React from "react"; -import { Platform, Text, useWindowDimensions, View } from "react-native"; +import { Text, useWindowDimensions, View } from "react-native"; import { Image } from "expo-image"; import { BlurView } from "expo-blur"; import { gutters, Palette } from "../../../styles"; @@ -7,10 +7,10 @@ import { FONT_FAMILY } from "../../../styles/Fonts"; import FontAwesome from "@expo/vector-icons/FontAwesome"; import palette from "../../../styles/Palette"; -export default function PersonaCard({ item, isLock = false, height }) { +export default function PersonaCard({ item, index, isLock = false, height }) { + const isImageOnLeft = index % 2 === 0; const { height: windowHeight } = useWindowDimensions(); - const baseHeight = Math.max(height ?? windowHeight, 1); - const cardHeight = Platform.OS === "web" ? Math.round(baseHeight) : baseHeight; + const cardHeight = Math.max(height ?? windowHeight, 1); return ( + {isLock && ( + + )} { const { @@ -26,10 +34,11 @@ const Home = () => { selectedProject, selectedProjectId, } = useUser(); + const { setTooltip } = useMinuit(); const projects = useMemo( () => (Array.isArray(userProjects) ? userProjects : []), - [userProjects] + [userProjects], ); const currentProject = useMemo(() => { @@ -60,22 +69,9 @@ const Home = () => { } }, []); - const handleSelectProject = (project) => { - if (!project?.id) return; - selectProject(project.id); - setActiveStageIndex(findFirstUnlockedStageIndex(project)); - }; - - const handleModifyProject = (project) => { - if (!project?.id) return; - selectProject(project.id); - setActiveStageIndex(findFirstUnlockedStageIndex(project)); - navigate(Routes.Home); - }; - const stageStates = useMemo( () => getCreationStageStates(currentProject), - [currentProject] + [currentProject], ); const firstUnlockedIndex = useMemo(() => { @@ -84,6 +80,10 @@ const Home = () => { }, [stageStates]); const [activeStageIndex, setActiveStageIndex] = useState(firstUnlockedIndex); + const [menuVisible, setMenuVisible] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(null); + const [menuProject, setMenuProject] = useState(null); + const menuAnchorRef = useRef(null); useEffect(() => { setActiveStageIndex((prev) => { @@ -99,6 +99,86 @@ const Home = () => { }); }, [stageStates, firstUnlockedIndex]); + const handleSelectProject = useCallback( + (project) => { + if (!project?.id) return; + selectProject(project.id); + setActiveStageIndex(findFirstUnlockedStageIndex(project)); + }, + [selectProject], + ); + + const handleCloseMenu = useCallback(() => { + setMenuVisible(false); + setMenuAnchor(null); + setMenuProject(null); + menuAnchorRef.current = null; + }, []); + + const handleDeleteProject = useCallback(async () => { + if (!menuProject?.id) return; + try { + await projectsRef.doc(menuProject.id).delete(); + setTooltip?.({ type: "success", text: "Projet supprimé" }); + } catch (error) { + setTooltip?.({ + type: "error", + text: error?.message || "Suppression impossible", + }); + } finally { + handleCloseMenu(); + } + }, [handleCloseMenu, menuProject?.id, setTooltip]); + + const handleModifyProject = useCallback((project, anchor) => { + if (!project?.id) return; + + const prevAnchor = menuAnchorRef.current; + const normalizedAnchor = + typeof anchor === "number" + ? { top: anchor } + : anchor && typeof anchor === "object" + ? anchor + : {}; + + setMenuProject(project); + setMenuAnchor(normalizedAnchor); + setMenuVisible((prev) => { + if (!prev) return true; + const prevTop = + typeof prevAnchor?.top === "number" ? prevAnchor.top : null; + const nextTop = + typeof normalizedAnchor?.top === "number" ? normalizedAnchor.top : null; + return prevTop !== nextTop; + }); + menuAnchorRef.current = normalizedAnchor; + }, []); + + const moreMenuItems = useMemo(() => { + if (!menuProject?.id) return []; + return [ + { + label: "Supprimer", + onPress: () => + Alert.alert( + "Confirmer la suppression", + "Cette action supprimera définitivement ce projet.", + [ + { text: "Annuler", style: "cancel" }, + { + text: "Supprimer", + style: "destructive", + onPress: () => { + handleDeleteProject(); + }, + }, + ], + { cancelable: true }, + ), + }, + ]; + }, [handleDeleteProject, menuProject?.id]); + const activeStage = stageStates[activeStageIndex] || stageStates[firstUnlockedIndex]; const stageAction = useMemo(() => { @@ -139,7 +219,7 @@ const Home = () => { const songwriterAction = useMemo( () => getStageAction("songwriter", null), - [] + [], ); const handleStartNew = useCallback(() => { @@ -149,7 +229,7 @@ const Home = () => { console.log( "navigating to", songwriterAction.route, - songwriterAction.params + songwriterAction.params, ); navigate(songwriterAction.route, songwriterAction.params); } @@ -185,6 +265,15 @@ const Home = () => { /> {!isWeb && } +