generate two covers

This commit is contained in:
2025-10-23 10:16:36 +02:00
parent b4375d04be
commit ebe26542fe
9 changed files with 754 additions and 436 deletions
+269 -173
View File
@@ -1,9 +1,23 @@
import React from "react";
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { icons } from "../../assets";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
arraysAreSame,
formatStructureLabel,
getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
@@ -17,17 +31,16 @@ const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
const buildSortableItems = (values = []) => {
const counters = {};
return sanitizeStructureList(values).map((segment, index) => {
counters[segment] = (counters[segment] || 0) + 1;
return {
id: `${segment}-${index}-${randomSuffix()}`,
type: segment,
value: segment,
};
});
};
const createSortableItem = (type, index = 0) => ({
id: `${type}-${index}-${randomSuffix()}`,
type,
value: type,
});
const buildSortableItems = (values = []) =>
sanitizeStructureList(values).map((type, index) =>
createSortableItem(type, index)
);
const reorder = (list, from, to) => {
if (from === to) return list;
@@ -40,108 +53,137 @@ const reorder = (list, from, to) => {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
const sanitized = React.useMemo(
const sanitizedBase = useMemo(
() => sanitizeStructureList(baseStructure),
[baseStructure]
);
const [items, setItems] = React.useState(() => buildSortableItems(sanitized));
const [activeItemId, setActiveItemId] = React.useState(null);
const containerRef = React.useRef(null);
const dragStateRef = React.useRef({
const [sortableItems, setSortableItems] = useState(() =>
buildSortableItems(sanitizedBase)
);
const [activeItemId, setActiveItemId] = useState(null);
const containerRef = useRef(null);
const dragStateRef = useRef({
itemId: null,
containerTop: 0,
});
React.useEffect(() => {
console.debug("[CustomizeSongStructure.web] sanitized", sanitized);
setItems(buildSortableItems(sanitized));
dragStateRef.current = { itemId: null, containerTop: 0 };
setActiveItemId(null);
}, [sanitized]);
const extractValues = useCallback(
(items) =>
(items || []).map((item) => item?.type || item?.value).filter(Boolean),
[]
);
React.useEffect(() => {
console.debug("[CustomizeSongStructure.web] items", items);
onChange?.(
items.map((item) => (item?.type || item?.value)).filter(Boolean)
);
}, [items, onChange]);
useEffect(() => {
setSortableItems((prev) => {
const currentValues = extractValues(prev);
if (arraysAreSame(currentValues, sanitizedBase)) {
return prev;
}
return buildSortableItems(sanitizedBase);
});
}, [sanitizedBase, extractValues]);
const labeledItems = React.useMemo(() => {
useEffect(() => {
onChange?.(extractValues(sortableItems));
}, [sortableItems, extractValues, onChange]);
const labeledItems = useMemo(() => {
const counts = {};
return items.map((item) => {
const type = item?.type || item?.value;
counts[type] = (counts[type] || 0) + 1;
return sortableItems.map((item) => {
const baseType = item.type || item.value;
const nextCount = (counts[baseType] || 0) + 1;
counts[baseType] = nextCount;
return {
...item,
type,
value: type,
label: formatStructureLabel(type, counts[type]),
type: baseType,
value: baseType,
label: formatStructureLabel(baseType, nextCount),
};
});
}, [items]);
}, [sortableItems]);
const selectedTypes = React.useMemo(() => {
const set = new Set();
items.forEach((item) => {
if (item?.type) set.add(item.type);
else if (item?.value) set.add(item.value);
});
return set;
}, [items]);
const optionalSegments = React.useMemo(
const optionalSegments = useMemo(
() =>
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
const meta = getSegmentMeta(type);
return { type, label: meta?.label };
return { type, label: meta?.label || formatStructureLabel(type, 1) };
}),
[]
);
const toggleOptionalSegment = React.useCallback((type) => {
setItems((prev) => {
const currentValues = prev.map((item) => item.type || item.value);
if (currentValues.includes(type)) {
const filtered = currentValues.filter((value) => value !== type);
return buildSortableItems(filtered);
}
const meta = getSegmentMeta(type);
let nextValues = [...currentValues];
if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
}
nextValues.push(type);
return buildSortableItems(nextValues);
const segmentCounts = useMemo(() => {
const counts = {};
extractValues(sortableItems).forEach((type) => {
counts[type] = (counts[type] || 0) + 1;
});
}, []);
return counts;
}, [sortableItems, extractValues]);
const handleGrant = (index, event) => {
const item = labeledItems[index];
if (!item) return;
const rect = containerRef.current?.getBoundingClientRect();
const scrollY = getScrollY();
const nativeEvent = event.nativeEvent || {};
const fallbackY =
typeof nativeEvent.pageY === "number"
? nativeEvent.pageY
: typeof nativeEvent.clientY === "number"
? nativeEvent.clientY + scrollY
: scrollY;
const containerTop = rect ? rect.top + scrollY : fallbackY;
dragStateRef.current = {
itemId: item.id,
containerTop,
};
setActiveItemId(item.id);
if (typeof document !== "undefined") {
document.body.style.userSelect = "none";
}
};
const handleRemoveItem = useCallback(
(itemId) => {
setSortableItems((prev) => {
const remaining = prev.filter((item) => item.id !== itemId);
const sanitized = sanitizeStructureList(extractValues(remaining));
return buildSortableItems(sanitized);
});
},
[extractValues]
);
const handleMove = (event) => {
const handleOptionalSegmentPress = useCallback(
(type) => {
const meta = getSegmentMeta(type);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
let nextValues = [...currentValues];
if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
}
const allowsMultiple = meta?.allowMultiple !== false;
if (!allowsMultiple && nextValues.includes(type)) {
const filtered = nextValues.filter((value) => value !== type);
const sanitized = sanitizeStructureList(filtered);
return buildSortableItems(sanitized);
}
nextValues.push(type);
const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized);
});
},
[extractValues]
);
const handleGrant = useCallback(
(itemId, event) => {
const rect = containerRef.current?.getBoundingClientRect();
const scrollY = getScrollY();
const nativeEvent = event.nativeEvent || {};
const fallbackY =
typeof nativeEvent.pageY === "number"
? nativeEvent.pageY
: typeof nativeEvent.clientY === "number"
? nativeEvent.clientY + scrollY
: scrollY;
const containerTop = rect ? rect.top + scrollY : fallbackY;
dragStateRef.current = {
itemId,
containerTop,
};
setActiveItemId(itemId);
if (typeof document !== "undefined") {
document.body.style.userSelect = "none";
}
},
[]
);
const handleMove = useCallback((event) => {
const { itemId, containerTop } = dragStateRef.current;
if (!itemId) return;
const nativeEvent = event.nativeEvent || {};
@@ -154,7 +196,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
: scrollY;
const pointerY = absoluteY - containerTop;
setItems((prev) => {
setSortableItems((prev) => {
const currentIndex = prev.findIndex((it) => it.id === itemId);
if (currentIndex === -1) return prev;
const targetIndex = clamp(
@@ -165,11 +207,11 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
if (targetIndex === currentIndex) return prev;
return reorder(prev, currentIndex, targetIndex);
});
};
}, []);
const handleRelease = () => {
setItems((prev) =>
buildSortableItems(prev.map((item) => item.type || item.value))
const handleRelease = useCallback(() => {
setSortableItems((prev) =>
buildSortableItems(extractValues(prev))
);
dragStateRef.current = {
itemId: null,
@@ -179,86 +221,94 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
if (typeof document !== "undefined") {
document.body.style.userSelect = "";
}
};
}, [extractValues]);
if (!labeledItems.length) {
const StructureRow = ({ item }) => {
const baseType = item?.type || item?.value;
const meta = getSegmentMeta(baseType);
const canRemove = meta?.optional === true;
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la structure de ta chanson"
subTitle="Sélectionne une structure pour commencer"
/>
<View style={styles.optionsContainer}>
{optionalSegments.map((segment) => {
const selected = selectedTypes.has(segment.type);
return (
<View
style={[
styles.itemContainer,
activeItemId === item.id && styles.activeItem,
]}
>
<View style={styles.row}>
<Text style={styles.rowText}>{item.label}</Text>
<View style={styles.rowActions}>
{canRemove && (
<TouchableOpacity
key={segment.type}
activeOpacity={0.85}
onPress={() => toggleOptionalSegment(segment.type)}
style={[styles.optionItem, selected && styles.selectedItem]}
onPress={() => handleRemoveItem(item.id)}
style={styles.removeButton}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
<Text style={styles.optionText}>{segment.label}</Text>
<Image
source={selected ? icons.check : icons.add}
style={[styles.optionIcon]}
/>
<Image source={icons.close} style={styles.removeIcon} />
</TouchableOpacity>
);
})}
)}
<View
style={styles.handleWrapper}
onStartShouldSetResponder={() => true}
onResponderGrant={(event) => handleGrant(item.id, event)}
onResponderMove={handleMove}
onResponderRelease={handleRelease}
onResponderTerminate={handleRelease}
>
<Image source={icons.dragDots} style={styles.handleIcon} />
</View>
</View>
</View>
</View>
);
}
};
return (
<View style={{ flex: 1, gap: 10, marginTop: 16 }}>
<CreateLyricsHeader
title="Personnalise la structure de ta chanson"
subTitle="Glisse-dépose pour réorganiser"
subTitle="Réorganise par glisser-déposer"
/>
<View style={styles.listContainer} ref={containerRef}>
{labeledItems.map((item, index) => (
<View
key={item.id}
style={[
styles.itemContainer,
activeItemId === item.id && styles.activeItem,
]}
onStartShouldSetResponder={() => true}
onResponderGrant={(event) => handleGrant(index, event)}
onResponderMove={handleMove}
onResponderRelease={handleRelease}
onResponderTerminate={handleRelease}
>
<View style={styles.row}>
<Text style={styles.rowText}>{item.label}</Text>
<Image source={icons.dragDots} style={styles.handleIcon} />
</View>
<View style={{ flex: 1 }}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text>
</View>
))}
</View>
<View style={styles.optionsContainer}>
{optionalSegments.map((segment) => {
const selected = selectedTypes.has(segment.type);
return (
<TouchableOpacity
key={segment.type}
activeOpacity={0.85}
onPress={() => toggleOptionalSegment(segment.type)}
style={[styles.optionItem, selected && styles.selectedItem]}
>
<Text style={styles.optionText}>{segment.label}</Text>
<Image
source={selected ? icons.check : icons.add}
style={[
styles.optionIcon,
selected && styles.optionIconSelected,
]}
/>
</TouchableOpacity>
);
})}
<View style={styles.listContainer} ref={containerRef}>
{labeledItems.map((item) => (
<StructureRow key={item.id} item={item} />
))}
</View>
<View style={[styles.sectionTag, styles.addSectionTag]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View>
<View style={styles.optionsContainer}>
{optionalSegments.map((segment) => {
const count = segmentCounts[segment.type] || 0;
const selected = count > 0;
return (
<TouchableOpacity
key={segment.type}
activeOpacity={0.85}
onPress={() => handleOptionalSegmentPress(segment.type)}
style={[styles.optionItem, selected && styles.selectedItem]}
>
<Text style={styles.rowText}>{segment.label}</Text>
<View
style={[
styles.countBadge,
selected && styles.countBadgeActive,
]}
>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
</TouchableOpacity>
);
})}
</View>
</ScrollView>
</View>
</View>
);
@@ -267,8 +317,29 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
export default CustomizeSongStructure;
const styles = StyleSheet.create({
scrollContent: {
paddingBottom: 24,
gap: 12,
},
sectionTag: {
alignSelf: "flex-start",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.12)",
marginTop: 12,
},
addSectionTag: {
marginTop: 24,
},
sectionTagText: {
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
letterSpacing: 1,
textTransform: "uppercase",
},
listContainer: {
flex: 1,
gap: 12,
},
itemContainer: {
@@ -298,6 +369,25 @@ const styles = StyleSheet.create({
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
rowActions: {
flexDirection: "row",
alignItems: "center",
},
removeButton: {
padding: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
marginRight: 8,
},
removeIcon: {
width: 16,
height: 16,
tintColor: Palette.white,
},
handleWrapper: {
padding: 6,
borderRadius: 999,
},
handleIcon: {
width: 22,
height: 22,
@@ -305,8 +395,8 @@ const styles = StyleSheet.create({
cursor: "grab",
},
optionsContainer: {
marginTop: 16,
gap: 12,
marginTop: 12,
},
optionItem: {
height: ROW_HEIGHT,
@@ -323,19 +413,25 @@ const styles = StyleSheet.create({
alignItems: "center",
justifyContent: "space-between",
},
optionText: {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
optionIcon: {
width: 20,
height: 20,
tintColor: Palette.white,
},
selectedItem: {
borderWidth: 1,
borderColor: Palette.white,
},
countBadge: {
minWidth: 28,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
alignItems: "center",
justifyContent: "center",
},
countBadgeActive: {
backgroundColor: Palette.transparentWhite,
},
countBadgeText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
});