Alors avant d'attaquer la solution à la problématique, essayons d'abord de la comprendre.

Rappelle-toi ce que l'on désire faire dans cette partie : permettre aux utilisateurs de modifier le titre d'une photo. Les photos sont récupérées depuis l'API dans le composant App et sont passées en "props" au composant "Photo". Naïvement, on aurait donc fait la chose suivante pour modifier leur titre :
{"language":"text/html","content":"<template>\n\t<!-- .. -->\n\t<input :value=\"title\" @update=\"title = $event.target.value\">\n</template>","filename":"Photo.vue"}
Ou en version raccourcie avec v-model :
{"language":"text/html","content":"<template>\n\t<!-- .. -->\n\t<input v-model=\"title\">\n</template>","filename":"Photo.vue"}
Le problème apparait alors avec ESLint :
9:36 error Unexpected mutation of "title" prop vue/no-mutating-props

Cela signifie qu'on a une erreur car on essaie de modifier la valeur d'une props, ce que Vue ne souhaite pas. En effet, la logique de Vue consiste à dire qu'un enfant ne peut pas modifier les données de son parent de cette manière, ça pourrait amener à des problématiques de "consistance" de l'application. C'est ce qu'on appelle un problème de "State management" (et on a des solutions pour palier à ce problème dans des grosses applications grâce à Vuex notamment, on verra ça bien plus tard).

Hé bien on va utiliser v-model mais de manière un peu différente ;). Premièrement, il faut appliquer v-model sur le composant enfant directement dans le composant parent.

C'est vrai que c'était peu clair. Un exemple te parlera sans doute mieux !
{"language":"text/html","content":"<template>\n <modal v-show=\"open\" @closeModal=\"close\">\n <template #title>Plus d'info</template>\n <template #default>\n Quelques explications à propos du site...\n </template>\n </modal>\n <h1 id=\"welcome\">Bienvenue sur notre site d'avis de photos !</h1>\n <button @click=\"open = !open\" class=\"button centered\">{{ openCloseText }}</button>\n\n <slider :slides=\"photos\" id=\"slider\">\n <template #default=\"{slide : photo, key : index}\">\n <h2>Photo {{ index+1 }}/{{ photos.length }}</h2>\n <photo \n v-bind=\"photo\"\n\t\t\t\tv-model=\"photo.title\"\n ></photo>\n </template>\n </slider>\n</template>\n\n<script>\nimport Photo from './components/Photo.vue'\nimport Slider from './components/Slider.vue'\nimport Modal from './components/Modal.vue'\n\nconst API_URL = 'https://api.nasa.gov/planetary/apod?start_date=2021-02-01&end_date=2021-02-21&api_key=DEMO_KEY'\nexport default {\n name: 'App',\n components: {\n Photo, Slider, Modal\n },\n data() {\n return {\n photos: [],\n open:false,\n }\n },\n created() {\n fetch(API_URL)\n .then(result => result.json())\n .then(result => {this.photos = result})\n },\n computed: {\n openCloseText() {\n return open ? 'En savoir +' : 'Fermer la modal'\n }\n },\n methods: {\n close(message) {\n this.open = false\n console.log(message)\n }\n }\n}\n</script>\n\n<style>\n #app{\n font-family: 'Roboto', sans-serif;\n }\n\n #welcome {\n text-align:center;\n }\n\n .button{\n display:block;\n border:none;\n padding:10px 15px;\n font-size:1.2em;\n color:white;\n background: #818CF8;\n cursor:pointer;\n }\n\n .centered{\n margin:10px auto;\n }\n\n #slider{\n width:80%;\n margin:auto;\n }\n</style>\n","filename":"App.vue"}
Mais il y a un léger problème avec ce code. Pour le déceler, il faut comprendre ce que fait v-model lorsqu'on l'utilise sur un composant.
Au début de ce chapitre je t'ai rappelé la chose suivante :
{"language":"text/html","content":"<input v-model=\"champ\">\n<!-- est équivalent à -->\n<input :value=\"champ\" @input=\"champ = $event.target.value\">","filename":""}
Mais avec les composants, ce n'est pas ce qui se fait. Non, on a avec les composants l'équivalence suivante :
{"language":"text/html","content":"<mon-composant v-model=\"champ\" />\n<!-- est équivalent à -->\n<mon-composant :model-value=\"champ\" @update:model-value=\"champ = $event\" />","filename":""}

Tu as tout compris. Et oui, il aura toujours ce nom là. Maintenant qu'on a mis au clair ce que faisait v-model sur un composant, il est donc tant de modifier notre code car actuellement nous avons un "problème". En effet, nous faisons v-bind="photo" et v-model="photo.title" sur le même élément. Au final, on aura donc deux fois la valeur title qui sera passée, sous deux noms de props différents. Mais si on veut tirer profit de notre v-model, il faut modifier notre composant Photo en rajoutant la props pour "model-value" !
{"language":"text/html","content":"<template>\n <figure class=\"photo\" v-bind=\"$attrs\">\n <img :src=\"url\" :alt=\"title + 'prise par' +copyright\" />\n <figcaption>\n <h2>{{ title }} prise le {{ formattedDate }} par {{ copyright }}</h2>\n <p>{{ explanation }}</p>\n </figcaption>\n </figure>\n <button @click=\"currentTab = 'Views'\" class=\"button tab\" :class=\"{underline:(currentTab == 'Views')}\">Avis</button>\n <button @click=\"currentTab = 'ViewForm'\" class=\"button tab\" :class=\"{underline:(currentTab == 'ViewForm')}\">Rédiger un avis</button>\n <keep-alive>\n <component :is=\"currentTab\" v-bind=\"dynamicProps\" @add-view=\"storeView\"></component>\n </keep-alive>\n</template>\n\n<script>\nimport Views from './Views.vue'\nimport ViewForm from './ViewForm.vue'\n\nexport default {\n components:{\n Views, ViewForm\n },\n props:{\n url: {\n type:String, \n required:true,\n default:'http://uneimagepardefaut.png'\n },\n modelValue: { // Je rajoute la props \"model-value\". Attention kebab-case / CamelCase\n type:String,\n required:true,\n },\n title: {\n type:String,\n required:true,\n },\n date: {\n type:String,\n required:true,\n default:Date.now()\n },\n explanation: {\n type:String,\n required:true,\n },\n copyright: {\n type:String,\n required:false,\n default:'un inconnu'\n }\n },\n data() {\n return {\n currentTab:'Views',\n views: [ ]\n }\n },\n computed: {\n formattedDate() {\n let date = new Date(this.date)\n let day = Number(date.getDate()) >= 10 ? date.getDate() : '0'+date.getDate()\n return `${day}/${date.getMonth()}/${date.getFullYear()}`\n },\n dynamicProps() {\n if(this.currentTab == 'Views') {\n return { views:this.views }\n }\n return null\n }\n }, \n methods:{\n storeView(author, content) {\n this.views.push({author:author, content:content})\n }\n }\n}\n</script>\n\n<style scoped>\n .underline{\n text-decoration: underline\n }\n .tab{\n display:inline-block;\n border-radius: 20px 20px 0 0;\n }\n\n .photo img{\n max-height:300px;\n }\n\n .photo{\n text-align:center;\n }\n</style>","filename":"Photo.vue"}
Ensuite, on peut mettre notre input pour modifier le champ title en utilisant la props "model-value". Attention cependant, on ne pourra pas utiliser v-model dessus. Nous ce qu'on veut c'est envoyer la donnée au parent. D'ailleurs, le parent écoute un évènement nommé "update:model-value" ! Il faut donc lui envoyer grâce à un emits !
{"language":"text/html","content":"<template>\n <figure class=\"photo\" v-bind=\"$attrs\">\n <img :src=\"url\" :alt=\"title + 'prise par' +copyright\" />\n <figcaption>\n <h2>{{ title }} prise le {{ formattedDate }} par {{ copyright }}</h2>\n <p>{{ explanation }}</p>\n </figcaption>\n <input\n :value=\"modelValue\"\n @input=\"$emit('update:modelValue', $event.target.value)\"\n >\n </figure>\n <button @click=\"currentTab = 'Views'\" class=\"button tab\" :class=\"{underline:(currentTab == 'Views')}\">Avis</button>\n <button @click=\"currentTab = 'ViewForm'\" class=\"button tab\" :class=\"{underline:(currentTab == 'ViewForm')}\">Rédiger un avis</button>\n <keep-alive>\n <component :is=\"currentTab\" v-bind=\"dynamicProps\" @add-view=\"storeView\"></component>\n </keep-alive>\n</template>\n\n<script>\nimport Views from './Views.vue'\nimport ViewForm from './ViewForm.vue'\n\nexport default {\n components:{\n Views, ViewForm\n },\n emits:['update:modelValue'],\n props:{\n url: {\n type:String, \n required:true,\n default:'http://uneimagepardefaut.png'\n },\n modelValue: {\n type:String,\n required:true,\n },\n title: {\n type:String,\n required:true,\n },\n date: {\n type:String,\n required:true,\n default:Date.now()\n },\n explanation: {\n type:String,\n required:true,\n },\n copyright: {\n type:String,\n required:false,\n default:'un inconnu'\n }\n },\n data() {\n return {\n currentTab:'Views',\n views: [ ]\n }\n },\n computed: {\n formattedDate() {\n let date = new Date(this.date)\n let day = Number(date.getDate()) >= 10 ? date.getDate() : '0'+date.getDate()\n return `${day}/${date.getMonth()}/${date.getFullYear()}`\n },\n dynamicProps() {\n if(this.currentTab == 'Views') {\n return { views:this.views }\n }\n return null\n }\n }, \n methods:{\n storeView(author, content) {\n this.views.push({author:author, content:content})\n }\n }\n}\n</script>\n\n<style scoped>\n .underline{\n text-decoration: underline\n }\n .tab{\n display:inline-block;\n border-radius: 20px 20px 0 0;\n }\n\n .photo img{\n max-height:300px;\n }\n\n .photo{\n text-align:center;\n }\n</style>","filename":"Photo.vue"}
Et voilà, le problème est réglé ! On a un v-model qui marche, qui répercute tout correctement !

Lequel ?

Tu as tout à fait raison. En fait, on peut nommer les v-model... En utilisant v-model:nom="valeur". Du coup on va modifier notre composant App.vue :
{"language":"text/html","content":"<template>\n <modal v-show=\"open\" @closeModal=\"close\">\n <template #title>Plus d'info</template>\n <template #default>\n Quelques explications à propos du site...\n </template>\n </modal>\n <h1 id=\"welcome\">Bienvenue sur notre site d'avis de photos !</h1>\n <button @click=\"open = !open\" class=\"button centered\">{{ openCloseText }}</button>\n\n <slider :slides=\"photos\" id=\"slider\">\n <template #default=\"{slide : photo, key : index}\">\n <h2>Photo {{ index+1 }}/{{ photos.length }}</h2>\n <photo \n :url=\"photo.url\" \n :copyright=\"photo.copyright\"\n :date=\"photo.date\"\n v-model:title=\"photo.title\"\n v-model:explanation=\"photo.explanation\"\n\n ></photo>\n </template>\n </slider>\n</template>\n\n<script>\nimport Photo from './components/Photo.vue'\nimport Slider from './components/Slider.vue'\nimport Modal from './components/Modal.vue'\n\nconst API_URL = 'https://api.nasa.gov/planetary/apod?start_date=2021-02-01&end_date=2021-02-21&api_key=DEMO_KEY'\nexport default {\n name: 'App',\n components: {\n Photo, Slider, Modal\n },\n data() {\n return {\n photos: [],\n open:false,\n }\n },\n created() {\n fetch(API_URL)\n .then(result => result.json())\n .then(result => {this.photos = result})\n },\n computed: {\n openCloseText() {\n return open ? 'En savoir +' : 'Fermer la modal'\n }\n },\n methods: {\n close(message) {\n this.open = false\n console.log(message)\n }\n }\n}\n</script>\n\n<style>\n #app{\n font-family: 'Roboto', sans-serif;\n }\n\n #welcome {\n text-align:center;\n }\n\n .button{\n display:block;\n border:none;\n padding:10px 15px;\n font-size:1.2em;\n color:white;\n background: #818CF8;\n cursor:pointer;\n }\n\n .centered{\n margin:10px auto;\n }\n\n #slider{\n width:80%;\n margin:auto;\n }\n</style>\n","filename":"App.vue"}
Et modifier notre composant Photo.vue :
{"language":"text/html","content":"<template>\n <figure class=\"photo\" v-bind=\"$attrs\">\n <img :src=\"url\" :alt=\"title + 'prise par' +copyright\" />\n <figcaption>\n <h2>{{ title }} prise le {{ formattedDate }} par {{ copyright }}</h2>\n <p>{{ explanation }}</p>\n </figcaption>\n <div>\n <label for=\"title\">Modifier le titre : </label>\n <input\n id=\"title\"\n :value=\"title\"\n @input=\"$emit('update:title', $event.target.value)\"\n >\n </div>\n <div>\n <label for=\"explanation\">Modifier l'explication : </label>\n <textarea \n id=\"explanation\"\n :value=\"explanation\"\n @input=\"$emit('update:explanation', $event.target.value)\"\n ></textarea>\n </div>\n </figure>\n <button @click=\"currentTab = 'Views'\" class=\"button tab\" :class=\"{underline:(currentTab == 'Views')}\">Avis</button>\n <button @click=\"currentTab = 'ViewForm'\" class=\"button tab\" :class=\"{underline:(currentTab == 'ViewForm')}\">Rédiger un avis</button>\n <keep-alive>\n <component :is=\"currentTab\" v-bind=\"dynamicProps\" @add-view=\"storeView\"></component>\n </keep-alive>\n</template>\n\n<script>\nimport Views from './Views.vue'\nimport ViewForm from './ViewForm.vue'\n\nexport default {\n components:{\n Views, ViewForm\n },\n emits:['update:title', 'update:explanation'],\n props:{\n url: {\n type:String, \n required:true,\n default:'http://uneimagepardefaut.png'\n },\n title: {\n type:String,\n required:true,\n },\n date: {\n type:String,\n required:true,\n default:Date.now()\n },\n explanation: {\n type:String,\n required:true,\n },\n copyright: {\n type:String,\n required:false,\n default:'un inconnu'\n }\n },\n data() {\n return {\n currentTab:'Views',\n views: [ ]\n }\n },\n computed: {\n formattedDate() {\n let date = new Date(this.date)\n let day = Number(date.getDate()) >= 10 ? date.getDate() : '0'+date.getDate()\n return `${day}/${date.getMonth()}/${date.getFullYear()}`\n },\n dynamicProps() {\n if(this.currentTab == 'Views') {\n return { views:this.views }\n }\n return null\n }\n }, \n methods:{\n storeView(author, content) {\n this.views.push({author:author, content:content})\n }\n }\n}\n</script>\n\n<style scoped>\n .underline{\n text-decoration: underline\n }\n .tab{\n display:inline-block;\n border-radius: 20px 20px 0 0;\n }\n\n .photo img{\n max-height:300px;\n }\n\n .photo{\n text-align:center;\n }\n</style>","filename":"Photo.vue"}
Et voilà ! Tu sais utiliser v-model sur des props !

Hé bien... Au niveau des composants on commence à être bons ! Tu as terminé ton apprentissage ! Félicitations ;). Je suis très heureux que tu sois arrivé jusqu'ici ! Si jamais tu as envie d'aller un peu plus loin, d'améliorer certains points, et surtout de savoir vers quoi tu peux te diriger pour continuer ton apprentissage avec Vue.JS, je te laisse me suivre sur le dernier chapitre de ce cours ! J'ai terminé cette partie