411 lines
13 KiB
JavaScript
411 lines
13 KiB
JavaScript
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
import { ScrollView, StyleSheet, Text, View } from "react-native";
|
|
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
|
|
import { background, icons } from "../../assets";
|
|
import alert from "../../components/Alert";
|
|
import BorderGradientButton from "../../components/BorderGradientButton";
|
|
import GradientButton from "../../components/GradientButton";
|
|
import ItemContainer from "../../components/ItemContainer/ItemContainer";
|
|
import MusicLandHeader from "../../components/MusicLandHeader";
|
|
import firebase, { projectsRef } from "../../config/firebase";
|
|
import { isWeb } from "../../hooks/useLayoutType";
|
|
import Page from "../../layouts/Page";
|
|
import { Routes } from "../../navigation";
|
|
import { navigate } from "../../navigation/NavigationService";
|
|
import { useUser } from "../../providers/UserDataProvider";
|
|
import { Palette, gutters } from "../../styles";
|
|
import { FONT_FAMILY } from "../../styles/Fonts";
|
|
import CustomInput from "./components/CustomInput";
|
|
|
|
const Lyrics = ({ navigation }) => {
|
|
const scrollRef = useRef(null);
|
|
const [containerLayout, setContainerLayout] = useState(null);
|
|
const { setIsLoading } = useMinuit();
|
|
const { selectedProjectId, selectedProject } = useUser();
|
|
const [isFocus, setIsFocus] = useState(null);
|
|
const [itemsContainerLayout, setItemsContainerLayout] = useState([]);
|
|
|
|
// Effective sources from provider only
|
|
const projectTitle = selectedProject?.title || "";
|
|
const projectLyrics = Array.isArray(selectedProject?.lyrics)
|
|
? selectedProject.lyrics
|
|
: [];
|
|
const projectConfig = selectedProject?.config || null;
|
|
const projectSelections = selectedProject?.selections || null;
|
|
const projectHasLyrics = !!selectedProject?.hasLyrics;
|
|
|
|
const initial = useMemo(() => {
|
|
const title = projectTitle || "";
|
|
const aiSections = Array.isArray(projectLyrics) ? projectLyrics : [];
|
|
// Respecter l'ordre de la structure choisie si disponible
|
|
const targetStructure = Array.isArray(projectConfig?.structure)
|
|
? projectConfig.structure.map((t) => (t || "").toLowerCase())
|
|
: null;
|
|
|
|
// 1) Si des paroles existent déjà, les utiliser en priorité
|
|
if (aiSections.length) {
|
|
// Optionnel: si une structure cible de même longueur existe, garder l'ordre courant
|
|
// et harmoniser les types en minuscule.
|
|
return {
|
|
title,
|
|
sections: aiSections.map((s) => ({
|
|
type: (s?.type || "").toLowerCase(),
|
|
lyrics: s?.lyrics || "",
|
|
})),
|
|
};
|
|
}
|
|
|
|
// 2) Sinon, créer à partir de la structure si fournie
|
|
if (targetStructure && targetStructure.length) {
|
|
return {
|
|
title,
|
|
sections: targetStructure.map((t) => ({ type: t, lyrics: "" })),
|
|
};
|
|
}
|
|
|
|
// 3) Fallback vide
|
|
return { title, sections: [] };
|
|
}, [projectTitle, projectLyrics, projectConfig]);
|
|
|
|
const [titleValue, setTitleValue] = useState(initial.title || "");
|
|
const [sections, setSections] = useState(initial.sections || []);
|
|
const setSectionAt = (index, value) => {
|
|
setSections((prev) => {
|
|
const next = [...prev];
|
|
if (next[index]) next[index] = { ...next[index], lyrics: value };
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Plus d'appel direct à la cloud function ici: on retourne à l'étape précédente
|
|
const regenerate = useCallback(() => {
|
|
navigate(Routes.CreateLyricsWithAi);
|
|
}, []);
|
|
|
|
const sanitize = (obj) => {
|
|
if (obj === undefined) return null;
|
|
if (obj === null) return null;
|
|
if (Array.isArray(obj)) return obj.map((v) => sanitize(v));
|
|
if (typeof obj === "object") {
|
|
const out = {};
|
|
Object.keys(obj).forEach((k) => {
|
|
const v = obj[k];
|
|
if (v === undefined) return; // omit undefined
|
|
out[k] = sanitize(v);
|
|
});
|
|
return out;
|
|
}
|
|
return obj;
|
|
};
|
|
|
|
const alertMessage = useCallback((title, message) => {
|
|
alert(title, message);
|
|
}, []);
|
|
|
|
const confirmSensitiveContent = useCallback(
|
|
(title, message) =>
|
|
new Promise((resolve) => {
|
|
alert(title, message, [
|
|
{
|
|
text: "Annuler",
|
|
style: "cancel",
|
|
onPress: () => resolve(false),
|
|
},
|
|
{
|
|
text: "Continuer",
|
|
style: "destructive",
|
|
onPress: () => resolve(true),
|
|
},
|
|
]);
|
|
}),
|
|
[]
|
|
);
|
|
|
|
const onValidate = useCallback(async () => {
|
|
try {
|
|
await setIsLoading(true);
|
|
const titleTrimmed = (titleValue || "").trim();
|
|
if (!titleTrimmed) {
|
|
alertMessage("Titre manquant", "Veuillez renseigner un titre.");
|
|
return;
|
|
}
|
|
const invalid = (sections || []).some((s) => !(s?.lyrics || "").trim());
|
|
if (invalid) {
|
|
alertMessage(
|
|
"Champs incomplets",
|
|
"Chaque couplet et refrain doit contenir du texte."
|
|
);
|
|
return;
|
|
}
|
|
const normalizedNewLyrics = (sections || []).map((s) => ({
|
|
type: (s?.type || "").toLowerCase(),
|
|
lyrics: (s?.lyrics || "").trim(),
|
|
}));
|
|
const normalizedOldLyrics = Array.isArray(projectLyrics)
|
|
? projectLyrics.map((s) => ({
|
|
type: (s?.type || "").toLowerCase(),
|
|
lyrics: (s?.lyrics || "").trim(),
|
|
}))
|
|
: [];
|
|
const sameLength =
|
|
normalizedOldLyrics.length === normalizedNewLyrics.length;
|
|
const isSame =
|
|
sameLength &&
|
|
normalizedOldLyrics.every(
|
|
(s, i) =>
|
|
s.type === normalizedNewLyrics[i]?.type &&
|
|
s.lyrics === normalizedNewLyrics[i]?.lyrics
|
|
);
|
|
|
|
// 1) Appel de la Cloud Function de modération avant tout enregistrement
|
|
try {
|
|
const callable = firebase
|
|
.functions()
|
|
.httpsCallable("lyrics-analyseLyricsToxicity");
|
|
const { data } = await callable({
|
|
title: titleTrimmed,
|
|
lyrics: normalizedNewLyrics,
|
|
});
|
|
|
|
if (!data?.success) {
|
|
// Blocage dur: afficher le message et ne pas sauvegarder
|
|
if (data?.errorCode === "TOXIC_CONTENT_BLOCKED") {
|
|
const quotes = Array.isArray(data?.result?.excerpts)
|
|
? data.result.excerpts
|
|
.slice(0, 3)
|
|
.map((e) => `• ${e.quote}`)
|
|
.join("\n")
|
|
: null;
|
|
alertMessage(
|
|
"Contenu interdit",
|
|
[data?.message, quotes].filter(Boolean).join("\n\n")
|
|
);
|
|
return; // stop here
|
|
}
|
|
// Erreur d'analyse: informer et arrêter
|
|
if (data?.errorCode === "ANALYSE_FAILED") {
|
|
alertMessage(
|
|
"Analyse indisponible",
|
|
"Impossible de vérifier la toxicité pour le moment. Réessayez plus tard."
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Cas signalé mais non bloquant: demander confirmation
|
|
if (data?.errorCode === "TOXIC_CONTENT_FLAGGED") {
|
|
const quotes = Array.isArray(data?.result?.excerpts)
|
|
? data.result.excerpts
|
|
.slice(0, 3)
|
|
.map((e) => `• ${e.quote}`)
|
|
.join("\n")
|
|
: null;
|
|
const msg = [data?.message, quotes].filter(Boolean).join("\n\n");
|
|
|
|
const proceed = await confirmSensitiveContent(
|
|
"Contenu potentiellement sensible",
|
|
msg
|
|
);
|
|
if (!proceed) return;
|
|
}
|
|
} catch (moderationError) {
|
|
console.log(
|
|
"Moderation call failed",
|
|
moderationError?.message || moderationError
|
|
);
|
|
alertMessage(
|
|
"Analyse indisponible",
|
|
"Impossible de vérifier la toxicité pour le moment. Réessayez plus tard."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const baseData = {
|
|
title: titleTrimmed,
|
|
lyrics: normalizedNewLyrics,
|
|
config: sanitize(projectConfig),
|
|
selections: sanitize(projectSelections),
|
|
updatedAt: firebase.firestore.FieldValue.serverTimestamp(),
|
|
hasLyrics: projectHasLyrics,
|
|
};
|
|
|
|
const updateData = { ...baseData };
|
|
if (!isSame) {
|
|
updateData.musicUrls = firebase.firestore.FieldValue.delete();
|
|
updateData.musicStatus = firebase.firestore.FieldValue.delete();
|
|
await projectsRef
|
|
.doc(selectedProjectId)
|
|
.set(updateData, { merge: true });
|
|
}
|
|
navigate(Routes.BottomTab, {
|
|
screen: Routes.HomeStack,
|
|
params: {
|
|
screen: Routes.Home,
|
|
params: { showLyricsCongrats: true },
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.log(e);
|
|
alertMessage("Erreur", "Échec de l'enregistrement dans le projet.");
|
|
} finally {
|
|
await setIsLoading(false);
|
|
}
|
|
}, [
|
|
titleValue,
|
|
sections,
|
|
projectConfig,
|
|
projectSelections,
|
|
alertMessage,
|
|
confirmSensitiveContent,
|
|
sanitize,
|
|
setIsLoading,
|
|
selectedProjectId,
|
|
projectHasLyrics,
|
|
projectLyrics,
|
|
]);
|
|
|
|
return (
|
|
<Page
|
|
backgroundImg={isWeb ? background.libraryBgWeb : background.writingBG}
|
|
headerType="NONE"
|
|
>
|
|
<MusicLandHeader
|
|
onPressBack={() => navigate(Routes.Home)}
|
|
progress={95}
|
|
logo={icons.musicLandWriting}
|
|
/>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
marginTop: 16,
|
|
gap: 48,
|
|
}}
|
|
onLayout={(e) => setContainerLayout(e.nativeEvent.layout)}
|
|
>
|
|
<ItemContainer
|
|
height={containerLayout?.height}
|
|
disableKeyboardHeight={isFocus !== sections.length - 1 || !isFocus}
|
|
>
|
|
<ScrollView
|
|
ref={scrollRef}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 10,
|
|
gap: 10,
|
|
flexGrow: 1,
|
|
}}
|
|
>
|
|
<View style={styles.headerContainer}>
|
|
<Text style={styles.headerTitle}>Proposition de paroles</Text>
|
|
<Text style={styles.instructions}>
|
|
Relis attentivement la proposition générée avant de valider.
|
|
</Text>
|
|
<Text style={styles.instructions}>
|
|
<Text style={styles.instructionsHighlight}>
|
|
Personnalisation :
|
|
</Text>{" "}
|
|
modifie le titre et chaque section pour que les paroles te
|
|
ressemblent.
|
|
</Text>
|
|
<Text style={styles.instructions}>
|
|
<Text style={styles.instructionsHighlight}>
|
|
Nouvelle génération :
|
|
</Text>{" "}
|
|
appuie sur "Générer d'autres paroles" si tu souhaites une autre
|
|
suggestion.
|
|
</Text>
|
|
</View>
|
|
<CustomInput
|
|
label="Titre"
|
|
placeholder="Titre"
|
|
height={45}
|
|
value={titleValue}
|
|
setValue={setTitleValue}
|
|
multiline={false}
|
|
maxLength={60}
|
|
onLayout={(e) => {
|
|
e.persist();
|
|
setItemsContainerLayout((prev) => [
|
|
...prev,
|
|
e?.nativeEvent?.layout,
|
|
]);
|
|
}}
|
|
/>
|
|
{sections.map((s, idx) => {
|
|
// Calculer l'index humain par type
|
|
const type = (s?.type || "").toLowerCase();
|
|
const countBefore = sections
|
|
.slice(0, idx)
|
|
.filter((x) => (x?.type || "").toLowerCase() === type).length;
|
|
const labelBase = type === "refrain" ? "Refrain" : "Couplet";
|
|
const label = `${labelBase} ${countBefore + 1}`;
|
|
return (
|
|
<CustomInput
|
|
key={idx}
|
|
label={label}
|
|
placeholder={labelBase}
|
|
height={type === "refrain" ? 170 : 225}
|
|
value={s?.lyrics || ""}
|
|
setValue={(val) => setSectionAt(idx, val)}
|
|
onFocus={() => {
|
|
setIsFocus(idx);
|
|
scrollRef?.current?.scrollTo({
|
|
y: itemsContainerLayout[idx + 1]?.y,
|
|
});
|
|
}}
|
|
onLayout={(e) => {
|
|
e.persist();
|
|
setItemsContainerLayout((prev) => [
|
|
...prev,
|
|
e?.nativeEvent?.layout,
|
|
]);
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</ItemContainer>
|
|
</View>
|
|
<View
|
|
style={{
|
|
paddingTop: gutters,
|
|
paddingBottom: gutters * 2,
|
|
paddingHorizontal: gutters,
|
|
gap: 12,
|
|
}}
|
|
>
|
|
{!projectHasLyrics && (
|
|
<BorderGradientButton
|
|
title="Générer d'autres paroles"
|
|
onPress={regenerate}
|
|
/>
|
|
)}
|
|
<GradientButton title="Valider" onPress={onValidate} />
|
|
</View>
|
|
</Page>
|
|
);
|
|
};
|
|
|
|
export default Lyrics;
|
|
|
|
const styles = StyleSheet.create({
|
|
headerContainer: {
|
|
gap: 8,
|
|
paddingHorizontal: 2,
|
|
},
|
|
headerTitle: {
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
fontSize: 20,
|
|
},
|
|
instructions: {
|
|
color: Palette.white,
|
|
fontFamily: FONT_FAMILY.InterRegular,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
},
|
|
instructionsHighlight: {
|
|
fontFamily: FONT_FAMILY.InterSemiBold,
|
|
},
|
|
});
|