433 lines
12 KiB
JavaScript
433 lines
12 KiB
JavaScript
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,
|
|
sanitizeStructureList,
|
|
} from "../../utils/songStructure";
|
|
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
|
|
|
const ROW_HEIGHT = 54;
|
|
|
|
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
|
|
|
|
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
|
|
|
|
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;
|
|
const next = [...list];
|
|
const [moved] = next.splice(from, 1);
|
|
next.splice(to, 0, moved);
|
|
return next;
|
|
};
|
|
|
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
|
|
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
|
const sanitizedBase = useMemo(
|
|
() => sanitizeStructureList(baseStructure),
|
|
[baseStructure]
|
|
);
|
|
const [sortableItems, setSortableItems] = useState(() =>
|
|
buildSortableItems(sanitizedBase)
|
|
);
|
|
const [activeItemId, setActiveItemId] = useState(null);
|
|
const containerRef = useRef(null);
|
|
const dragStateRef = useRef({
|
|
itemId: null,
|
|
containerTop: 0,
|
|
});
|
|
|
|
const extractValues = useCallback(
|
|
(items) =>
|
|
(items || []).map((item) => item?.type || item?.value).filter(Boolean),
|
|
[]
|
|
);
|
|
|
|
useEffect(() => {
|
|
setSortableItems((prev) => {
|
|
const currentValues = extractValues(prev);
|
|
if (arraysAreSame(currentValues, sanitizedBase)) {
|
|
return prev;
|
|
}
|
|
return buildSortableItems(sanitizedBase);
|
|
});
|
|
}, [sanitizedBase, extractValues]);
|
|
|
|
useEffect(() => {
|
|
onChange?.(extractValues(sortableItems));
|
|
}, [sortableItems, extractValues, onChange]);
|
|
|
|
const labeledItems = useMemo(() => {
|
|
const counts = {};
|
|
return sortableItems.map((item) => {
|
|
const baseType = item.type || item.value;
|
|
const nextCount = (counts[baseType] || 0) + 1;
|
|
counts[baseType] = nextCount;
|
|
return {
|
|
...item,
|
|
type: baseType,
|
|
value: baseType,
|
|
label: formatStructureLabel(baseType, nextCount),
|
|
};
|
|
});
|
|
}, [sortableItems]);
|
|
|
|
const optionalSegments = useMemo(
|
|
() =>
|
|
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
|
|
const meta = getSegmentMeta(type);
|
|
return { type, label: meta?.label || formatStructureLabel(type, 1) };
|
|
}),
|
|
[]
|
|
);
|
|
|
|
const segmentCounts = useMemo(() => {
|
|
const counts = {};
|
|
extractValues(sortableItems).forEach((type) => {
|
|
counts[type] = (counts[type] || 0) + 1;
|
|
});
|
|
return counts;
|
|
}, [sortableItems, extractValues]);
|
|
|
|
const handleRemoveItem = useCallback(
|
|
(itemId) => {
|
|
setSortableItems((prev) => {
|
|
const remaining = prev.filter((item) => item.id !== itemId);
|
|
const sanitized = sanitizeStructureList(extractValues(remaining));
|
|
return buildSortableItems(sanitized);
|
|
});
|
|
},
|
|
[extractValues]
|
|
);
|
|
|
|
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 || {};
|
|
const scrollY = getScrollY();
|
|
const absoluteY =
|
|
typeof nativeEvent.pageY === "number"
|
|
? nativeEvent.pageY
|
|
: typeof nativeEvent.clientY === "number"
|
|
? nativeEvent.clientY + scrollY
|
|
: scrollY;
|
|
const pointerY = absoluteY - containerTop;
|
|
|
|
setSortableItems((prev) => {
|
|
const currentIndex = prev.findIndex((it) => it.id === itemId);
|
|
if (currentIndex === -1) return prev;
|
|
const targetIndex = clamp(
|
|
Math.floor(pointerY / ROW_HEIGHT),
|
|
0,
|
|
Math.max(prev.length - 1, 0)
|
|
);
|
|
if (targetIndex === currentIndex) return prev;
|
|
return reorder(prev, currentIndex, targetIndex);
|
|
});
|
|
}, []);
|
|
|
|
const handleRelease = useCallback(() => {
|
|
setSortableItems((prev) => buildSortableItems(extractValues(prev)));
|
|
dragStateRef.current = {
|
|
itemId: null,
|
|
containerTop: 0,
|
|
};
|
|
setActiveItemId(null);
|
|
if (typeof document !== "undefined") {
|
|
document.body.style.userSelect = "";
|
|
}
|
|
}, [extractValues]);
|
|
|
|
const StructureRow = ({ item }) => {
|
|
const baseType = item?.type || item?.value;
|
|
const meta = getSegmentMeta(baseType);
|
|
const canRemove = meta?.optional === true;
|
|
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
|
|
onPress={() => handleRemoveItem(item.id)}
|
|
style={styles.removeButton}
|
|
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
|
>
|
|
<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="Réorganise par glisser-déposer"
|
|
/>
|
|
<View style={{ flex: 1 }}>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View style={styles.sectionTag}>
|
|
<Text style={styles.sectionTagText}>Structure actuelle</Text>
|
|
</View>
|
|
<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>
|
|
);
|
|
};
|
|
|
|
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: {
|
|
gap: 12,
|
|
},
|
|
itemContainer: {
|
|
height: ROW_HEIGHT,
|
|
borderRadius: 14,
|
|
backgroundColor: Palette.glass,
|
|
shadowColor: "#00000040",
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 3.84,
|
|
elevation: 5,
|
|
overflow: "hidden",
|
|
},
|
|
activeItem: {
|
|
borderWidth: 1,
|
|
borderColor: Palette.white,
|
|
},
|
|
row: {
|
|
flex: 1,
|
|
paddingHorizontal: 16,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
},
|
|
rowText: {
|
|
fontSize: 16,
|
|
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,
|
|
tintColor: Palette.white,
|
|
cursor: "grab",
|
|
},
|
|
optionsContainer: {
|
|
gap: 12,
|
|
marginTop: 12,
|
|
},
|
|
optionItem: {
|
|
height: ROW_HEIGHT,
|
|
borderRadius: 14,
|
|
backgroundColor: Palette.glass,
|
|
shadowColor: "#00000040",
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 3.84,
|
|
elevation: 5,
|
|
overflow: "hidden",
|
|
paddingHorizontal: 16,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
},
|
|
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,
|
|
},
|
|
});
|