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 ( navigate(Routes.Home)} progress={95} logo={icons.musicLandWriting} /> setContainerLayout(e.nativeEvent.layout)} > Proposition de paroles Relis attentivement la proposition générée avant de valider. Personnalisation : {" "} modifie le titre et chaque section pour que les paroles te ressemblent. Nouvelle génération : {" "} appuie sur "Générer d'autres paroles" si tu souhaites une autre suggestion. { 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 ( setSectionAt(idx, val)} onFocus={() => { setIsFocus(idx); scrollRef?.current?.scrollTo({ y: itemsContainerLayout[idx + 1]?.y, }); }} onLayout={(e) => { e.persist(); setItemsContainerLayout((prev) => [ ...prev, e?.nativeEvent?.layout, ]); }} /> ); })} {!projectHasLyrics && ( )} ); }; 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, }, });