new song structure
This commit is contained in:
@@ -1,30 +1,30 @@
|
||||
import React from "react";
|
||||
import { Image, StyleSheet, Text, View } from "react-native";
|
||||
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { icons } from "../../assets";
|
||||
import { Palette } from "../../styles";
|
||||
import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
import {
|
||||
formatStructureLabel,
|
||||
getSegmentMeta,
|
||||
OPTIONAL_STRUCTURE_SEGMENTS,
|
||||
sanitizeStructureList,
|
||||
} from "../../utils/songStructure";
|
||||
import CreateLyricsHeader from "./components/CreateLyricsHeader";
|
||||
|
||||
const ROW_HEIGHT = 54;
|
||||
const VALID_SEGMENTS = ["couplet", "refrain"];
|
||||
|
||||
const getScrollY = () => (typeof window !== "undefined" ? window.scrollY : 0);
|
||||
|
||||
const sanitizeStructure = (structure = []) =>
|
||||
structure
|
||||
.map((segment) => `${segment}`.trim().toLowerCase())
|
||||
.filter((segment) => VALID_SEGMENTS.includes(segment));
|
||||
const randomSuffix = () => Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const buildItems = (structure = []) => {
|
||||
const counters = { couplet: 0, refrain: 0 };
|
||||
const counters = {};
|
||||
return structure.map((segment, index) => {
|
||||
counters[segment] += 1;
|
||||
counters[segment] = (counters[segment] || 0) + 1;
|
||||
return {
|
||||
id: `${segment}-${index}-${counters[segment]}`,
|
||||
id: `${segment}-${index}-${randomSuffix()}`,
|
||||
type: segment,
|
||||
value: segment,
|
||||
label: `${segment === "couplet" ? "Couplet" : "Refrain"} ${
|
||||
counters[segment]
|
||||
}`,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -41,7 +41,7 @@ const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
const sanitized = React.useMemo(
|
||||
() => sanitizeStructure(baseStructure),
|
||||
() => sanitizeStructureList(baseStructure),
|
||||
[baseStructure]
|
||||
);
|
||||
const [items, setItems] = React.useState(() => buildItems(sanitized));
|
||||
@@ -55,11 +55,10 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
React.useEffect(() => {
|
||||
console.debug("[CustomizeSongStructure.web] sanitized", sanitized);
|
||||
setItems((prev) => {
|
||||
if (
|
||||
prev.length === sanitized.length &&
|
||||
prev.every((item, idx) => item.value === sanitized[idx])
|
||||
) {
|
||||
return prev;
|
||||
const prevTypes = prev.map((item) => item.type);
|
||||
if (prevTypes.length === sanitized.length) {
|
||||
const same = prevTypes.every((type, idx) => type === sanitized[idx]);
|
||||
if (same) return prev;
|
||||
}
|
||||
return buildItems(sanitized);
|
||||
});
|
||||
@@ -69,11 +68,59 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
|
||||
React.useEffect(() => {
|
||||
console.debug("[CustomizeSongStructure.web] items", items);
|
||||
onChange?.(items.map((item) => item.value));
|
||||
onChange?.(items.map((item) => item.type));
|
||||
}, [items, onChange]);
|
||||
|
||||
const labeledItems = React.useMemo(() => {
|
||||
const counts = {};
|
||||
return items.map((item) => {
|
||||
counts[item.type] = (counts[item.type] || 0) + 1;
|
||||
return {
|
||||
...item,
|
||||
label: formatStructureLabel(item.type, counts[item.type]),
|
||||
};
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const selectedTypes = React.useMemo(
|
||||
() => new Set(items.map((item) => item.type)),
|
||||
[items]
|
||||
);
|
||||
|
||||
const optionalSegments = React.useMemo(
|
||||
() =>
|
||||
OPTIONAL_STRUCTURE_SEGMENTS.map((type) => {
|
||||
const meta = getSegmentMeta(type);
|
||||
return { type, label: meta?.label };
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const toggleOptionalSegment = React.useCallback((type) => {
|
||||
setItems((prev) => {
|
||||
const exists = prev.some((item) => item.type === type);
|
||||
if (exists) {
|
||||
return prev.filter((item) => item.type !== type);
|
||||
}
|
||||
const meta = getSegmentMeta(type);
|
||||
let next = [...prev];
|
||||
if (meta?.exclusiveGroup) {
|
||||
next = next.filter((item) => {
|
||||
const itemMeta = getSegmentMeta(item.type);
|
||||
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
|
||||
});
|
||||
}
|
||||
next.push({
|
||||
id: `${type}-${Date.now()}-${randomSuffix()}`,
|
||||
type,
|
||||
value: type,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleGrant = (index, event) => {
|
||||
const item = items[index];
|
||||
const item = labeledItems[index];
|
||||
if (!item) return;
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const scrollY = getScrollY();
|
||||
@@ -132,13 +179,32 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!items.length) {
|
||||
if (!labeledItems.length) {
|
||||
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 (
|
||||
<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]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +216,7 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
subTitle="Glisse-dépose pour réorganiser"
|
||||
/>
|
||||
<View style={styles.listContainer} ref={containerRef}>
|
||||
{items.map((item, index) => (
|
||||
{labeledItems.map((item, index) => (
|
||||
<View
|
||||
key={item.id}
|
||||
style={[
|
||||
@@ -170,6 +236,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
|
||||
</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>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -214,4 +302,38 @@ const styles = StyleSheet.create({
|
||||
tintColor: Palette.white,
|
||||
cursor: "grab",
|
||||
},
|
||||
optionsContainer: {
|
||||
marginTop: 16,
|
||||
gap: 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",
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
color: Palette.white,
|
||||
fontFamily: FONT_FAMILY.InterRegular,
|
||||
},
|
||||
optionIcon: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
tintColor: Palette.white,
|
||||
},
|
||||
|
||||
selectedItem: {
|
||||
borderWidth: 1,
|
||||
borderColor: Palette.white,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user