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 && }
+