[{"data":1,"prerenderedAt":20132},["ShallowReactive",2],{"post-\u002Ffr\u002Fposts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation":3,"post-link-fr-2349bbcd-1535-4f33-8da7-299f46af18b9":5475},{"id":4,"title":5,"body":6,"contentId":5463,"date":5464,"description":5465,"extension":5466,"meta":5467,"navigation":329,"path":5468,"rawbody":5469,"robots":5470,"schemaOrg":5470,"seo":5471,"sitemap":5472,"stem":5473,"__hash__":5474},"posts\u002Ffr\u002Fposts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation.md","Nuxt Devient Full-Stack : Comment Valider des formulaires ?",{"type":7,"value":8,"toc":5457},"minimark",[9,26,32,5453],[10,11,12,13,20,21,25],"p",{},"Depuis des semaines, je construis une application full-stack appelée Orion avec Nuxt et ",[14,15,19],"a",{"href":16,"rel":17},"https:\u002F\u002Fhub.nuxt.com",[18],"nofollow","NuxtHub",". Tout au long de ce parcours, j'ai rencontré de nombreux défis, et l'un d'eux est la validation des formulaires. Dans cet article, je vais vous montrer comment j'ai réussi à ",[22,23,24],"strong",{},"valider les formulaires à la fois côté client et côté serveur sans duplications"," de la logique de validation, des champs de texte simples aux téléchargements de fichiers complexes avec NuxtHub.",[10,27,28],{},[29,30,31],"em",{},"Orion est une collection de modèles basée sur la communauté pour votre prochain projet, des pages de destination aux applications web complètes. NuxtHub est une plateforme de déploiement et d'administration pour Nuxt, propulsée par Cloudflare.",[33,34,36,45,53,58,77,88,92,95,256,259,369,375,380,385,401,561,567],"callout-read-more",{"id":35},"2349bbcd-1535-4f33-8da7-299f46af18b9",[10,37,38],{},[29,39,40,41,44],{},"Pendant cet article, je vais parler de ",[14,42,19],{"href":16,"rel":43},[18]," car il fournit les primitives pour construire des applications full-stack, mais même si vous ne l'utilisez pas, vous pouvez facilement suivre cet article. Les formulaires seront gérés sans aucune bibliothèque.",[10,46,47,48],{},"Vous pouvez parcourir le code exemple dans ",[49,50,52],"git-hub-link",{"repo":51},"barbapapazes\u002Fvalidate-forms-in-nuxt",".",[54,55,57],"h2",{"id":56},"le-problème","Le Problème",[10,59,60,61,64,65,68,69,72,73,76],{},"Sur le web, les formulaires sont omniprésents. Ils sont le principal moyen d'interaction et de demande d'entrée utilisateur. Mais sur le web, il y a une règle essentielle : ",[22,62,63],{},"ne jamais faire confiance au client"," (absolument jamais et en aucun cas). Cela signifie que ",[22,66,67],{},"toutes les données"," provenant du client doivent être ",[22,70,71],{},"vérifiées"," et ",[22,74,75],{},"sanitisées"," sur le serveur avant d'être traitées ou stockées dans une base de données. Un client peut être un navigateur, une application mobile, une requête HTTP ou tout autre objet capable d'envoyer des données à votre serveur, construit par vous ou non.",[10,78,79,80,83,84,87],{},"Lorsque vous construisez une MPA traditionnelle (Multi-Page Application), seul le serveur est responsable de la validation. Pour une SPA (Single-Page Application), à la fois le client et le serveur sont responsables de la validation pour fournir une meilleure expérience utilisateur. ",[29,81,82],{},"Cela est simplifié car il existe un moyen de donner une impression de validation côté client avec une MPA traditionnelle, mais ce n'est pas le sujet de cet article."," MAIS, même si les données sont validées côté client, il est ",[22,85,86],{},"obligatoire"," de les valider côté serveur.",[54,89,91],{"id":90},"le-formulaire-le-plus-simple","Le Formulaire le Plus Simple",[10,93,94],{},"Commençons notre parcours avec le formulaire le plus simple : une seule entrée de texte.",[96,97,102],"pre",{"className":98,"code":99,"language":100,"meta":101,"style":101},"language-vue shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u003Ctemplate>\n  \u003Cform method=\"post\" action=\"\u002Ftag\">\n    \u003Cinput type=\"text\" placeholder=\"Nom\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n","vue","",[103,104,105,121,160,198,219,226,236,246],"code",{"__ignoreMap":101},[106,107,110,114,118],"span",{"class":108,"line":109},"line",1,[106,111,113],{"class":112},"sMK4o","\u003C",[106,115,117],{"class":116},"swJcz","template",[106,119,120],{"class":112},">\n",[106,122,124,127,130,134,137,140,144,146,149,151,153,156,158],{"class":108,"line":123},2,[106,125,126],{"class":112},"  \u003C",[106,128,129],{"class":116},"form",[106,131,133],{"class":132},"spNyl"," method",[106,135,136],{"class":112},"=",[106,138,139],{"class":112},"\"",[106,141,143],{"class":142},"sfazB","post",[106,145,139],{"class":112},[106,147,148],{"class":132}," action",[106,150,136],{"class":112},[106,152,139],{"class":112},[106,154,155],{"class":142},"\u002Ftag",[106,157,139],{"class":112},[106,159,120],{"class":112},[106,161,163,166,169,172,174,176,179,181,184,186,188,191,193,196],{"class":108,"line":162},3,[106,164,165],{"class":112},"    \u003C",[106,167,168],{"class":116},"input",[106,170,171],{"class":132}," type",[106,173,136],{"class":112},[106,175,139],{"class":112},[106,177,178],{"class":142},"text",[106,180,139],{"class":112},[106,182,183],{"class":132}," placeholder",[106,185,136],{"class":112},[106,187,139],{"class":112},[106,189,190],{"class":142},"Nom",[106,192,139],{"class":112},[106,194,195],{"class":132}," required",[106,197,120],{"class":112},[106,199,201,203,206,208,210,212,215,217],{"class":108,"line":200},4,[106,202,165],{"class":112},[106,204,205],{"class":116},"button",[106,207,171],{"class":132},[106,209,136],{"class":112},[106,211,139],{"class":112},[106,213,214],{"class":142},"submit",[106,216,139],{"class":112},[106,218,120],{"class":112},[106,220,222],{"class":108,"line":221},5,[106,223,225],{"class":224},"sTEyZ","      Créer Tag\n",[106,227,229,232,234],{"class":108,"line":228},6,[106,230,231],{"class":112},"    \u003C\u002F",[106,233,205],{"class":116},[106,235,120],{"class":112},[106,237,239,242,244],{"class":108,"line":238},7,[106,240,241],{"class":112},"  \u003C\u002F",[106,243,129],{"class":116},[106,245,120],{"class":112},[106,247,249,252,254],{"class":108,"line":248},8,[106,250,251],{"class":112},"\u003C\u002F",[106,253,117],{"class":116},[106,255,120],{"class":112},[10,257,258],{},"Ensuite, côté serveur, je peux créer une route pour gérer la soumission du formulaire :",[96,260,264],{"className":261,"code":262,"language":263,"meta":101,"style":101},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","export default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n","ts",[103,265,266,301,325,331,337,341,362],{"__ignoreMap":101},[106,267,268,272,275,279,282,285,288,292,295,298],{"class":108,"line":109},[106,269,271],{"class":270},"s7zQu","export",[106,273,274],{"class":270}," default",[106,276,278],{"class":277},"s2Zo4"," defineEventHandler",[106,280,281],{"class":224},"(",[106,283,284],{"class":132},"async",[106,286,287],{"class":112}," (",[106,289,291],{"class":290},"sHdIc","event",[106,293,294],{"class":112},")",[106,296,297],{"class":132}," =>",[106,299,300],{"class":112}," {\n",[106,302,303,306,309,312,315,318,320,322],{"class":108,"line":123},[106,304,305],{"class":132},"  const",[106,307,308],{"class":224}," body",[106,310,311],{"class":112}," =",[106,313,314],{"class":270}," await",[106,316,317],{"class":277}," readBody",[106,319,281],{"class":116},[106,321,291],{"class":224},[106,323,324],{"class":116},")\n",[106,326,327],{"class":108,"line":162},[106,328,330],{"emptyLinePlaceholder":329},true,"\n",[106,332,333],{"class":108,"line":200},[106,334,336],{"class":335},"sHwdD","  \u002F\u002F Enregistrer dans la base de données...\n",[106,338,339],{"class":108,"line":221},[106,340,330],{"emptyLinePlaceholder":329},[106,342,343,346,349,351,353,356,360],{"class":108,"line":228},[106,344,345],{"class":270},"  return",[106,347,348],{"class":277}," sendNoContent",[106,350,281],{"class":116},[106,352,291],{"class":224},[106,354,355],{"class":112},",",[106,357,359],{"class":358},"sbssI"," 201",[106,361,324],{"class":116},[106,363,364,367],{"class":108,"line":238},[106,365,366],{"class":112},"}",[106,368,324],{"class":224},[10,370,371,372],{},"Pour l'instant, je n'ai aucune validation. Je peux envoyer n'importe quoi au serveur, et il l'acceptera. ",[29,373,374],{},"C'est un énorme problème de sécurité.",[10,376,377],{},[29,378,379],{},"Rappelez-vous que la façon dont je gère la validation est spécifique à une SPA. Une MPA traditionnelle aurait ajouté des erreurs aux messages flash et redirigé la demande.",[381,382,384],"h3",{"id":383},"valider-le-corps","Valider le Corps",[10,386,387,388,393,394,397,398,52],{},"Pour valider le corps côté serveur, j'utilise ",[14,389,392],{"href":390,"rel":391},"https:\u002F\u002Fzod.dev",[18],"Zod",". ",[29,395,396],{},"Zod est une validation de schéma en TypeScript avec inférence de type statique."," La bonne nouvelle est qu'h3, le routeur et le framework web qui alimentent Nitro, la couche serveur de Nuxt, fournit une utilitaire pour valider le corps : ",[103,399,400],{},"readValidatedBody",[96,402,404],{"className":261,"code":403,"language":263,"meta":101,"style":101},"import { z } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const body = await readValidatedBody(event, z.object({\n    name: z.string(),\n  }).parse)\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n",[103,405,406,432,436,458,489,510,524,528,532,537,554],{"__ignoreMap":101},[106,407,408,411,414,417,420,423,426,429],{"class":108,"line":109},[106,409,410],{"class":270},"import",[106,412,413],{"class":112}," {",[106,415,416],{"class":224}," z",[106,418,419],{"class":112}," }",[106,421,422],{"class":270}," from",[106,424,425],{"class":112}," '",[106,427,428],{"class":142},"zod",[106,430,431],{"class":112},"'\n",[106,433,434],{"class":108,"line":123},[106,435,330],{"emptyLinePlaceholder":329},[106,437,438,440,442,444,446,448,450,452,454,456],{"class":108,"line":162},[106,439,271],{"class":270},[106,441,274],{"class":270},[106,443,278],{"class":277},[106,445,281],{"class":224},[106,447,284],{"class":132},[106,449,287],{"class":112},[106,451,291],{"class":290},[106,453,294],{"class":112},[106,455,297],{"class":132},[106,457,300],{"class":112},[106,459,460,462,464,466,468,471,473,475,477,479,481,484,486],{"class":108,"line":200},[106,461,305],{"class":132},[106,463,308],{"class":224},[106,465,311],{"class":112},[106,467,314],{"class":270},[106,469,470],{"class":277}," readValidatedBody",[106,472,281],{"class":116},[106,474,291],{"class":224},[106,476,355],{"class":112},[106,478,416],{"class":224},[106,480,52],{"class":112},[106,482,483],{"class":277},"object",[106,485,281],{"class":116},[106,487,488],{"class":112},"{\n",[106,490,491,494,497,499,501,504,507],{"class":108,"line":221},[106,492,493],{"class":116},"    name",[106,495,496],{"class":112},":",[106,498,416],{"class":224},[106,500,52],{"class":112},[106,502,503],{"class":277},"string",[106,505,506],{"class":116},"()",[106,508,509],{"class":112},",\n",[106,511,512,515,517,519,522],{"class":108,"line":228},[106,513,514],{"class":112},"  }",[106,516,294],{"class":116},[106,518,52],{"class":112},[106,520,521],{"class":224},"parse",[106,523,324],{"class":116},[106,525,526],{"class":108,"line":238},[106,527,330],{"emptyLinePlaceholder":329},[106,529,530],{"class":108,"line":248},[106,531,336],{"class":335},[106,533,535],{"class":108,"line":534},9,[106,536,330],{"emptyLinePlaceholder":329},[106,538,540,542,544,546,548,550,552],{"class":108,"line":539},10,[106,541,345],{"class":270},[106,543,348],{"class":277},[106,545,281],{"class":116},[106,547,291],{"class":224},[106,549,355],{"class":112},[106,551,359],{"class":358},[106,553,324],{"class":116},[106,555,557,559],{"class":108,"line":556},11,[106,558,366],{"class":112},[106,560,324],{"class":224},[10,562,563,564,566],{},"L'utilitaire ",[103,565,400],{}," lancera automatiquement une erreur si le corps ne correspond pas au schéma. Par exemple, si le nom est manquant, la requête échouera.",[568,569,573,576,583,867,874,1162,1171,1175,1178,1697,1721,1725,1728,1731,1742,1829,1838,1841,1844,1938,1941,2052,2055,2059,2066,2071,2075,2078,2211,2222,2239,2745,2748,2755,3030,3033,3468,3475,3478,3571,3575,3584,3766,3770,3773,3779,3782,3961,3964,4265,4268,4274,4625,4632,4894,4904,5249,5255,5427,5431,5447,5450],"callout",{"icon":570,"text":571,"to":572},"i-unjs-h3","Valider les Données avec H3","https:\u002F\u002Fh3.unjs.io\u002Fexamples\u002Fvalidate-data",[10,574,575],{},"Par défaut, l'erreur sera gérée par le serveur et lancera une erreur 500, mais je peux capturer l'erreur pour renvoyer un message d'erreur plus convivial.",[10,577,578,579,582],{},"La première solution consiste à utiliser la méthode ",[103,580,581],{},"safeParse"," qui ne lancera pas d'erreur mais renverra un résultat avec l'erreur le cas échéant.",[96,584,586],{"className":261,"code":585,"language":263,"meta":101,"style":101},"import { z } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const result = await readValidatedBody(event, z.object({\n    name: z.string(),\n  }).safeParse)\n\n  if (!result.success) {\n    throw createError({\n      status: 400,\n      message: 'Données non valides',\n      data: result.error.errors[0].message,\n    })\n  }\n\n  const body = result.data\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n",[103,587,588,606,610,632,661,677,689,693,716,728,740,757,793,801,807,812,828,833,838,843,860],{"__ignoreMap":101},[106,589,590,592,594,596,598,600,602,604],{"class":108,"line":109},[106,591,410],{"class":270},[106,593,413],{"class":112},[106,595,416],{"class":224},[106,597,419],{"class":112},[106,599,422],{"class":270},[106,601,425],{"class":112},[106,603,428],{"class":142},[106,605,431],{"class":112},[106,607,608],{"class":108,"line":123},[106,609,330],{"emptyLinePlaceholder":329},[106,611,612,614,616,618,620,622,624,626,628,630],{"class":108,"line":162},[106,613,271],{"class":270},[106,615,274],{"class":270},[106,617,278],{"class":277},[106,619,281],{"class":224},[106,621,284],{"class":132},[106,623,287],{"class":112},[106,625,291],{"class":290},[106,627,294],{"class":112},[106,629,297],{"class":132},[106,631,300],{"class":112},[106,633,634,636,639,641,643,645,647,649,651,653,655,657,659],{"class":108,"line":200},[106,635,305],{"class":132},[106,637,638],{"class":224}," result",[106,640,311],{"class":112},[106,642,314],{"class":270},[106,644,470],{"class":277},[106,646,281],{"class":116},[106,648,291],{"class":224},[106,650,355],{"class":112},[106,652,416],{"class":224},[106,654,52],{"class":112},[106,656,483],{"class":277},[106,658,281],{"class":116},[106,660,488],{"class":112},[106,662,663,665,667,669,671,673,675],{"class":108,"line":221},[106,664,493],{"class":116},[106,666,496],{"class":112},[106,668,416],{"class":224},[106,670,52],{"class":112},[106,672,503],{"class":277},[106,674,506],{"class":116},[106,676,509],{"class":112},[106,678,679,681,683,685,687],{"class":108,"line":228},[106,680,514],{"class":112},[106,682,294],{"class":116},[106,684,52],{"class":112},[106,686,581],{"class":224},[106,688,324],{"class":116},[106,690,691],{"class":108,"line":238},[106,692,330],{"emptyLinePlaceholder":329},[106,694,695,698,700,703,706,708,711,714],{"class":108,"line":248},[106,696,697],{"class":270},"  if",[106,699,287],{"class":116},[106,701,702],{"class":112},"!",[106,704,705],{"class":224},"result",[106,707,52],{"class":112},[106,709,710],{"class":224},"success",[106,712,713],{"class":116},") ",[106,715,488],{"class":112},[106,717,718,721,724,726],{"class":108,"line":534},[106,719,720],{"class":270},"    throw",[106,722,723],{"class":277}," createError",[106,725,281],{"class":116},[106,727,488],{"class":112},[106,729,730,733,735,738],{"class":108,"line":539},[106,731,732],{"class":116},"      status",[106,734,496],{"class":112},[106,736,737],{"class":358}," 400",[106,739,509],{"class":112},[106,741,742,745,747,749,752,755],{"class":108,"line":556},[106,743,744],{"class":116},"      message",[106,746,496],{"class":112},[106,748,425],{"class":112},[106,750,751],{"class":142},"Données non valides",[106,753,754],{"class":112},"'",[106,756,509],{"class":112},[106,758,760,763,765,767,769,772,774,777,780,783,786,788,791],{"class":108,"line":759},12,[106,761,762],{"class":116},"      data",[106,764,496],{"class":112},[106,766,638],{"class":224},[106,768,52],{"class":112},[106,770,771],{"class":224},"error",[106,773,52],{"class":112},[106,775,776],{"class":224},"errors",[106,778,779],{"class":116},"[",[106,781,782],{"class":358},"0",[106,784,785],{"class":116},"]",[106,787,52],{"class":112},[106,789,790],{"class":224},"message",[106,792,509],{"class":112},[106,794,796,799],{"class":108,"line":795},13,[106,797,798],{"class":112},"    }",[106,800,324],{"class":116},[106,802,804],{"class":108,"line":803},14,[106,805,806],{"class":112},"  }\n",[106,808,810],{"class":108,"line":809},15,[106,811,330],{"emptyLinePlaceholder":329},[106,813,815,817,819,821,823,825],{"class":108,"line":814},16,[106,816,305],{"class":132},[106,818,308],{"class":224},[106,820,311],{"class":112},[106,822,638],{"class":224},[106,824,52],{"class":112},[106,826,827],{"class":224},"data\n",[106,829,831],{"class":108,"line":830},17,[106,832,330],{"emptyLinePlaceholder":329},[106,834,836],{"class":108,"line":835},18,[106,837,336],{"class":335},[106,839,841],{"class":108,"line":840},19,[106,842,330],{"emptyLinePlaceholder":329},[106,844,846,848,850,852,854,856,858],{"class":108,"line":845},20,[106,847,345],{"class":270},[106,849,348],{"class":277},[106,851,281],{"class":116},[106,853,291],{"class":224},[106,855,355],{"class":112},[106,857,359],{"class":358},[106,859,324],{"class":116},[106,861,863,865],{"class":108,"line":862},21,[106,864,366],{"class":112},[106,866,324],{"class":224},[10,868,869,870,873],{},"La seconde solution consiste à utiliser la méthode ",[103,871,872],{},"parseAsync"," pour attacher une gestion d'erreur manuelle à la promesse.",[96,875,877],{"className":261,"code":876,"language":263,"meta":101,"style":101},"import type { ZodError } from 'zod'\nimport { z } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const body = await readValidatedBody(event, data => z.object({\n    name: z.string(),\n  }).parseAsync(data).catch((error) => {\n    if (error instanceof ZodError) {\n      throw createError({\n        status: 400,\n        message: 'Données non valides',\n        data: error.errors[0].message,\n      })\n    }\n  }))\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n",[103,878,879,900,918,922,944,977,993,1027,1046,1057,1068,1083,1109,1116,1121,1128,1132,1136,1140,1156],{"__ignoreMap":101},[106,880,881,883,885,887,890,892,894,896,898],{"class":108,"line":109},[106,882,410],{"class":270},[106,884,171],{"class":270},[106,886,413],{"class":112},[106,888,889],{"class":224}," ZodError",[106,891,419],{"class":112},[106,893,422],{"class":270},[106,895,425],{"class":112},[106,897,428],{"class":142},[106,899,431],{"class":112},[106,901,902,904,906,908,910,912,914,916],{"class":108,"line":123},[106,903,410],{"class":270},[106,905,413],{"class":112},[106,907,416],{"class":224},[106,909,419],{"class":112},[106,911,422],{"class":270},[106,913,425],{"class":112},[106,915,428],{"class":142},[106,917,431],{"class":112},[106,919,920],{"class":108,"line":162},[106,921,330],{"emptyLinePlaceholder":329},[106,923,924,926,928,930,932,934,936,938,940,942],{"class":108,"line":200},[106,925,271],{"class":270},[106,927,274],{"class":270},[106,929,278],{"class":277},[106,931,281],{"class":224},[106,933,284],{"class":132},[106,935,287],{"class":112},[106,937,291],{"class":290},[106,939,294],{"class":112},[106,941,297],{"class":132},[106,943,300],{"class":112},[106,945,946,948,950,952,954,956,958,960,962,965,967,969,971,973,975],{"class":108,"line":221},[106,947,305],{"class":132},[106,949,308],{"class":224},[106,951,311],{"class":112},[106,953,314],{"class":270},[106,955,470],{"class":277},[106,957,281],{"class":116},[106,959,291],{"class":224},[106,961,355],{"class":112},[106,963,964],{"class":290}," data",[106,966,297],{"class":132},[106,968,416],{"class":224},[106,970,52],{"class":112},[106,972,483],{"class":277},[106,974,281],{"class":116},[106,976,488],{"class":112},[106,978,979,981,983,985,987,989,991],{"class":108,"line":228},[106,980,493],{"class":116},[106,982,496],{"class":112},[106,984,416],{"class":224},[106,986,52],{"class":112},[106,988,503],{"class":277},[106,990,506],{"class":116},[106,992,509],{"class":112},[106,994,995,997,999,1001,1003,1005,1008,1010,1012,1015,1017,1019,1021,1023,1025],{"class":108,"line":238},[106,996,514],{"class":112},[106,998,294],{"class":116},[106,1000,52],{"class":112},[106,1002,872],{"class":277},[106,1004,281],{"class":116},[106,1006,1007],{"class":224},"data",[106,1009,294],{"class":116},[106,1011,52],{"class":112},[106,1013,1014],{"class":277},"catch",[106,1016,281],{"class":116},[106,1018,281],{"class":112},[106,1020,771],{"class":290},[106,1022,294],{"class":112},[106,1024,297],{"class":132},[106,1026,300],{"class":112},[106,1028,1029,1032,1034,1036,1039,1042,1044],{"class":108,"line":248},[106,1030,1031],{"class":270},"    if",[106,1033,287],{"class":116},[106,1035,771],{"class":224},[106,1037,1038],{"class":112}," instanceof",[106,1040,889],{"class":1041},"sBMFI",[106,1043,713],{"class":116},[106,1045,488],{"class":112},[106,1047,1048,1051,1053,1055],{"class":108,"line":534},[106,1049,1050],{"class":270},"      throw",[106,1052,723],{"class":277},[106,1054,281],{"class":116},[106,1056,488],{"class":112},[106,1058,1059,1062,1064,1066],{"class":108,"line":539},[106,1060,1061],{"class":116},"        status",[106,1063,496],{"class":112},[106,1065,737],{"class":358},[106,1067,509],{"class":112},[106,1069,1070,1073,1075,1077,1079,1081],{"class":108,"line":556},[106,1071,1072],{"class":116},"        message",[106,1074,496],{"class":112},[106,1076,425],{"class":112},[106,1078,751],{"class":142},[106,1080,754],{"class":112},[106,1082,509],{"class":112},[106,1084,1085,1088,1090,1093,1095,1097,1099,1101,1103,1105,1107],{"class":108,"line":759},[106,1086,1087],{"class":116},"        data",[106,1089,496],{"class":112},[106,1091,1092],{"class":224}," error",[106,1094,52],{"class":112},[106,1096,776],{"class":224},[106,1098,779],{"class":116},[106,1100,782],{"class":358},[106,1102,785],{"class":116},[106,1104,52],{"class":112},[106,1106,790],{"class":224},[106,1108,509],{"class":112},[106,1110,1111,1114],{"class":108,"line":795},[106,1112,1113],{"class":112},"      }",[106,1115,324],{"class":116},[106,1117,1118],{"class":108,"line":803},[106,1119,1120],{"class":112},"    }\n",[106,1122,1123,1125],{"class":108,"line":809},[106,1124,514],{"class":112},[106,1126,1127],{"class":116},"))\n",[106,1129,1130],{"class":108,"line":814},[106,1131,330],{"emptyLinePlaceholder":329},[106,1133,1134],{"class":108,"line":830},[106,1135,336],{"class":335},[106,1137,1138],{"class":108,"line":835},[106,1139,330],{"emptyLinePlaceholder":329},[106,1141,1142,1144,1146,1148,1150,1152,1154],{"class":108,"line":840},[106,1143,345],{"class":270},[106,1145,348],{"class":277},[106,1147,281],{"class":116},[106,1149,291],{"class":224},[106,1151,355],{"class":112},[106,1153,359],{"class":358},[106,1155,324],{"class":116},[106,1157,1158,1160],{"class":108,"line":845},[106,1159,366],{"class":112},[106,1161,324],{"class":224},[10,1163,1164],{},[29,1165,1166,1167,1170],{},"Vous pourriez envelopper le validateur avec un ",[103,1168,1169],{},"try\u002Fcatch",", mais je trouve cela moins élégant et lisible.",[381,1172,1174],{"id":1173},"valider-le-frontend","Valider le Frontend",[10,1176,1177],{},"Côté frontend, je peux également utiliser Zod pour valider le formulaire avant de l'envoyer au serveur.",[96,1179,1181],{"className":98,"code":1180,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nimport type { ZodError } from 'zod'\nimport { z } from 'zod'\n\nconst schema = z.object({\n  name: z.string({ message: 'Obligatoire' }), \u002F\u002F Je peux personnaliser le message d'erreur affiché à l'utilisateur\n})\n\nconst state = reactive({\n  name: '',\n})\n\nasync function onSubmit() {\n  try {\n    const data = schema.parse(state)\n    await $fetch('\u002Ftag', {\n      method: 'post',\n      body: data,\n    })\n  }\n  catch (error) {\n    if (error instanceof ZodError) {\n      throw createError({\n        status: 400,\n        message: 'Données non valides',\n        data: error.errors,\n      })\n    }\n  }\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cform @submit.prevent=\"onSubmit()\">\n    \u003Cinput v-model=\"state.name\" type=\"text\" placeholder=\"Nom\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n",[103,1182,1183,1206,1226,1244,1248,1268,1307,1313,1317,1333,1344,1350,1354,1368,1375,1398,1418,1433,1444,1450,1454,1467,1484,1495,1506,1521,1536,1543,1548,1553,1559,1568,1573,1582,1603,1646,1665,1670,1679,1688],{"__ignoreMap":101},[106,1184,1185,1187,1190,1193,1195,1197,1199,1201,1204],{"class":108,"line":109},[106,1186,113],{"class":112},[106,1188,1189],{"class":116},"script",[106,1191,1192],{"class":132}," lang",[106,1194,136],{"class":112},[106,1196,139],{"class":112},[106,1198,263],{"class":142},[106,1200,139],{"class":112},[106,1202,1203],{"class":132}," setup",[106,1205,120],{"class":112},[106,1207,1208,1210,1212,1214,1216,1218,1220,1222,1224],{"class":108,"line":123},[106,1209,410],{"class":270},[106,1211,171],{"class":270},[106,1213,413],{"class":112},[106,1215,889],{"class":224},[106,1217,419],{"class":112},[106,1219,422],{"class":270},[106,1221,425],{"class":112},[106,1223,428],{"class":142},[106,1225,431],{"class":112},[106,1227,1228,1230,1232,1234,1236,1238,1240,1242],{"class":108,"line":162},[106,1229,410],{"class":270},[106,1231,413],{"class":112},[106,1233,416],{"class":224},[106,1235,419],{"class":112},[106,1237,422],{"class":270},[106,1239,425],{"class":112},[106,1241,428],{"class":142},[106,1243,431],{"class":112},[106,1245,1246],{"class":108,"line":200},[106,1247,330],{"emptyLinePlaceholder":329},[106,1249,1250,1253,1256,1258,1260,1262,1264,1266],{"class":108,"line":221},[106,1251,1252],{"class":132},"const",[106,1254,1255],{"class":224}," schema ",[106,1257,136],{"class":112},[106,1259,416],{"class":224},[106,1261,52],{"class":112},[106,1263,483],{"class":277},[106,1265,281],{"class":224},[106,1267,488],{"class":112},[106,1269,1270,1273,1275,1277,1279,1281,1283,1286,1289,1291,1293,1296,1298,1300,1302,1304],{"class":108,"line":228},[106,1271,1272],{"class":116},"  name",[106,1274,496],{"class":112},[106,1276,416],{"class":224},[106,1278,52],{"class":112},[106,1280,503],{"class":277},[106,1282,281],{"class":224},[106,1284,1285],{"class":112},"{",[106,1287,1288],{"class":116}," message",[106,1290,496],{"class":112},[106,1292,425],{"class":112},[106,1294,1295],{"class":142},"Obligatoire",[106,1297,754],{"class":112},[106,1299,419],{"class":112},[106,1301,294],{"class":224},[106,1303,355],{"class":112},[106,1305,1306],{"class":335}," \u002F\u002F Je peux personnaliser le message d'erreur affiché à l'utilisateur\n",[106,1308,1309,1311],{"class":108,"line":238},[106,1310,366],{"class":112},[106,1312,324],{"class":224},[106,1314,1315],{"class":108,"line":248},[106,1316,330],{"emptyLinePlaceholder":329},[106,1318,1319,1321,1324,1326,1329,1331],{"class":108,"line":534},[106,1320,1252],{"class":132},[106,1322,1323],{"class":224}," state ",[106,1325,136],{"class":112},[106,1327,1328],{"class":277}," reactive",[106,1330,281],{"class":224},[106,1332,488],{"class":112},[106,1334,1335,1337,1339,1342],{"class":108,"line":539},[106,1336,1272],{"class":116},[106,1338,496],{"class":112},[106,1340,1341],{"class":112}," ''",[106,1343,509],{"class":112},[106,1345,1346,1348],{"class":108,"line":556},[106,1347,366],{"class":112},[106,1349,324],{"class":224},[106,1351,1352],{"class":108,"line":759},[106,1353,330],{"emptyLinePlaceholder":329},[106,1355,1356,1358,1361,1364,1366],{"class":108,"line":795},[106,1357,284],{"class":132},[106,1359,1360],{"class":132}," function",[106,1362,1363],{"class":277}," onSubmit",[106,1365,506],{"class":112},[106,1367,300],{"class":112},[106,1369,1370,1373],{"class":108,"line":803},[106,1371,1372],{"class":270},"  try",[106,1374,300],{"class":112},[106,1376,1377,1380,1382,1384,1387,1389,1391,1393,1396],{"class":108,"line":809},[106,1378,1379],{"class":132},"    const",[106,1381,964],{"class":224},[106,1383,311],{"class":112},[106,1385,1386],{"class":224}," schema",[106,1388,52],{"class":112},[106,1390,521],{"class":277},[106,1392,281],{"class":116},[106,1394,1395],{"class":224},"state",[106,1397,324],{"class":116},[106,1399,1400,1403,1406,1408,1410,1412,1414,1416],{"class":108,"line":814},[106,1401,1402],{"class":270},"    await",[106,1404,1405],{"class":277}," $fetch",[106,1407,281],{"class":116},[106,1409,754],{"class":112},[106,1411,155],{"class":142},[106,1413,754],{"class":112},[106,1415,355],{"class":112},[106,1417,300],{"class":112},[106,1419,1420,1423,1425,1427,1429,1431],{"class":108,"line":830},[106,1421,1422],{"class":116},"      method",[106,1424,496],{"class":112},[106,1426,425],{"class":112},[106,1428,143],{"class":142},[106,1430,754],{"class":112},[106,1432,509],{"class":112},[106,1434,1435,1438,1440,1442],{"class":108,"line":835},[106,1436,1437],{"class":116},"      body",[106,1439,496],{"class":112},[106,1441,964],{"class":224},[106,1443,509],{"class":112},[106,1445,1446,1448],{"class":108,"line":840},[106,1447,798],{"class":112},[106,1449,324],{"class":116},[106,1451,1452],{"class":108,"line":845},[106,1453,806],{"class":112},[106,1455,1456,1459,1461,1463,1465],{"class":108,"line":862},[106,1457,1458],{"class":270},"  catch",[106,1460,287],{"class":116},[106,1462,771],{"class":224},[106,1464,713],{"class":116},[106,1466,488],{"class":112},[106,1468,1470,1472,1474,1476,1478,1480,1482],{"class":108,"line":1469},22,[106,1471,1031],{"class":270},[106,1473,287],{"class":116},[106,1475,771],{"class":224},[106,1477,1038],{"class":112},[106,1479,889],{"class":1041},[106,1481,713],{"class":116},[106,1483,488],{"class":112},[106,1485,1487,1489,1491,1493],{"class":108,"line":1486},23,[106,1488,1050],{"class":270},[106,1490,723],{"class":277},[106,1492,281],{"class":116},[106,1494,488],{"class":112},[106,1496,1498,1500,1502,1504],{"class":108,"line":1497},24,[106,1499,1061],{"class":116},[106,1501,496],{"class":112},[106,1503,737],{"class":358},[106,1505,509],{"class":112},[106,1507,1509,1511,1513,1515,1517,1519],{"class":108,"line":1508},25,[106,1510,1072],{"class":116},[106,1512,496],{"class":112},[106,1514,425],{"class":112},[106,1516,751],{"class":142},[106,1518,754],{"class":112},[106,1520,509],{"class":112},[106,1522,1524,1526,1528,1530,1532,1534],{"class":108,"line":1523},26,[106,1525,1087],{"class":116},[106,1527,496],{"class":112},[106,1529,1092],{"class":224},[106,1531,52],{"class":112},[106,1533,776],{"class":224},[106,1535,509],{"class":112},[106,1537,1539,1541],{"class":108,"line":1538},27,[106,1540,1113],{"class":112},[106,1542,324],{"class":116},[106,1544,1546],{"class":108,"line":1545},28,[106,1547,1120],{"class":112},[106,1549,1551],{"class":108,"line":1550},29,[106,1552,806],{"class":112},[106,1554,1556],{"class":108,"line":1555},30,[106,1557,1558],{"class":112},"}\n",[106,1560,1562,1564,1566],{"class":108,"line":1561},31,[106,1563,251],{"class":112},[106,1565,1189],{"class":116},[106,1567,120],{"class":112},[106,1569,1571],{"class":108,"line":1570},32,[106,1572,330],{"emptyLinePlaceholder":329},[106,1574,1576,1578,1580],{"class":108,"line":1575},33,[106,1577,113],{"class":112},[106,1579,117],{"class":116},[106,1581,120],{"class":112},[106,1583,1585,1587,1589,1592,1594,1596,1599,1601],{"class":108,"line":1584},34,[106,1586,126],{"class":112},[106,1588,129],{"class":116},[106,1590,1591],{"class":132}," @submit.prevent",[106,1593,136],{"class":112},[106,1595,139],{"class":112},[106,1597,1598],{"class":142},"onSubmit()",[106,1600,139],{"class":112},[106,1602,120],{"class":112},[106,1604,1606,1608,1610,1613,1615,1617,1620,1622,1624,1626,1628,1630,1632,1634,1636,1638,1640,1642,1644],{"class":108,"line":1605},35,[106,1607,165],{"class":112},[106,1609,168],{"class":116},[106,1611,1612],{"class":132}," v-model",[106,1614,136],{"class":112},[106,1616,139],{"class":112},[106,1618,1619],{"class":142},"state.name",[106,1621,139],{"class":112},[106,1623,171],{"class":132},[106,1625,136],{"class":112},[106,1627,139],{"class":112},[106,1629,178],{"class":142},[106,1631,139],{"class":112},[106,1633,183],{"class":132},[106,1635,136],{"class":112},[106,1637,139],{"class":112},[106,1639,190],{"class":142},[106,1641,139],{"class":112},[106,1643,195],{"class":132},[106,1645,120],{"class":112},[106,1647,1649,1651,1653,1655,1657,1659,1661,1663],{"class":108,"line":1648},36,[106,1650,165],{"class":112},[106,1652,205],{"class":116},[106,1654,171],{"class":132},[106,1656,136],{"class":112},[106,1658,139],{"class":112},[106,1660,214],{"class":142},[106,1662,139],{"class":112},[106,1664,120],{"class":112},[106,1666,1668],{"class":108,"line":1667},37,[106,1669,225],{"class":224},[106,1671,1673,1675,1677],{"class":108,"line":1672},38,[106,1674,231],{"class":112},[106,1676,205],{"class":116},[106,1678,120],{"class":112},[106,1680,1682,1684,1686],{"class":108,"line":1681},39,[106,1683,241],{"class":112},[106,1685,129],{"class":116},[106,1687,120],{"class":112},[106,1689,1691,1693,1695],{"class":108,"line":1690},40,[106,1692,251],{"class":112},[106,1694,117],{"class":116},[106,1696,120],{"class":112},[10,1698,1699,1700,1703,1704,1709,1710,1715,1716,52],{},"L'implémentation est très simple et directe. Je pourrais enregistrer les erreurs dans une variable pour les afficher à l'utilisateur. Si vous souhaitez aller plus loin, je vous recommande d'essayer le composant ",[103,1701,1702],{},"Form"," de ",[14,1705,1708],{"href":1706,"rel":1707},"https:\u002F\u002Fui.nuxt.com\u002F",[18],"Nuxt UI"," ou une bibliothèque de validation comme ",[14,1711,1714],{"href":1712,"rel":1713},"https:\u002F\u002Fvee-validate.logaretm.com\u002Fv4\u002F",[18],"VeeValidate"," ou ",[14,1717,1720],{"href":1718,"rel":1719},"https:\u002F\u002Fformkit.com\u002F",[18],"FormKit",[54,1722,1724],{"id":1723},"refactorisons","Refactorisons",[10,1726,1727],{},"Maintenant que j'ai la logique de validation à la fois côté client et côté serveur, voyons s'il y a un code intéressant à refactoriser.",[10,1729,1730],{},"La première chose que je remarque est que la logique de validation, côté client et serveur, est la même, et c'est totalement normal. Mais que se passe-t-il si je décide de changer la logique de validation côté serveur parce que mon API est utilisée par une autre application ? Mon frontend pourrait être cassé car la logique de validation sera différente. Pour éviter cela, il serait peut-être mieux d'écrire le validateur une seule fois et de l'utiliser des deux côtés. Qu'en pensez-vous ?",[10,1732,1733,1734,1737,1738,1741],{},"Je peux créer un fichier ",[103,1735,1736],{},"validators.ts"," dans le dossier ",[103,1739,1740],{},"utils"," de la partie client (à la racine) d'une application Nuxt. Ce fichier contiendra tous les validateurs pour mon application et sera partagé entre le client et le serveur.",[96,1743,1745],{"className":261,"code":1744,"language":263,"meta":101,"style":101},"import { z } from 'zod'\n\nexport const createTagValidator = z.object({\n  name: z.string({ message: 'Obligatoire' }),\n})\n",[103,1746,1747,1765,1769,1791,1823],{"__ignoreMap":101},[106,1748,1749,1751,1753,1755,1757,1759,1761,1763],{"class":108,"line":109},[106,1750,410],{"class":270},[106,1752,413],{"class":112},[106,1754,416],{"class":224},[106,1756,419],{"class":112},[106,1758,422],{"class":270},[106,1760,425],{"class":112},[106,1762,428],{"class":142},[106,1764,431],{"class":112},[106,1766,1767],{"class":108,"line":123},[106,1768,330],{"emptyLinePlaceholder":329},[106,1770,1771,1773,1776,1779,1781,1783,1785,1787,1789],{"class":108,"line":162},[106,1772,271],{"class":270},[106,1774,1775],{"class":132}," const",[106,1777,1778],{"class":224}," createTagValidator ",[106,1780,136],{"class":112},[106,1782,416],{"class":224},[106,1784,52],{"class":112},[106,1786,483],{"class":277},[106,1788,281],{"class":224},[106,1790,488],{"class":112},[106,1792,1793,1795,1797,1799,1801,1803,1805,1807,1809,1811,1813,1815,1817,1819,1821],{"class":108,"line":200},[106,1794,1272],{"class":116},[106,1796,496],{"class":112},[106,1798,416],{"class":224},[106,1800,52],{"class":112},[106,1802,503],{"class":277},[106,1804,281],{"class":224},[106,1806,1285],{"class":112},[106,1808,1288],{"class":116},[106,1810,496],{"class":112},[106,1812,425],{"class":112},[106,1814,1295],{"class":142},[106,1816,754],{"class":112},[106,1818,419],{"class":112},[106,1820,294],{"class":224},[106,1822,509],{"class":112},[106,1824,1825,1827],{"class":108,"line":221},[106,1826,366],{"class":112},[106,1828,324],{"class":224},[1830,1831,1832],"blockquote",{},[10,1833,1834,1837],{},[106,1835,1836],{},"!TIP","\nCe fichier peut être utilisé côté serveur mais a été créé côté client, donc vous devez explicitement importer tout ce dont vous avez besoin pour éviter des problèmes de rendu.",[10,1839,1840],{},"Ce validateur contient le message pour l'erreur pour aider l'utilisateur à comprendre ce qui ne va pas.",[10,1842,1843],{},"Grâce à la fonction d'auto-import de Nuxt, je peux facilement l'utiliser côté client :",[96,1845,1847],{"className":98,"code":1846,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst schema = createTagValidator\n\n\u002F\u002F ...\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cform>\n  \u003C!-- ... -->\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n",[103,1848,1849,1869,1880,1884,1889,1897,1901,1909,1917,1922,1930],{"__ignoreMap":101},[106,1850,1851,1853,1855,1857,1859,1861,1863,1865,1867],{"class":108,"line":109},[106,1852,113],{"class":112},[106,1854,1189],{"class":116},[106,1856,1192],{"class":132},[106,1858,136],{"class":112},[106,1860,139],{"class":112},[106,1862,263],{"class":142},[106,1864,139],{"class":112},[106,1866,1203],{"class":132},[106,1868,120],{"class":112},[106,1870,1871,1873,1875,1877],{"class":108,"line":123},[106,1872,1252],{"class":132},[106,1874,1255],{"class":224},[106,1876,136],{"class":112},[106,1878,1879],{"class":224}," createTagValidator\n",[106,1881,1882],{"class":108,"line":162},[106,1883,330],{"emptyLinePlaceholder":329},[106,1885,1886],{"class":108,"line":200},[106,1887,1888],{"class":335},"\u002F\u002F ...\n",[106,1890,1891,1893,1895],{"class":108,"line":221},[106,1892,251],{"class":112},[106,1894,1189],{"class":116},[106,1896,120],{"class":112},[106,1898,1899],{"class":108,"line":228},[106,1900,330],{"emptyLinePlaceholder":329},[106,1902,1903,1905,1907],{"class":108,"line":238},[106,1904,113],{"class":112},[106,1906,117],{"class":116},[106,1908,120],{"class":112},[106,1910,1911,1913,1915],{"class":108,"line":248},[106,1912,126],{"class":112},[106,1914,129],{"class":116},[106,1916,120],{"class":112},[106,1918,1919],{"class":108,"line":534},[106,1920,1921],{"class":335},"  \u003C!-- ... -->\n",[106,1923,1924,1926,1928],{"class":108,"line":539},[106,1925,241],{"class":112},[106,1927,129],{"class":116},[106,1929,120],{"class":112},[106,1931,1932,1934,1936],{"class":108,"line":556},[106,1933,251],{"class":112},[106,1935,117],{"class":116},[106,1937,120],{"class":112},[10,1939,1940],{},"Ensuite, vous pouvez également l'utiliser côté serveur en l'importation :",[96,1942,1944],{"className":261,"code":1943,"language":263,"meta":101,"style":101},"import { createTagValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  const body = await readValidatedBody(event, createTagValidator.parse)\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n",[103,1945,1946,1966,1970,1992,2018,2022,2026,2030,2046],{"__ignoreMap":101},[106,1947,1948,1950,1952,1955,1957,1959,1961,1964],{"class":108,"line":109},[106,1949,410],{"class":270},[106,1951,413],{"class":112},[106,1953,1954],{"class":224}," createTagValidator",[106,1956,419],{"class":112},[106,1958,422],{"class":270},[106,1960,425],{"class":112},[106,1962,1963],{"class":142},"~\u002Futils\u002Fvalidators",[106,1965,431],{"class":112},[106,1967,1968],{"class":108,"line":123},[106,1969,330],{"emptyLinePlaceholder":329},[106,1971,1972,1974,1976,1978,1980,1982,1984,1986,1988,1990],{"class":108,"line":162},[106,1973,271],{"class":270},[106,1975,274],{"class":270},[106,1977,278],{"class":277},[106,1979,281],{"class":224},[106,1981,284],{"class":132},[106,1983,287],{"class":112},[106,1985,291],{"class":290},[106,1987,294],{"class":112},[106,1989,297],{"class":132},[106,1991,300],{"class":112},[106,1993,1994,1996,1998,2000,2002,2004,2006,2008,2010,2012,2014,2016],{"class":108,"line":200},[106,1995,305],{"class":132},[106,1997,308],{"class":224},[106,1999,311],{"class":112},[106,2001,314],{"class":270},[106,2003,470],{"class":277},[106,2005,281],{"class":116},[106,2007,291],{"class":224},[106,2009,355],{"class":112},[106,2011,1954],{"class":224},[106,2013,52],{"class":112},[106,2015,521],{"class":224},[106,2017,324],{"class":116},[106,2019,2020],{"class":108,"line":221},[106,2021,330],{"emptyLinePlaceholder":329},[106,2023,2024],{"class":108,"line":228},[106,2025,336],{"class":335},[106,2027,2028],{"class":108,"line":238},[106,2029,330],{"emptyLinePlaceholder":329},[106,2031,2032,2034,2036,2038,2040,2042,2044],{"class":108,"line":248},[106,2033,345],{"class":270},[106,2035,348],{"class":277},[106,2037,281],{"class":116},[106,2039,291],{"class":224},[106,2041,355],{"class":112},[106,2043,359],{"class":358},[106,2045,324],{"class":116},[106,2047,2048,2050],{"class":108,"line":534},[106,2049,366],{"class":112},[106,2051,324],{"class":224},[10,2053,2054],{},"Maintenant, je peux changer en toute sécurité la logique de validation sans casser le frontend, car ils suivent les mêmes règles, et je n'ai pas à dupliquer le code.",[54,2056,2058],{"id":2057},"gestion-des-fichiers","Gestion des Fichiers",[10,2060,2061,2062,2065],{},"Pour cette partie, je vais vous montrer deux façons différentes de valider les fichiers côté serveur. La première utilisera Zod et quelques raffinements, et la seconde utilisera l'utilitaire ",[103,2063,2064],{},"ensureBlob"," de NuxtHub.",[10,2067,2068],{},[29,2069,2070],{},"Cette validation n'est pas complète et devrait être améliorée pour gérer plus de cas.",[381,2072,2074],{"id":2073},"avec-zod","Avec Zod",[10,2076,2077],{},"Pour cette seconde partie, je vais vous montrer comment télécharger un fichier sur le serveur. D'abord, ajoutons notre entrée dans le formulaire :",[96,2079,2081],{"className":98,"code":2080,"language":100,"meta":101,"style":101},"\u003Ctemplate>\n  \u003Cform action=\"\u002Fapi\u002Fimage\" enctype=\"multipart\u002Fform-data\" method=\"post\">\n    \u003Cinput type=\"file\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n",[103,2082,2083,2091,2132,2165,2183,2187,2195,2203],{"__ignoreMap":101},[106,2084,2085,2087,2089],{"class":108,"line":109},[106,2086,113],{"class":112},[106,2088,117],{"class":116},[106,2090,120],{"class":112},[106,2092,2093,2095,2097,2099,2101,2103,2106,2108,2111,2113,2115,2118,2120,2122,2124,2126,2128,2130],{"class":108,"line":123},[106,2094,126],{"class":112},[106,2096,129],{"class":116},[106,2098,148],{"class":132},[106,2100,136],{"class":112},[106,2102,139],{"class":112},[106,2104,2105],{"class":142},"\u002Fapi\u002Fimage",[106,2107,139],{"class":112},[106,2109,2110],{"class":132}," enctype",[106,2112,136],{"class":112},[106,2114,139],{"class":112},[106,2116,2117],{"class":142},"multipart\u002Fform-data",[106,2119,139],{"class":112},[106,2121,133],{"class":132},[106,2123,136],{"class":112},[106,2125,139],{"class":112},[106,2127,143],{"class":142},[106,2129,139],{"class":112},[106,2131,120],{"class":112},[106,2133,2134,2136,2138,2140,2142,2144,2147,2149,2152,2154,2156,2159,2161,2163],{"class":108,"line":162},[106,2135,165],{"class":112},[106,2137,168],{"class":116},[106,2139,171],{"class":132},[106,2141,136],{"class":112},[106,2143,139],{"class":112},[106,2145,2146],{"class":142},"file",[106,2148,139],{"class":112},[106,2150,2151],{"class":132}," accept",[106,2153,136],{"class":112},[106,2155,139],{"class":112},[106,2157,2158],{"class":142},"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg",[106,2160,139],{"class":112},[106,2162,195],{"class":132},[106,2164,120],{"class":112},[106,2166,2167,2169,2171,2173,2175,2177,2179,2181],{"class":108,"line":200},[106,2168,165],{"class":112},[106,2170,205],{"class":116},[106,2172,171],{"class":132},[106,2174,136],{"class":112},[106,2176,139],{"class":112},[106,2178,214],{"class":142},[106,2180,139],{"class":112},[106,2182,120],{"class":112},[106,2184,2185],{"class":108,"line":221},[106,2186,225],{"class":224},[106,2188,2189,2191,2193],{"class":108,"line":228},[106,2190,231],{"class":112},[106,2192,205],{"class":116},[106,2194,120],{"class":112},[106,2196,2197,2199,2201],{"class":108,"line":238},[106,2198,241],{"class":112},[106,2200,129],{"class":116},[106,2202,120],{"class":112},[106,2204,2205,2207,2209],{"class":108,"line":248},[106,2206,251],{"class":112},[106,2208,117],{"class":116},[106,2210,120],{"class":112},[10,2212,2213,2214,2217,2218,2221],{},"Ensuite, je peux gérer le fichier côté serveur, mais c'est un peu plus complexe car j'envoie un ",[103,2215,2216],{},"formData"," et h3 ne fournit pas d'utilitaire comme ",[103,2219,2220],{},"readValidatedFormData",". Donc, je dois le gérer manuellement.",[10,2223,2224,2225,2227,2228,2231,2232,2235,2236,2238],{},"D'abord, je vais lire le ",[103,2226,2216],{}," en utilisant l'utilitaire ",[103,2229,2230],{},"readFormData",", puis j'extraire l'",[103,2233,2234],{},"image"," du ",[103,2237,2216],{}," et la valider en utilisant Zod.",[96,2240,2242],{"className":261,"code":2241,"language":263,"meta":101,"style":101},"import { any, object } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const formData = await readFormData(event)\n\n  const image = formData.get('image')\n  await object({\n    image: any()\n      .refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' })\n      .refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' })\n      .refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' })\n      .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n  }).parseAsync({ image }).catch((error) => {\n    throw createError({\n      status: 400,\n      message: 'Image non valide',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Enregistrer l'image dans une base de données ou un système de fichiers\n\n  return sendNoContent(event, 201)\n})\n",[103,2243,2244,2268,2272,2294,2314,2318,2344,2355,2367,2413,2466,2524,2604,2640,2650,2660,2675,2698,2704,2710,2714,2719,2723,2739],{"__ignoreMap":101},[106,2245,2246,2248,2250,2253,2255,2258,2260,2262,2264,2266],{"class":108,"line":109},[106,2247,410],{"class":270},[106,2249,413],{"class":112},[106,2251,2252],{"class":224}," any",[106,2254,355],{"class":112},[106,2256,2257],{"class":224}," object",[106,2259,419],{"class":112},[106,2261,422],{"class":270},[106,2263,425],{"class":112},[106,2265,428],{"class":142},[106,2267,431],{"class":112},[106,2269,2270],{"class":108,"line":123},[106,2271,330],{"emptyLinePlaceholder":329},[106,2273,2274,2276,2278,2280,2282,2284,2286,2288,2290,2292],{"class":108,"line":162},[106,2275,271],{"class":270},[106,2277,274],{"class":270},[106,2279,278],{"class":277},[106,2281,281],{"class":224},[106,2283,284],{"class":132},[106,2285,287],{"class":112},[106,2287,291],{"class":290},[106,2289,294],{"class":112},[106,2291,297],{"class":132},[106,2293,300],{"class":112},[106,2295,2296,2298,2301,2303,2305,2308,2310,2312],{"class":108,"line":200},[106,2297,305],{"class":132},[106,2299,2300],{"class":224}," formData",[106,2302,311],{"class":112},[106,2304,314],{"class":270},[106,2306,2307],{"class":277}," readFormData",[106,2309,281],{"class":116},[106,2311,291],{"class":224},[106,2313,324],{"class":116},[106,2315,2316],{"class":108,"line":221},[106,2317,330],{"emptyLinePlaceholder":329},[106,2319,2320,2322,2325,2327,2329,2331,2334,2336,2338,2340,2342],{"class":108,"line":228},[106,2321,305],{"class":132},[106,2323,2324],{"class":224}," image",[106,2326,311],{"class":112},[106,2328,2300],{"class":224},[106,2330,52],{"class":112},[106,2332,2333],{"class":277},"get",[106,2335,281],{"class":116},[106,2337,754],{"class":112},[106,2339,2234],{"class":142},[106,2341,754],{"class":112},[106,2343,324],{"class":116},[106,2345,2346,2349,2351,2353],{"class":108,"line":238},[106,2347,2348],{"class":270},"  await",[106,2350,2257],{"class":277},[106,2352,281],{"class":116},[106,2354,488],{"class":112},[106,2356,2357,2360,2362,2364],{"class":108,"line":248},[106,2358,2359],{"class":116},"    image",[106,2361,496],{"class":112},[106,2363,2252],{"class":277},[106,2365,2366],{"class":116},"()\n",[106,2368,2369,2372,2375,2377,2379,2381,2383,2385,2388,2390,2392,2394,2396,2398,2401,2404,2407,2409,2411],{"class":108,"line":534},[106,2370,2371],{"class":112},"      .",[106,2373,2374],{"class":277},"refine",[106,2376,281],{"class":116},[106,2378,2234],{"class":290},[106,2380,297],{"class":132},[106,2382,2324],{"class":224},[106,2384,1038],{"class":112},[106,2386,2387],{"class":1041}," File",[106,2389,355],{"class":112},[106,2391,413],{"class":112},[106,2393,1288],{"class":116},[106,2395,496],{"class":112},[106,2397,425],{"class":112},[106,2399,2400],{"class":142},"L",[106,2402,2403],{"class":224},"\\'",[106,2405,2406],{"class":142},"image doit être un fichier",[106,2408,754],{"class":112},[106,2410,419],{"class":112},[106,2412,324],{"class":116},[106,2414,2415,2417,2419,2421,2423,2425,2427,2429,2432,2435,2438,2441,2443,2445,2447,2449,2451,2453,2455,2457,2460,2462,2464],{"class":108,"line":539},[106,2416,2371],{"class":112},[106,2418,2374],{"class":277},[106,2420,281],{"class":116},[106,2422,2234],{"class":290},[106,2424,297],{"class":132},[106,2426,2324],{"class":224},[106,2428,52],{"class":112},[106,2430,2431],{"class":224},"size",[106,2433,2434],{"class":112}," \u003C",[106,2436,2437],{"class":358}," 1024",[106,2439,2440],{"class":112}," *",[106,2442,2437],{"class":358},[106,2444,355],{"class":112},[106,2446,413],{"class":112},[106,2448,1288],{"class":116},[106,2450,496],{"class":112},[106,2452,425],{"class":112},[106,2454,2400],{"class":142},[106,2456,2403],{"class":224},[106,2458,2459],{"class":142},"image doit faire moins de 1 Mo",[106,2461,754],{"class":112},[106,2463,419],{"class":112},[106,2465,324],{"class":116},[106,2467,2468,2470,2472,2474,2476,2478,2480,2482,2485,2487,2490,2492,2494,2497,2499,2501,2503,2505,2507,2509,2511,2513,2515,2518,2520,2522],{"class":108,"line":556},[106,2469,2371],{"class":112},[106,2471,2374],{"class":277},[106,2473,281],{"class":116},[106,2475,2234],{"class":290},[106,2477,297],{"class":132},[106,2479,2324],{"class":224},[106,2481,52],{"class":112},[106,2483,2484],{"class":224},"type",[106,2486,52],{"class":112},[106,2488,2489],{"class":277},"startsWith",[106,2491,281],{"class":116},[106,2493,754],{"class":112},[106,2495,2496],{"class":142},"image\u002F",[106,2498,754],{"class":112},[106,2500,294],{"class":116},[106,2502,355],{"class":112},[106,2504,413],{"class":112},[106,2506,1288],{"class":116},[106,2508,496],{"class":112},[106,2510,425],{"class":112},[106,2512,2400],{"class":142},[106,2514,2403],{"class":224},[106,2516,2517],{"class":142},"image doit être une image",[106,2519,754],{"class":112},[106,2521,419],{"class":112},[106,2523,324],{"class":116},[106,2525,2526,2528,2530,2532,2534,2536,2539,2541,2544,2546,2548,2550,2553,2555,2557,2559,2562,2564,2566,2568,2571,2573,2575,2577,2579,2581,2583,2585,2587,2589,2591,2593,2595,2598,2600,2602],{"class":108,"line":759},[106,2527,2371],{"class":112},[106,2529,2374],{"class":277},[106,2531,281],{"class":116},[106,2533,2234],{"class":290},[106,2535,297],{"class":132},[106,2537,2538],{"class":116}," [",[106,2540,754],{"class":112},[106,2542,2543],{"class":142},"image\u002Fpng",[106,2545,754],{"class":112},[106,2547,355],{"class":112},[106,2549,425],{"class":112},[106,2551,2552],{"class":142},"image\u002Fjpeg",[106,2554,754],{"class":112},[106,2556,355],{"class":112},[106,2558,425],{"class":112},[106,2560,2561],{"class":142},"image\u002Fjpg",[106,2563,754],{"class":112},[106,2565,785],{"class":116},[106,2567,52],{"class":112},[106,2569,2570],{"class":277},"includes",[106,2572,281],{"class":116},[106,2574,2234],{"class":224},[106,2576,52],{"class":112},[106,2578,2484],{"class":224},[106,2580,294],{"class":116},[106,2582,355],{"class":112},[106,2584,413],{"class":112},[106,2586,1288],{"class":116},[106,2588,496],{"class":112},[106,2590,425],{"class":112},[106,2592,2400],{"class":142},[106,2594,2403],{"class":224},[106,2596,2597],{"class":142},"image doit être un JPEG ou un PNG",[106,2599,754],{"class":112},[106,2601,419],{"class":112},[106,2603,324],{"class":116},[106,2605,2606,2608,2610,2612,2614,2616,2618,2620,2622,2624,2626,2628,2630,2632,2634,2636,2638],{"class":108,"line":795},[106,2607,514],{"class":112},[106,2609,294],{"class":116},[106,2611,52],{"class":112},[106,2613,872],{"class":277},[106,2615,281],{"class":116},[106,2617,1285],{"class":112},[106,2619,2324],{"class":224},[106,2621,419],{"class":112},[106,2623,294],{"class":116},[106,2625,52],{"class":112},[106,2627,1014],{"class":277},[106,2629,281],{"class":116},[106,2631,281],{"class":112},[106,2633,771],{"class":290},[106,2635,294],{"class":112},[106,2637,297],{"class":132},[106,2639,300],{"class":112},[106,2641,2642,2644,2646,2648],{"class":108,"line":803},[106,2643,720],{"class":270},[106,2645,723],{"class":277},[106,2647,281],{"class":116},[106,2649,488],{"class":112},[106,2651,2652,2654,2656,2658],{"class":108,"line":809},[106,2653,732],{"class":116},[106,2655,496],{"class":112},[106,2657,737],{"class":358},[106,2659,509],{"class":112},[106,2661,2662,2664,2666,2668,2671,2673],{"class":108,"line":814},[106,2663,744],{"class":116},[106,2665,496],{"class":112},[106,2667,425],{"class":112},[106,2669,2670],{"class":142},"Image non valide",[106,2672,754],{"class":112},[106,2674,509],{"class":112},[106,2676,2677,2679,2681,2683,2685,2687,2689,2691,2693,2695],{"class":108,"line":830},[106,2678,762],{"class":116},[106,2680,496],{"class":112},[106,2682,1092],{"class":224},[106,2684,52],{"class":112},[106,2686,776],{"class":224},[106,2688,779],{"class":116},[106,2690,782],{"class":358},[106,2692,785],{"class":116},[106,2694,52],{"class":112},[106,2696,2697],{"class":224},"message\n",[106,2699,2700,2702],{"class":108,"line":835},[106,2701,798],{"class":112},[106,2703,324],{"class":116},[106,2705,2706,2708],{"class":108,"line":840},[106,2707,514],{"class":112},[106,2709,324],{"class":116},[106,2711,2712],{"class":108,"line":845},[106,2713,330],{"emptyLinePlaceholder":329},[106,2715,2716],{"class":108,"line":862},[106,2717,2718],{"class":335},"  \u002F\u002F Enregistrer l'image dans une base de données ou un système de fichiers\n",[106,2720,2721],{"class":108,"line":1469},[106,2722,330],{"emptyLinePlaceholder":329},[106,2724,2725,2727,2729,2731,2733,2735,2737],{"class":108,"line":1486},[106,2726,345],{"class":270},[106,2728,348],{"class":277},[106,2730,281],{"class":116},[106,2732,291],{"class":224},[106,2734,355],{"class":112},[106,2736,359],{"class":358},[106,2738,324],{"class":116},[106,2740,2741,2743],{"class":108,"line":1497},[106,2742,366],{"class":112},[106,2744,324],{"class":224},[10,2746,2747],{},"Côté client, je peux également valider le fichier en utilisant la même technique qu'auparavant et en partageant le validateur entre le client et le serveur.",[10,2749,2750,2751,2754],{},"D'abord, je dois déplacer le validateur dans le fichier ",[103,2752,2753],{},"utils\u002Fvalidators.ts"," :",[96,2756,2758],{"className":261,"code":2757,"language":263,"meta":101,"style":101},"import { any, object } from 'zod'\n\nexport const createImageValidator = object({\n  image: any()\n    .refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' })\n    .refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' })\n    .refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' })\n    .refine(image => ['image\u002Fjpeg', 'image\u002Fpng'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n})\n",[103,2759,2760,2782,2786,2803,2814,2857,2906,2960,3024],{"__ignoreMap":101},[106,2761,2762,2764,2766,2768,2770,2772,2774,2776,2778,2780],{"class":108,"line":109},[106,2763,410],{"class":270},[106,2765,413],{"class":112},[106,2767,2252],{"class":224},[106,2769,355],{"class":112},[106,2771,2257],{"class":224},[106,2773,419],{"class":112},[106,2775,422],{"class":270},[106,2777,425],{"class":112},[106,2779,428],{"class":142},[106,2781,431],{"class":112},[106,2783,2784],{"class":108,"line":123},[106,2785,330],{"emptyLinePlaceholder":329},[106,2787,2788,2790,2792,2795,2797,2799,2801],{"class":108,"line":162},[106,2789,271],{"class":270},[106,2791,1775],{"class":132},[106,2793,2794],{"class":224}," createImageValidator ",[106,2796,136],{"class":112},[106,2798,2257],{"class":277},[106,2800,281],{"class":224},[106,2802,488],{"class":112},[106,2804,2805,2808,2810,2812],{"class":108,"line":200},[106,2806,2807],{"class":116},"  image",[106,2809,496],{"class":112},[106,2811,2252],{"class":277},[106,2813,2366],{"class":224},[106,2815,2816,2819,2821,2823,2825,2827,2830,2833,2835,2837,2839,2841,2843,2845,2847,2849,2851,2853,2855],{"class":108,"line":221},[106,2817,2818],{"class":112},"    .",[106,2820,2374],{"class":277},[106,2822,281],{"class":224},[106,2824,2234],{"class":290},[106,2826,297],{"class":132},[106,2828,2829],{"class":224}," image ",[106,2831,2832],{"class":112},"instanceof",[106,2834,2387],{"class":1041},[106,2836,355],{"class":112},[106,2838,413],{"class":112},[106,2840,1288],{"class":116},[106,2842,496],{"class":112},[106,2844,425],{"class":112},[106,2846,2400],{"class":142},[106,2848,2403],{"class":224},[106,2850,2406],{"class":142},[106,2852,754],{"class":112},[106,2854,419],{"class":112},[106,2856,324],{"class":224},[106,2858,2859,2861,2863,2865,2867,2869,2871,2873,2876,2878,2880,2882,2884,2886,2888,2890,2892,2894,2896,2898,2900,2902,2904],{"class":108,"line":228},[106,2860,2818],{"class":112},[106,2862,2374],{"class":277},[106,2864,281],{"class":224},[106,2866,2234],{"class":290},[106,2868,297],{"class":132},[106,2870,2324],{"class":224},[106,2872,52],{"class":112},[106,2874,2875],{"class":224},"size ",[106,2877,113],{"class":112},[106,2879,2437],{"class":358},[106,2881,2440],{"class":112},[106,2883,2437],{"class":358},[106,2885,355],{"class":112},[106,2887,413],{"class":112},[106,2889,1288],{"class":116},[106,2891,496],{"class":112},[106,2893,425],{"class":112},[106,2895,2400],{"class":142},[106,2897,2403],{"class":224},[106,2899,2459],{"class":142},[106,2901,754],{"class":112},[106,2903,419],{"class":112},[106,2905,324],{"class":224},[106,2907,2908,2910,2912,2914,2916,2918,2920,2922,2924,2926,2928,2930,2932,2934,2936,2938,2940,2942,2944,2946,2948,2950,2952,2954,2956,2958],{"class":108,"line":238},[106,2909,2818],{"class":112},[106,2911,2374],{"class":277},[106,2913,281],{"class":224},[106,2915,2234],{"class":290},[106,2917,297],{"class":132},[106,2919,2324],{"class":224},[106,2921,52],{"class":112},[106,2923,2484],{"class":224},[106,2925,52],{"class":112},[106,2927,2489],{"class":277},[106,2929,281],{"class":224},[106,2931,754],{"class":112},[106,2933,2496],{"class":142},[106,2935,754],{"class":112},[106,2937,294],{"class":224},[106,2939,355],{"class":112},[106,2941,413],{"class":112},[106,2943,1288],{"class":116},[106,2945,496],{"class":112},[106,2947,425],{"class":112},[106,2949,2400],{"class":142},[106,2951,2403],{"class":224},[106,2953,2517],{"class":142},[106,2955,754],{"class":112},[106,2957,419],{"class":112},[106,2959,324],{"class":224},[106,2961,2962,2964,2966,2968,2970,2972,2974,2976,2978,2980,2982,2984,2986,2988,2990,2992,2994,2997,2999,3002,3004,3006,3008,3010,3012,3014,3016,3018,3020,3022],{"class":108,"line":248},[106,2963,2818],{"class":112},[106,2965,2374],{"class":277},[106,2967,281],{"class":224},[106,2969,2234],{"class":290},[106,2971,297],{"class":132},[106,2973,2538],{"class":224},[106,2975,754],{"class":112},[106,2977,2552],{"class":142},[106,2979,754],{"class":112},[106,2981,355],{"class":112},[106,2983,425],{"class":112},[106,2985,2543],{"class":142},[106,2987,754],{"class":112},[106,2989,785],{"class":224},[106,2991,52],{"class":112},[106,2993,2570],{"class":277},[106,2995,2996],{"class":224},"(image",[106,2998,52],{"class":112},[106,3000,3001],{"class":224},"type)",[106,3003,355],{"class":112},[106,3005,413],{"class":112},[106,3007,1288],{"class":116},[106,3009,496],{"class":112},[106,3011,425],{"class":112},[106,3013,2400],{"class":142},[106,3015,2403],{"class":224},[106,3017,2597],{"class":142},[106,3019,754],{"class":112},[106,3021,419],{"class":112},[106,3023,324],{"class":224},[106,3025,3026,3028],{"class":108,"line":534},[106,3027,366],{"class":112},[106,3029,324],{"class":224},[10,3031,3032],{},"Ensuite, je peux l'utiliser lors de la soumission du formulaire pour éviter d'envoyer des données non valides au serveur :",[96,3034,3036],{"className":98,"code":3035,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nasync function onImageSubmit(event: Event) {\n  const target = event.target as HTMLFormElement\n  const formData = new FormData(target)\n\n  const image = formData.get('image')\n  try {\n    await createImageValidator.parseAsync({ image })\n\n    await $fetch('\u002Fapi\u002Fimage', {\n      method: 'POST',\n      body: formData,\n    })\n  }\n  catch (error) {\n    console.error(error)\n  }\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003Ch1>Valider les Formulaires avec Nuxt\u003C\u002Fh1>\n\n    \u003Ch2>\n      Image\n    \u003C\u002Fh2>\n    \u003Cform enctype=\"multipart\u002Fform-data\" @submit.prevent=\"onImageSubmit($event)\">\n      \u003Cinput type=\"file\" name=\"image\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\">\n      \u003Cbutton type=\"submit\">\n        Soumettre\n      \u003C\u002Fbutton>\n    \u003C\u002Fform>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[103,3037,3038,3058,3080,3103,3123,3127,3151,3157,3178,3182,3200,3215,3225,3231,3235,3247,3262,3266,3270,3278,3282,3290,3299,3318,3322,3330,3335,3343,3372,3412,3430,3435,3444,3452,3460],{"__ignoreMap":101},[106,3039,3040,3042,3044,3046,3048,3050,3052,3054,3056],{"class":108,"line":109},[106,3041,113],{"class":112},[106,3043,1189],{"class":116},[106,3045,1192],{"class":132},[106,3047,136],{"class":112},[106,3049,139],{"class":112},[106,3051,263],{"class":142},[106,3053,139],{"class":112},[106,3055,1203],{"class":132},[106,3057,120],{"class":112},[106,3059,3060,3062,3064,3067,3069,3071,3073,3076,3078],{"class":108,"line":123},[106,3061,284],{"class":132},[106,3063,1360],{"class":132},[106,3065,3066],{"class":277}," onImageSubmit",[106,3068,281],{"class":112},[106,3070,291],{"class":290},[106,3072,496],{"class":112},[106,3074,3075],{"class":1041}," Event",[106,3077,294],{"class":112},[106,3079,300],{"class":112},[106,3081,3082,3084,3087,3089,3092,3094,3097,3100],{"class":108,"line":162},[106,3083,305],{"class":132},[106,3085,3086],{"class":224}," target",[106,3088,311],{"class":112},[106,3090,3091],{"class":224}," event",[106,3093,52],{"class":112},[106,3095,3096],{"class":224},"target",[106,3098,3099],{"class":270}," as",[106,3101,3102],{"class":1041}," HTMLFormElement\n",[106,3104,3105,3107,3109,3111,3114,3117,3119,3121],{"class":108,"line":200},[106,3106,305],{"class":132},[106,3108,2300],{"class":224},[106,3110,311],{"class":112},[106,3112,3113],{"class":112}," new",[106,3115,3116],{"class":277}," FormData",[106,3118,281],{"class":116},[106,3120,3096],{"class":224},[106,3122,324],{"class":116},[106,3124,3125],{"class":108,"line":221},[106,3126,330],{"emptyLinePlaceholder":329},[106,3128,3129,3131,3133,3135,3137,3139,3141,3143,3145,3147,3149],{"class":108,"line":228},[106,3130,305],{"class":132},[106,3132,2324],{"class":224},[106,3134,311],{"class":112},[106,3136,2300],{"class":224},[106,3138,52],{"class":112},[106,3140,2333],{"class":277},[106,3142,281],{"class":116},[106,3144,754],{"class":112},[106,3146,2234],{"class":142},[106,3148,754],{"class":112},[106,3150,324],{"class":116},[106,3152,3153,3155],{"class":108,"line":238},[106,3154,1372],{"class":270},[106,3156,300],{"class":112},[106,3158,3159,3161,3164,3166,3168,3170,3172,3174,3176],{"class":108,"line":248},[106,3160,1402],{"class":270},[106,3162,3163],{"class":224}," createImageValidator",[106,3165,52],{"class":112},[106,3167,872],{"class":277},[106,3169,281],{"class":116},[106,3171,1285],{"class":112},[106,3173,2324],{"class":224},[106,3175,419],{"class":112},[106,3177,324],{"class":116},[106,3179,3180],{"class":108,"line":534},[106,3181,330],{"emptyLinePlaceholder":329},[106,3183,3184,3186,3188,3190,3192,3194,3196,3198],{"class":108,"line":539},[106,3185,1402],{"class":270},[106,3187,1405],{"class":277},[106,3189,281],{"class":116},[106,3191,754],{"class":112},[106,3193,2105],{"class":142},[106,3195,754],{"class":112},[106,3197,355],{"class":112},[106,3199,300],{"class":112},[106,3201,3202,3204,3206,3208,3211,3213],{"class":108,"line":556},[106,3203,1422],{"class":116},[106,3205,496],{"class":112},[106,3207,425],{"class":112},[106,3209,3210],{"class":142},"POST",[106,3212,754],{"class":112},[106,3214,509],{"class":112},[106,3216,3217,3219,3221,3223],{"class":108,"line":759},[106,3218,1437],{"class":116},[106,3220,496],{"class":112},[106,3222,2300],{"class":224},[106,3224,509],{"class":112},[106,3226,3227,3229],{"class":108,"line":795},[106,3228,798],{"class":112},[106,3230,324],{"class":116},[106,3232,3233],{"class":108,"line":803},[106,3234,806],{"class":112},[106,3236,3237,3239,3241,3243,3245],{"class":108,"line":809},[106,3238,1458],{"class":270},[106,3240,287],{"class":116},[106,3242,771],{"class":224},[106,3244,713],{"class":116},[106,3246,488],{"class":112},[106,3248,3249,3252,3254,3256,3258,3260],{"class":108,"line":814},[106,3250,3251],{"class":224},"    console",[106,3253,52],{"class":112},[106,3255,771],{"class":277},[106,3257,281],{"class":116},[106,3259,771],{"class":224},[106,3261,324],{"class":116},[106,3263,3264],{"class":108,"line":830},[106,3265,806],{"class":112},[106,3267,3268],{"class":108,"line":835},[106,3269,1558],{"class":112},[106,3271,3272,3274,3276],{"class":108,"line":840},[106,3273,251],{"class":112},[106,3275,1189],{"class":116},[106,3277,120],{"class":112},[106,3279,3280],{"class":108,"line":845},[106,3281,330],{"emptyLinePlaceholder":329},[106,3283,3284,3286,3288],{"class":108,"line":862},[106,3285,113],{"class":112},[106,3287,117],{"class":116},[106,3289,120],{"class":112},[106,3291,3292,3294,3297],{"class":108,"line":1469},[106,3293,126],{"class":112},[106,3295,3296],{"class":116},"div",[106,3298,120],{"class":112},[106,3300,3301,3303,3306,3309,3312,3314,3316],{"class":108,"line":1486},[106,3302,165],{"class":112},[106,3304,3305],{"class":116},"h1",[106,3307,3308],{"class":112},">",[106,3310,3311],{"class":224},"Valider les Formulaires avec Nuxt",[106,3313,251],{"class":112},[106,3315,3305],{"class":116},[106,3317,120],{"class":112},[106,3319,3320],{"class":108,"line":1497},[106,3321,330],{"emptyLinePlaceholder":329},[106,3323,3324,3326,3328],{"class":108,"line":1508},[106,3325,165],{"class":112},[106,3327,54],{"class":116},[106,3329,120],{"class":112},[106,3331,3332],{"class":108,"line":1523},[106,3333,3334],{"class":224},"      Image\n",[106,3336,3337,3339,3341],{"class":108,"line":1538},[106,3338,231],{"class":112},[106,3340,54],{"class":116},[106,3342,120],{"class":112},[106,3344,3345,3347,3349,3351,3353,3355,3357,3359,3361,3363,3365,3368,3370],{"class":108,"line":1545},[106,3346,165],{"class":112},[106,3348,129],{"class":116},[106,3350,2110],{"class":132},[106,3352,136],{"class":112},[106,3354,139],{"class":112},[106,3356,2117],{"class":142},[106,3358,139],{"class":112},[106,3360,1591],{"class":132},[106,3362,136],{"class":112},[106,3364,139],{"class":112},[106,3366,3367],{"class":142},"onImageSubmit($event)",[106,3369,139],{"class":112},[106,3371,120],{"class":112},[106,3373,3374,3377,3379,3381,3383,3385,3387,3389,3392,3394,3396,3398,3400,3402,3404,3406,3408,3410],{"class":108,"line":1550},[106,3375,3376],{"class":112},"      \u003C",[106,3378,168],{"class":116},[106,3380,171],{"class":132},[106,3382,136],{"class":112},[106,3384,139],{"class":112},[106,3386,2146],{"class":142},[106,3388,139],{"class":112},[106,3390,3391],{"class":132}," name",[106,3393,136],{"class":112},[106,3395,139],{"class":112},[106,3397,2234],{"class":142},[106,3399,139],{"class":112},[106,3401,2151],{"class":132},[106,3403,136],{"class":112},[106,3405,139],{"class":112},[106,3407,2158],{"class":142},[106,3409,139],{"class":112},[106,3411,120],{"class":112},[106,3413,3414,3416,3418,3420,3422,3424,3426,3428],{"class":108,"line":1555},[106,3415,3376],{"class":112},[106,3417,205],{"class":116},[106,3419,171],{"class":132},[106,3421,136],{"class":112},[106,3423,139],{"class":112},[106,3425,214],{"class":142},[106,3427,139],{"class":112},[106,3429,120],{"class":112},[106,3431,3432],{"class":108,"line":1561},[106,3433,3434],{"class":224},"        Soumettre\n",[106,3436,3437,3440,3442],{"class":108,"line":1570},[106,3438,3439],{"class":112},"      \u003C\u002F",[106,3441,205],{"class":116},[106,3443,120],{"class":112},[106,3445,3446,3448,3450],{"class":108,"line":1575},[106,3447,231],{"class":112},[106,3449,129],{"class":116},[106,3451,120],{"class":112},[106,3453,3454,3456,3458],{"class":108,"line":1584},[106,3455,241],{"class":112},[106,3457,3296],{"class":116},[106,3459,120],{"class":112},[106,3461,3462,3464,3466],{"class":108,"line":1605},[106,3463,251],{"class":112},[106,3465,117],{"class":116},[106,3467,120],{"class":112},[10,3469,3470,3471,3474],{},"Si la validation échoue, la requête ne sera pas envoyée au serveur et l'erreur sera affichée dans la console. Bien sûr, vous pouvez gérer l'erreur comme vous le souhaitez, comme utiliser un ",[103,3472,3473],{},"toast"," ou afficher un message d'erreur sous l'entrée concernée.",[10,3476,3477],{},"Côté serveur, je peux remplacer la logique de validation par le validateur :",[96,3479,3481],{"className":261,"code":3480,"language":263,"meta":101,"style":101},"import { saveImageValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  \u002F\u002F ...\n\n  await saveImageValidator.parseAsync({ image })\n\n  \u002F\u002F ...\n})\n",[103,3482,3483,3502,3506,3528,3533,3537,3557,3561,3565],{"__ignoreMap":101},[106,3484,3485,3487,3489,3492,3494,3496,3498,3500],{"class":108,"line":109},[106,3486,410],{"class":270},[106,3488,413],{"class":112},[106,3490,3491],{"class":224}," saveImageValidator",[106,3493,419],{"class":112},[106,3495,422],{"class":270},[106,3497,425],{"class":112},[106,3499,1963],{"class":142},[106,3501,431],{"class":112},[106,3503,3504],{"class":108,"line":123},[106,3505,330],{"emptyLinePlaceholder":329},[106,3507,3508,3510,3512,3514,3516,3518,3520,3522,3524,3526],{"class":108,"line":162},[106,3509,271],{"class":270},[106,3511,274],{"class":270},[106,3513,278],{"class":277},[106,3515,281],{"class":224},[106,3517,284],{"class":132},[106,3519,287],{"class":112},[106,3521,291],{"class":290},[106,3523,294],{"class":112},[106,3525,297],{"class":132},[106,3527,300],{"class":112},[106,3529,3530],{"class":108,"line":200},[106,3531,3532],{"class":335},"  \u002F\u002F ...\n",[106,3534,3535],{"class":108,"line":221},[106,3536,330],{"emptyLinePlaceholder":329},[106,3538,3539,3541,3543,3545,3547,3549,3551,3553,3555],{"class":108,"line":228},[106,3540,2348],{"class":270},[106,3542,3491],{"class":224},[106,3544,52],{"class":112},[106,3546,872],{"class":277},[106,3548,281],{"class":116},[106,3550,1285],{"class":112},[106,3552,2324],{"class":224},[106,3554,419],{"class":112},[106,3556,324],{"class":116},[106,3558,3559],{"class":108,"line":238},[106,3560,330],{"emptyLinePlaceholder":329},[106,3562,3563],{"class":108,"line":248},[106,3564,3532],{"class":335},[106,3566,3567,3569],{"class":108,"line":534},[106,3568,366],{"class":112},[106,3570,324],{"class":224},[381,3572,3574],{"id":3573},"avec-nuxthub","Avec NuxtHub",[10,3576,3577,3578,3580,3581],{},"NuxtHub est livré avec un utilitaire nommé ",[103,3579,2064],{}," qui peut être utilisé pour vérifier la taille et le type du fichier. Cet utilitaire est très utile car il lancera automatiquement une erreur si le fichier n'est pas valide, mais ne peut être utilisé que côté serveur. Cela signifie que j'aurai un schéma Zod côté client et un utilitaire NuxtHub côté serveur. ",[29,3582,3583],{},"Utilisez des constantes pour partager les limites de validation entre le schéma Zod et l'utilitaire NuxtHub.",[96,3585,3587],{"className":261,"code":3586,"language":263,"meta":101,"style":101},"export default defineEventHandler(async (event) => {\n  const formData = await readFormData(event)\n\n  const image = formData.get('image') as Blob\n  ensureBlob(image, {\n    maxSize: '1Mo',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Enregistrer l'image dans une base de données ou un système de fichiers\n\n  return sendNoContent(event, 201)\n})\n",[103,3588,3589,3611,3629,3633,3663,3676,3692,3726,3732,3736,3740,3744,3760],{"__ignoreMap":101},[106,3590,3591,3593,3595,3597,3599,3601,3603,3605,3607,3609],{"class":108,"line":109},[106,3592,271],{"class":270},[106,3594,274],{"class":270},[106,3596,278],{"class":277},[106,3598,281],{"class":224},[106,3600,284],{"class":132},[106,3602,287],{"class":112},[106,3604,291],{"class":290},[106,3606,294],{"class":112},[106,3608,297],{"class":132},[106,3610,300],{"class":112},[106,3612,3613,3615,3617,3619,3621,3623,3625,3627],{"class":108,"line":123},[106,3614,305],{"class":132},[106,3616,2300],{"class":224},[106,3618,311],{"class":112},[106,3620,314],{"class":270},[106,3622,2307],{"class":277},[106,3624,281],{"class":116},[106,3626,291],{"class":224},[106,3628,324],{"class":116},[106,3630,3631],{"class":108,"line":162},[106,3632,330],{"emptyLinePlaceholder":329},[106,3634,3635,3637,3639,3641,3643,3645,3647,3649,3651,3653,3655,3657,3660],{"class":108,"line":200},[106,3636,305],{"class":132},[106,3638,2324],{"class":224},[106,3640,311],{"class":112},[106,3642,2300],{"class":224},[106,3644,52],{"class":112},[106,3646,2333],{"class":277},[106,3648,281],{"class":116},[106,3650,754],{"class":112},[106,3652,2234],{"class":142},[106,3654,754],{"class":112},[106,3656,713],{"class":116},[106,3658,3659],{"class":270},"as",[106,3661,3662],{"class":1041}," Blob\n",[106,3664,3665,3668,3670,3672,3674],{"class":108,"line":221},[106,3666,3667],{"class":277},"  ensureBlob",[106,3669,281],{"class":116},[106,3671,2234],{"class":224},[106,3673,355],{"class":112},[106,3675,300],{"class":112},[106,3677,3678,3681,3683,3685,3688,3690],{"class":108,"line":228},[106,3679,3680],{"class":116},"    maxSize",[106,3682,496],{"class":112},[106,3684,425],{"class":112},[106,3686,3687],{"class":142},"1Mo",[106,3689,754],{"class":112},[106,3691,509],{"class":112},[106,3693,3694,3697,3699,3701,3703,3705,3707,3709,3711,3713,3715,3717,3719,3721,3723],{"class":108,"line":238},[106,3695,3696],{"class":116},"    types",[106,3698,496],{"class":112},[106,3700,2538],{"class":116},[106,3702,754],{"class":112},[106,3704,2543],{"class":142},[106,3706,754],{"class":112},[106,3708,355],{"class":112},[106,3710,425],{"class":112},[106,3712,2552],{"class":142},[106,3714,754],{"class":112},[106,3716,355],{"class":112},[106,3718,425],{"class":112},[106,3720,2561],{"class":142},[106,3722,754],{"class":112},[106,3724,3725],{"class":116},"]\n",[106,3727,3728,3730],{"class":108,"line":248},[106,3729,514],{"class":112},[106,3731,324],{"class":116},[106,3733,3734],{"class":108,"line":534},[106,3735,330],{"emptyLinePlaceholder":329},[106,3737,3738],{"class":108,"line":539},[106,3739,2718],{"class":335},[106,3741,3742],{"class":108,"line":556},[106,3743,330],{"emptyLinePlaceholder":329},[106,3745,3746,3748,3750,3752,3754,3756,3758],{"class":108,"line":759},[106,3747,345],{"class":270},[106,3749,348],{"class":277},[106,3751,281],{"class":116},[106,3753,291],{"class":224},[106,3755,355],{"class":112},[106,3757,359],{"class":358},[106,3759,324],{"class":116},[106,3761,3762,3764],{"class":108,"line":795},[106,3763,366],{"class":112},[106,3765,324],{"class":224},[54,3767,3769],{"id":3768},"texte-et-blob-dans-le-même-formulaire","Texte et Blob dans le Même Formulaire",[10,3771,3772],{},"Jusqu'à présent, j'ai seulement un type d'entrée dans mon formulaire, texte ou fichier. Mais que se passe-t-il si je veux avoir les deux dans le même formulaire ?",[10,3774,3775,3776,3778],{},"Vous devez savoir que vous devez absolument utiliser un ",[103,3777,2216],{}," pour envoyer le formulaire au serveur en raison de l'entrée de fichier.",[10,3780,3781],{},"Le formulaire est toujours très simple :",[96,3783,3785],{"className":98,"code":3784,"language":100,"meta":101,"style":101},"\u003Ctemplate>\n  \u003Cform action=\"\u002Fapi\u002Ftext-image\" enctype=\"multipart\u002Fform-data\" method=\"post\">\n    \u003Cinput type=\"text\" name=\"name\" placeholder=\"Nom\" required>\n    \u003Cinput type=\"file\" name=\"image\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n",[103,3786,3787,3795,3834,3875,3915,3933,3937,3945,3953],{"__ignoreMap":101},[106,3788,3789,3791,3793],{"class":108,"line":109},[106,3790,113],{"class":112},[106,3792,117],{"class":116},[106,3794,120],{"class":112},[106,3796,3797,3799,3801,3803,3805,3807,3810,3812,3814,3816,3818,3820,3822,3824,3826,3828,3830,3832],{"class":108,"line":123},[106,3798,126],{"class":112},[106,3800,129],{"class":116},[106,3802,148],{"class":132},[106,3804,136],{"class":112},[106,3806,139],{"class":112},[106,3808,3809],{"class":142},"\u002Fapi\u002Ftext-image",[106,3811,139],{"class":112},[106,3813,2110],{"class":132},[106,3815,136],{"class":112},[106,3817,139],{"class":112},[106,3819,2117],{"class":142},[106,3821,139],{"class":112},[106,3823,133],{"class":132},[106,3825,136],{"class":112},[106,3827,139],{"class":112},[106,3829,143],{"class":142},[106,3831,139],{"class":112},[106,3833,120],{"class":112},[106,3835,3836,3838,3840,3842,3844,3846,3848,3850,3852,3854,3856,3859,3861,3863,3865,3867,3869,3871,3873],{"class":108,"line":162},[106,3837,165],{"class":112},[106,3839,168],{"class":116},[106,3841,171],{"class":132},[106,3843,136],{"class":112},[106,3845,139],{"class":112},[106,3847,178],{"class":142},[106,3849,139],{"class":112},[106,3851,3391],{"class":132},[106,3853,136],{"class":112},[106,3855,139],{"class":112},[106,3857,3858],{"class":142},"name",[106,3860,139],{"class":112},[106,3862,183],{"class":132},[106,3864,136],{"class":112},[106,3866,139],{"class":112},[106,3868,190],{"class":142},[106,3870,139],{"class":112},[106,3872,195],{"class":132},[106,3874,120],{"class":112},[106,3876,3877,3879,3881,3883,3885,3887,3889,3891,3893,3895,3897,3899,3901,3903,3905,3907,3909,3911,3913],{"class":108,"line":200},[106,3878,165],{"class":112},[106,3880,168],{"class":116},[106,3882,171],{"class":132},[106,3884,136],{"class":112},[106,3886,139],{"class":112},[106,3888,2146],{"class":142},[106,3890,139],{"class":112},[106,3892,3391],{"class":132},[106,3894,136],{"class":112},[106,3896,139],{"class":112},[106,3898,2234],{"class":142},[106,3900,139],{"class":112},[106,3902,2151],{"class":132},[106,3904,136],{"class":112},[106,3906,139],{"class":112},[106,3908,2158],{"class":142},[106,3910,139],{"class":112},[106,3912,195],{"class":132},[106,3914,120],{"class":112},[106,3916,3917,3919,3921,3923,3925,3927,3929,3931],{"class":108,"line":221},[106,3918,165],{"class":112},[106,3920,205],{"class":116},[106,3922,171],{"class":132},[106,3924,136],{"class":112},[106,3926,139],{"class":112},[106,3928,214],{"class":142},[106,3930,139],{"class":112},[106,3932,120],{"class":112},[106,3934,3935],{"class":108,"line":228},[106,3936,225],{"class":224},[106,3938,3939,3941,3943],{"class":108,"line":238},[106,3940,231],{"class":112},[106,3942,205],{"class":116},[106,3944,120],{"class":112},[106,3946,3947,3949,3951],{"class":108,"line":248},[106,3948,241],{"class":112},[106,3950,129],{"class":116},[106,3952,120],{"class":112},[106,3954,3955,3957,3959],{"class":108,"line":534},[106,3956,251],{"class":112},[106,3958,117],{"class":116},[106,3960,120],{"class":112},[10,3962,3963],{},"Maintenant, je peux créer un validateur en utilisant Zod :",[96,3965,3967],{"className":261,"code":3966,"language":263,"meta":101,"style":101},"import { any, object, string } from 'zod'\n\nexport const createValidator = object({\n  name: string({ message: 'Obligatoire' }),\n  image: any().refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' }).refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' }).refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' }).refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n})\n",[103,3968,3969,3996,4000,4017,4045,4259],{"__ignoreMap":101},[106,3970,3971,3973,3975,3977,3979,3981,3983,3986,3988,3990,3992,3994],{"class":108,"line":109},[106,3972,410],{"class":270},[106,3974,413],{"class":112},[106,3976,2252],{"class":224},[106,3978,355],{"class":112},[106,3980,2257],{"class":224},[106,3982,355],{"class":112},[106,3984,3985],{"class":224}," string",[106,3987,419],{"class":112},[106,3989,422],{"class":270},[106,3991,425],{"class":112},[106,3993,428],{"class":142},[106,3995,431],{"class":112},[106,3997,3998],{"class":108,"line":123},[106,3999,330],{"emptyLinePlaceholder":329},[106,4001,4002,4004,4006,4009,4011,4013,4015],{"class":108,"line":162},[106,4003,271],{"class":270},[106,4005,1775],{"class":132},[106,4007,4008],{"class":224}," createValidator ",[106,4010,136],{"class":112},[106,4012,2257],{"class":277},[106,4014,281],{"class":224},[106,4016,488],{"class":112},[106,4018,4019,4021,4023,4025,4027,4029,4031,4033,4035,4037,4039,4041,4043],{"class":108,"line":200},[106,4020,1272],{"class":116},[106,4022,496],{"class":112},[106,4024,3985],{"class":277},[106,4026,281],{"class":224},[106,4028,1285],{"class":112},[106,4030,1288],{"class":116},[106,4032,496],{"class":112},[106,4034,425],{"class":112},[106,4036,1295],{"class":142},[106,4038,754],{"class":112},[106,4040,419],{"class":112},[106,4042,294],{"class":224},[106,4044,509],{"class":112},[106,4046,4047,4049,4051,4053,4055,4057,4059,4061,4063,4065,4067,4069,4071,4073,4075,4077,4079,4081,4083,4085,4087,4089,4091,4093,4095,4097,4099,4101,4103,4105,4107,4109,4111,4113,4115,4117,4119,4121,4123,4125,4127,4129,4131,4133,4135,4137,4139,4141,4143,4145,4147,4149,4151,4153,4155,4157,4159,4161,4163,4165,4167,4169,4171,4173,4175,4177,4179,4181,4183,4185,4187,4189,4191,4193,4195,4197,4199,4201,4203,4205,4207,4209,4211,4213,4215,4217,4219,4221,4223,4225,4227,4229,4231,4233,4235,4237,4239,4241,4243,4245,4247,4249,4251,4253,4255,4257],{"class":108,"line":221},[106,4048,2807],{"class":116},[106,4050,496],{"class":112},[106,4052,2252],{"class":277},[106,4054,506],{"class":224},[106,4056,52],{"class":112},[106,4058,2374],{"class":277},[106,4060,281],{"class":224},[106,4062,2234],{"class":290},[106,4064,297],{"class":132},[106,4066,2829],{"class":224},[106,4068,2832],{"class":112},[106,4070,2387],{"class":1041},[106,4072,355],{"class":112},[106,4074,413],{"class":112},[106,4076,1288],{"class":116},[106,4078,496],{"class":112},[106,4080,425],{"class":112},[106,4082,2400],{"class":142},[106,4084,2403],{"class":224},[106,4086,2406],{"class":142},[106,4088,754],{"class":112},[106,4090,419],{"class":112},[106,4092,294],{"class":224},[106,4094,52],{"class":112},[106,4096,2374],{"class":277},[106,4098,281],{"class":224},[106,4100,2234],{"class":290},[106,4102,297],{"class":132},[106,4104,2324],{"class":224},[106,4106,52],{"class":112},[106,4108,2875],{"class":224},[106,4110,113],{"class":112},[106,4112,2437],{"class":358},[106,4114,2440],{"class":112},[106,4116,2437],{"class":358},[106,4118,355],{"class":112},[106,4120,413],{"class":112},[106,4122,1288],{"class":116},[106,4124,496],{"class":112},[106,4126,425],{"class":112},[106,4128,2400],{"class":142},[106,4130,2403],{"class":224},[106,4132,2459],{"class":142},[106,4134,754],{"class":112},[106,4136,419],{"class":112},[106,4138,294],{"class":224},[106,4140,52],{"class":112},[106,4142,2374],{"class":277},[106,4144,281],{"class":224},[106,4146,2234],{"class":290},[106,4148,297],{"class":132},[106,4150,2324],{"class":224},[106,4152,52],{"class":112},[106,4154,2484],{"class":224},[106,4156,52],{"class":112},[106,4158,2489],{"class":277},[106,4160,281],{"class":224},[106,4162,754],{"class":112},[106,4164,2496],{"class":142},[106,4166,754],{"class":112},[106,4168,294],{"class":224},[106,4170,355],{"class":112},[106,4172,413],{"class":112},[106,4174,1288],{"class":116},[106,4176,496],{"class":112},[106,4178,425],{"class":112},[106,4180,2400],{"class":142},[106,4182,2403],{"class":224},[106,4184,2517],{"class":142},[106,4186,754],{"class":112},[106,4188,419],{"class":112},[106,4190,294],{"class":224},[106,4192,52],{"class":112},[106,4194,2374],{"class":277},[106,4196,281],{"class":224},[106,4198,2234],{"class":290},[106,4200,297],{"class":132},[106,4202,2538],{"class":224},[106,4204,754],{"class":112},[106,4206,2543],{"class":142},[106,4208,754],{"class":112},[106,4210,355],{"class":112},[106,4212,425],{"class":112},[106,4214,2552],{"class":142},[106,4216,754],{"class":112},[106,4218,355],{"class":112},[106,4220,425],{"class":112},[106,4222,2561],{"class":142},[106,4224,754],{"class":112},[106,4226,785],{"class":224},[106,4228,52],{"class":112},[106,4230,2570],{"class":277},[106,4232,2996],{"class":224},[106,4234,52],{"class":112},[106,4236,3001],{"class":224},[106,4238,355],{"class":112},[106,4240,413],{"class":112},[106,4242,1288],{"class":116},[106,4244,496],{"class":112},[106,4246,425],{"class":112},[106,4248,2400],{"class":142},[106,4250,2403],{"class":224},[106,4252,2597],{"class":142},[106,4254,754],{"class":112},[106,4256,419],{"class":112},[106,4258,324],{"class":224},[106,4260,4261,4263],{"class":108,"line":228},[106,4262,366],{"class":112},[106,4264,324],{"class":224},[10,4266,4267],{},"Ce validateur peut être utilisé comme auparavant, sur le serveur et le client.",[10,4269,4270,4271,4273],{},"Si vous préférez utiliser l'utilitaire ",[103,4272,2064],{}," de NuxtHub, je vous recommande de diviser le validateur en deux parties : une pour le texte et une pour l'image. Cela signifie que le texte et l'image seront utilisés côté client en les fusionnant en un seul objet, et uniquement le texte sera utilisé côté serveur.",[96,4275,4277],{"className":261,"code":4276,"language":263,"meta":101,"style":101},"import { any, object, string } from 'zod'\n\nexport const createTextValidator = object({\n  name: string({ message: 'Obligatoire' }),\n})\n\nexport const createImageValidator = object({\n  image: any().refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' }).refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' }).refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' }).refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n})\n\nexport const createValidator = createTextValidator.merge(createImageValidator)\n",[103,4278,4279,4305,4309,4326,4354,4360,4364,4380,4594,4600,4604],{"__ignoreMap":101},[106,4280,4281,4283,4285,4287,4289,4291,4293,4295,4297,4299,4301,4303],{"class":108,"line":109},[106,4282,410],{"class":270},[106,4284,413],{"class":112},[106,4286,2252],{"class":224},[106,4288,355],{"class":112},[106,4290,2257],{"class":224},[106,4292,355],{"class":112},[106,4294,3985],{"class":224},[106,4296,419],{"class":112},[106,4298,422],{"class":270},[106,4300,425],{"class":112},[106,4302,428],{"class":142},[106,4304,431],{"class":112},[106,4306,4307],{"class":108,"line":123},[106,4308,330],{"emptyLinePlaceholder":329},[106,4310,4311,4313,4315,4318,4320,4322,4324],{"class":108,"line":162},[106,4312,271],{"class":270},[106,4314,1775],{"class":132},[106,4316,4317],{"class":224}," createTextValidator ",[106,4319,136],{"class":112},[106,4321,2257],{"class":277},[106,4323,281],{"class":224},[106,4325,488],{"class":112},[106,4327,4328,4330,4332,4334,4336,4338,4340,4342,4344,4346,4348,4350,4352],{"class":108,"line":200},[106,4329,1272],{"class":116},[106,4331,496],{"class":112},[106,4333,3985],{"class":277},[106,4335,281],{"class":224},[106,4337,1285],{"class":112},[106,4339,1288],{"class":116},[106,4341,496],{"class":112},[106,4343,425],{"class":112},[106,4345,1295],{"class":142},[106,4347,754],{"class":112},[106,4349,419],{"class":112},[106,4351,294],{"class":224},[106,4353,509],{"class":112},[106,4355,4356,4358],{"class":108,"line":221},[106,4357,366],{"class":112},[106,4359,324],{"class":224},[106,4361,4362],{"class":108,"line":228},[106,4363,330],{"emptyLinePlaceholder":329},[106,4365,4366,4368,4370,4372,4374,4376,4378],{"class":108,"line":238},[106,4367,271],{"class":270},[106,4369,1775],{"class":132},[106,4371,2794],{"class":224},[106,4373,136],{"class":112},[106,4375,2257],{"class":277},[106,4377,281],{"class":224},[106,4379,488],{"class":112},[106,4381,4382,4384,4386,4388,4390,4392,4394,4396,4398,4400,4402,4404,4406,4408,4410,4412,4414,4416,4418,4420,4422,4424,4426,4428,4430,4432,4434,4436,4438,4440,4442,4444,4446,4448,4450,4452,4454,4456,4458,4460,4462,4464,4466,4468,4470,4472,4474,4476,4478,4480,4482,4484,4486,4488,4490,4492,4494,4496,4498,4500,4502,4504,4506,4508,4510,4512,4514,4516,4518,4520,4522,4524,4526,4528,4530,4532,4534,4536,4538,4540,4542,4544,4546,4548,4550,4552,4554,4556,4558,4560,4562,4564,4566,4568,4570,4572,4574,4576,4578,4580,4582,4584,4586,4588,4590,4592],{"class":108,"line":248},[106,4383,2807],{"class":116},[106,4385,496],{"class":112},[106,4387,2252],{"class":277},[106,4389,506],{"class":224},[106,4391,52],{"class":112},[106,4393,2374],{"class":277},[106,4395,281],{"class":224},[106,4397,2234],{"class":290},[106,4399,297],{"class":132},[106,4401,2829],{"class":224},[106,4403,2832],{"class":112},[106,4405,2387],{"class":1041},[106,4407,355],{"class":112},[106,4409,413],{"class":112},[106,4411,1288],{"class":116},[106,4413,496],{"class":112},[106,4415,425],{"class":112},[106,4417,2400],{"class":142},[106,4419,2403],{"class":224},[106,4421,2406],{"class":142},[106,4423,754],{"class":112},[106,4425,419],{"class":112},[106,4427,294],{"class":224},[106,4429,52],{"class":112},[106,4431,2374],{"class":277},[106,4433,281],{"class":224},[106,4435,2234],{"class":290},[106,4437,297],{"class":132},[106,4439,2324],{"class":224},[106,4441,52],{"class":112},[106,4443,2875],{"class":224},[106,4445,113],{"class":112},[106,4447,2437],{"class":358},[106,4449,2440],{"class":112},[106,4451,2437],{"class":358},[106,4453,355],{"class":112},[106,4455,413],{"class":112},[106,4457,1288],{"class":116},[106,4459,496],{"class":112},[106,4461,425],{"class":112},[106,4463,2400],{"class":142},[106,4465,2403],{"class":224},[106,4467,2459],{"class":142},[106,4469,754],{"class":112},[106,4471,419],{"class":112},[106,4473,294],{"class":224},[106,4475,52],{"class":112},[106,4477,2374],{"class":277},[106,4479,281],{"class":224},[106,4481,2234],{"class":290},[106,4483,297],{"class":132},[106,4485,2324],{"class":224},[106,4487,52],{"class":112},[106,4489,2484],{"class":224},[106,4491,52],{"class":112},[106,4493,2489],{"class":277},[106,4495,281],{"class":224},[106,4497,754],{"class":112},[106,4499,2496],{"class":142},[106,4501,754],{"class":112},[106,4503,294],{"class":224},[106,4505,355],{"class":112},[106,4507,413],{"class":112},[106,4509,1288],{"class":116},[106,4511,496],{"class":112},[106,4513,425],{"class":112},[106,4515,2400],{"class":142},[106,4517,2403],{"class":224},[106,4519,2517],{"class":142},[106,4521,754],{"class":112},[106,4523,419],{"class":112},[106,4525,294],{"class":224},[106,4527,52],{"class":112},[106,4529,2374],{"class":277},[106,4531,281],{"class":224},[106,4533,2234],{"class":290},[106,4535,297],{"class":132},[106,4537,2538],{"class":224},[106,4539,754],{"class":112},[106,4541,2543],{"class":142},[106,4543,754],{"class":112},[106,4545,355],{"class":112},[106,4547,425],{"class":112},[106,4549,2552],{"class":142},[106,4551,754],{"class":112},[106,4553,355],{"class":112},[106,4555,425],{"class":112},[106,4557,2561],{"class":142},[106,4559,754],{"class":112},[106,4561,785],{"class":224},[106,4563,52],{"class":112},[106,4565,2570],{"class":277},[106,4567,2996],{"class":224},[106,4569,52],{"class":112},[106,4571,3001],{"class":224},[106,4573,355],{"class":112},[106,4575,413],{"class":112},[106,4577,1288],{"class":116},[106,4579,496],{"class":112},[106,4581,425],{"class":112},[106,4583,2400],{"class":142},[106,4585,2403],{"class":224},[106,4587,2597],{"class":142},[106,4589,754],{"class":112},[106,4591,419],{"class":112},[106,4593,324],{"class":224},[106,4595,4596,4598],{"class":108,"line":534},[106,4597,366],{"class":112},[106,4599,324],{"class":224},[106,4601,4602],{"class":108,"line":539},[106,4603,330],{"emptyLinePlaceholder":329},[106,4605,4606,4608,4610,4612,4614,4617,4619,4622],{"class":108,"line":556},[106,4607,271],{"class":270},[106,4609,1775],{"class":132},[106,4611,4008],{"class":224},[106,4613,136],{"class":112},[106,4615,4616],{"class":224}," createTextValidator",[106,4618,52],{"class":112},[106,4620,4621],{"class":277},"merge",[106,4623,4624],{"class":224},"(createImageValidator)\n",[10,4626,4627,4628,4631],{},"Côté client, j'utiliserai le ",[103,4629,4630],{},"createValidator"," pour valider le formulaire avant de l'envoyer au serveur :",[96,4633,4635],{"className":98,"code":4634,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nasync function onSubmit(event: Event) {\n  const target = event.target as HTMLFormElement\n  const formData = new FormData(target)\n\n  const name = formData.get('name')\n  const image = formData.get('image')\n  try {\n    await createValidator.parseAsync({ name, image })\n\n    await $fetch('\u002Fapi\u002Ftext-image', {\n      method: 'POST',\n      body: formData,\n    })\n  }\n  catch (error) {\n    console.error(error)\n  }\n}\n\u003C\u002Fscript>\n",[103,4636,4637,4657,4677,4695,4713,4717,4741,4765,4771,4796,4800,4818,4832,4842,4848,4852,4864,4878,4882,4886],{"__ignoreMap":101},[106,4638,4639,4641,4643,4645,4647,4649,4651,4653,4655],{"class":108,"line":109},[106,4640,113],{"class":112},[106,4642,1189],{"class":116},[106,4644,1192],{"class":132},[106,4646,136],{"class":112},[106,4648,139],{"class":112},[106,4650,263],{"class":142},[106,4652,139],{"class":112},[106,4654,1203],{"class":132},[106,4656,120],{"class":112},[106,4658,4659,4661,4663,4665,4667,4669,4671,4673,4675],{"class":108,"line":123},[106,4660,284],{"class":132},[106,4662,1360],{"class":132},[106,4664,1363],{"class":277},[106,4666,281],{"class":112},[106,4668,291],{"class":290},[106,4670,496],{"class":112},[106,4672,3075],{"class":1041},[106,4674,294],{"class":112},[106,4676,300],{"class":112},[106,4678,4679,4681,4683,4685,4687,4689,4691,4693],{"class":108,"line":162},[106,4680,305],{"class":132},[106,4682,3086],{"class":224},[106,4684,311],{"class":112},[106,4686,3091],{"class":224},[106,4688,52],{"class":112},[106,4690,3096],{"class":224},[106,4692,3099],{"class":270},[106,4694,3102],{"class":1041},[106,4696,4697,4699,4701,4703,4705,4707,4709,4711],{"class":108,"line":200},[106,4698,305],{"class":132},[106,4700,2300],{"class":224},[106,4702,311],{"class":112},[106,4704,3113],{"class":112},[106,4706,3116],{"class":277},[106,4708,281],{"class":116},[106,4710,3096],{"class":224},[106,4712,324],{"class":116},[106,4714,4715],{"class":108,"line":221},[106,4716,330],{"emptyLinePlaceholder":329},[106,4718,4719,4721,4723,4725,4727,4729,4731,4733,4735,4737,4739],{"class":108,"line":228},[106,4720,305],{"class":132},[106,4722,3391],{"class":224},[106,4724,311],{"class":112},[106,4726,2300],{"class":224},[106,4728,52],{"class":112},[106,4730,2333],{"class":277},[106,4732,281],{"class":116},[106,4734,754],{"class":112},[106,4736,3858],{"class":142},[106,4738,754],{"class":112},[106,4740,324],{"class":116},[106,4742,4743,4745,4747,4749,4751,4753,4755,4757,4759,4761,4763],{"class":108,"line":238},[106,4744,305],{"class":132},[106,4746,2324],{"class":224},[106,4748,311],{"class":112},[106,4750,2300],{"class":224},[106,4752,52],{"class":112},[106,4754,2333],{"class":277},[106,4756,281],{"class":116},[106,4758,754],{"class":112},[106,4760,2234],{"class":142},[106,4762,754],{"class":112},[106,4764,324],{"class":116},[106,4766,4767,4769],{"class":108,"line":248},[106,4768,1372],{"class":270},[106,4770,300],{"class":112},[106,4772,4773,4775,4778,4780,4782,4784,4786,4788,4790,4792,4794],{"class":108,"line":534},[106,4774,1402],{"class":270},[106,4776,4777],{"class":224}," createValidator",[106,4779,52],{"class":112},[106,4781,872],{"class":277},[106,4783,281],{"class":116},[106,4785,1285],{"class":112},[106,4787,3391],{"class":224},[106,4789,355],{"class":112},[106,4791,2324],{"class":224},[106,4793,419],{"class":112},[106,4795,324],{"class":116},[106,4797,4798],{"class":108,"line":539},[106,4799,330],{"emptyLinePlaceholder":329},[106,4801,4802,4804,4806,4808,4810,4812,4814,4816],{"class":108,"line":556},[106,4803,1402],{"class":270},[106,4805,1405],{"class":277},[106,4807,281],{"class":116},[106,4809,754],{"class":112},[106,4811,3809],{"class":142},[106,4813,754],{"class":112},[106,4815,355],{"class":112},[106,4817,300],{"class":112},[106,4819,4820,4822,4824,4826,4828,4830],{"class":108,"line":759},[106,4821,1422],{"class":116},[106,4823,496],{"class":112},[106,4825,425],{"class":112},[106,4827,3210],{"class":142},[106,4829,754],{"class":112},[106,4831,509],{"class":112},[106,4833,4834,4836,4838,4840],{"class":108,"line":795},[106,4835,1437],{"class":116},[106,4837,496],{"class":112},[106,4839,2300],{"class":224},[106,4841,509],{"class":112},[106,4843,4844,4846],{"class":108,"line":803},[106,4845,798],{"class":112},[106,4847,324],{"class":116},[106,4849,4850],{"class":108,"line":809},[106,4851,806],{"class":112},[106,4853,4854,4856,4858,4860,4862],{"class":108,"line":814},[106,4855,1458],{"class":270},[106,4857,287],{"class":116},[106,4859,771],{"class":224},[106,4861,713],{"class":116},[106,4863,488],{"class":112},[106,4865,4866,4868,4870,4872,4874,4876],{"class":108,"line":830},[106,4867,3251],{"class":224},[106,4869,52],{"class":112},[106,4871,771],{"class":277},[106,4873,281],{"class":116},[106,4875,771],{"class":224},[106,4877,324],{"class":116},[106,4879,4880],{"class":108,"line":835},[106,4881,806],{"class":112},[106,4883,4884],{"class":108,"line":840},[106,4885,1558],{"class":112},[106,4887,4888,4890,4892],{"class":108,"line":845},[106,4889,251],{"class":112},[106,4891,1189],{"class":116},[106,4893,120],{"class":112},[10,4895,4896,4897,4900,4901,4903],{},"Côté serveur, j'utiliserai le ",[103,4898,4899],{},"createTextValidator"," pour valider le texte et l'utilitaire ",[103,4902,2064],{}," pour valider l'image :",[96,4905,4907],{"className":261,"code":4906,"language":263,"meta":101,"style":101},"import { createTextValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  const formData = await readFormData(event)\n\n  const name = formData.get('name')\n  const data = await createTextValidator.parseAsync({ name }).catch((error) => {\n    throw createError({\n      status: 400,\n      message: 'Données non valides',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Enregistrer le texte dans la base de données...\n\n  const image = formData.get('image') as Blob\n  ensureBlob(image, {\n    maxSize: '1 Mo',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Enregistrer l'image dans une base de données ou un système de fichiers\n\n  return sendNoContent(event, 201)\n})\n",[103,4908,4909,4927,4931,4953,4971,4975,4999,5041,5051,5061,5075,5097,5103,5109,5113,5118,5122,5150,5162,5177,5209,5215,5219,5223,5227,5243],{"__ignoreMap":101},[106,4910,4911,4913,4915,4917,4919,4921,4923,4925],{"class":108,"line":109},[106,4912,410],{"class":270},[106,4914,413],{"class":112},[106,4916,4616],{"class":224},[106,4918,419],{"class":112},[106,4920,422],{"class":270},[106,4922,425],{"class":112},[106,4924,1963],{"class":142},[106,4926,431],{"class":112},[106,4928,4929],{"class":108,"line":123},[106,4930,330],{"emptyLinePlaceholder":329},[106,4932,4933,4935,4937,4939,4941,4943,4945,4947,4949,4951],{"class":108,"line":162},[106,4934,271],{"class":270},[106,4936,274],{"class":270},[106,4938,278],{"class":277},[106,4940,281],{"class":224},[106,4942,284],{"class":132},[106,4944,287],{"class":112},[106,4946,291],{"class":290},[106,4948,294],{"class":112},[106,4950,297],{"class":132},[106,4952,300],{"class":112},[106,4954,4955,4957,4959,4961,4963,4965,4967,4969],{"class":108,"line":200},[106,4956,305],{"class":132},[106,4958,2300],{"class":224},[106,4960,311],{"class":112},[106,4962,314],{"class":270},[106,4964,2307],{"class":277},[106,4966,281],{"class":116},[106,4968,291],{"class":224},[106,4970,324],{"class":116},[106,4972,4973],{"class":108,"line":221},[106,4974,330],{"emptyLinePlaceholder":329},[106,4976,4977,4979,4981,4983,4985,4987,4989,4991,4993,4995,4997],{"class":108,"line":228},[106,4978,305],{"class":132},[106,4980,3391],{"class":224},[106,4982,311],{"class":112},[106,4984,2300],{"class":224},[106,4986,52],{"class":112},[106,4988,2333],{"class":277},[106,4990,281],{"class":116},[106,4992,754],{"class":112},[106,4994,3858],{"class":142},[106,4996,754],{"class":112},[106,4998,324],{"class":116},[106,5000,5001,5003,5005,5007,5009,5011,5013,5015,5017,5019,5021,5023,5025,5027,5029,5031,5033,5035,5037,5039],{"class":108,"line":238},[106,5002,305],{"class":132},[106,5004,964],{"class":224},[106,5006,311],{"class":112},[106,5008,314],{"class":270},[106,5010,4616],{"class":224},[106,5012,52],{"class":112},[106,5014,872],{"class":277},[106,5016,281],{"class":116},[106,5018,1285],{"class":112},[106,5020,3391],{"class":224},[106,5022,419],{"class":112},[106,5024,294],{"class":116},[106,5026,52],{"class":112},[106,5028,1014],{"class":277},[106,5030,281],{"class":116},[106,5032,281],{"class":112},[106,5034,771],{"class":290},[106,5036,294],{"class":112},[106,5038,297],{"class":132},[106,5040,300],{"class":112},[106,5042,5043,5045,5047,5049],{"class":108,"line":248},[106,5044,720],{"class":270},[106,5046,723],{"class":277},[106,5048,281],{"class":116},[106,5050,488],{"class":112},[106,5052,5053,5055,5057,5059],{"class":108,"line":534},[106,5054,732],{"class":116},[106,5056,496],{"class":112},[106,5058,737],{"class":358},[106,5060,509],{"class":112},[106,5062,5063,5065,5067,5069,5071,5073],{"class":108,"line":539},[106,5064,744],{"class":116},[106,5066,496],{"class":112},[106,5068,425],{"class":112},[106,5070,751],{"class":142},[106,5072,754],{"class":112},[106,5074,509],{"class":112},[106,5076,5077,5079,5081,5083,5085,5087,5089,5091,5093,5095],{"class":108,"line":556},[106,5078,762],{"class":116},[106,5080,496],{"class":112},[106,5082,1092],{"class":224},[106,5084,52],{"class":112},[106,5086,776],{"class":224},[106,5088,779],{"class":116},[106,5090,782],{"class":358},[106,5092,785],{"class":116},[106,5094,52],{"class":112},[106,5096,2697],{"class":224},[106,5098,5099,5101],{"class":108,"line":759},[106,5100,798],{"class":112},[106,5102,324],{"class":116},[106,5104,5105,5107],{"class":108,"line":795},[106,5106,514],{"class":112},[106,5108,324],{"class":116},[106,5110,5111],{"class":108,"line":803},[106,5112,330],{"emptyLinePlaceholder":329},[106,5114,5115],{"class":108,"line":809},[106,5116,5117],{"class":335},"  \u002F\u002F Enregistrer le texte dans la base de données...\n",[106,5119,5120],{"class":108,"line":814},[106,5121,330],{"emptyLinePlaceholder":329},[106,5123,5124,5126,5128,5130,5132,5134,5136,5138,5140,5142,5144,5146,5148],{"class":108,"line":830},[106,5125,305],{"class":132},[106,5127,2324],{"class":224},[106,5129,311],{"class":112},[106,5131,2300],{"class":224},[106,5133,52],{"class":112},[106,5135,2333],{"class":277},[106,5137,281],{"class":116},[106,5139,754],{"class":112},[106,5141,2234],{"class":142},[106,5143,754],{"class":112},[106,5145,713],{"class":116},[106,5147,3659],{"class":270},[106,5149,3662],{"class":1041},[106,5151,5152,5154,5156,5158,5160],{"class":108,"line":835},[106,5153,3667],{"class":277},[106,5155,281],{"class":116},[106,5157,2234],{"class":224},[106,5159,355],{"class":112},[106,5161,300],{"class":112},[106,5163,5164,5166,5168,5170,5173,5175],{"class":108,"line":840},[106,5165,3680],{"class":116},[106,5167,496],{"class":112},[106,5169,425],{"class":112},[106,5171,5172],{"class":142},"1 Mo",[106,5174,754],{"class":112},[106,5176,509],{"class":112},[106,5178,5179,5181,5183,5185,5187,5189,5191,5193,5195,5197,5199,5201,5203,5205,5207],{"class":108,"line":845},[106,5180,3696],{"class":116},[106,5182,496],{"class":112},[106,5184,2538],{"class":116},[106,5186,754],{"class":112},[106,5188,2543],{"class":142},[106,5190,754],{"class":112},[106,5192,355],{"class":112},[106,5194,425],{"class":112},[106,5196,2552],{"class":142},[106,5198,754],{"class":112},[106,5200,355],{"class":112},[106,5202,425],{"class":112},[106,5204,2561],{"class":142},[106,5206,754],{"class":112},[106,5208,3725],{"class":116},[106,5210,5211,5213],{"class":108,"line":862},[106,5212,514],{"class":112},[106,5214,324],{"class":116},[106,5216,5217],{"class":108,"line":1469},[106,5218,330],{"emptyLinePlaceholder":329},[106,5220,5221],{"class":108,"line":1486},[106,5222,2718],{"class":335},[106,5224,5225],{"class":108,"line":1497},[106,5226,330],{"emptyLinePlaceholder":329},[106,5228,5229,5231,5233,5235,5237,5239,5241],{"class":108,"line":1508},[106,5230,345],{"class":270},[106,5232,348],{"class":277},[106,5234,281],{"class":116},[106,5236,291],{"class":224},[106,5238,355],{"class":112},[106,5240,359],{"class":358},[106,5242,324],{"class":116},[106,5244,5245,5247],{"class":108,"line":1523},[106,5246,366],{"class":112},[106,5248,324],{"class":224},[10,5250,5251,5252,5254],{},"Pour des formulaires plus longs, je pourrais utiliser une boucle pour extraire chaque champ du formulaire et les valider. Dans cette boucle, je peux gérer des cas spécifiques comme des tableaux de champs multi-sélection ou des fichiers ou ignorer certains champs (qui pourraient être validés en utilisant ",[103,5253,2064],{},").",[96,5256,5258],{"className":261,"code":5257,"language":263,"meta":101,"style":101},"const data = {}\nfor (const [key, value] of formData.entries()) {\n  if (key === 'moduleId[]') {\n    data[key] = formData.getAll(key)\n    continue\n  }\n  else if (key === 'image') {\n    continue\n  }\n  else {\n    data[key] = value\n  }\n}\n",[103,5259,5260,5272,5308,5330,5357,5362,5366,5390,5394,5398,5404,5419,5423],{"__ignoreMap":101},[106,5261,5262,5264,5267,5269],{"class":108,"line":109},[106,5263,1252],{"class":132},[106,5265,5266],{"class":224}," data ",[106,5268,136],{"class":112},[106,5270,5271],{"class":112}," {}\n",[106,5273,5274,5277,5279,5281,5283,5286,5288,5291,5293,5296,5298,5300,5303,5306],{"class":108,"line":123},[106,5275,5276],{"class":270},"for",[106,5278,287],{"class":224},[106,5280,1252],{"class":132},[106,5282,2538],{"class":112},[106,5284,5285],{"class":224},"key",[106,5287,355],{"class":112},[106,5289,5290],{"class":224}," value",[106,5292,785],{"class":112},[106,5294,5295],{"class":112}," of",[106,5297,2300],{"class":224},[106,5299,52],{"class":112},[106,5301,5302],{"class":277},"entries",[106,5304,5305],{"class":224},"()) ",[106,5307,488],{"class":112},[106,5309,5310,5312,5314,5316,5319,5321,5324,5326,5328],{"class":108,"line":162},[106,5311,697],{"class":270},[106,5313,287],{"class":116},[106,5315,5285],{"class":224},[106,5317,5318],{"class":112}," ===",[106,5320,425],{"class":112},[106,5322,5323],{"class":142},"moduleId[]",[106,5325,754],{"class":112},[106,5327,713],{"class":116},[106,5329,488],{"class":112},[106,5331,5332,5335,5337,5339,5342,5344,5346,5348,5351,5353,5355],{"class":108,"line":200},[106,5333,5334],{"class":224},"    data",[106,5336,779],{"class":116},[106,5338,5285],{"class":224},[106,5340,5341],{"class":116},"] ",[106,5343,136],{"class":112},[106,5345,2300],{"class":224},[106,5347,52],{"class":112},[106,5349,5350],{"class":277},"getAll",[106,5352,281],{"class":116},[106,5354,5285],{"class":224},[106,5356,324],{"class":116},[106,5358,5359],{"class":108,"line":221},[106,5360,5361],{"class":270},"    continue\n",[106,5363,5364],{"class":108,"line":228},[106,5365,806],{"class":112},[106,5367,5368,5371,5374,5376,5378,5380,5382,5384,5386,5388],{"class":108,"line":238},[106,5369,5370],{"class":270},"  else",[106,5372,5373],{"class":270}," if",[106,5375,287],{"class":116},[106,5377,5285],{"class":224},[106,5379,5318],{"class":112},[106,5381,425],{"class":112},[106,5383,2234],{"class":142},[106,5385,754],{"class":112},[106,5387,713],{"class":116},[106,5389,488],{"class":112},[106,5391,5392],{"class":108,"line":248},[106,5393,5361],{"class":270},[106,5395,5396],{"class":108,"line":534},[106,5397,806],{"class":112},[106,5399,5400,5402],{"class":108,"line":539},[106,5401,5370],{"class":270},[106,5403,300],{"class":112},[106,5405,5406,5408,5410,5412,5414,5416],{"class":108,"line":556},[106,5407,5334],{"class":224},[106,5409,779],{"class":116},[106,5411,5285],{"class":224},[106,5413,5341],{"class":116},[106,5415,136],{"class":112},[106,5417,5418],{"class":224}," value\n",[106,5420,5421],{"class":108,"line":759},[106,5422,806],{"class":112},[106,5424,5425],{"class":108,"line":795},[106,5426,1558],{"class":112},[54,5428,5430],{"id":5429},"conclusion","Conclusion",[10,5432,5433,5434,5437,5438],{},"Et ",[29,5435,5436],{},"voilà"," ! Vous êtes maintenant capables de valider des formulaires à la fois côté client et côté serveur sans dupliquer la logique de validation. C'est un grand pas en avant dans la construction d'une application full-stack avec Nuxt. J'espère que cet article vous aidera à construire votre prochaine application en toute confiance. ",[29,5439,5440,5441,5446],{},"Vous devriez absolument consulter la ",[14,5442,5445],{"href":5443,"rel":5444},"https:\u002F\u002Fhub.nuxt.com\u002Fdocs",[18],"documentation de NuxtHub"," pour en savoir plus sur la plateforme et le pouvoir qu'elle offre pour construire des applications full-stack avec Nuxt.",[10,5448,5449],{},"N'hésitez pas à me faire savoir si vous avez des questions ou des commentaires. Je pourrais faire quelques erreurs, alors n'hésitez pas à me corriger. Je suis toujours à la recherche de moyens pour améliorer mon code et mes articles.",[10,5451,5452],{},"Merci de votre lecture !",[5454,5455,5456],"style",{},"html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}",{"title":101,"searchDepth":123,"depth":123,"links":5458},[5459,5460],{"id":56,"depth":123,"text":57},{"id":90,"depth":123,"text":91,"children":5461},[5462],{"id":383,"depth":162,"text":384},"8c077ba8-63ca-4784-b693-0e1fa8623bf9","2024-06-07","Découvrez comment valider les formulaires à la fois côté client et côté serveur sans dupliquer la logique de validation.","md",{},"\u002Ffr\u002Fposts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation","---\ncontentId: 8c077ba8-63ca-4784-b693-0e1fa8623bf9\ntitle: \"Nuxt Devient Full-Stack : Comment Valider des formulaires ?\"\ndescription: Découvrez comment valider les formulaires à la fois côté client et côté serveur sans dupliquer la logique de validation.\ndate: 2024-06-07\n---\n\nDepuis des semaines, je construis une application full-stack appelée Orion avec Nuxt et [NuxtHub](https:\u002F\u002Fhub.nuxt.com). Tout au long de ce parcours, j'ai rencontré de nombreux défis, et l'un d'eux est la validation des formulaires. Dans cet article, je vais vous montrer comment j'ai réussi à **valider les formulaires à la fois côté client et côté serveur sans duplications** de la logique de validation, des champs de texte simples aux téléchargements de fichiers complexes avec NuxtHub.\n\n_Orion est une collection de modèles basée sur la communauté pour votre prochain projet, des pages de destination aux applications web complètes. NuxtHub est une plateforme de déploiement et d'administration pour Nuxt, propulsée par Cloudflare._\n\n\u003CCalloutReadMore id=\"2349bbcd-1535-4f33-8da7-299f46af18b9\" \u002F>\n\n_Pendant cet article, je vais parler de [NuxtHub](https:\u002F\u002Fhub.nuxt.com) car il fournit les primitives pour construire des applications full-stack, mais même si vous ne l'utilisez pas, vous pouvez facilement suivre cet article. Les formulaires seront gérés sans aucune bibliothèque._\n\nVous pouvez parcourir le code exemple dans \u003CGitHubLink repo=\"barbapapazes\u002Fvalidate-forms-in-nuxt\" \u002F>.\n\n## Le Problème\n\nSur le web, les formulaires sont omniprésents. Ils sont le principal moyen d'interaction et de demande d'entrée utilisateur. Mais sur le web, il y a une règle essentielle : **ne jamais faire confiance au client** (absolument jamais et en aucun cas). Cela signifie que **toutes les données** provenant du client doivent être **vérifiées** et **sanitisées** sur le serveur avant d'être traitées ou stockées dans une base de données. Un client peut être un navigateur, une application mobile, une requête HTTP ou tout autre objet capable d'envoyer des données à votre serveur, construit par vous ou non.\n\nLorsque vous construisez une MPA traditionnelle (Multi-Page Application), seul le serveur est responsable de la validation. Pour une SPA (Single-Page Application), à la fois le client et le serveur sont responsables de la validation pour fournir une meilleure expérience utilisateur. _Cela est simplifié car il existe un moyen de donner une impression de validation côté client avec une MPA traditionnelle, mais ce n'est pas le sujet de cet article._ MAIS, même si les données sont validées côté client, il est **obligatoire** de les valider côté serveur.\n\n## Le Formulaire le Plus Simple\n\nCommençons notre parcours avec le formulaire le plus simple : une seule entrée de texte.\n\n```vue\n\u003Ctemplate>\n  \u003Cform method=\"post\" action=\"\u002Ftag\">\n    \u003Cinput type=\"text\" placeholder=\"Nom\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nEnsuite, côté serveur, je peux créer une route pour gérer la soumission du formulaire :\n\n```ts\nexport default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n```\n\nPour l'instant, je n'ai aucune validation. Je peux envoyer n'importe quoi au serveur, et il l'acceptera. _C'est un énorme problème de sécurité._\n\n_Rappelez-vous que la façon dont je gère la validation est spécifique à une SPA. Une MPA traditionnelle aurait ajouté des erreurs aux messages flash et redirigé la demande._\n\n### Valider le Corps\n\nPour valider le corps côté serveur, j'utilise [Zod](https:\u002F\u002Fzod.dev). _Zod est une validation de schéma en TypeScript avec inférence de type statique._ La bonne nouvelle est qu'h3, le routeur et le framework web qui alimentent Nitro, la couche serveur de Nuxt, fournit une utilitaire pour valider le corps : `readValidatedBody`.\n\n```ts\nimport { z } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const body = await readValidatedBody(event, z.object({\n    name: z.string(),\n  }).parse)\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n```\n\nL'utilitaire `readValidatedBody` lancera automatiquement une erreur si le corps ne correspond pas au schéma. Par exemple, si le nom est manquant, la requête échouera.\n\n\u003CCallout icon=\"i-unjs-h3\" text=\"Valider les Données avec H3\" to=\"https:\u002F\u002Fh3.unjs.io\u002Fexamples\u002Fvalidate-data\" \u002F>\n\nPar défaut, l'erreur sera gérée par le serveur et lancera une erreur 500, mais je peux capturer l'erreur pour renvoyer un message d'erreur plus convivial.\n\nLa première solution consiste à utiliser la méthode `safeParse` qui ne lancera pas d'erreur mais renverra un résultat avec l'erreur le cas échéant.\n\n```ts\nimport { z } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const result = await readValidatedBody(event, z.object({\n    name: z.string(),\n  }).safeParse)\n\n  if (!result.success) {\n    throw createError({\n      status: 400,\n      message: 'Données non valides',\n      data: result.error.errors[0].message,\n    })\n  }\n\n  const body = result.data\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n```\n\nLa seconde solution consiste à utiliser la méthode `parseAsync` pour attacher une gestion d'erreur manuelle à la promesse.\n\n```ts\nimport type { ZodError } from 'zod'\nimport { z } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const body = await readValidatedBody(event, data => z.object({\n    name: z.string(),\n  }).parseAsync(data).catch((error) => {\n    if (error instanceof ZodError) {\n      throw createError({\n        status: 400,\n        message: 'Données non valides',\n        data: error.errors[0].message,\n      })\n    }\n  }))\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n```\n\n_Vous pourriez envelopper le validateur avec un `try\u002Fcatch`, mais je trouve cela moins élégant et lisible._\n\n### Valider le Frontend\n\nCôté frontend, je peux également utiliser Zod pour valider le formulaire avant de l'envoyer au serveur.\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nimport type { ZodError } from 'zod'\nimport { z } from 'zod'\n\nconst schema = z.object({\n  name: z.string({ message: 'Obligatoire' }), \u002F\u002F Je peux personnaliser le message d'erreur affiché à l'utilisateur\n})\n\nconst state = reactive({\n  name: '',\n})\n\nasync function onSubmit() {\n  try {\n    const data = schema.parse(state)\n    await $fetch('\u002Ftag', {\n      method: 'post',\n      body: data,\n    })\n  }\n  catch (error) {\n    if (error instanceof ZodError) {\n      throw createError({\n        status: 400,\n        message: 'Données non valides',\n        data: error.errors,\n      })\n    }\n  }\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cform @submit.prevent=\"onSubmit()\">\n    \u003Cinput v-model=\"state.name\" type=\"text\" placeholder=\"Nom\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nL'implémentation est très simple et directe. Je pourrais enregistrer les erreurs dans une variable pour les afficher à l'utilisateur. Si vous souhaitez aller plus loin, je vous recommande d'essayer le composant `Form` de [Nuxt UI](https:\u002F\u002Fui.nuxt.com\u002F) ou une bibliothèque de validation comme [VeeValidate](https:\u002F\u002Fvee-validate.logaretm.com\u002Fv4\u002F) ou [FormKit](https:\u002F\u002Fformkit.com\u002F).\n\n## Refactorisons\n\nMaintenant que j'ai la logique de validation à la fois côté client et côté serveur, voyons s'il y a un code intéressant à refactoriser.\n\nLa première chose que je remarque est que la logique de validation, côté client et serveur, est la même, et c'est totalement normal. Mais que se passe-t-il si je décide de changer la logique de validation côté serveur parce que mon API est utilisée par une autre application ? Mon frontend pourrait être cassé car la logique de validation sera différente. Pour éviter cela, il serait peut-être mieux d'écrire le validateur une seule fois et de l'utiliser des deux côtés. Qu'en pensez-vous ?\n\nJe peux créer un fichier `validators.ts` dans le dossier `utils` de la partie client (à la racine) d'une application Nuxt. Ce fichier contiendra tous les validateurs pour mon application et sera partagé entre le client et le serveur.\n\n```ts\nimport { z } from 'zod'\n\nexport const createTagValidator = z.object({\n  name: z.string({ message: 'Obligatoire' }),\n})\n```\n\n> [!TIP]\n> Ce fichier peut être utilisé côté serveur mais a été créé côté client, donc vous devez explicitement importer tout ce dont vous avez besoin pour éviter des problèmes de rendu.\n\nCe validateur contient le message pour l'erreur pour aider l'utilisateur à comprendre ce qui ne va pas.\n\nGrâce à la fonction d'auto-import de Nuxt, je peux facilement l'utiliser côté client :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst schema = createTagValidator\n\n\u002F\u002F ...\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cform>\n  \u003C!-- ... -->\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nEnsuite, vous pouvez également l'utiliser côté serveur en l'importation :\n\n```ts\nimport { createTagValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  const body = await readValidatedBody(event, createTagValidator.parse)\n\n  \u002F\u002F Enregistrer dans la base de données...\n\n  return sendNoContent(event, 201)\n})\n```\n\nMaintenant, je peux changer en toute sécurité la logique de validation sans casser le frontend, car ils suivent les mêmes règles, et je n'ai pas à dupliquer le code.\n\n## Gestion des Fichiers\n\nPour cette partie, je vais vous montrer deux façons différentes de valider les fichiers côté serveur. La première utilisera Zod et quelques raffinements, et la seconde utilisera l'utilitaire `ensureBlob` de NuxtHub.\n\n_Cette validation n'est pas complète et devrait être améliorée pour gérer plus de cas._\n\n### Avec Zod\n\nPour cette seconde partie, je vais vous montrer comment télécharger un fichier sur le serveur. D'abord, ajoutons notre entrée dans le formulaire :\n\n```vue\n\u003Ctemplate>\n  \u003Cform action=\"\u002Fapi\u002Fimage\" enctype=\"multipart\u002Fform-data\" method=\"post\">\n    \u003Cinput type=\"file\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nEnsuite, je peux gérer le fichier côté serveur, mais c'est un peu plus complexe car j'envoie un `formData` et h3 ne fournit pas d'utilitaire comme `readValidatedFormData`. Donc, je dois le gérer manuellement.\n\nD'abord, je vais lire le `formData` en utilisant l'utilitaire `readFormData`, puis j'extraire l'`image` du `formData` et la valider en utilisant Zod.\n\n```ts\nimport { any, object } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const formData = await readFormData(event)\n\n  const image = formData.get('image')\n  await object({\n    image: any()\n      .refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' })\n      .refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' })\n      .refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' })\n      .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n  }).parseAsync({ image }).catch((error) => {\n    throw createError({\n      status: 400,\n      message: 'Image non valide',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Enregistrer l'image dans une base de données ou un système de fichiers\n\n  return sendNoContent(event, 201)\n})\n```\n\nCôté client, je peux également valider le fichier en utilisant la même technique qu'auparavant et en partageant le validateur entre le client et le serveur.\n\nD'abord, je dois déplacer le validateur dans le fichier `utils\u002Fvalidators.ts` :\n\n```ts\nimport { any, object } from 'zod'\n\nexport const createImageValidator = object({\n  image: any()\n    .refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' })\n    .refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' })\n    .refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' })\n    .refine(image => ['image\u002Fjpeg', 'image\u002Fpng'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n})\n```\n\nEnsuite, je peux l'utiliser lors de la soumission du formulaire pour éviter d'envoyer des données non valides au serveur :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nasync function onImageSubmit(event: Event) {\n  const target = event.target as HTMLFormElement\n  const formData = new FormData(target)\n\n  const image = formData.get('image')\n  try {\n    await createImageValidator.parseAsync({ image })\n\n    await $fetch('\u002Fapi\u002Fimage', {\n      method: 'POST',\n      body: formData,\n    })\n  }\n  catch (error) {\n    console.error(error)\n  }\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv>\n    \u003Ch1>Valider les Formulaires avec Nuxt\u003C\u002Fh1>\n\n    \u003Ch2>\n      Image\n    \u003C\u002Fh2>\n    \u003Cform enctype=\"multipart\u002Fform-data\" @submit.prevent=\"onImageSubmit($event)\">\n      \u003Cinput type=\"file\" name=\"image\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\">\n      \u003Cbutton type=\"submit\">\n        Soumettre\n      \u003C\u002Fbutton>\n    \u003C\u002Fform>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n```\n\nSi la validation échoue, la requête ne sera pas envoyée au serveur et l'erreur sera affichée dans la console. Bien sûr, vous pouvez gérer l'erreur comme vous le souhaitez, comme utiliser un `toast` ou afficher un message d'erreur sous l'entrée concernée.\n\nCôté serveur, je peux remplacer la logique de validation par le validateur :\n\n```ts\nimport { saveImageValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  \u002F\u002F ...\n\n  await saveImageValidator.parseAsync({ image })\n\n  \u002F\u002F ...\n})\n```\n\n### Avec NuxtHub\n\nNuxtHub est livré avec un utilitaire nommé `ensureBlob` qui peut être utilisé pour vérifier la taille et le type du fichier. Cet utilitaire est très utile car il lancera automatiquement une erreur si le fichier n'est pas valide, mais ne peut être utilisé que côté serveur. Cela signifie que j'aurai un schéma Zod côté client et un utilitaire NuxtHub côté serveur. _Utilisez des constantes pour partager les limites de validation entre le schéma Zod et l'utilitaire NuxtHub._\n\n```ts\nexport default defineEventHandler(async (event) => {\n  const formData = await readFormData(event)\n\n  const image = formData.get('image') as Blob\n  ensureBlob(image, {\n    maxSize: '1Mo',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Enregistrer l'image dans une base de données ou un système de fichiers\n\n  return sendNoContent(event, 201)\n})\n```\n\n## Texte et Blob dans le Même Formulaire\n\nJusqu'à présent, j'ai seulement un type d'entrée dans mon formulaire, texte ou fichier. Mais que se passe-t-il si je veux avoir les deux dans le même formulaire ?\n\nVous devez savoir que vous devez absolument utiliser un `formData` pour envoyer le formulaire au serveur en raison de l'entrée de fichier.\n\nLe formulaire est toujours très simple :\n\n```vue\n\u003Ctemplate>\n  \u003Cform action=\"\u002Fapi\u002Ftext-image\" enctype=\"multipart\u002Fform-data\" method=\"post\">\n    \u003Cinput type=\"text\" name=\"name\" placeholder=\"Nom\" required>\n    \u003Cinput type=\"file\" name=\"image\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\" required>\n    \u003Cbutton type=\"submit\">\n      Créer Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nMaintenant, je peux créer un validateur en utilisant Zod :\n\n```ts\nimport { any, object, string } from 'zod'\n\nexport const createValidator = object({\n  name: string({ message: 'Obligatoire' }),\n  image: any().refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' }).refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' }).refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' }).refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n})\n```\n\nCe validateur peut être utilisé comme auparavant, sur le serveur et le client.\n\nSi vous préférez utiliser l'utilitaire `ensureBlob` de NuxtHub, je vous recommande de diviser le validateur en deux parties : une pour le texte et une pour l'image. Cela signifie que le texte et l'image seront utilisés côté client en les fusionnant en un seul objet, et uniquement le texte sera utilisé côté serveur.\n\n```ts\nimport { any, object, string } from 'zod'\n\nexport const createTextValidator = object({\n  name: string({ message: 'Obligatoire' }),\n})\n\nexport const createImageValidator = object({\n  image: any().refine(image => image instanceof File, { message: 'L\\'image doit être un fichier' }).refine(image => image.size \u003C 1024 * 1024, { message: 'L\\'image doit faire moins de 1 Mo' }).refine(image => image.type.startsWith('image\u002F'), { message: 'L\\'image doit être une image' }).refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'L\\'image doit être un JPEG ou un PNG' })\n})\n\nexport const createValidator = createTextValidator.merge(createImageValidator)\n```\n\nCôté client, j'utiliserai le `createValidator` pour valider le formulaire avant de l'envoyer au serveur :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nasync function onSubmit(event: Event) {\n  const target = event.target as HTMLFormElement\n  const formData = new FormData(target)\n\n  const name = formData.get('name')\n  const image = formData.get('image')\n  try {\n    await createValidator.parseAsync({ name, image })\n\n    await $fetch('\u002Fapi\u002Ftext-image', {\n      method: 'POST',\n      body: formData,\n    })\n  }\n  catch (error) {\n    console.error(error)\n  }\n}\n\u003C\u002Fscript>\n```\n\nCôté serveur, j'utiliserai le `createTextValidator` pour valider le texte et l'utilitaire `ensureBlob` pour valider l'image :\n\n```ts\nimport { createTextValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  const formData = await readFormData(event)\n\n  const name = formData.get('name')\n  const data = await createTextValidator.parseAsync({ name }).catch((error) => {\n    throw createError({\n      status: 400,\n      message: 'Données non valides',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Enregistrer le texte dans la base de données...\n\n  const image = formData.get('image') as Blob\n  ensureBlob(image, {\n    maxSize: '1 Mo',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Enregistrer l'image dans une base de données ou un système de fichiers\n\n  return sendNoContent(event, 201)\n})\n```\n\nPour des formulaires plus longs, je pourrais utiliser une boucle pour extraire chaque champ du formulaire et les valider. Dans cette boucle, je peux gérer des cas spécifiques comme des tableaux de champs multi-sélection ou des fichiers ou ignorer certains champs (qui pourraient être validés en utilisant `ensureBlob`).\n\n```ts\nconst data = {}\nfor (const [key, value] of formData.entries()) {\n  if (key === 'moduleId[]') {\n    data[key] = formData.getAll(key)\n    continue\n  }\n  else if (key === 'image') {\n    continue\n  }\n  else {\n    data[key] = value\n  }\n}\n```\n\n## Conclusion\n\nEt _voilà_ ! Vous êtes maintenant capables de valider des formulaires à la fois côté client et côté serveur sans dupliquer la logique de validation. C'est un grand pas en avant dans la construction d'une application full-stack avec Nuxt. J'espère que cet article vous aidera à construire votre prochaine application en toute confiance. _Vous devriez absolument consulter la [documentation de NuxtHub](https:\u002F\u002Fhub.nuxt.com\u002Fdocs) pour en savoir plus sur la plateforme et le pouvoir qu'elle offre pour construire des applications full-stack avec Nuxt._\n\nN'hésitez pas à me faire savoir si vous avez des questions ou des commentaires. Je pourrais faire quelques erreurs, alors n'hésitez pas à me corriger. Je suis toujours à la recherche de moyens pour améliorer mon code et mes articles.\n\nMerci de votre lecture !\n",null,{"title":5,"description":5465},{"loc":5468},"fr\u002Fposts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation","UnvxnnkvgTeO4duH9JEcZsC_VwGrInWRErZmVACOu-U",{"id":5476,"title":5477,"body":5478,"contentId":35,"date":20098,"description":20099,"extension":5466,"meta":20100,"navigation":329,"path":20101,"rawbody":20102,"robots":5470,"schemaOrg":5470,"seo":20103,"sitemap":20104,"stem":20130,"__hash__":20131},"posts\u002Ffr\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey.md","Créer une App Nuxt Full-Stack : une Aventure sur Twitch",{"type":7,"value":5479,"toc":20094},[5480,5485,5488,5514,5517,5522,5525,5528,5532,5539,5563,5569,5575,5582,5595,5611,5778,5781,5791,5814,5817,5822,5829,5833,5839,5855,5858,5861,20091],[10,5481,5482],{},[22,5483,5484],{},"Je l'ai enfin fait !",[10,5486,5487],{},"Après des mois à y penser, j'ai enfin lancé mon premier stream sur Twitch, le 28 avril 2024. C'est une étape très importante pour moi car j'adore partager mes connaissances et parler de ce que je fais.",[3296,5489,5495,5496],{"className":5490},[5491,5492,5493,5494],"grid","grid-cols-1","md:grid-cols-2","gap-4","\n  ",[5497,5498,5495,5499],"calloutfollowmeontwitch",{},[568,5500,5495,5504],{"icon":5501,"text":5502,"href":5503},"i-simple-icons-youtube","Voir les rediffusions","https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes",[568,5505,5495,5509],{"icon":5506,"text":5507,"href":5508},"i-simple-icons-x","Ne manquez rien","https:\u002F\u002Fx.com\u002Fsoubiran_",[568,5510],{"icon":5511,"text":5512,"href":5513},"i-simple-icons-github","L'application Full-Stack","https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion",[10,5515,5516],{},"Quand j'étais à l'école, j'organisais des cours pour expliquer la programmation à mes camarades. J'ai aussi rejoint une communauté pour expliquer les choses sur les ordinateurs, j'ai donné quelques conférences, et récemment, j'ai commencé à écrire des articles sur mon blog.",[10,5518,5519],{},[22,5520,5521],{},"Twitch est juste une nouvelle façon de continuer à faire ce que j'aime !",[10,5523,5524],{},"Dans les semaines à venir, je partagerai avec vous mon parcours pour construire une application Full-Stack Nuxt, déployée sur Cloudflare, appelée Orion. J'expliquerai comment je l'ai construite, les choix que j'ai faits, et les problèmes que j'ai rencontrés. Mon objectif à travers cet article est de vous donner une vue honnête et réaliste des avantages et des inconvénients de la création d'une application Full-Stack en edge avec Nuxt en mai 2024, à travers le processus de construction d'une application réelle.",[10,5526,5527],{},"Dans cet article, je partagerai avec vous chaque étape de cette première série de streams. Si vous ne parlez pas français (car les streams sont en français), si vous avez manqué un stream, ou si vous préférez simplement lire, cet article est fait pour vous !",[54,5529,5531],{"id":5530},"découverte-de-nuxthub-et-début-de-lapplication-full-stack-nuxt","Découverte de NuxtHub et Début de l'Application Full-Stack Nuxt",[10,5533,5534],{},[5535,5536],"img",{"alt":5537,"src":5538},"Vignette de mon premier live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-04-28.png",[10,5540,5541],{},[29,5542,5543,5547,5548,5553,5554,5558,5559,52],{},[14,5544,5546],{"href":5513,"rel":5545},[18],"Orion"," est une application Full-Stack construite avec Nuxt et développée en direct ",[14,5549,5552],{"href":5550,"rel":5551},"https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes",[18],"sur Twitch",". Vous pouvez voir la rediffusion sur ",[14,5555,5557],{"href":5503,"rel":5556},[18],"YouTube"," et le code source est open-source sur ",[14,5560,5562],{"href":5513,"rel":5561},[18],"GitHub",[10,5564,5565,5566],{},"C'était mon premier stream, et pour être honnête, j'étais un peu stressé. Le moment où vous devez passer de la scène \"Starting Soon\" à la caméra est difficile. Mais une fois que j'ai commencé à parler, tout s'est bien passé. ",[29,5567,5568],{},"Cependant, cela ne veut pas dire que c'est le meilleur stream que vous verrez jamais. Nous allons nous améliorer !",[10,5570,5571],{},[5535,5572],{"alt":5573,"src":5574},"Moi, pas très à l'aise devant la caméra.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-04-28-me.png",[10,5576,5577,5578,5581],{},"Tout d'abord, je présente ",[14,5579,19],{"href":16,"rel":5580},[18],". C'est une plateforme développée par l'équipe Nuxt pour faciliter le déploiement et la gestion des applications Nuxt déployées sur Cloudflare. En quelques clics, vous pouvez déployer votre application Nuxt sur Cloudflare. C'est vraiment magique ! Grâce à un module Nuxt, vous pourrez développer votre application localement et ensuite la déployer sans modifier votre code. Tout, de la base de données au stockage du blog, fonctionne parfaitement dans les deux environnements.",[10,5583,5584,5585,5594],{},"J'ai essayé de déployer un modèle depuis NuxtHub pour montrer à quel point il est facile d'utiliser la plateforme. J'ai choisi le modèle Atinotes. Malheureusement, cette application produit un bundle de plus de 1 Mo, la limite pour le niveau gratuit, donc le déploiement échoue. Dommage pour une démonstration. ",[29,5586,5587,5588,5593],{},"Après cet échec, j'ai pensé à un moyen de prévenir cela et d'informer les utilisateurs si un modèle nécessite un plan payant ou non. ",[14,5589,5592],{"href":5590,"rel":5591},"https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F13",[18],"J'ai soulevé un problème"," qui a été rapidement corrigé !",". La démo attendra plus tard.",[10,5596,5597,5598,1703,5605,5610],{},"Après cet effet de démonstration, nous avons commencé à coder l'application Full-Stack Nuxt en ajoutant l'authentification. Pour ce faire, nous utilisons le module ",[14,5599,5602],{"href":5600,"rel":5601},"https:\u002F\u002Fgithub.com\u002Fatinux\u002Fnuxt-auth-utils",[18],[103,5603,5604],{},"nuxt-auth-utils",[14,5606,5609],{"href":5607,"rel":5608},"https:\u002F\u002Fgithub.com\u002FAtinux",[18],"Sébastien Chopin",". Avec quelques lignes de code, nous ajoutons l'authentification. Voici le code que nous avons utilisé :",[96,5612,5614],{"className":261,"code":5613,"language":263,"meta":101,"style":101},"export default oauth.githubEventHandler({\n  config: {\n    emailRequired: true,\n  },\n  async onSuccess(event, { user, tokens }) {\n    await setUserSession(event, {\n      user: {\n        githubId: user.githubId,\n      },\n    })\n\n    return sendRedirect(event, '\u002F')\n  },\n})\n",[103,5615,5616,5634,5643,5656,5661,5690,5705,5714,5730,5735,5741,5745,5768,5772],{"__ignoreMap":101},[106,5617,5618,5620,5622,5625,5627,5630,5632],{"class":108,"line":109},[106,5619,271],{"class":270},[106,5621,274],{"class":270},[106,5623,5624],{"class":224}," oauth",[106,5626,52],{"class":112},[106,5628,5629],{"class":277},"githubEventHandler",[106,5631,281],{"class":224},[106,5633,488],{"class":112},[106,5635,5636,5639,5641],{"class":108,"line":123},[106,5637,5638],{"class":116},"  config",[106,5640,496],{"class":112},[106,5642,300],{"class":112},[106,5644,5645,5648,5650,5654],{"class":108,"line":162},[106,5646,5647],{"class":116},"    emailRequired",[106,5649,496],{"class":112},[106,5651,5653],{"class":5652},"sfNiH"," true",[106,5655,509],{"class":112},[106,5657,5658],{"class":108,"line":200},[106,5659,5660],{"class":112},"  },\n",[106,5662,5663,5666,5669,5671,5673,5675,5677,5680,5682,5685,5688],{"class":108,"line":221},[106,5664,5665],{"class":132},"  async",[106,5667,5668],{"class":116}," onSuccess",[106,5670,281],{"class":112},[106,5672,291],{"class":290},[106,5674,355],{"class":112},[106,5676,413],{"class":112},[106,5678,5679],{"class":290}," user",[106,5681,355],{"class":112},[106,5683,5684],{"class":290}," tokens",[106,5686,5687],{"class":112}," })",[106,5689,300],{"class":112},[106,5691,5692,5694,5697,5699,5701,5703],{"class":108,"line":228},[106,5693,1402],{"class":270},[106,5695,5696],{"class":277}," setUserSession",[106,5698,281],{"class":116},[106,5700,291],{"class":224},[106,5702,355],{"class":112},[106,5704,300],{"class":112},[106,5706,5707,5710,5712],{"class":108,"line":238},[106,5708,5709],{"class":116},"      user",[106,5711,496],{"class":112},[106,5713,300],{"class":112},[106,5715,5716,5719,5721,5723,5725,5728],{"class":108,"line":248},[106,5717,5718],{"class":116},"        githubId",[106,5720,496],{"class":112},[106,5722,5679],{"class":224},[106,5724,52],{"class":112},[106,5726,5727],{"class":224},"githubId",[106,5729,509],{"class":112},[106,5731,5732],{"class":108,"line":534},[106,5733,5734],{"class":112},"      },\n",[106,5736,5737,5739],{"class":108,"line":539},[106,5738,798],{"class":112},[106,5740,324],{"class":116},[106,5742,5743],{"class":108,"line":556},[106,5744,330],{"emptyLinePlaceholder":329},[106,5746,5747,5750,5753,5755,5757,5759,5761,5764,5766],{"class":108,"line":759},[106,5748,5749],{"class":270},"    return",[106,5751,5752],{"class":277}," sendRedirect",[106,5754,281],{"class":116},[106,5756,291],{"class":224},[106,5758,355],{"class":112},[106,5760,425],{"class":112},[106,5762,5763],{"class":142},"\u002F",[106,5765,754],{"class":112},[106,5767,324],{"class":116},[106,5769,5770],{"class":108,"line":795},[106,5771,5660],{"class":112},[106,5773,5774,5776],{"class":108,"line":803},[106,5775,366],{"class":112},[106,5777,324],{"class":224},[10,5779,5780],{},"C'est vraiment simple et ça fonctionne à merveille, bien intégré dans une application Nuxt.",[10,5782,5783,5784,5790],{},"Pour terminer le stream, j'ai rapidement configuré NuxtHub dans mon application pour montrer à quel point il est facile de jouer avec une base de données. J'ai essayé d'utiliser SQL brut, mais la recette pour migrer la base de données ne fonctionnait pas. ",[29,5785,5786],{},[14,5787,5592],{"href":5788,"rel":5789},"https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F15",[18]," à ce sujet et il est maintenant corrigé ! Après ce deuxième échec, j'ai décidé de commencer à utiliser Drizzle ORM, un ORM simple mais puissant compatible avec les workers de Cloudflare. Dans tous les cas, j'aurais dû utiliser Drizzle car gérer tout avec SQL brut est trop de travail.",[10,5792,5793,5794,5800,5801,5804,5805,5810,5811],{},"Pendant le live, quelqu'un a demandé comment appliquer les migrations à la base de données de production. Je dois admettre que je ne connaissais pas la réponse et rien dans la documentation n'était clair à ce sujet. ",[29,5795,5796],{},[14,5797,5592],{"href":5798,"rel":5799},"https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F14",[18]," à ce sujet, qui a reçu une réponse. ",[29,5802,5803],{},"Il faut déployer votre application puis démarrer le développement local avec une connexion distante pour exécuter les migrations"," Clairement, c'est loin d'être idéal pour le moment, mais avec les prochaines ",[14,5806,5809],{"href":5807,"rel":5808},"https:\u002F\u002Fnitro.unjs.io\u002Fguide\u002Ftasks",[18],"tâches Nitro",", cela devrait être plus facile dans un avenir proche. ",[29,5812,5813],{},"Je l'espère vraiment.",[10,5815,5816],{},"Globalement, je suis tellement heureux d'avoir commencé ce stream. Même si ce n'est pas encore parfait, commencer est déjà un grand pas en avant, et je sais que les futurs streams ne feront que devenir plus faciles à commencer.",[10,5818,5819],{},[29,5820,5821],{},"J'étais tellement stressé que je suis allé très vite dans mes explications, donc j'ai décidé que le prochain stream serait plus bavard pour réexpliquer différents concepts.",[10,5823,5824,5825],{},"Le projet est open-source et disponible sur GitHub à ",[49,5826,5828],{"repo":5827},"barbapapazes\u002Forion"," ! Donnez-lui une étoile si vous l'aimez ! ⭐",[54,5830,5832],{"id":5831},"discussion-sur-cloudflare-nuxthub-et-github-oauth","Discussion sur Cloudflare, NuxtHub et GitHub OAuth",[10,5834,5835],{},[5535,5836],{"alt":5837,"src":5838},"Vignette de mon deuxième live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-02.png",[10,5840,5841],{},[29,5842,5843,5547,5846,5553,5849,5558,5852,52],{},[14,5844,5546],{"href":5513,"rel":5845},[18],[14,5847,5552],{"href":5550,"rel":5848},[18],[14,5850,5557],{"href":5503,"rel":5851},[18],[14,5853,5562],{"href":5513,"rel":5854},[18],[10,5856,5857],{},"J'étais frustré par mon premier live stream. Avec le stress, je n'ai pas expliqué des concepts comme ce qu'est Cloudflare, ce qu'est l'edge et comment NuxtHub est lié à Cloudflare. Mais je sais qu'il est important d'expliquer ces concepts puisque ce sont des technologies nouvelles et que tout le monde ne les connaît pas.",[10,5859,5860],{},"À StrasbourgJS ou Devoxx, j'ai parlé d'UnJS et des problèmes résolus par l'écosystème. Beaucoup de personnes, pendant ou après, m'ont demandé ce qu'est l'edge. Qu'est-ce que c'est ? Pourquoi en avons-nous besoin ? D'où cela vient-il ?",[33,5862,5864],{"id":5863},"a1291894-3de4-444e-b49b-a5dec98f3e27",[33,5865,5867,5870,5874,5877,5880,5886,5889,5898,5908,5911,5914,5953,5956,5959,5965,5968,5975,5981,5991,5997,6000,6003,6025,6029,6035,6038,6044,6074,6077,6082,6088,6096,6799,6802,6804,6807,6811,6815,6821,6839,6842,6846,6849,6855,6858,6863,6872,6875,6908,6911,6915,6933,6940,6943,6947,6957,7005,7009,7012,7018,7022,7033,7179,7196,7203,7350,7356,7360,7363,7487,7493,7498,7789,7795,7798,7931,7934,8400,8414,8421,8426,8429],{"id":5866},"f3e97a6a-ce47-42b8-8493-06eb37f8e964",[10,5868,5869],{},"Le but de mes streams est de tester cette nouvelle technologie, et avec ces retours, il était nécessaire d'expliquer ces concepts.",[381,5871,5873],{"id":5872},"cloudflare-en-un-coup-dœil","Cloudflare en un coup d'œil",[10,5875,5876],{},"Cloudflare est une entreprise qui propose de nombreux services pour rendre le web plus rapide et plus sécurisé, comme une solution zéro-trust, un WAF, un CDN, un DNS, une protection contre les DDoS, et plus encore, pour les entreprises comme pour les particuliers.",[10,5878,5879],{},"Leur produit le plus connu est le proxy inverse. Cloudflare agit comme le point d'entrée principal et unique pour votre trafic. Ensuite, il dirigera, en interne, le trafic vers le bon serveur en utilisant le DNS. Cela permet de cacher votre IP serveur, de mettre en cache les ressources statiques et de protéger votre serveur contre les attaques DDoS.",[10,5881,5882],{},[5535,5883],{"alt":5884,"src":5885},"Schéma du proxy inverse.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Freverse-proxy.png",[10,5887,5888],{},"Pour fournir ces services, ils ont construit un réseau de centres de données à travers le monde pour être le plus proche possible des utilisateurs afin de servir le contenu mis en cache plus rapidement et de pouvoir atténuer les attaques. Cela s'appelle le réseau edge. Il est à la frontière entre les utilisateurs et le web, pour lequel il constitue la passerelle.",[10,5890,5891,5892,5897],{},"En septembre 2017, ",[14,5893,5896],{"href":5894,"rel":5895},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-workers",[18],"ils ont annoncé Cloudflare Workers",". Pour comprendre l'objectif à l'époque, citons l'article :",[1830,5899,5900,5903],{},[10,5901,5902],{},"Que faire si vous souhaitez équilibrer la charge avec un algorithme d'affinité personnalisé ? Que faire si les règles de mise en cache HTTP standard ne sont pas tout à fait adaptées et que vous avez besoin de logique personnalisée pour améliorer votre taux de cache ? Que faire si vous voulez écrire des règles WAF personnalisées adaptées à votre application ?",[10,5904,5905],{},[22,5906,5907],{},"Vous voulez écrire du code",[10,5909,5910],{},"Les Workers ont été lancés pour permettre aux développeurs de personnaliser l'infrastructure selon leurs besoins. La solution Cloudflare Workers est construite sur l'API Service Worker, une norme dans le navigateur pour intercepter et modifier les requêtes et les réponses.",[10,5912,5913],{},"Depuis lors, ils ont construit une plateforme complète pour les développeurs autour des workers :",[5915,5916,5917,5925,5932,5939,5946],"ul",{},[5918,5919,5920],"li",{},[14,5921,5924],{"href":5922,"rel":5923},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-workers-kv",[18],"Workers KV, magasin de clés-valeurs sans serveur",[5918,5926,5927],{},[14,5928,5931],{"href":5929,"rel":5930},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-d1",[18],"D1, base de données SQL sans serveur",[5918,5933,5934],{},[14,5935,5938],{"href":5936,"rel":5937},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-r2-object-storage",[18],"R2, stockage d'objets sans serveur",[5918,5940,5941],{},[14,5942,5945],{"href":5943,"rel":5944},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-queues",[18],"Queues, files d'attente de tâches sans serveur",[5918,5947,5948],{},[14,5949,5952],{"href":5950,"rel":5951},"https:\u002F\u002Fblog.cloudflare.com\u002Fworkers-ai",[18],"Workers AI, inférence GPU sans serveur",[10,5954,5955],{},"Avec tous ces services, il est maintenant (théoriquement, et c'est le but de cette série de répondre à la question) possible de construire une application entièrement sur le réseau mondial de Cloudflare.",[381,5957,19],{"id":5958},"nuxthub",[10,5960,5961],{},[5535,5962],{"alt":5963,"src":5964},"Page d'accueil de NuxtHub.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-home-page.png",[10,5966,5967],{},"Depuis la version 3 de Nuxt, l'équipe pousse l'edge. Nitro, la partie serveur de Nuxt, peut être déployée partout, sur les plateformes actuelles et futures, et est optimisée pour l'edge. H3, le serveur HTTP sous-jacent, se réveille en moins de 2 ms, et la sortie de build de Nitro est inférieure à 1 Mo, modules Node inclus.",[10,5969,5970,5971,5974],{},"NuxtHub est la continuité de cet effort. D'abord, c'est une plateforme pour déployer des applications Nuxt sur Cloudflare. C'est un déploiement en deux clics. Vous connectez votre compte Cloudflare car c'est construit sur Cloudflare, vous choisissez un modèle ou un dépôt, vous cliquez sur déployer, et c'est tout. ",[29,5972,5973],{},"Votre application est en direct sur l'edge."," On a l'impression que c'est de la magie.",[10,5976,5977],{},[5535,5978],{"alt":5979,"src":5980},"Modèles NuxtHub pouvant être déployés en deux clics.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-deploy.png",[10,5982,5983,5984,52],{},"NuxtHub est également un module pour votre application Nuxt afin d'ajouter des fonctionnalités d'accès aux données. Localement, vous pourrez jouer instantanément avec une base de données, un magasin KV, et même un stockage d'objets. En production, vous n'avez pas besoin de changer votre code, il fonctionnera de la même manière. C'est clairement la véritable magie de NuxtHub, alimentée par ",[14,5985,5988],{"href":5986,"rel":5987},"https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworker",[18],[103,5989,5990],{},"workerd",[10,5992,5993],{},[5535,5994],{"alt":5995,"src":5996},"Schéma de NuxtHub (de hub.nuxt.com).","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-schema.png",[10,5998,5999],{},"Il y a aussi une fonctionnalité permettant à un environnement de développement local d'interagir avec les données distantes. Pas sûr du cas d'utilisation, mais c'est définitivement intéressant.",[10,6001,6002],{},"Même si la plateforme est encore nouvelle, annoncée lors de Vue Amsterdam 2024, elle est déjà très prometteuse. L'équipe est très réactive, et la plateforme évolue rapidement. Je suis très impatient de voir ce que l'avenir réserve à NuxtHub, mais il y a quelques indices dans la documentation :",[1830,6004,6005,6008],{},[10,6006,6007],{},"Nous prévoyons de fournir une expérience backend complète pour les applications Nuxt à travers divers packages @nuxthub.",[5915,6009,6010,6013,6016,6019,6022],{},[5918,6011,6012],{},"@nuxthub\u002Fcore : Package principal pour fournir des fonctionnalités de stockage",[5918,6014,6015],{},"@nuxthub\u002Fauth : Ajouter l'authentification pour la gestion des utilisateurs (bientôt)",[5918,6017,6018],{},"@nuxthub\u002Femail : Envoyer des e-mails transactionnels à vos utilisateurs (bientôt)",[5918,6020,6021],{},"@nuxthub\u002Fanalytics : Comprendre votre trafic et suivre les événements au sein de votre application et API (bientôt)",[5918,6023,6024],{},"@nuxthub\u002F... : Vous le nommez !",[381,6026,6028],{"id":6027},"github-oauth","GitHub OAuth",[10,6030,6031,6032,6034],{},"Lors du précédent live stream, j'ai ajouté l'authentification, en utilisant ",[103,6033,5604],{}," à Orion, l'application Full-Stack Nuxt. J'ai utilisé GitHub OAuth, et je pense qu'il est important de voir comment cela fonctionne en coulisses.",[10,6036,6037],{},"Pendant le live, nous avons plongé dans le code, et j'ai expliqué comment cela fonctionne. Voici un schéma pour vous aider à comprendre le processus :",[10,6039,6040],{},[5535,6041],{"alt":6042,"src":6043},"Schéma GitHub OAuth.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Foauth-github.png",[6045,6046,6047,6053,6056,6059,6062,6065,6068,6071],"ol",{},[5918,6048,6049,6050,52],{},"L'utilisateur clique sur le bouton de connexion, qui pointe vers ",[103,6051,6052],{},"\u002Fauth\u002Fgithub",[5918,6054,6055],{},"Le serveur envoie une redirection vers la page OAuth de GitHub.",[5918,6057,6058],{},"L'utilisateur accepte l'autorisation de GitHub.",[5918,6060,6061],{},"GitHub redirige l'utilisateur vers l'URL de rappel de l'application avec un code.",[5918,6063,6064],{},"Le serveur échange le code contre des tokens.",[5918,6066,6067],{},"Le serveur utilise les tokens pour obtenir les informations de l'utilisateur.",[5918,6069,6070],{},"Le serveur stocke l'utilisateur dans une session.",[5918,6072,6073],{},"Le serveur redirige l'utilisateur vers la page d'accueil.",[10,6075,6076],{},"Dans ces étapes, le serveur est Nitro, et nous ne parlons pas des erreurs possibles. Il y a de nombreuses erreurs possibles comme le refus de l'autorisation par l'utilisateur, un code invalide, des tokens invalides, l'utilisateur non trouvé, etc. Chaque erreur doit être gérée pour offrir une bonne expérience utilisateur.",[6078,6079,6081],"h4",{"id":6080},"code-du-gestionnaire-oauth-github","Code du gestionnaire OAuth GitHub",[10,6083,6084,6085,6087],{},"Voyons maintenant le code de ",[103,6086,5604],{}," pour comprendre comment il fonctionne.",[10,6089,6090,6091,52],{},"Pour simplifier, j'ai retiré certaines parties du code qui ne sont pas nécessaires à la compréhension du processus, et je me suis concentré sur les étapes principales. Vous pouvez trouver le code complet dans le ",[14,6092,6095],{"href":6093,"rel":6094},"https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-auth-utils",[18],"dépôt GitHub",[96,6097,6099],{"className":261,"code":6098,"language":263,"meta":101,"style":101},"\u002F**\n * Gestionnaire d'événements OAuth GitHub.\n *\n * _Le code est simplifié pour l'article et ne fonctionne pas tel quel._\n *\u002F\nexport function githubEventHandler({ config, onSuccess }: OAuthConfig\u003COAuthGitHubConfig>) {\n  return eventHandler(async (event: H3Event) => {\n    config = { \u002F** ... *\u002F}\n\n    \u002F**\n     * Récupérer la requête depuis l'événement.\n     *\u002F\n    const query = getQuery(event)\n\n    if (query.error) {\n      \u002F\u002F Gérer l'erreur\n      return\n    }\n\n    if (!query.code) {\n      \u002F**\n       * Rediriger vers la page OAuth GitHub s'il n'y a pas de code dans la requête.\n       * Si l'utilisateur accepte l'autorisation, GitHub redirigera l'utilisateur vers l'application avec le code.\n       * Le code est un code unique à échanger contre les tokens.\n       *\u002F\n      const redirectUrl = getRequestURL(event).href\n      return sendRedirect(\n        event,\n        withQuery(config.authorizationURL as string, {\n          client_id: config.clientId,\n          redirect_uri: redirectUrl,\n          scope: config.scope.join(' '),\n          ...config.authorizationParams,\n        }),\n      )\n    }\n\n    \u002F**\n     * Utiliser le code pour obtenir les tokens.\n     *\u002F\n    const tokens: any = await $fetch(\n      config.tokenURL as string,\n      {\n        method: 'POST',\n        body: {\n          code: query.code,\n        },\n      },\n    )\n\n    const accessToken = tokens.access_token\n\n    \u002F**\n     * Utiliser le token d'accès pour obtenir les informations de l'utilisateur.\n     *\u002F\n    const user: any = await ofetch('https:\u002F\u002Fapi.github.com\u002Fuser', {\n      headers: {\n        Authorization: `token ${accessToken}`,\n      },\n    })\n\n    \u002F**\n     * Appeler le callback onSuccess avec l'utilisateur et les tokens.\n     *\n     * L'OAuth est terminé, et l'utilisateur peut maintenant être authentifié avec notre système.\n     * Avec Nitro, nous stockons l'utilisateur dans une session au sein d'un cookie.\n     *\u002F\n    return onSuccess(event, {\n      user,\n      tokens,\n    })\n  })\n}\n",[103,6100,6101,6106,6111,6116,6121,6126,6161,6187,6201,6205,6210,6215,6220,6238,6242,6259,6264,6269,6273,6277,6295,6300,6305,6310,6315,6320,6344,6354,6361,6384,6400,6411,6440,6454,6463,6468,6472,6476,6480,6485,6489,6508,6525,6531,6547,6557,6573,6579,6584,6590,6595,6612,6617,6622,6628,6633,6664,6674,6699,6704,6711,6716,6721,6727,6733,6739,6745,6750,6765,6772,6780,6787,6794],{"__ignoreMap":101},[106,6102,6103],{"class":108,"line":109},[106,6104,6105],{"class":335},"\u002F**\n",[106,6107,6108],{"class":108,"line":123},[106,6109,6110],{"class":335}," * Gestionnaire d'événements OAuth GitHub.\n",[106,6112,6113],{"class":108,"line":162},[106,6114,6115],{"class":335}," *\n",[106,6117,6118],{"class":108,"line":200},[106,6119,6120],{"class":335}," * _Le code est simplifié pour l'article et ne fonctionne pas tel quel._\n",[106,6122,6123],{"class":108,"line":221},[106,6124,6125],{"class":335}," *\u002F\n",[106,6127,6128,6130,6132,6135,6138,6141,6143,6145,6148,6151,6153,6156,6159],{"class":108,"line":228},[106,6129,271],{"class":270},[106,6131,1360],{"class":132},[106,6133,6134],{"class":277}," githubEventHandler",[106,6136,6137],{"class":112},"({",[106,6139,6140],{"class":290}," config",[106,6142,355],{"class":112},[106,6144,5668],{"class":290},[106,6146,6147],{"class":112}," }:",[106,6149,6150],{"class":1041}," OAuthConfig",[106,6152,113],{"class":112},[106,6154,6155],{"class":1041},"OAuthGitHubConfig",[106,6157,6158],{"class":112},">)",[106,6160,300],{"class":112},[106,6162,6163,6165,6168,6170,6172,6174,6176,6178,6181,6183,6185],{"class":108,"line":238},[106,6164,345],{"class":270},[106,6166,6167],{"class":277}," eventHandler",[106,6169,281],{"class":116},[106,6171,284],{"class":132},[106,6173,287],{"class":112},[106,6175,291],{"class":290},[106,6177,496],{"class":112},[106,6179,6180],{"class":1041}," H3Event",[106,6182,294],{"class":112},[106,6184,297],{"class":132},[106,6186,300],{"class":112},[106,6188,6189,6192,6194,6196,6199],{"class":108,"line":248},[106,6190,6191],{"class":224},"    config",[106,6193,311],{"class":112},[106,6195,413],{"class":112},[106,6197,6198],{"class":335}," \u002F** ... *\u002F",[106,6200,1558],{"class":112},[106,6202,6203],{"class":108,"line":534},[106,6204,330],{"emptyLinePlaceholder":329},[106,6206,6207],{"class":108,"line":539},[106,6208,6209],{"class":335},"    \u002F**\n",[106,6211,6212],{"class":108,"line":556},[106,6213,6214],{"class":335},"     * Récupérer la requête depuis l'événement.\n",[106,6216,6217],{"class":108,"line":759},[106,6218,6219],{"class":335},"     *\u002F\n",[106,6221,6222,6224,6227,6229,6232,6234,6236],{"class":108,"line":795},[106,6223,1379],{"class":132},[106,6225,6226],{"class":224}," query",[106,6228,311],{"class":112},[106,6230,6231],{"class":277}," getQuery",[106,6233,281],{"class":116},[106,6235,291],{"class":224},[106,6237,324],{"class":116},[106,6239,6240],{"class":108,"line":803},[106,6241,330],{"emptyLinePlaceholder":329},[106,6243,6244,6246,6248,6251,6253,6255,6257],{"class":108,"line":809},[106,6245,1031],{"class":270},[106,6247,287],{"class":116},[106,6249,6250],{"class":224},"query",[106,6252,52],{"class":112},[106,6254,771],{"class":224},[106,6256,713],{"class":116},[106,6258,488],{"class":112},[106,6260,6261],{"class":108,"line":814},[106,6262,6263],{"class":335},"      \u002F\u002F Gérer l'erreur\n",[106,6265,6266],{"class":108,"line":830},[106,6267,6268],{"class":270},"      return\n",[106,6270,6271],{"class":108,"line":835},[106,6272,1120],{"class":112},[106,6274,6275],{"class":108,"line":840},[106,6276,330],{"emptyLinePlaceholder":329},[106,6278,6279,6281,6283,6285,6287,6289,6291,6293],{"class":108,"line":845},[106,6280,1031],{"class":270},[106,6282,287],{"class":116},[106,6284,702],{"class":112},[106,6286,6250],{"class":224},[106,6288,52],{"class":112},[106,6290,103],{"class":224},[106,6292,713],{"class":116},[106,6294,488],{"class":112},[106,6296,6297],{"class":108,"line":862},[106,6298,6299],{"class":335},"      \u002F**\n",[106,6301,6302],{"class":108,"line":1469},[106,6303,6304],{"class":335},"       * Rediriger vers la page OAuth GitHub s'il n'y a pas de code dans la requête.\n",[106,6306,6307],{"class":108,"line":1486},[106,6308,6309],{"class":335},"       * Si l'utilisateur accepte l'autorisation, GitHub redirigera l'utilisateur vers l'application avec le code.\n",[106,6311,6312],{"class":108,"line":1497},[106,6313,6314],{"class":335},"       * Le code est un code unique à échanger contre les tokens.\n",[106,6316,6317],{"class":108,"line":1508},[106,6318,6319],{"class":335},"       *\u002F\n",[106,6321,6322,6325,6328,6330,6333,6335,6337,6339,6341],{"class":108,"line":1523},[106,6323,6324],{"class":132},"      const",[106,6326,6327],{"class":224}," redirectUrl",[106,6329,311],{"class":112},[106,6331,6332],{"class":277}," getRequestURL",[106,6334,281],{"class":116},[106,6336,291],{"class":224},[106,6338,294],{"class":116},[106,6340,52],{"class":112},[106,6342,6343],{"class":224},"href\n",[106,6345,6346,6349,6351],{"class":108,"line":1538},[106,6347,6348],{"class":270},"      return",[106,6350,5752],{"class":277},[106,6352,6353],{"class":116},"(\n",[106,6355,6356,6359],{"class":108,"line":1545},[106,6357,6358],{"class":224},"        event",[106,6360,509],{"class":112},[106,6362,6363,6366,6368,6371,6373,6376,6378,6380,6382],{"class":108,"line":1550},[106,6364,6365],{"class":277},"        withQuery",[106,6367,281],{"class":116},[106,6369,6370],{"class":224},"config",[106,6372,52],{"class":112},[106,6374,6375],{"class":224},"authorizationURL",[106,6377,3099],{"class":270},[106,6379,3985],{"class":1041},[106,6381,355],{"class":112},[106,6383,300],{"class":112},[106,6385,6386,6389,6391,6393,6395,6398],{"class":108,"line":1555},[106,6387,6388],{"class":116},"          client_id",[106,6390,496],{"class":112},[106,6392,6140],{"class":224},[106,6394,52],{"class":112},[106,6396,6397],{"class":224},"clientId",[106,6399,509],{"class":112},[106,6401,6402,6405,6407,6409],{"class":108,"line":1561},[106,6403,6404],{"class":116},"          redirect_uri",[106,6406,496],{"class":112},[106,6408,6327],{"class":224},[106,6410,509],{"class":112},[106,6412,6413,6416,6418,6420,6422,6425,6427,6430,6432,6434,6436,6438],{"class":108,"line":1570},[106,6414,6415],{"class":116},"          scope",[106,6417,496],{"class":112},[106,6419,6140],{"class":224},[106,6421,52],{"class":112},[106,6423,6424],{"class":224},"scope",[106,6426,52],{"class":112},[106,6428,6429],{"class":277},"join",[106,6431,281],{"class":116},[106,6433,754],{"class":112},[106,6435,425],{"class":112},[106,6437,294],{"class":116},[106,6439,509],{"class":112},[106,6441,6442,6445,6447,6449,6452],{"class":108,"line":1575},[106,6443,6444],{"class":112},"          ...",[106,6446,6370],{"class":224},[106,6448,52],{"class":112},[106,6450,6451],{"class":224},"authorizationParams",[106,6453,509],{"class":112},[106,6455,6456,6459,6461],{"class":108,"line":1584},[106,6457,6458],{"class":112},"        }",[106,6460,294],{"class":116},[106,6462,509],{"class":112},[106,6464,6465],{"class":108,"line":1605},[106,6466,6467],{"class":116},"      )\n",[106,6469,6470],{"class":108,"line":1648},[106,6471,1120],{"class":112},[106,6473,6474],{"class":108,"line":1667},[106,6475,330],{"emptyLinePlaceholder":329},[106,6477,6478],{"class":108,"line":1672},[106,6479,6209],{"class":335},[106,6481,6482],{"class":108,"line":1681},[106,6483,6484],{"class":335},"     * Utiliser le code pour obtenir les tokens.\n",[106,6486,6487],{"class":108,"line":1690},[106,6488,6219],{"class":335},[106,6490,6492,6494,6496,6498,6500,6502,6504,6506],{"class":108,"line":6491},41,[106,6493,1379],{"class":132},[106,6495,5684],{"class":224},[106,6497,496],{"class":112},[106,6499,2252],{"class":1041},[106,6501,311],{"class":112},[106,6503,314],{"class":270},[106,6505,1405],{"class":277},[106,6507,6353],{"class":116},[106,6509,6511,6514,6516,6519,6521,6523],{"class":108,"line":6510},42,[106,6512,6513],{"class":224},"      config",[106,6515,52],{"class":112},[106,6517,6518],{"class":224},"tokenURL",[106,6520,3099],{"class":270},[106,6522,3985],{"class":1041},[106,6524,509],{"class":112},[106,6526,6528],{"class":108,"line":6527},43,[106,6529,6530],{"class":112},"      {\n",[106,6532,6534,6537,6539,6541,6543,6545],{"class":108,"line":6533},44,[106,6535,6536],{"class":116},"        method",[106,6538,496],{"class":112},[106,6540,425],{"class":112},[106,6542,3210],{"class":142},[106,6544,754],{"class":112},[106,6546,509],{"class":112},[106,6548,6550,6553,6555],{"class":108,"line":6549},45,[106,6551,6552],{"class":116},"        body",[106,6554,496],{"class":112},[106,6556,300],{"class":112},[106,6558,6560,6563,6565,6567,6569,6571],{"class":108,"line":6559},46,[106,6561,6562],{"class":116},"          code",[106,6564,496],{"class":112},[106,6566,6226],{"class":224},[106,6568,52],{"class":112},[106,6570,103],{"class":224},[106,6572,509],{"class":112},[106,6574,6576],{"class":108,"line":6575},47,[106,6577,6578],{"class":112},"        },\n",[106,6580,6582],{"class":108,"line":6581},48,[106,6583,5734],{"class":112},[106,6585,6587],{"class":108,"line":6586},49,[106,6588,6589],{"class":116},"    )\n",[106,6591,6593],{"class":108,"line":6592},50,[106,6594,330],{"emptyLinePlaceholder":329},[106,6596,6598,6600,6603,6605,6607,6609],{"class":108,"line":6597},51,[106,6599,1379],{"class":132},[106,6601,6602],{"class":224}," accessToken",[106,6604,311],{"class":112},[106,6606,5684],{"class":224},[106,6608,52],{"class":112},[106,6610,6611],{"class":224},"access_token\n",[106,6613,6615],{"class":108,"line":6614},52,[106,6616,330],{"emptyLinePlaceholder":329},[106,6618,6620],{"class":108,"line":6619},53,[106,6621,6209],{"class":335},[106,6623,6625],{"class":108,"line":6624},54,[106,6626,6627],{"class":335},"     * Utiliser le token d'accès pour obtenir les informations de l'utilisateur.\n",[106,6629,6631],{"class":108,"line":6630},55,[106,6632,6219],{"class":335},[106,6634,6636,6638,6640,6642,6644,6646,6648,6651,6653,6655,6658,6660,6662],{"class":108,"line":6635},56,[106,6637,1379],{"class":132},[106,6639,5679],{"class":224},[106,6641,496],{"class":112},[106,6643,2252],{"class":1041},[106,6645,311],{"class":112},[106,6647,314],{"class":270},[106,6649,6650],{"class":277}," ofetch",[106,6652,281],{"class":116},[106,6654,754],{"class":112},[106,6656,6657],{"class":142},"https:\u002F\u002Fapi.github.com\u002Fuser",[106,6659,754],{"class":112},[106,6661,355],{"class":112},[106,6663,300],{"class":112},[106,6665,6667,6670,6672],{"class":108,"line":6666},57,[106,6668,6669],{"class":116},"      headers",[106,6671,496],{"class":112},[106,6673,300],{"class":112},[106,6675,6677,6680,6682,6685,6688,6691,6694,6697],{"class":108,"line":6676},58,[106,6678,6679],{"class":116},"        Authorization",[106,6681,496],{"class":112},[106,6683,6684],{"class":112}," `",[106,6686,6687],{"class":142},"token ",[106,6689,6690],{"class":112},"${",[106,6692,6693],{"class":224},"accessToken",[106,6695,6696],{"class":112},"}`",[106,6698,509],{"class":112},[106,6700,6702],{"class":108,"line":6701},59,[106,6703,5734],{"class":112},[106,6705,6707,6709],{"class":108,"line":6706},60,[106,6708,798],{"class":112},[106,6710,324],{"class":116},[106,6712,6714],{"class":108,"line":6713},61,[106,6715,330],{"emptyLinePlaceholder":329},[106,6717,6719],{"class":108,"line":6718},62,[106,6720,6209],{"class":335},[106,6722,6724],{"class":108,"line":6723},63,[106,6725,6726],{"class":335},"     * Appeler le callback onSuccess avec l'utilisateur et les tokens.\n",[106,6728,6730],{"class":108,"line":6729},64,[106,6731,6732],{"class":335},"     *\n",[106,6734,6736],{"class":108,"line":6735},65,[106,6737,6738],{"class":335},"     * L'OAuth est terminé, et l'utilisateur peut maintenant être authentifié avec notre système.\n",[106,6740,6742],{"class":108,"line":6741},66,[106,6743,6744],{"class":335},"     * Avec Nitro, nous stockons l'utilisateur dans une session au sein d'un cookie.\n",[106,6746,6748],{"class":108,"line":6747},67,[106,6749,6219],{"class":335},[106,6751,6753,6755,6757,6759,6761,6763],{"class":108,"line":6752},68,[106,6754,5749],{"class":270},[106,6756,5668],{"class":277},[106,6758,281],{"class":116},[106,6760,291],{"class":224},[106,6762,355],{"class":112},[106,6764,300],{"class":112},[106,6766,6768,6770],{"class":108,"line":6767},69,[106,6769,5709],{"class":224},[106,6771,509],{"class":112},[106,6773,6775,6778],{"class":108,"line":6774},70,[106,6776,6777],{"class":224},"      tokens",[106,6779,509],{"class":112},[106,6781,6783,6785],{"class":108,"line":6782},71,[106,6784,798],{"class":112},[106,6786,324],{"class":116},[106,6788,6790,6792],{"class":108,"line":6789},72,[106,6791,514],{"class":112},[106,6793,324],{"class":116},[106,6795,6797],{"class":108,"line":6796},73,[106,6798,1558],{"class":112},[10,6800,6801],{},"Comme on peut s'y attendre, le code est assez simple, et nous reconnaissons facilement les étapes que nous avons vues dans le schéma.",[381,6803,5430],{"id":5429},[10,6805,6806],{},"Je n'ai pas codé durant ce live. Je voulais expliquer de nombreux concepts importants pour aider chacun à comprendre le contexte du projet Orion. J'espère que c'était clair et que vous avez appris quelque chose de nouveau.",[10,6808,5824,6809],{},[49,6810,5828],{"repo":5827},[54,6812,6814],{"id":6813},"schéma-sql-et-premiers-éléments-de-linterface-admin-dorion","Schéma SQL et Premiers Éléments de l'Interface Admin d'Orion",[10,6816,6817],{},[5535,6818],{"alt":6819,"src":6820},"Vignette de mon troisième live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-03.png",[10,6822,6823],{},[29,6824,6825,6828,6829,6832,6833,5558,6836,52],{},[14,6826,5546],{"href":5513,"rel":6827},[18]," est une application full-stack construite avec Nuxt et développée en direct ",[14,6830,5552],{"href":5550,"rel":6831},[18],". Vous pouvez voir le replay sur ",[14,6834,5557],{"href":5503,"rel":6835},[18],[14,6837,5562],{"href":5513,"rel":6838},[18],[10,6840,6841],{},"Dans ce troisième stream, j'ai créé le schéma SQL pour la base de données. J'ai ensuite configuré Nuxt UI et Nuxt UI Pro pour créer la mise en page de l'administration et le premier formulaire pour les catégories. Cet article est un résumé du stream et du travail réalisé.",[381,6843,6845],{"id":6844},"schéma-sql","Schéma SQL",[10,6847,6848],{},"Qu'est-ce qu'un schéma SQL et pourquoi ai-je créé ce schéma SQL ? Ce sont deux bonnes questions.",[10,6850,6851,6852],{},"Un schéma SQL est un moyen d'organiser la base de données. C'est une feuille de route sur la manière dont la base de données sera structurée. Il est donc très utile de réfléchir à cela avant de commencer à coder. Il n'est pas nécessaire qu'il soit parfait, mais il aide à itérer sur la structure de la base de données, à éviter les erreurs et à avoir une vision claire du projet. ",[29,6853,6854],{},"Je pense personnellement que c'est un indispensable lorsqu'on commence un nouveau projet.",[10,6856,6857],{},"Avant d'écrire quoi que ce soit pour le projet Orion, j'ai donc créé le schéma SQL.",[10,6859,6860],{},[5535,6861],{"alt":6845,"src":6862},"\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fsql-schema.png",[10,6864,6865,6866,6871],{},"Pour construire ce schéma, j'ai utilisé ",[14,6867,6870],{"href":6868,"rel":6869},"https:\u002F\u002Fdrawsql.app\u002F",[18],"drawSQL",". C'est une application web pour créer des schémas SQL avec une interface utilisateur agréable. Elle est très facile à utiliser et gratuite pour les petits projets. Je la recommande.",[10,6873,6874],{},"La base de données pour le projet Orion, l'application full-stack Nuxt, est simple et courante pour ce type de projet.",[5915,6876,6877,6884,6890,6896,6902],{},[5918,6878,6879,6880,6883],{},"Une table ",[103,6881,6882],{},"users"," pour stocker les utilisateurs de l'application. Stocker les utilisateurs est utile pour l'interface admin, pour bannir un utilisateur ou pour donner des droits d'administrateur.",[5918,6885,6879,6886,6889],{},[103,6887,6888],{},"categories"," pour stocker les catégories du thème comme blog, e-commerce, documentation, etc. C'est une table simple avec un nom et un slug. Le slug est utilisé pour l'URL de la catégorie.",[5918,6891,6879,6892,6895],{},[103,6893,6894],{},"modules"," pour stocker les modules du thème. La table aura un nom, un slug et un type (officiel, communauté). Cela sera utile pour filtrer les thèmes par modules.",[5918,6897,6879,6898,6901],{},[103,6899,6900],{},"themes"," pour stocker le contenu principal de l'application. Elle aura de nombreuses colonnes, mais nous verrons cela dans un autre stream.",[5918,6903,6879,6904,6907],{},[103,6905,6906],{},"feedbacks"," pour stocker les retours des administrateurs sur un thème. En effet, un thème doit être évalué avant d'être publié et les retours seront utilisés pour indiquer au créateur ce qu'il doit améliorer.",[10,6909,6910],{},"Ce schéma est un bon point de départ pour clarifier les choses et savoir où commencer, mais je sais déjà qu'il évoluera.",[381,6912,6914],{"id":6913},"nuxt-ui-et-nuxt-ui-pro","Nuxt UI et Nuxt UI Pro",[10,6916,6917,6918,6923,6924,6929,6930],{},"Nuxt UI est une bibliothèque de composants créée par l'équipe de Nuxt. Initialement, c'était un outil interne pour leurs propres projets comme ",[14,6919,6922],{"href":6920,"rel":6921},"https:\u002F\u002Fvolta.net",[18],"Volta",". En mai 2023, ils ont décidé de ",[14,6925,6928],{"href":6926,"rel":6927},"https:\u002F\u002Fgithub.com\u002Fnuxt\u002Fui\u002Freleases\u002Ftag\u002Fv2.0.0",[18],"le rendre open-source"," et de le rendre disponible gratuitement pour tout le monde. ",[29,6931,6932],{},"Aujourd'hui, Nuxt UI est mon choix de prédilection pour construire des interfaces utilisateur avec Nuxt car il est simple, beau et pourtant très personnalisable.",[10,6934,6935,6936,6939],{},"Nuxt UI Pro est construit sur Nuxt UI et fournit des composants liés aux mises en page, comme une page, un hero, un en-tête et des mises en page complètes comme un tableau de bord, un blog, une documentation, etc. C'est un produit payant, mais il en vaut la peine. ",[29,6937,6938],{},"Je l'utilise pour Orion, l'application full-stack, car il fait gagner du temps et est magnifique."," Le mieux, c'est qu'il est gratuit pendant le développement (le processus de build nécessitera une clé), donc vous pouvez l'essayer avant de l'acheter.",[10,6941,6942],{},"Pendant le stream, j'ai commencé par installer Nuxt UI et Nuxt UI Pro. J'ai ensuite créé la mise en page pour l'interface d'administration et la page pour les catégories. Voyons le code.",[381,6944,6946],{"id":6945},"mise-en-page-admin","Mise en Page Admin",[10,6948,6949,6950,6953,6954,52],{},"Avec Nuxt UI Pro, cette partie a été très facile. Il n'y a pas encore de navbar ni de sidebar. ",[29,6951,6952],{},"L'objectif est d'ajouter des fonctionnalités et des composants étape par étape, selon les besoins, et d'éviter de trop anticiper."," Voici le code pour la mise en page ",[103,6955,6956],{},"layouts\u002Fadmin.vue",[96,6958,6960],{"className":98,"code":6959,"language":100,"meta":101,"style":101},"\u003Ctemplate>\n  \u003CUDashboardLayout>\n    \u003Cslot \u002F>\n  \u003C\u002FUDashboardLayout>\n\u003C\u002Ftemplate>\n",[103,6961,6962,6970,6979,6989,6997],{"__ignoreMap":101},[106,6963,6964,6966,6968],{"class":108,"line":109},[106,6965,113],{"class":112},[106,6967,117],{"class":116},[106,6969,120],{"class":112},[106,6971,6972,6974,6977],{"class":108,"line":123},[106,6973,126],{"class":112},[106,6975,6976],{"class":116},"UDashboardLayout",[106,6978,120],{"class":112},[106,6980,6981,6983,6986],{"class":108,"line":162},[106,6982,165],{"class":112},[106,6984,6985],{"class":116},"slot",[106,6987,6988],{"class":112}," \u002F>\n",[106,6990,6991,6993,6995],{"class":108,"line":200},[106,6992,241],{"class":112},[106,6994,6976],{"class":116},[106,6996,120],{"class":112},[106,6998,6999,7001,7003],{"class":108,"line":221},[106,7000,251],{"class":112},[106,7002,117],{"class":116},[106,7004,120],{"class":112},[381,7006,7008],{"id":7007},"page-des-catégories","Page des Catégories",[10,7010,7011],{},"Avant de développer la vue, j'ai créé l'endpoint API pour obtenir et mettre à jour les catégories.",[10,7013,7014],{},[5535,7015],{"alt":7016,"src":7017},"Résultat de la mise en page et de la page pour les catégories.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Flayout-and-page-for-categories.png",[6078,7019,7021],{"id":7020},"api-des-catégories","API des Catégories",[10,7023,7024,7025,7028,7029,7032],{},"Grâce à Nitro et Drizzle (configuré dans le premier stream), c'est très simple. Dans le fichier ",[103,7026,7027],{},"server\u002Fapi\u002Fcategories\u002Findex.get.ts"," (le ",[103,7030,7031],{},".get."," est utilisé pour spécifier le verbe HTTP), j'ai écrit le code suivant :",[96,7034,7036],{"className":261,"code":7035,"language":263,"meta":101,"style":101},"export default defineEventHandler(async () => {\n  const categories = await useDrizzle().select({\n    id: tables.categories.id,\n    slug: tables.categories.slug,\n    name: tables.categories.name\n  }).from(tables.categories)\n\n  return categories\n})\n",[103,7037,7038,7057,7082,7103,7123,7140,7162,7166,7173],{"__ignoreMap":101},[106,7039,7040,7042,7044,7046,7048,7050,7053,7055],{"class":108,"line":109},[106,7041,271],{"class":270},[106,7043,274],{"class":270},[106,7045,278],{"class":277},[106,7047,281],{"class":224},[106,7049,284],{"class":132},[106,7051,7052],{"class":112}," ()",[106,7054,297],{"class":132},[106,7056,300],{"class":112},[106,7058,7059,7061,7064,7066,7068,7071,7073,7075,7078,7080],{"class":108,"line":123},[106,7060,305],{"class":132},[106,7062,7063],{"class":224}," categories",[106,7065,311],{"class":112},[106,7067,314],{"class":270},[106,7069,7070],{"class":277}," useDrizzle",[106,7072,506],{"class":116},[106,7074,52],{"class":112},[106,7076,7077],{"class":277},"select",[106,7079,281],{"class":116},[106,7081,488],{"class":112},[106,7083,7084,7087,7089,7092,7094,7096,7098,7101],{"class":108,"line":162},[106,7085,7086],{"class":116},"    id",[106,7088,496],{"class":112},[106,7090,7091],{"class":224}," tables",[106,7093,52],{"class":112},[106,7095,6888],{"class":224},[106,7097,52],{"class":112},[106,7099,7100],{"class":224},"id",[106,7102,509],{"class":112},[106,7104,7105,7108,7110,7112,7114,7116,7118,7121],{"class":108,"line":200},[106,7106,7107],{"class":116},"    slug",[106,7109,496],{"class":112},[106,7111,7091],{"class":224},[106,7113,52],{"class":112},[106,7115,6888],{"class":224},[106,7117,52],{"class":112},[106,7119,7120],{"class":224},"slug",[106,7122,509],{"class":112},[106,7124,7125,7127,7129,7131,7133,7135,7137],{"class":108,"line":221},[106,7126,493],{"class":116},[106,7128,496],{"class":112},[106,7130,7091],{"class":224},[106,7132,52],{"class":112},[106,7134,6888],{"class":224},[106,7136,52],{"class":112},[106,7138,7139],{"class":224},"name\n",[106,7141,7142,7144,7146,7148,7151,7153,7156,7158,7160],{"class":108,"line":228},[106,7143,514],{"class":112},[106,7145,294],{"class":116},[106,7147,52],{"class":112},[106,7149,7150],{"class":277},"from",[106,7152,281],{"class":116},[106,7154,7155],{"class":224},"tables",[106,7157,52],{"class":112},[106,7159,6888],{"class":224},[106,7161,324],{"class":116},[106,7163,7164],{"class":108,"line":238},[106,7165,330],{"emptyLinePlaceholder":329},[106,7167,7168,7170],{"class":108,"line":248},[106,7169,345],{"class":270},[106,7171,7172],{"class":224}," categories\n",[106,7174,7175,7177],{"class":108,"line":534},[106,7176,366],{"class":112},[106,7178,324],{"class":224},[10,7180,563,7181,7184,7185,7187,7188,7190,7191,393,7193],{},[103,7182,7183],{},"useDrizzle"," est un wrapper autour de l'ORM Drizzle. Ce code renverra toutes les catégories avec seulement les champs ",[103,7186,7100],{},", ",[103,7189,7120],{},", et ",[103,7192,3858],{},[29,7194,7195],{},"C'est une bonne pratique de ne renvoyer que les champs dont vous avez besoin pour éviter d'envoyer trop de données.",[10,7197,7198,7199,7202],{},"Ensuite, j'ai créé l'endpoint pour créer une nouvelle catégorie, et c'est le même processus. J'ai créé le fichier ",[103,7200,7201],{},"server\u002Fapi\u002Fcategories\u002Findex.post.ts"," avec le code suivant :",[96,7204,7206],{"className":261,"code":7205,"language":263,"meta":101,"style":101},"export default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  await useDrizzle().insert(tables.categories).values({\n    slug: useSlugify(body.name),\n    name: body.name,\n  }).execute()\n\n  return body\n})\n",[103,7207,7208,7230,7248,7252,7284,7306,7320,7333,7337,7344],{"__ignoreMap":101},[106,7209,7210,7212,7214,7216,7218,7220,7222,7224,7226,7228],{"class":108,"line":109},[106,7211,271],{"class":270},[106,7213,274],{"class":270},[106,7215,278],{"class":277},[106,7217,281],{"class":224},[106,7219,284],{"class":132},[106,7221,287],{"class":112},[106,7223,291],{"class":290},[106,7225,294],{"class":112},[106,7227,297],{"class":132},[106,7229,300],{"class":112},[106,7231,7232,7234,7236,7238,7240,7242,7244,7246],{"class":108,"line":123},[106,7233,305],{"class":132},[106,7235,308],{"class":224},[106,7237,311],{"class":112},[106,7239,314],{"class":270},[106,7241,317],{"class":277},[106,7243,281],{"class":116},[106,7245,291],{"class":224},[106,7247,324],{"class":116},[106,7249,7250],{"class":108,"line":162},[106,7251,330],{"emptyLinePlaceholder":329},[106,7253,7254,7256,7258,7260,7262,7265,7267,7269,7271,7273,7275,7277,7280,7282],{"class":108,"line":200},[106,7255,2348],{"class":270},[106,7257,7070],{"class":277},[106,7259,506],{"class":116},[106,7261,52],{"class":112},[106,7263,7264],{"class":277},"insert",[106,7266,281],{"class":116},[106,7268,7155],{"class":224},[106,7270,52],{"class":112},[106,7272,6888],{"class":224},[106,7274,294],{"class":116},[106,7276,52],{"class":112},[106,7278,7279],{"class":277},"values",[106,7281,281],{"class":116},[106,7283,488],{"class":112},[106,7285,7286,7288,7290,7293,7295,7298,7300,7302,7304],{"class":108,"line":221},[106,7287,7107],{"class":116},[106,7289,496],{"class":112},[106,7291,7292],{"class":277}," useSlugify",[106,7294,281],{"class":116},[106,7296,7297],{"class":224},"body",[106,7299,52],{"class":112},[106,7301,3858],{"class":224},[106,7303,294],{"class":116},[106,7305,509],{"class":112},[106,7307,7308,7310,7312,7314,7316,7318],{"class":108,"line":228},[106,7309,493],{"class":116},[106,7311,496],{"class":112},[106,7313,308],{"class":224},[106,7315,52],{"class":112},[106,7317,3858],{"class":224},[106,7319,509],{"class":112},[106,7321,7322,7324,7326,7328,7331],{"class":108,"line":238},[106,7323,514],{"class":112},[106,7325,294],{"class":116},[106,7327,52],{"class":112},[106,7329,7330],{"class":277},"execute",[106,7332,2366],{"class":116},[106,7334,7335],{"class":108,"line":248},[106,7336,330],{"emptyLinePlaceholder":329},[106,7338,7339,7341],{"class":108,"line":534},[106,7340,345],{"class":270},[106,7342,7343],{"class":224}," body\n",[106,7345,7346,7348],{"class":108,"line":539},[106,7347,366],{"class":112},[106,7349,324],{"class":224},[10,7351,7352,7353,52],{},"Dans ce code, il n'y a pas de vérification de sécurité, et chaque utilisateur peut créer une catégorie. Ce n'est clairement pas un cas d'utilisation pour notre application, mais la vérification sera ajoutée plus tard. En même temps, je n'ai pas vérifié le corps de la requête. Il est important de valider les données reçues d'un client pour éviter les problèmes de sécurité. ",[22,7354,7355],{},"Ce sera le sujet du prochain stream",[54,7357,7359],{"id":7358},"contenu-des-catégories","Contenu des Catégories",[10,7361,7362],{},"La page des catégories est construite avec Nuxt UI Pro, ce qui rend le code très simple.",[96,7364,7366],{"className":98,"code":7365,"language":100,"meta":101,"style":101},"\u003Cscript setup lang=\"ts\">\ndefinePageMeta({\n  layout: 'admin'\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUDashboardPage>\n    \u003CUDashboardPanel grow>\n      \u003C!-- Contenu -->\n    \u003C\u002FUDashboardPanel>\n  \u003C\u002FUDashboardPage>\n\u003C\u002Ftemplate>\n",[103,7367,7368,7388,7397,7411,7417,7425,7429,7437,7446,7458,7463,7471,7479],{"__ignoreMap":101},[106,7369,7370,7372,7374,7376,7378,7380,7382,7384,7386],{"class":108,"line":109},[106,7371,113],{"class":112},[106,7373,1189],{"class":116},[106,7375,1203],{"class":132},[106,7377,1192],{"class":132},[106,7379,136],{"class":112},[106,7381,139],{"class":112},[106,7383,263],{"class":142},[106,7385,139],{"class":112},[106,7387,120],{"class":112},[106,7389,7390,7393,7395],{"class":108,"line":123},[106,7391,7392],{"class":277},"definePageMeta",[106,7394,281],{"class":224},[106,7396,488],{"class":112},[106,7398,7399,7402,7404,7406,7409],{"class":108,"line":162},[106,7400,7401],{"class":116},"  layout",[106,7403,496],{"class":112},[106,7405,425],{"class":112},[106,7407,7408],{"class":142},"admin",[106,7410,431],{"class":112},[106,7412,7413,7415],{"class":108,"line":200},[106,7414,366],{"class":112},[106,7416,324],{"class":224},[106,7418,7419,7421,7423],{"class":108,"line":221},[106,7420,251],{"class":112},[106,7422,1189],{"class":116},[106,7424,120],{"class":112},[106,7426,7427],{"class":108,"line":228},[106,7428,330],{"emptyLinePlaceholder":329},[106,7430,7431,7433,7435],{"class":108,"line":238},[106,7432,113],{"class":112},[106,7434,117],{"class":116},[106,7436,120],{"class":112},[106,7438,7439,7441,7444],{"class":108,"line":248},[106,7440,126],{"class":112},[106,7442,7443],{"class":116},"UDashboardPage",[106,7445,120],{"class":112},[106,7447,7448,7450,7453,7456],{"class":108,"line":534},[106,7449,165],{"class":112},[106,7451,7452],{"class":116},"UDashboardPanel",[106,7454,7455],{"class":132}," grow",[106,7457,120],{"class":112},[106,7459,7460],{"class":108,"line":539},[106,7461,7462],{"class":335},"      \u003C!-- Contenu -->\n",[106,7464,7465,7467,7469],{"class":108,"line":556},[106,7466,231],{"class":112},[106,7468,7452],{"class":116},[106,7470,120],{"class":112},[106,7472,7473,7475,7477],{"class":108,"line":759},[106,7474,241],{"class":112},[106,7476,7443],{"class":116},[106,7478,120],{"class":112},[106,7480,7481,7483,7485],{"class":108,"line":795},[106,7482,251],{"class":112},[106,7484,117],{"class":116},[106,7486,120],{"class":112},[10,7488,7489,7490,7492],{},"Dans le ",[103,7491,7452],{},", nous pouvons ajouter un en-tête avec le titre de la page, un bouton pour créer une nouvelle catégorie, et un tableau pour afficher les catégories.",[10,7494,7495,7496,2754],{},"Commençons par ajouter le tableau dans le ",[103,7497,7452],{},[96,7499,7501],{"className":98,"code":7500,"language":100,"meta":101,"style":101},"\u003Cscript setup lang=\"ts\">\nconst columns = [{\n  key: 'id',\n  label: '#'\n}, {\n  key: 'name',\n  label: 'Nom',\n  sortable: true\n}, {\n  key: 'slug',\n  label: 'Slug',\n}]\n\nconst { data: categories, pending } = await useFetch('\u002Fapi\u002Fcategories', {\n  deep: false,\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUTable :columns=\"columns\" :rows=\"categories\" :loading=\"pending\" \u002F>\n\u003C\u002Ftemplate>\n",[103,7502,7503,7523,7536,7551,7565,7572,7586,7600,7610,7616,7630,7645,7651,7655,7694,7706,7712,7720,7724,7732,7781],{"__ignoreMap":101},[106,7504,7505,7507,7509,7511,7513,7515,7517,7519,7521],{"class":108,"line":109},[106,7506,113],{"class":112},[106,7508,1189],{"class":116},[106,7510,1203],{"class":132},[106,7512,1192],{"class":132},[106,7514,136],{"class":112},[106,7516,139],{"class":112},[106,7518,263],{"class":142},[106,7520,139],{"class":112},[106,7522,120],{"class":112},[106,7524,7525,7527,7530,7532,7534],{"class":108,"line":123},[106,7526,1252],{"class":132},[106,7528,7529],{"class":224}," columns ",[106,7531,136],{"class":112},[106,7533,2538],{"class":224},[106,7535,488],{"class":112},[106,7537,7538,7541,7543,7545,7547,7549],{"class":108,"line":162},[106,7539,7540],{"class":116},"  key",[106,7542,496],{"class":112},[106,7544,425],{"class":112},[106,7546,7100],{"class":142},[106,7548,754],{"class":112},[106,7550,509],{"class":112},[106,7552,7553,7556,7558,7560,7563],{"class":108,"line":200},[106,7554,7555],{"class":116},"  label",[106,7557,496],{"class":112},[106,7559,425],{"class":112},[106,7561,7562],{"class":142},"#",[106,7564,431],{"class":112},[106,7566,7567,7570],{"class":108,"line":221},[106,7568,7569],{"class":112},"},",[106,7571,300],{"class":112},[106,7573,7574,7576,7578,7580,7582,7584],{"class":108,"line":228},[106,7575,7540],{"class":116},[106,7577,496],{"class":112},[106,7579,425],{"class":112},[106,7581,3858],{"class":142},[106,7583,754],{"class":112},[106,7585,509],{"class":112},[106,7587,7588,7590,7592,7594,7596,7598],{"class":108,"line":238},[106,7589,7555],{"class":116},[106,7591,496],{"class":112},[106,7593,425],{"class":112},[106,7595,190],{"class":142},[106,7597,754],{"class":112},[106,7599,509],{"class":112},[106,7601,7602,7605,7607],{"class":108,"line":248},[106,7603,7604],{"class":116},"  sortable",[106,7606,496],{"class":112},[106,7608,7609],{"class":5652}," true\n",[106,7611,7612,7614],{"class":108,"line":534},[106,7613,7569],{"class":112},[106,7615,300],{"class":112},[106,7617,7618,7620,7622,7624,7626,7628],{"class":108,"line":539},[106,7619,7540],{"class":116},[106,7621,496],{"class":112},[106,7623,425],{"class":112},[106,7625,7120],{"class":142},[106,7627,754],{"class":112},[106,7629,509],{"class":112},[106,7631,7632,7634,7636,7638,7641,7643],{"class":108,"line":556},[106,7633,7555],{"class":116},[106,7635,496],{"class":112},[106,7637,425],{"class":112},[106,7639,7640],{"class":142},"Slug",[106,7642,754],{"class":112},[106,7644,509],{"class":112},[106,7646,7647,7649],{"class":108,"line":759},[106,7648,366],{"class":112},[106,7650,3725],{"class":224},[106,7652,7653],{"class":108,"line":795},[106,7654,330],{"emptyLinePlaceholder":329},[106,7656,7657,7659,7661,7663,7665,7667,7669,7672,7674,7676,7678,7681,7683,7685,7688,7690,7692],{"class":108,"line":803},[106,7658,1252],{"class":132},[106,7660,413],{"class":112},[106,7662,964],{"class":116},[106,7664,496],{"class":112},[106,7666,7063],{"class":224},[106,7668,355],{"class":112},[106,7670,7671],{"class":224}," pending ",[106,7673,366],{"class":112},[106,7675,311],{"class":112},[106,7677,314],{"class":270},[106,7679,7680],{"class":277}," useFetch",[106,7682,281],{"class":224},[106,7684,754],{"class":112},[106,7686,7687],{"class":142},"\u002Fapi\u002Fcategories",[106,7689,754],{"class":112},[106,7691,355],{"class":112},[106,7693,300],{"class":112},[106,7695,7696,7699,7701,7704],{"class":108,"line":809},[106,7697,7698],{"class":116},"  deep",[106,7700,496],{"class":112},[106,7702,7703],{"class":5652}," false",[106,7705,509],{"class":112},[106,7707,7708,7710],{"class":108,"line":814},[106,7709,366],{"class":112},[106,7711,324],{"class":224},[106,7713,7714,7716,7718],{"class":108,"line":830},[106,7715,251],{"class":112},[106,7717,1189],{"class":116},[106,7719,120],{"class":112},[106,7721,7722],{"class":108,"line":835},[106,7723,330],{"emptyLinePlaceholder":329},[106,7725,7726,7728,7730],{"class":108,"line":840},[106,7727,113],{"class":112},[106,7729,117],{"class":116},[106,7731,120],{"class":112},[106,7733,7734,7736,7739,7741,7744,7746,7748,7750,7752,7754,7757,7759,7761,7763,7765,7767,7770,7772,7774,7777,7779],{"class":108,"line":845},[106,7735,126],{"class":112},[106,7737,7738],{"class":116},"UTable",[106,7740,2754],{"class":112},[106,7742,7743],{"class":132},"columns",[106,7745,136],{"class":112},[106,7747,139],{"class":112},[106,7749,7743],{"class":224},[106,7751,139],{"class":112},[106,7753,2754],{"class":112},[106,7755,7756],{"class":132},"rows",[106,7758,136],{"class":112},[106,7760,139],{"class":112},[106,7762,6888],{"class":224},[106,7764,139],{"class":112},[106,7766,2754],{"class":112},[106,7768,7769],{"class":132},"loading",[106,7771,136],{"class":112},[106,7773,139],{"class":112},[106,7775,7776],{"class":224},"pending",[106,7778,139],{"class":112},[106,7780,6988],{"class":112},[106,7782,7783,7785,7787],{"class":108,"line":862},[106,7784,251],{"class":112},[106,7786,117],{"class":116},[106,7788,120],{"class":112},[10,7790,7791,7792],{},"C'est difficile d'être plus simple ! ",[29,7793,7794],{},"Merci à Nuxt UI pour ce travail !",[10,7796,7797],{},"Ensuite, ajoutons l'en-tête avec le titre et le bouton pour créer une nouvelle catégorie :",[96,7799,7801],{"className":98,"code":7800,"language":100,"meta":101,"style":101},"\u003Ctemplate>\n  \u003CUDashboardNavbar\n    title=\"Catégories\"\n  >\n    \u003Ctemplate #right>\n      \u003CUButton\n        label=\"Nouvelle catégorie\"\n        trailing-icon=\"i-heroicons-plus\"\n        color=\"gray\"\n      \u002F>\n    \u003C\u002Ftemplate>\n  \u003C\u002FUDashboardNavbar>\n\u003C\u002Ftemplate>\n",[103,7802,7803,7811,7818,7833,7838,7852,7859,7873,7887,7901,7906,7914,7923],{"__ignoreMap":101},[106,7804,7805,7807,7809],{"class":108,"line":109},[106,7806,113],{"class":112},[106,7808,117],{"class":116},[106,7810,120],{"class":112},[106,7812,7813,7815],{"class":108,"line":123},[106,7814,126],{"class":112},[106,7816,7817],{"class":116},"UDashboardNavbar\n",[106,7819,7820,7823,7825,7827,7830],{"class":108,"line":162},[106,7821,7822],{"class":132},"    title",[106,7824,136],{"class":112},[106,7826,139],{"class":112},[106,7828,7829],{"class":142},"Catégories",[106,7831,7832],{"class":112},"\"\n",[106,7834,7835],{"class":108,"line":200},[106,7836,7837],{"class":112},"  >\n",[106,7839,7840,7842,7844,7847,7850],{"class":108,"line":221},[106,7841,165],{"class":112},[106,7843,117],{"class":116},[106,7845,7846],{"class":112}," #",[106,7848,7849],{"class":132},"right",[106,7851,120],{"class":112},[106,7853,7854,7856],{"class":108,"line":228},[106,7855,3376],{"class":112},[106,7857,7858],{"class":116},"UButton\n",[106,7860,7861,7864,7866,7868,7871],{"class":108,"line":238},[106,7862,7863],{"class":132},"        label",[106,7865,136],{"class":112},[106,7867,139],{"class":112},[106,7869,7870],{"class":142},"Nouvelle catégorie",[106,7872,7832],{"class":112},[106,7874,7875,7878,7880,7882,7885],{"class":108,"line":248},[106,7876,7877],{"class":132},"        trailing-icon",[106,7879,136],{"class":112},[106,7881,139],{"class":112},[106,7883,7884],{"class":142},"i-heroicons-plus",[106,7886,7832],{"class":112},[106,7888,7889,7892,7894,7896,7899],{"class":108,"line":534},[106,7890,7891],{"class":132},"        color",[106,7893,136],{"class":112},[106,7895,139],{"class":112},[106,7897,7898],{"class":142},"gray",[106,7900,7832],{"class":112},[106,7902,7903],{"class":108,"line":539},[106,7904,7905],{"class":112},"      \u002F>\n",[106,7907,7908,7910,7912],{"class":108,"line":556},[106,7909,231],{"class":112},[106,7911,117],{"class":116},[106,7913,120],{"class":112},[106,7915,7916,7918,7921],{"class":108,"line":759},[106,7917,241],{"class":112},[106,7919,7920],{"class":116},"UDashboardNavbar",[106,7922,120],{"class":112},[106,7924,7925,7927,7929],{"class":108,"line":795},[106,7926,251],{"class":112},[106,7928,117],{"class":116},[106,7930,120],{"class":112},[10,7932,7933],{},"Toujours très facile. Le bouton sera utilisé pour ouvrir un modal avec un formulaire pour créer une nouvelle catégorie. J'ai commencé à créer le formulaire mais de manière très simple :",[96,7935,7937],{"className":98,"code":7936,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nimport type { FormSubmitEvent } from '#ui\u002Ftypes'\nimport type { output } from 'zod'\nimport { object, string } from 'zod'\n\nconst schema = object({\n  name: string({ message: 'Requis' }),\n})\n\ntype Schema = output\u003Ctypeof schema>\n\nconst state = reactive({\n  name: undefined,\n})\n\nasync function onSubmit(event: FormSubmitEvent\u003CSchema>) {\n  await $fetch('\u002Fapi\u002Fcategories', {\n    method: 'POST',\n    body: event.data,\n  })\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUForm :schema=\"schema\" :state=\"state\" @submit=\"onSubmit\">\n    \u003CUFormGroup label=\"Nom\" name=\"name\">\n      \u003CUInput v-model=\"state.name\" \u002F>\n    \u003C\u002FUFormGroup>\n\n    \u003CUButton type=\"submit\">\n      Soumettre\n    \u003C\u002FUButton>\n  \u003C\u002FUForm>\n\u003C\u002Ftemplate>\n",[103,7938,7939,7959,7981,8002,8024,8028,8042,8071,8077,8081,8099,8103,8117,8126,8132,8136,8161,8179,8194,8209,8215,8219,8227,8231,8239,8287,8317,8340,8348,8352,8371,8376,8384,8392],{"__ignoreMap":101},[106,7940,7941,7943,7945,7947,7949,7951,7953,7955,7957],{"class":108,"line":109},[106,7942,113],{"class":112},[106,7944,1189],{"class":116},[106,7946,1192],{"class":132},[106,7948,136],{"class":112},[106,7950,139],{"class":112},[106,7952,263],{"class":142},[106,7954,139],{"class":112},[106,7956,1203],{"class":132},[106,7958,120],{"class":112},[106,7960,7961,7963,7965,7967,7970,7972,7974,7976,7979],{"class":108,"line":123},[106,7962,410],{"class":270},[106,7964,171],{"class":270},[106,7966,413],{"class":112},[106,7968,7969],{"class":224}," FormSubmitEvent",[106,7971,419],{"class":112},[106,7973,422],{"class":270},[106,7975,425],{"class":112},[106,7977,7978],{"class":142},"#ui\u002Ftypes",[106,7980,431],{"class":112},[106,7982,7983,7985,7987,7989,7992,7994,7996,7998,8000],{"class":108,"line":162},[106,7984,410],{"class":270},[106,7986,171],{"class":270},[106,7988,413],{"class":112},[106,7990,7991],{"class":224}," output",[106,7993,419],{"class":112},[106,7995,422],{"class":270},[106,7997,425],{"class":112},[106,7999,428],{"class":142},[106,8001,431],{"class":112},[106,8003,8004,8006,8008,8010,8012,8014,8016,8018,8020,8022],{"class":108,"line":200},[106,8005,410],{"class":270},[106,8007,413],{"class":112},[106,8009,2257],{"class":224},[106,8011,355],{"class":112},[106,8013,3985],{"class":224},[106,8015,419],{"class":112},[106,8017,422],{"class":270},[106,8019,425],{"class":112},[106,8021,428],{"class":142},[106,8023,431],{"class":112},[106,8025,8026],{"class":108,"line":221},[106,8027,330],{"emptyLinePlaceholder":329},[106,8029,8030,8032,8034,8036,8038,8040],{"class":108,"line":228},[106,8031,1252],{"class":132},[106,8033,1255],{"class":224},[106,8035,136],{"class":112},[106,8037,2257],{"class":277},[106,8039,281],{"class":224},[106,8041,488],{"class":112},[106,8043,8044,8046,8048,8050,8052,8054,8056,8058,8060,8063,8065,8067,8069],{"class":108,"line":238},[106,8045,1272],{"class":116},[106,8047,496],{"class":112},[106,8049,3985],{"class":277},[106,8051,281],{"class":224},[106,8053,1285],{"class":112},[106,8055,1288],{"class":116},[106,8057,496],{"class":112},[106,8059,425],{"class":112},[106,8061,8062],{"class":142},"Requis",[106,8064,754],{"class":112},[106,8066,419],{"class":112},[106,8068,294],{"class":224},[106,8070,509],{"class":112},[106,8072,8073,8075],{"class":108,"line":248},[106,8074,366],{"class":112},[106,8076,324],{"class":224},[106,8078,8079],{"class":108,"line":534},[106,8080,330],{"emptyLinePlaceholder":329},[106,8082,8083,8085,8088,8090,8092,8095,8097],{"class":108,"line":539},[106,8084,2484],{"class":132},[106,8086,8087],{"class":1041}," Schema",[106,8089,311],{"class":112},[106,8091,7991],{"class":1041},[106,8093,8094],{"class":112},"\u003Ctypeof",[106,8096,1386],{"class":224},[106,8098,120],{"class":112},[106,8100,8101],{"class":108,"line":556},[106,8102,330],{"emptyLinePlaceholder":329},[106,8104,8105,8107,8109,8111,8113,8115],{"class":108,"line":759},[106,8106,1252],{"class":132},[106,8108,1323],{"class":224},[106,8110,136],{"class":112},[106,8112,1328],{"class":277},[106,8114,281],{"class":224},[106,8116,488],{"class":112},[106,8118,8119,8121,8123],{"class":108,"line":795},[106,8120,1272],{"class":116},[106,8122,496],{"class":112},[106,8124,8125],{"class":112}," undefined,\n",[106,8127,8128,8130],{"class":108,"line":803},[106,8129,366],{"class":112},[106,8131,324],{"class":224},[106,8133,8134],{"class":108,"line":809},[106,8135,330],{"emptyLinePlaceholder":329},[106,8137,8138,8140,8142,8144,8146,8148,8150,8152,8154,8157,8159],{"class":108,"line":814},[106,8139,284],{"class":132},[106,8141,1360],{"class":132},[106,8143,1363],{"class":277},[106,8145,281],{"class":112},[106,8147,291],{"class":290},[106,8149,496],{"class":112},[106,8151,7969],{"class":1041},[106,8153,113],{"class":112},[106,8155,8156],{"class":1041},"Schema",[106,8158,6158],{"class":112},[106,8160,300],{"class":112},[106,8162,8163,8165,8167,8169,8171,8173,8175,8177],{"class":108,"line":830},[106,8164,2348],{"class":270},[106,8166,1405],{"class":277},[106,8168,281],{"class":116},[106,8170,754],{"class":112},[106,8172,7687],{"class":142},[106,8174,754],{"class":112},[106,8176,355],{"class":112},[106,8178,300],{"class":112},[106,8180,8181,8184,8186,8188,8190,8192],{"class":108,"line":835},[106,8182,8183],{"class":116},"    method",[106,8185,496],{"class":112},[106,8187,425],{"class":112},[106,8189,3210],{"class":142},[106,8191,754],{"class":112},[106,8193,509],{"class":112},[106,8195,8196,8199,8201,8203,8205,8207],{"class":108,"line":840},[106,8197,8198],{"class":116},"    body",[106,8200,496],{"class":112},[106,8202,3091],{"class":224},[106,8204,52],{"class":112},[106,8206,1007],{"class":224},[106,8208,509],{"class":112},[106,8210,8211,8213],{"class":108,"line":845},[106,8212,514],{"class":112},[106,8214,324],{"class":116},[106,8216,8217],{"class":108,"line":862},[106,8218,1558],{"class":112},[106,8220,8221,8223,8225],{"class":108,"line":1469},[106,8222,251],{"class":112},[106,8224,1189],{"class":116},[106,8226,120],{"class":112},[106,8228,8229],{"class":108,"line":1486},[106,8230,330],{"emptyLinePlaceholder":329},[106,8232,8233,8235,8237],{"class":108,"line":1497},[106,8234,113],{"class":112},[106,8236,117],{"class":116},[106,8238,120],{"class":112},[106,8240,8241,8243,8246,8248,8251,8253,8255,8257,8259,8261,8263,8265,8267,8269,8271,8274,8276,8278,8280,8283,8285],{"class":108,"line":1508},[106,8242,126],{"class":112},[106,8244,8245],{"class":116},"UForm",[106,8247,2754],{"class":112},[106,8249,8250],{"class":132},"schema",[106,8252,136],{"class":112},[106,8254,139],{"class":112},[106,8256,8250],{"class":224},[106,8258,139],{"class":112},[106,8260,2754],{"class":112},[106,8262,1395],{"class":132},[106,8264,136],{"class":112},[106,8266,139],{"class":112},[106,8268,1395],{"class":224},[106,8270,139],{"class":112},[106,8272,8273],{"class":112}," @",[106,8275,214],{"class":132},[106,8277,136],{"class":112},[106,8279,139],{"class":112},[106,8281,8282],{"class":224},"onSubmit",[106,8284,139],{"class":112},[106,8286,120],{"class":112},[106,8288,8289,8291,8294,8297,8299,8301,8303,8305,8307,8309,8311,8313,8315],{"class":108,"line":1523},[106,8290,165],{"class":112},[106,8292,8293],{"class":116},"UFormGroup",[106,8295,8296],{"class":132}," label",[106,8298,136],{"class":112},[106,8300,139],{"class":112},[106,8302,190],{"class":142},[106,8304,139],{"class":112},[106,8306,3391],{"class":132},[106,8308,136],{"class":112},[106,8310,139],{"class":112},[106,8312,3858],{"class":142},[106,8314,139],{"class":112},[106,8316,120],{"class":112},[106,8318,8319,8321,8324,8326,8328,8330,8332,8334,8336,8338],{"class":108,"line":1538},[106,8320,3376],{"class":112},[106,8322,8323],{"class":116},"UInput",[106,8325,1612],{"class":132},[106,8327,136],{"class":112},[106,8329,139],{"class":112},[106,8331,1395],{"class":224},[106,8333,52],{"class":112},[106,8335,3858],{"class":224},[106,8337,139],{"class":112},[106,8339,6988],{"class":112},[106,8341,8342,8344,8346],{"class":108,"line":1545},[106,8343,231],{"class":112},[106,8345,8293],{"class":116},[106,8347,120],{"class":112},[106,8349,8350],{"class":108,"line":1550},[106,8351,330],{"emptyLinePlaceholder":329},[106,8353,8354,8356,8359,8361,8363,8365,8367,8369],{"class":108,"line":1555},[106,8355,165],{"class":112},[106,8357,8358],{"class":116},"UButton",[106,8360,171],{"class":132},[106,8362,136],{"class":112},[106,8364,139],{"class":112},[106,8366,214],{"class":142},[106,8368,139],{"class":112},[106,8370,120],{"class":112},[106,8372,8373],{"class":108,"line":1561},[106,8374,8375],{"class":224},"      Soumettre\n",[106,8377,8378,8380,8382],{"class":108,"line":1570},[106,8379,231],{"class":112},[106,8381,8358],{"class":116},[106,8383,120],{"class":112},[106,8385,8386,8388,8390],{"class":108,"line":1575},[106,8387,241],{"class":112},[106,8389,8245],{"class":116},[106,8391,120],{"class":112},[106,8393,8394,8396,8398],{"class":108,"line":1584},[106,8395,251],{"class":112},[106,8397,117],{"class":116},[106,8399,120],{"class":112},[10,8401,8402,8403,8406,8407,8409,8410,8413],{},"J'utilise le formulaire tel qu'expliqué dans la documentation de Nuxt UI. Je n'ai pas vraiment choisi Yup pour la validation mais c'était la première option proposée. ",[29,8404,8405],{},"Nous verrons si c'est un bon choix à l'avenir."," Dans la méthode ",[103,8408,8282],{},", je fais simplement une requête ",[103,8411,8412],{},"fetch"," à l'API pour créer une nouvelle catégorie. Dans le prochain stream, nous l'améliorerons en gérant les états de succès et d'erreur.",[10,8415,8416,8417,8420],{},"Ce composant est placé dans un modal en utilisant le composant ",[103,8418,8419],{},"UModal"," et en écoutant l'événement de clic sur le bouton pour ouvrir le modal.",[10,8422,8423],{},[29,8424,8425],{},"Un point sur lequel je ne suis pas sûr est la gestion de la réponse de l'API. Devrais-je retourner la nouvelle catégorie et l'ajouter dans le tableau, ou devrais-je rafraîchir le tableau ? Pour le moment, j'ai opté pour la seconde solution.",[10,8427,8428],{},"Utiliser Nuxt UI m'aide à construire l'interface admin rapidement, sans avoir besoin de composants personnalisés.",[568,8430,8433,8437,8440,8444,8448,8454,8470,8473,8477,8480,8498,8686,8695,8707,8711,8718,8724,8886,8892,9348,9351,9358,9692,9696,9699,9706,9818,9825,9832,9842,10060,10063,10117,10120,10124,10127,10131,10135,10141,10157,10174,10255,10270,10295],{"icon":5511,"text":8431,"to":5513,"target":8432},"Voir le code source","_blank",[381,8434,8436],{"id":8435},"fin-du-stream","Fin du Stream",[10,8438,8439],{},"C'était définitivement un bon stream. Le projet avance sans trop de difficultés et c'est un plaisir de travailler dessus. J'attends avec impatience le prochain stream pour continuer à travailler sur l'interface admin et les formulaires. Nuxt UI et Nuxt UI Pro sont des changeurs de jeu : code simple, facile à utiliser, et je peux me concentrer sur les fonctionnalités de l'application.",[10,8441,5824,8442],{},[49,8443,5828],{"repo":5827},[54,8445,8447],{"id":8446},"continuons-linterface-admin","Continuons l'Interface Admin",[10,8449,8450],{},[5535,8451],{"alt":8452,"src":8453},"Thumbnail de mon quatrième live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-04.png",[10,8455,8456],{},[29,8457,8458,6828,8461,5553,8464,5558,8467,52],{},[14,8459,5546],{"href":5513,"rel":8460},[18],[14,8462,5552],{"href":5550,"rel":8463},[18],[14,8465,5557],{"href":5503,"rel":8466},[18],[14,8468,5562],{"href":5513,"rel":8469},[18],[10,8471,8472],{},"Lors du stream précédent, j'ai commencé à créer la page des catégories. J'ai mis en place les endpoints API pour obtenir et créer des catégories, et j'ai construit l'interface utilisateur pour afficher les catégories et le formulaire pour en créer une nouvelle. Dans ce stream, j'ai continué à travailler sur l'interface admin en ajoutant le formulaire de modification de catégorie et en créant une nouvelle page pour gérer les utilisateurs. J'ai également ajouté le bouncer pour sécuriser l'accès à la partie admin de l'application, tant pour le frontend que pour le backend. Voyons les travaux réalisés en détail.",[381,8474,8476],{"id":8475},"formulaire-de-modification-de-catégorie","Formulaire de Modification de Catégorie",[10,8478,8479],{},"Cette partie était très similaire au formulaire de création de catégorie. Au lieu de soumettre les données du formulaire à l'API pour créer une nouvelle catégorie, j'ai changé l'endpoint pour mettre à jour une catégorie existante.",[10,8481,8482,8483,8486,8487,8490,8491,8494,8495,8497],{},"J'ai créé le formulaire dans un nouveau composant ",[103,8484,8485],{},"components\u002Fcategories\u002FCategoryEditForm.vue"," (et renommé le précédent formulaire en ",[103,8488,8489],{},"CategoryCreateForm.vue","). J'ai également créé un nouvel endpoint ",[103,8492,8493],{},"server\u002Fapi\u002Fcategories\u002F[id].put.ts"," pour mettre à jour une catégorie. Cet endpoint met à jour une catégorie en utilisant l'",[103,8496,7100],{}," dans l'URL et le corps de la requête :",[96,8499,8501],{"className":261,"code":8500,"language":263,"meta":101,"style":101},"export default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  await useDrizzle().update(tables.categories).set({\n    slug: useSlugify(body.name),\n    name: body.name,\n  }).where(eq(tables.categories.id, params.id)).execute()\n\n  return sendNoContent(event, 200)\n})\n",[103,8502,8503,8525,8543,8547,8579,8599,8613,8659,8663,8680],{"__ignoreMap":101},[106,8504,8505,8507,8509,8511,8513,8515,8517,8519,8521,8523],{"class":108,"line":109},[106,8506,271],{"class":270},[106,8508,274],{"class":270},[106,8510,278],{"class":277},[106,8512,281],{"class":224},[106,8514,284],{"class":132},[106,8516,287],{"class":112},[106,8518,291],{"class":290},[106,8520,294],{"class":112},[106,8522,297],{"class":132},[106,8524,300],{"class":112},[106,8526,8527,8529,8531,8533,8535,8537,8539,8541],{"class":108,"line":123},[106,8528,305],{"class":132},[106,8530,308],{"class":224},[106,8532,311],{"class":112},[106,8534,314],{"class":270},[106,8536,317],{"class":277},[106,8538,281],{"class":116},[106,8540,291],{"class":224},[106,8542,324],{"class":116},[106,8544,8545],{"class":108,"line":162},[106,8546,330],{"emptyLinePlaceholder":329},[106,8548,8549,8551,8553,8555,8557,8560,8562,8564,8566,8568,8570,8572,8575,8577],{"class":108,"line":200},[106,8550,2348],{"class":270},[106,8552,7070],{"class":277},[106,8554,506],{"class":116},[106,8556,52],{"class":112},[106,8558,8559],{"class":277},"update",[106,8561,281],{"class":116},[106,8563,7155],{"class":224},[106,8565,52],{"class":112},[106,8567,6888],{"class":224},[106,8569,294],{"class":116},[106,8571,52],{"class":112},[106,8573,8574],{"class":277},"set",[106,8576,281],{"class":116},[106,8578,488],{"class":112},[106,8580,8581,8583,8585,8587,8589,8591,8593,8595,8597],{"class":108,"line":221},[106,8582,7107],{"class":116},[106,8584,496],{"class":112},[106,8586,7292],{"class":277},[106,8588,281],{"class":116},[106,8590,7297],{"class":224},[106,8592,52],{"class":112},[106,8594,3858],{"class":224},[106,8596,294],{"class":116},[106,8598,509],{"class":112},[106,8600,8601,8603,8605,8607,8609,8611],{"class":108,"line":228},[106,8602,493],{"class":116},[106,8604,496],{"class":112},[106,8606,308],{"class":224},[106,8608,52],{"class":112},[106,8610,3858],{"class":224},[106,8612,509],{"class":112},[106,8614,8615,8617,8619,8621,8624,8626,8629,8631,8633,8635,8637,8639,8641,8643,8646,8648,8650,8653,8655,8657],{"class":108,"line":238},[106,8616,514],{"class":112},[106,8618,294],{"class":116},[106,8620,52],{"class":112},[106,8622,8623],{"class":277},"where",[106,8625,281],{"class":116},[106,8627,8628],{"class":277},"eq",[106,8630,281],{"class":116},[106,8632,7155],{"class":224},[106,8634,52],{"class":112},[106,8636,6888],{"class":224},[106,8638,52],{"class":112},[106,8640,7100],{"class":224},[106,8642,355],{"class":112},[106,8644,8645],{"class":224}," params",[106,8647,52],{"class":112},[106,8649,7100],{"class":224},[106,8651,8652],{"class":116},"))",[106,8654,52],{"class":112},[106,8656,7330],{"class":277},[106,8658,2366],{"class":116},[106,8660,8661],{"class":108,"line":248},[106,8662,330],{"emptyLinePlaceholder":329},[106,8664,8665,8667,8669,8671,8673,8675,8678],{"class":108,"line":534},[106,8666,345],{"class":270},[106,8668,348],{"class":277},[106,8670,281],{"class":116},[106,8672,291],{"class":224},[106,8674,355],{"class":112},[106,8676,8677],{"class":358}," 200",[106,8679,324],{"class":116},[106,8681,8682,8684],{"class":108,"line":539},[106,8683,366],{"class":112},[106,8685,324],{"class":224},[10,8687,8688],{},[29,8689,8690,8691,8694],{},"Le code de statut 200 pourrait ne pas être le meilleur choix (204 est mieux pour un ",[103,8692,8693],{},"PUT","), mais je réfléchis encore à savoir si je devrais retourner la catégorie mise à jour ou non.",[10,8696,8697,8698,8703,8704],{},"J'ai également tenté de valider le corps de la requête en utilisant Yup, car c'était l'outil utilisé dans le frontend. Cependant, j'ai rapidement réalisé qu'il n'était pas possible de caster une chaîne en nombre. Je sais que myzod peut le faire, alors je l'ai essayé. J'ai rencontré ",[14,8699,8702],{"href":8700,"rel":8701},"https:\u002F\u002Fgithub.com\u002Funjs\u002Fh3\u002Fissues\u002F745",[18],"un problème"," et j'ai donc utilisé Zod. ",[29,8705,8706],{},"J'ai déplacé le frontend vers Zod en dehors du stream.",[381,8708,8710],{"id":8709},"page-des-utilisateurs","Page des Utilisateurs",[10,8712,8713,8714,8717],{},"En réalité, lorsqu'un utilisateur se connecte, les données associées de GitHub sont stockées dans la base de données (",[29,8715,8716],{},"en écrivant cette partie, je me demande si cela est nécessaire car cela signifie qu'une connexion est une opération d'écriture, ce qui est plus coûteux qu'une lecture",") et dans la session. Ce comportement est utile pour promouvoir l'utilisateur au statut d'admin ou pour bannir un utilisateur. Mais pour le moment, il n'y a pas de moyen de gérer les utilisateurs. Donc, j'ai créé une nouvelle page pour gérer les utilisateurs. Comme la page des catégories, la page affichera les utilisateurs dans un tableau. Cependant, il ne sera pas possible de créer ou de mettre à jour un utilisateur pour le moment.",[10,8719,8720,8721,2754],{},"Pour récupérer les utilisateurs depuis la base de données, j'ai créé l'endpoint API ",[103,8722,8723],{},"server\u002Fapi\u002Fusers\u002Findex.get.ts",[96,8725,8727],{"className":261,"code":8726,"language":263,"meta":101,"style":101},"export default defineEventHandler(async (event) => {\n  const users = await useDrizzle().select({\n    id: tables.users.id,\n    githubId: tables.users.githubId,\n    username: tables.users.username,\n    roleType: tables.users.roleType\n  }).from(tables.users)\n\n  return users\n})\n",[103,8728,8729,8751,8774,8792,8811,8831,8849,8869,8873,8880],{"__ignoreMap":101},[106,8730,8731,8733,8735,8737,8739,8741,8743,8745,8747,8749],{"class":108,"line":109},[106,8732,271],{"class":270},[106,8734,274],{"class":270},[106,8736,278],{"class":277},[106,8738,281],{"class":224},[106,8740,284],{"class":132},[106,8742,287],{"class":112},[106,8744,291],{"class":290},[106,8746,294],{"class":112},[106,8748,297],{"class":132},[106,8750,300],{"class":112},[106,8752,8753,8755,8758,8760,8762,8764,8766,8768,8770,8772],{"class":108,"line":123},[106,8754,305],{"class":132},[106,8756,8757],{"class":224}," users",[106,8759,311],{"class":112},[106,8761,314],{"class":270},[106,8763,7070],{"class":277},[106,8765,506],{"class":116},[106,8767,52],{"class":112},[106,8769,7077],{"class":277},[106,8771,281],{"class":116},[106,8773,488],{"class":112},[106,8775,8776,8778,8780,8782,8784,8786,8788,8790],{"class":108,"line":162},[106,8777,7086],{"class":116},[106,8779,496],{"class":112},[106,8781,7091],{"class":224},[106,8783,52],{"class":112},[106,8785,6882],{"class":224},[106,8787,52],{"class":112},[106,8789,7100],{"class":224},[106,8791,509],{"class":112},[106,8793,8794,8797,8799,8801,8803,8805,8807,8809],{"class":108,"line":200},[106,8795,8796],{"class":116},"    githubId",[106,8798,496],{"class":112},[106,8800,7091],{"class":224},[106,8802,52],{"class":112},[106,8804,6882],{"class":224},[106,8806,52],{"class":112},[106,8808,5727],{"class":224},[106,8810,509],{"class":112},[106,8812,8813,8816,8818,8820,8822,8824,8826,8829],{"class":108,"line":221},[106,8814,8815],{"class":116},"    username",[106,8817,496],{"class":112},[106,8819,7091],{"class":224},[106,8821,52],{"class":112},[106,8823,6882],{"class":224},[106,8825,52],{"class":112},[106,8827,8828],{"class":224},"username",[106,8830,509],{"class":112},[106,8832,8833,8836,8838,8840,8842,8844,8846],{"class":108,"line":228},[106,8834,8835],{"class":116},"    roleType",[106,8837,496],{"class":112},[106,8839,7091],{"class":224},[106,8841,52],{"class":112},[106,8843,6882],{"class":224},[106,8845,52],{"class":112},[106,8847,8848],{"class":224},"roleType\n",[106,8850,8851,8853,8855,8857,8859,8861,8863,8865,8867],{"class":108,"line":238},[106,8852,514],{"class":112},[106,8854,294],{"class":116},[106,8856,52],{"class":112},[106,8858,7150],{"class":277},[106,8860,281],{"class":116},[106,8862,7155],{"class":224},[106,8864,52],{"class":112},[106,8866,6882],{"class":224},[106,8868,324],{"class":116},[106,8870,8871],{"class":108,"line":248},[106,8872,330],{"emptyLinePlaceholder":329},[106,8874,8875,8877],{"class":108,"line":534},[106,8876,345],{"class":270},[106,8878,8879],{"class":224}," users\n",[106,8881,8882,8884],{"class":108,"line":539},[106,8883,366],{"class":112},[106,8885,324],{"class":224},[10,8887,8888,8889,8891],{},"Pour les afficher, j'utilise le composant ",[103,8890,7738],{}," de Nuxt UI Pro. Le code est très similaire à celui de la page des catégories :",[96,8893,8895],{"className":98,"code":8894,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst columns = [{\n  key: 'id',\n  label: '#'\n}, {\n  key: 'githubId',\n  label: 'GitHub ID',\n}, {\n  key: 'username',\n  label: 'Nom d\\'utilisateur',\n  sortable: true\n}, {\n  key: 'roleType',\n  label: 'Type de rôle'\n}]\nconst { data: users } = await useFetch('\u002Fapi\u002Fusers', {\n  deep: false,\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUDashboardPage>\n    \u003CUDashboardPanel grow>\n      \u003CUTable :columns=\"columns\" :rows=\"users\">\n        \u003Ctemplate #roleType-data=\"{ row }\">\n          \u003CUBadge variant=\"subtle\" :color=\"row.roleType === 'admin' ? 'amber' : 'primary'\">\n            {{ row.roleType }}\n          \u003C\u002FUBadge>\n        \u003C\u002Ftemplate>\n      \u003C\u002FUTable>\n    \u003C\u002FUDashboardPanel>\n  \u003C\u002FUDashboardPage>\n\u003C\u002Ftemplate>\n",[103,8896,8897,8917,8929,8943,8955,8961,8975,8990,8996,9010,9030,9038,9044,9059,9072,9078,9112,9122,9128,9136,9140,9148,9156,9166,9198,9225,9293,9298,9307,9316,9324,9332,9340],{"__ignoreMap":101},[106,8898,8899,8901,8903,8905,8907,8909,8911,8913,8915],{"class":108,"line":109},[106,8900,113],{"class":112},[106,8902,1189],{"class":116},[106,8904,1192],{"class":132},[106,8906,136],{"class":112},[106,8908,139],{"class":112},[106,8910,263],{"class":142},[106,8912,139],{"class":112},[106,8914,1203],{"class":132},[106,8916,120],{"class":112},[106,8918,8919,8921,8923,8925,8927],{"class":108,"line":123},[106,8920,1252],{"class":132},[106,8922,7529],{"class":224},[106,8924,136],{"class":112},[106,8926,2538],{"class":224},[106,8928,488],{"class":112},[106,8930,8931,8933,8935,8937,8939,8941],{"class":108,"line":162},[106,8932,7540],{"class":116},[106,8934,496],{"class":112},[106,8936,425],{"class":112},[106,8938,7100],{"class":142},[106,8940,754],{"class":112},[106,8942,509],{"class":112},[106,8944,8945,8947,8949,8951,8953],{"class":108,"line":200},[106,8946,7555],{"class":116},[106,8948,496],{"class":112},[106,8950,425],{"class":112},[106,8952,7562],{"class":142},[106,8954,431],{"class":112},[106,8956,8957,8959],{"class":108,"line":221},[106,8958,7569],{"class":112},[106,8960,300],{"class":112},[106,8962,8963,8965,8967,8969,8971,8973],{"class":108,"line":228},[106,8964,7540],{"class":116},[106,8966,496],{"class":112},[106,8968,425],{"class":112},[106,8970,5727],{"class":142},[106,8972,754],{"class":112},[106,8974,509],{"class":112},[106,8976,8977,8979,8981,8983,8986,8988],{"class":108,"line":238},[106,8978,7555],{"class":116},[106,8980,496],{"class":112},[106,8982,425],{"class":112},[106,8984,8985],{"class":142},"GitHub ID",[106,8987,754],{"class":112},[106,8989,509],{"class":112},[106,8991,8992,8994],{"class":108,"line":248},[106,8993,7569],{"class":112},[106,8995,300],{"class":112},[106,8997,8998,9000,9002,9004,9006,9008],{"class":108,"line":534},[106,8999,7540],{"class":116},[106,9001,496],{"class":112},[106,9003,425],{"class":112},[106,9005,8828],{"class":142},[106,9007,754],{"class":112},[106,9009,509],{"class":112},[106,9011,9012,9014,9016,9018,9021,9023,9026,9028],{"class":108,"line":539},[106,9013,7555],{"class":116},[106,9015,496],{"class":112},[106,9017,425],{"class":112},[106,9019,9020],{"class":142},"Nom d",[106,9022,2403],{"class":224},[106,9024,9025],{"class":142},"utilisateur",[106,9027,754],{"class":112},[106,9029,509],{"class":112},[106,9031,9032,9034,9036],{"class":108,"line":556},[106,9033,7604],{"class":116},[106,9035,496],{"class":112},[106,9037,7609],{"class":5652},[106,9039,9040,9042],{"class":108,"line":759},[106,9041,7569],{"class":112},[106,9043,300],{"class":112},[106,9045,9046,9048,9050,9052,9055,9057],{"class":108,"line":795},[106,9047,7540],{"class":116},[106,9049,496],{"class":112},[106,9051,425],{"class":112},[106,9053,9054],{"class":142},"roleType",[106,9056,754],{"class":112},[106,9058,509],{"class":112},[106,9060,9061,9063,9065,9067,9070],{"class":108,"line":803},[106,9062,7555],{"class":116},[106,9064,496],{"class":112},[106,9066,425],{"class":112},[106,9068,9069],{"class":142},"Type de rôle",[106,9071,431],{"class":112},[106,9073,9074,9076],{"class":108,"line":809},[106,9075,366],{"class":112},[106,9077,3725],{"class":224},[106,9079,9080,9082,9084,9086,9088,9091,9093,9095,9097,9099,9101,9103,9106,9108,9110],{"class":108,"line":814},[106,9081,1252],{"class":132},[106,9083,413],{"class":112},[106,9085,964],{"class":116},[106,9087,496],{"class":112},[106,9089,9090],{"class":224}," users ",[106,9092,366],{"class":112},[106,9094,311],{"class":112},[106,9096,314],{"class":270},[106,9098,7680],{"class":277},[106,9100,281],{"class":224},[106,9102,754],{"class":112},[106,9104,9105],{"class":142},"\u002Fapi\u002Fusers",[106,9107,754],{"class":112},[106,9109,355],{"class":112},[106,9111,300],{"class":112},[106,9113,9114,9116,9118,9120],{"class":108,"line":830},[106,9115,7698],{"class":116},[106,9117,496],{"class":112},[106,9119,7703],{"class":5652},[106,9121,509],{"class":112},[106,9123,9124,9126],{"class":108,"line":835},[106,9125,366],{"class":112},[106,9127,324],{"class":224},[106,9129,9130,9132,9134],{"class":108,"line":840},[106,9131,251],{"class":112},[106,9133,1189],{"class":116},[106,9135,120],{"class":112},[106,9137,9138],{"class":108,"line":845},[106,9139,330],{"emptyLinePlaceholder":329},[106,9141,9142,9144,9146],{"class":108,"line":862},[106,9143,113],{"class":112},[106,9145,117],{"class":116},[106,9147,120],{"class":112},[106,9149,9150,9152,9154],{"class":108,"line":1469},[106,9151,126],{"class":112},[106,9153,7443],{"class":116},[106,9155,120],{"class":112},[106,9157,9158,9160,9162,9164],{"class":108,"line":1486},[106,9159,165],{"class":112},[106,9161,7452],{"class":116},[106,9163,7455],{"class":132},[106,9165,120],{"class":112},[106,9167,9168,9170,9172,9174,9176,9178,9180,9182,9184,9186,9188,9190,9192,9194,9196],{"class":108,"line":1497},[106,9169,3376],{"class":112},[106,9171,7738],{"class":116},[106,9173,2754],{"class":112},[106,9175,7743],{"class":132},[106,9177,136],{"class":112},[106,9179,139],{"class":112},[106,9181,7743],{"class":224},[106,9183,139],{"class":112},[106,9185,2754],{"class":112},[106,9187,7756],{"class":132},[106,9189,136],{"class":112},[106,9191,139],{"class":112},[106,9193,6882],{"class":224},[106,9195,139],{"class":112},[106,9197,120],{"class":112},[106,9199,9200,9203,9205,9207,9210,9212,9214,9216,9219,9221,9223],{"class":108,"line":1508},[106,9201,9202],{"class":112},"        \u003C",[106,9204,117],{"class":116},[106,9206,7846],{"class":112},[106,9208,9209],{"class":132},"roleType-data",[106,9211,136],{"class":112},[106,9213,139],{"class":112},[106,9215,1285],{"class":112},[106,9217,9218],{"class":224}," row ",[106,9220,366],{"class":112},[106,9222,139],{"class":112},[106,9224,120],{"class":112},[106,9226,9227,9230,9233,9236,9238,9240,9243,9245,9247,9250,9252,9254,9257,9259,9261,9264,9266,9268,9270,9273,9275,9278,9280,9283,9285,9288,9291],{"class":108,"line":1523},[106,9228,9229],{"class":112},"          \u003C",[106,9231,9232],{"class":116},"UBadge",[106,9234,9235],{"class":132}," variant",[106,9237,136],{"class":112},[106,9239,139],{"class":112},[106,9241,9242],{"class":142},"subtle",[106,9244,139],{"class":112},[106,9246,2754],{"class":112},[106,9248,9249],{"class":132},"color",[106,9251,136],{"class":112},[106,9253,139],{"class":112},[106,9255,9256],{"class":224},"row",[106,9258,52],{"class":112},[106,9260,9054],{"class":224},[106,9262,9263],{"class":112}," === ",[106,9265,754],{"class":112},[106,9267,7408],{"class":142},[106,9269,754],{"class":112},[106,9271,9272],{"class":112}," ? ",[106,9274,754],{"class":112},[106,9276,9277],{"class":142},"amber",[106,9279,754],{"class":112},[106,9281,9282],{"class":112}," : ",[106,9284,754],{"class":112},[106,9286,9287],{"class":142},"primary",[106,9289,9290],{"class":112},"'\"",[106,9292,120],{"class":112},[106,9294,9295],{"class":108,"line":1538},[106,9296,9297],{"class":224},"            {{ row.roleType }}\n",[106,9299,9300,9303,9305],{"class":108,"line":1545},[106,9301,9302],{"class":112},"          \u003C\u002F",[106,9304,9232],{"class":116},[106,9306,120],{"class":112},[106,9308,9309,9312,9314],{"class":108,"line":1550},[106,9310,9311],{"class":112},"        \u003C\u002F",[106,9313,117],{"class":116},[106,9315,120],{"class":112},[106,9317,9318,9320,9322],{"class":108,"line":1555},[106,9319,3439],{"class":112},[106,9321,7738],{"class":116},[106,9323,120],{"class":112},[106,9325,9326,9328,9330],{"class":108,"line":1561},[106,9327,231],{"class":112},[106,9329,7452],{"class":116},[106,9331,120],{"class":112},[106,9333,9334,9336,9338],{"class":108,"line":1570},[106,9335,241],{"class":112},[106,9337,7443],{"class":116},[106,9339,120],{"class":112},[106,9341,9342,9344,9346],{"class":108,"line":1575},[106,9343,251],{"class":112},[106,9345,117],{"class":116},[106,9347,120],{"class":112},[10,9349,9350],{},"Comme prévu, le code est très similaire à celui de la page des catégories.",[10,9352,9353,9354,9357],{},"Maintenant que nous avons deux pages, une pour les catégories et une pour les utilisateurs, j'ai ajouté une barre latérale pour naviguer entre elles. Rien de compliqué ici, j'ai simplement utilisé le composant ",[103,9355,9356],{},"UDashboardSidebar"," de Nuxt UI Pro et ajouté les liens vers les pages des catégories et des utilisateurs :",[96,9359,9361],{"className":98,"code":9360,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst links = [{\n  id: 'categories',\n  label: 'Catégories',\n  icon: 'i-heroicons-tag',\n  to: '\u002Fadmin\u002Fcategories',\n  tooltip: {\n    text: 'Catégories',\n  }\n}, {\n  id: 'users',\n  label: 'Utilisateurs',\n  icon: 'i-heroicons-user-group',\n  to: '\u002Fadmin\u002Fusers',\n  tooltip: {\n    text: 'Utilisateurs',\n  }\n}]\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUDashboardLayout>\n    \u003CUDashboardPanel>\n      \u003CUDashboardSidebar>\n        \u003CUDashboardSidebarLinks :links=\"links\" \u002F>\n      \u003C\u002FUDashboardSidebar>\n    \u003C\u002FUDashboardPanel>\n\n    \u003Cslot \u002F>\n  \u003C\u002FUDashboardLayout>\n\u003C\u002Ftemplate>\n",[103,9362,9363,9383,9396,9411,9425,9441,9457,9466,9481,9485,9491,9505,9520,9535,9550,9558,9572,9576,9582,9590,9594,9602,9610,9618,9626,9648,9656,9664,9668,9676,9684],{"__ignoreMap":101},[106,9364,9365,9367,9369,9371,9373,9375,9377,9379,9381],{"class":108,"line":109},[106,9366,113],{"class":112},[106,9368,1189],{"class":116},[106,9370,1192],{"class":132},[106,9372,136],{"class":112},[106,9374,139],{"class":112},[106,9376,263],{"class":142},[106,9378,139],{"class":112},[106,9380,1203],{"class":132},[106,9382,120],{"class":112},[106,9384,9385,9387,9390,9392,9394],{"class":108,"line":123},[106,9386,1252],{"class":132},[106,9388,9389],{"class":224}," links ",[106,9391,136],{"class":112},[106,9393,2538],{"class":224},[106,9395,488],{"class":112},[106,9397,9398,9401,9403,9405,9407,9409],{"class":108,"line":162},[106,9399,9400],{"class":116},"  id",[106,9402,496],{"class":112},[106,9404,425],{"class":112},[106,9406,6888],{"class":142},[106,9408,754],{"class":112},[106,9410,509],{"class":112},[106,9412,9413,9415,9417,9419,9421,9423],{"class":108,"line":200},[106,9414,7555],{"class":116},[106,9416,496],{"class":112},[106,9418,425],{"class":112},[106,9420,7829],{"class":142},[106,9422,754],{"class":112},[106,9424,509],{"class":112},[106,9426,9427,9430,9432,9434,9437,9439],{"class":108,"line":221},[106,9428,9429],{"class":116},"  icon",[106,9431,496],{"class":112},[106,9433,425],{"class":112},[106,9435,9436],{"class":142},"i-heroicons-tag",[106,9438,754],{"class":112},[106,9440,509],{"class":112},[106,9442,9443,9446,9448,9450,9453,9455],{"class":108,"line":228},[106,9444,9445],{"class":116},"  to",[106,9447,496],{"class":112},[106,9449,425],{"class":112},[106,9451,9452],{"class":142},"\u002Fadmin\u002Fcategories",[106,9454,754],{"class":112},[106,9456,509],{"class":112},[106,9458,9459,9462,9464],{"class":108,"line":238},[106,9460,9461],{"class":116},"  tooltip",[106,9463,496],{"class":112},[106,9465,300],{"class":112},[106,9467,9468,9471,9473,9475,9477,9479],{"class":108,"line":248},[106,9469,9470],{"class":116},"    text",[106,9472,496],{"class":112},[106,9474,425],{"class":112},[106,9476,7829],{"class":142},[106,9478,754],{"class":112},[106,9480,509],{"class":112},[106,9482,9483],{"class":108,"line":534},[106,9484,806],{"class":112},[106,9486,9487,9489],{"class":108,"line":539},[106,9488,7569],{"class":112},[106,9490,300],{"class":112},[106,9492,9493,9495,9497,9499,9501,9503],{"class":108,"line":556},[106,9494,9400],{"class":116},[106,9496,496],{"class":112},[106,9498,425],{"class":112},[106,9500,6882],{"class":142},[106,9502,754],{"class":112},[106,9504,509],{"class":112},[106,9506,9507,9509,9511,9513,9516,9518],{"class":108,"line":759},[106,9508,7555],{"class":116},[106,9510,496],{"class":112},[106,9512,425],{"class":112},[106,9514,9515],{"class":142},"Utilisateurs",[106,9517,754],{"class":112},[106,9519,509],{"class":112},[106,9521,9522,9524,9526,9528,9531,9533],{"class":108,"line":795},[106,9523,9429],{"class":116},[106,9525,496],{"class":112},[106,9527,425],{"class":112},[106,9529,9530],{"class":142},"i-heroicons-user-group",[106,9532,754],{"class":112},[106,9534,509],{"class":112},[106,9536,9537,9539,9541,9543,9546,9548],{"class":108,"line":803},[106,9538,9445],{"class":116},[106,9540,496],{"class":112},[106,9542,425],{"class":112},[106,9544,9545],{"class":142},"\u002Fadmin\u002Fusers",[106,9547,754],{"class":112},[106,9549,509],{"class":112},[106,9551,9552,9554,9556],{"class":108,"line":809},[106,9553,9461],{"class":116},[106,9555,496],{"class":112},[106,9557,300],{"class":112},[106,9559,9560,9562,9564,9566,9568,9570],{"class":108,"line":814},[106,9561,9470],{"class":116},[106,9563,496],{"class":112},[106,9565,425],{"class":112},[106,9567,9515],{"class":142},[106,9569,754],{"class":112},[106,9571,509],{"class":112},[106,9573,9574],{"class":108,"line":830},[106,9575,806],{"class":112},[106,9577,9578,9580],{"class":108,"line":835},[106,9579,366],{"class":112},[106,9581,3725],{"class":224},[106,9583,9584,9586,9588],{"class":108,"line":840},[106,9585,251],{"class":112},[106,9587,1189],{"class":116},[106,9589,120],{"class":112},[106,9591,9592],{"class":108,"line":845},[106,9593,330],{"emptyLinePlaceholder":329},[106,9595,9596,9598,9600],{"class":108,"line":862},[106,9597,113],{"class":112},[106,9599,117],{"class":116},[106,9601,120],{"class":112},[106,9603,9604,9606,9608],{"class":108,"line":1469},[106,9605,126],{"class":112},[106,9607,6976],{"class":116},[106,9609,120],{"class":112},[106,9611,9612,9614,9616],{"class":108,"line":1486},[106,9613,165],{"class":112},[106,9615,7452],{"class":116},[106,9617,120],{"class":112},[106,9619,9620,9622,9624],{"class":108,"line":1497},[106,9621,3376],{"class":112},[106,9623,9356],{"class":116},[106,9625,120],{"class":112},[106,9627,9628,9630,9633,9635,9638,9640,9642,9644,9646],{"class":108,"line":1508},[106,9629,9202],{"class":112},[106,9631,9632],{"class":116},"UDashboardSidebarLinks",[106,9634,2754],{"class":112},[106,9636,9637],{"class":132},"links",[106,9639,136],{"class":112},[106,9641,139],{"class":112},[106,9643,9637],{"class":224},[106,9645,139],{"class":112},[106,9647,6988],{"class":112},[106,9649,9650,9652,9654],{"class":108,"line":1523},[106,9651,3439],{"class":112},[106,9653,9356],{"class":116},[106,9655,120],{"class":112},[106,9657,9658,9660,9662],{"class":108,"line":1538},[106,9659,231],{"class":112},[106,9661,7452],{"class":116},[106,9663,120],{"class":112},[106,9665,9666],{"class":108,"line":1545},[106,9667,330],{"emptyLinePlaceholder":329},[106,9669,9670,9672,9674],{"class":108,"line":1550},[106,9671,165],{"class":112},[106,9673,6985],{"class":116},[106,9675,6988],{"class":112},[106,9677,9678,9680,9682],{"class":108,"line":1555},[106,9679,241],{"class":112},[106,9681,6976],{"class":116},[106,9683,120],{"class":112},[106,9685,9686,9688,9690],{"class":108,"line":1561},[106,9687,251],{"class":112},[106,9689,117],{"class":116},[106,9691,120],{"class":112},[54,9693,9695],{"id":9694},"sécuriser-la-partie-administration","Sécuriser la Partie Administration",[10,9697,9698],{},"Lors de la construction de la partie administration d'une application, il est crucial de sécuriser et de restreindre l'accès, tant pour le frontend que pour le backend. Cela signifie que seuls les utilisateurs autorisés peuvent visualiser et manipuler les données.",[10,9700,9701,9702,9705],{},"Pour restreindre l'accès à la partie UI de l'administration, c'est-à-dire le frontend, j'ai utilisé un middleware. Il s'agit d'une fonction qui s'exécute avant la navigation vers la page et peut être utilisée pour vérifier si l'utilisateur est authentifié et dispose des bonnes permissions. Dans ce cas, j'ai créé un middleware ",[103,9703,9704],{},"middleware\u002Fadmin.ts"," pour vérifier si l'utilisateur est authentifié et a le rôle d'admin :",[96,9707,9709],{"className":261,"code":9708,"language":263,"meta":101,"style":101},"export default defineNuxtRouteMiddleware(() => {\n  const { user } = useUserSession()\n\n  const isAdmin = user.value?.roleType === 'admin'\n\n  if (!isAdmin)\n    return redirectTo('\u002F')\n})\n",[103,9710,9711,9728,9745,9749,9778,9782,9795,9812],{"__ignoreMap":101},[106,9712,9713,9715,9717,9720,9722,9724,9726],{"class":108,"line":109},[106,9714,271],{"class":270},[106,9716,274],{"class":270},[106,9718,9719],{"class":277}," defineNuxtRouteMiddleware",[106,9721,281],{"class":224},[106,9723,506],{"class":112},[106,9725,297],{"class":132},[106,9727,300],{"class":112},[106,9729,9730,9732,9734,9736,9738,9740,9743],{"class":108,"line":123},[106,9731,305],{"class":132},[106,9733,413],{"class":112},[106,9735,5679],{"class":224},[106,9737,419],{"class":112},[106,9739,311],{"class":112},[106,9741,9742],{"class":277}," useUserSession",[106,9744,2366],{"class":116},[106,9746,9747],{"class":108,"line":162},[106,9748,330],{"emptyLinePlaceholder":329},[106,9750,9751,9753,9756,9758,9760,9762,9765,9768,9770,9772,9774,9776],{"class":108,"line":200},[106,9752,305],{"class":132},[106,9754,9755],{"class":224}," isAdmin",[106,9757,311],{"class":112},[106,9759,5679],{"class":224},[106,9761,52],{"class":112},[106,9763,9764],{"class":224},"value",[106,9766,9767],{"class":112},"?.",[106,9769,9054],{"class":224},[106,9771,5318],{"class":112},[106,9773,425],{"class":112},[106,9775,7408],{"class":142},[106,9777,431],{"class":112},[106,9779,9780],{"class":108,"line":221},[106,9781,330],{"emptyLinePlaceholder":329},[106,9783,9784,9786,9788,9790,9793],{"class":108,"line":228},[106,9785,697],{"class":270},[106,9787,287],{"class":116},[106,9789,702],{"class":112},[106,9791,9792],{"class":224},"isAdmin",[106,9794,324],{"class":116},[106,9796,9797,9799,9802,9804,9806,9808,9810],{"class":108,"line":238},[106,9798,5749],{"class":270},[106,9800,9801],{"class":277}," redirectTo",[106,9803,281],{"class":116},[106,9805,754],{"class":112},[106,9807,5763],{"class":142},[106,9809,754],{"class":112},[106,9811,324],{"class":116},[106,9813,9814,9816],{"class":108,"line":248},[106,9815,366],{"class":112},[106,9817,324],{"class":224},[10,9819,9820],{},[29,9821,9822,9823,52],{},"Le middleware doit être utilisé sur les pages nécessitant une protection en utilisant ",[103,9824,7392],{},[10,9826,9827,9828,9831],{},"Je récupère l'utilisateur depuis la session en utilisant ",[103,9829,9830],{},"useUserSession"," et vérifie si l'utilisateur a le rôle d'admin. Sinon, je redirige l'utilisateur vers la page d'accueil.",[10,9833,9834,9835,9838,9839,2754],{},"Le backend est beaucoup plus critique que le frontend, mais il n'est pas plus difficile à sécuriser. Pour ce faire, j'ai créé un utilitaire nommé ",[103,9836,9837],{},"requireAdminUser"," dans le fichier ",[103,9840,9841],{},"server\u002Futils\u002Fsession.ts",[96,9843,9845],{"className":261,"code":9844,"language":263,"meta":101,"style":101},"import type { UserSessionRequired } from '#auth-utils'\nimport type { H3Event } from 'h3'\n\nexport async function requireAdminUser(event: H3Event): Promise\u003CUserSessionRequired> {\n  const userSession = await getUserSession(event)\n\n  if (!userSession.user || userSession.user.roleType !== 'admin') {\n    throw createError({\n      statusCode: 401,\n      message: 'Unauthorized',\n    })\n  }\n\n  return userSession as UserSessionRequired\n}\n",[103,9846,9847,9869,9889,9893,9928,9948,9952,9994,10004,10016,10031,10037,10041,10045,10056],{"__ignoreMap":101},[106,9848,9849,9851,9853,9855,9858,9860,9862,9864,9867],{"class":108,"line":109},[106,9850,410],{"class":270},[106,9852,171],{"class":270},[106,9854,413],{"class":112},[106,9856,9857],{"class":224}," UserSessionRequired",[106,9859,419],{"class":112},[106,9861,422],{"class":270},[106,9863,425],{"class":112},[106,9865,9866],{"class":142},"#auth-utils",[106,9868,431],{"class":112},[106,9870,9871,9873,9875,9877,9879,9881,9883,9885,9887],{"class":108,"line":123},[106,9872,410],{"class":270},[106,9874,171],{"class":270},[106,9876,413],{"class":112},[106,9878,6180],{"class":224},[106,9880,419],{"class":112},[106,9882,422],{"class":270},[106,9884,425],{"class":112},[106,9886,381],{"class":142},[106,9888,431],{"class":112},[106,9890,9891],{"class":108,"line":162},[106,9892,330],{"emptyLinePlaceholder":329},[106,9894,9895,9897,9900,9902,9905,9907,9909,9911,9913,9916,9919,9921,9924,9926],{"class":108,"line":200},[106,9896,271],{"class":270},[106,9898,9899],{"class":132}," async",[106,9901,1360],{"class":132},[106,9903,9904],{"class":277}," requireAdminUser",[106,9906,281],{"class":112},[106,9908,291],{"class":290},[106,9910,496],{"class":112},[106,9912,6180],{"class":1041},[106,9914,9915],{"class":112},"):",[106,9917,9918],{"class":1041}," Promise",[106,9920,113],{"class":112},[106,9922,9923],{"class":1041},"UserSessionRequired",[106,9925,3308],{"class":112},[106,9927,300],{"class":112},[106,9929,9930,9932,9935,9937,9939,9942,9944,9946],{"class":108,"line":221},[106,9931,305],{"class":132},[106,9933,9934],{"class":224}," userSession",[106,9936,311],{"class":112},[106,9938,314],{"class":270},[106,9940,9941],{"class":277}," getUserSession",[106,9943,281],{"class":116},[106,9945,291],{"class":224},[106,9947,324],{"class":116},[106,9949,9950],{"class":108,"line":228},[106,9951,330],{"emptyLinePlaceholder":329},[106,9953,9954,9956,9958,9960,9963,9965,9968,9971,9973,9975,9977,9979,9981,9984,9986,9988,9990,9992],{"class":108,"line":238},[106,9955,697],{"class":270},[106,9957,287],{"class":116},[106,9959,702],{"class":112},[106,9961,9962],{"class":224},"userSession",[106,9964,52],{"class":112},[106,9966,9967],{"class":224},"user",[106,9969,9970],{"class":112}," ||",[106,9972,9934],{"class":224},[106,9974,52],{"class":112},[106,9976,9967],{"class":224},[106,9978,52],{"class":112},[106,9980,9054],{"class":224},[106,9982,9983],{"class":112}," !==",[106,9985,425],{"class":112},[106,9987,7408],{"class":142},[106,9989,754],{"class":112},[106,9991,713],{"class":116},[106,9993,488],{"class":112},[106,9995,9996,9998,10000,10002],{"class":108,"line":248},[106,9997,720],{"class":270},[106,9999,723],{"class":277},[106,10001,281],{"class":116},[106,10003,488],{"class":112},[106,10005,10006,10009,10011,10014],{"class":108,"line":534},[106,10007,10008],{"class":116},"      statusCode",[106,10010,496],{"class":112},[106,10012,10013],{"class":358}," 401",[106,10015,509],{"class":112},[106,10017,10018,10020,10022,10024,10027,10029],{"class":108,"line":539},[106,10019,744],{"class":116},[106,10021,496],{"class":112},[106,10023,425],{"class":112},[106,10025,10026],{"class":142},"Unauthorized",[106,10028,754],{"class":112},[106,10030,509],{"class":112},[106,10032,10033,10035],{"class":108,"line":556},[106,10034,798],{"class":112},[106,10036,324],{"class":116},[106,10038,10039],{"class":108,"line":759},[106,10040,806],{"class":112},[106,10042,10043],{"class":108,"line":795},[106,10044,330],{"emptyLinePlaceholder":329},[106,10046,10047,10049,10051,10053],{"class":108,"line":803},[106,10048,345],{"class":270},[106,10050,9934],{"class":224},[106,10052,3099],{"class":270},[106,10054,10055],{"class":1041}," UserSessionRequired\n",[106,10057,10058],{"class":108,"line":809},[106,10059,1558],{"class":112},[10,10061,10062],{},"Cette fonction vérifie si l'utilisateur est authentifié et possède le rôle d'admin. Sinon, elle renvoie une erreur avec un code de statut 401. Cette fonction est utilisée dans les endpoints API pour les sécuriser :",[96,10064,10066],{"className":261,"code":10065,"language":263,"meta":101,"style":101},"export default defineEventHandler(async (event) => {\n  await requireAdminUser(event)\n\n  \u002F\u002F Code pour gérer la requête\n})\n",[103,10067,10068,10090,10102,10106,10111],{"__ignoreMap":101},[106,10069,10070,10072,10074,10076,10078,10080,10082,10084,10086,10088],{"class":108,"line":109},[106,10071,271],{"class":270},[106,10073,274],{"class":270},[106,10075,278],{"class":277},[106,10077,281],{"class":224},[106,10079,284],{"class":132},[106,10081,287],{"class":112},[106,10083,291],{"class":290},[106,10085,294],{"class":112},[106,10087,297],{"class":132},[106,10089,300],{"class":112},[106,10091,10092,10094,10096,10098,10100],{"class":108,"line":123},[106,10093,2348],{"class":270},[106,10095,9904],{"class":277},[106,10097,281],{"class":116},[106,10099,291],{"class":224},[106,10101,324],{"class":116},[106,10103,10104],{"class":108,"line":162},[106,10105,330],{"emptyLinePlaceholder":329},[106,10107,10108],{"class":108,"line":200},[106,10109,10110],{"class":335},"  \u002F\u002F Code pour gérer la requête\n",[106,10112,10113,10115],{"class":108,"line":221},[106,10114,366],{"class":112},[106,10116,324],{"class":224},[10,10118,10119],{},"Avec ces deux fonctions, le frontend et le backend de la partie admin sont sécurisés. Seuls les utilisateurs authentifiés avec le rôle d'admin peuvent accéder à la partie administration de l'application.",[381,10121,10123],{"id":10122},"à-venir","À Venir",[10,10125,10126],{},"Ce stream a été très productif et a mis en place des éléments importants pour l'avenir. Le prochain stream sera consacré aux modules et au créateur de thèmes. J'espère vraiment pouvoir mettre cela en production bientôt. Je suis vraiment impatient de voir les retours de la communauté.",[10,10128,5824,10129],{},[49,10130,5828],{"repo":5827},[54,10132,10134],{"id":10133},"récupérer-une-api-externe-pour-les-modules","Récupérer une API externe pour les modules",[10,10136,10137],{},[5535,10138],{"alt":10139,"src":10140},"Vignette de mon cinquième direct.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-18.png",[10,10142,10143],{},[29,10144,10145,6828,10148,6832,10151,5558,10154,52],{},[14,10146,5546],{"href":5513,"rel":10147},[18],[14,10149,5552],{"href":5550,"rel":10150},[18],[14,10152,5557],{"href":5503,"rel":10153},[18],[14,10155,5562],{"href":5513,"rel":10156},[18],[10,10158,10159,10160,10163,10164,10166,10167,10170,10171,10173],{},"Avant de continuer et d'implémenter la page des modules, j'ai décidé de modifier le comportement de ",[103,10161,10162],{},"useFetch"," sur les pages actuelles des Catégories et des Utilisateurs. En effet, le bloc composable ",[103,10165,10162],{}," bloque la navigation côté client jusqu'à ce que les données soient récupérées. Cela donne la sensation d'une application lente. Pour éviter cela, je peux utiliser le drapeau ",[103,10168,10169],{},"lazy",". Côté serveur, cela ne change rien et la page sera toujours servie avec les données. Côté client, la navigation sera instantanée et j'afficherai un squelette pendant que les données sont en cours de récupération en utilisant le ",[103,10172,7776],{}," ref.",[96,10175,10177],{"className":261,"code":10176,"language":263,"meta":101,"style":101},"const { data, pending } = await useFetch('\u002Fapi\u002Fcategories', {\n  deep: false,\n  lazy: true, \u002F\u002F \u003C= Ne bloque pas la navigation\n  default: () => []\n})\n",[103,10178,10179,10211,10221,10235,10249],{"__ignoreMap":101},[106,10180,10181,10183,10185,10187,10189,10191,10193,10195,10197,10199,10201,10203,10205,10207,10209],{"class":108,"line":109},[106,10182,1252],{"class":132},[106,10184,413],{"class":112},[106,10186,964],{"class":224},[106,10188,355],{"class":112},[106,10190,7671],{"class":224},[106,10192,366],{"class":112},[106,10194,311],{"class":112},[106,10196,314],{"class":270},[106,10198,7680],{"class":277},[106,10200,281],{"class":224},[106,10202,754],{"class":112},[106,10204,7687],{"class":142},[106,10206,754],{"class":112},[106,10208,355],{"class":112},[106,10210,300],{"class":112},[106,10212,10213,10215,10217,10219],{"class":108,"line":123},[106,10214,7698],{"class":116},[106,10216,496],{"class":112},[106,10218,7703],{"class":5652},[106,10220,509],{"class":112},[106,10222,10223,10226,10228,10230,10232],{"class":108,"line":162},[106,10224,10225],{"class":116},"  lazy",[106,10227,496],{"class":112},[106,10229,5653],{"class":5652},[106,10231,355],{"class":112},[106,10233,10234],{"class":335}," \u002F\u002F \u003C= Ne bloque pas la navigation\n",[106,10236,10237,10240,10242,10244,10246],{"class":108,"line":200},[106,10238,10239],{"class":277},"  default",[106,10241,496],{"class":112},[106,10243,7052],{"class":112},[106,10245,297],{"class":132},[106,10247,10248],{"class":224}," []\n",[106,10250,10251,10253],{"class":108,"line":221},[106,10252,366],{"class":112},[106,10254,324],{"class":224},[10,10256,10257,10258,10260,10261,10263,10264,10266,10267,10269],{},"Le ",[103,10259,7776],{}," ref est un booléen qui indique si les données sont en cours de récupération ou non. Je le passe simplement aux props ",[103,10262,7769],{}," du composant ",[103,10265,7738],{}," de Nuxt UI Pro et ",[29,10268,5436],{}," ! L'utilisateur verra un loader pendant que les données sont en cours de récupération et la navigation sera instantanée.",[3296,10271,5495,10274,5495,10286],{"flex":10272,"md":10273,"gap-x-2":101},"~ col","flex-row",[10275,10276,10277,10278,10277,10282,5495],"figure",{},"\n    ",[5535,10279],{"src":10280,"alt":10281,"loading":10169},"https:\u002F\u002Fimages.soubiran.dev\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fblocking-fetch.gif","Sans drapeau",[10283,10284,10285],"figcaption",{},"Sans drapeau lazy (blocage de la navigation)",[10275,10287,10277,10288,10277,10292,5495],{},[5535,10289],{"src":10290,"alt":10291,"loading":10169},"https:\u002F\u002Fimages.soubiran.dev\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Flazy-fetch.gif","Avec drapeau lazy",[10283,10293,10294],{},"Avec drapeau lazy (navigation instantanée)",[568,10296,10299,10303,10312,10319,10323,10334,10337,10341,10355,10364,10435,10449,10570,10580,10589,10810,10818,10821,10860,10873,10892,10895,11147,11153,11158,11161,11165,11172,11328,11331,11334,11337,11342,11345,11348,11352,11357,11360,11372,11375,11386,11389,11395,11412,11415,11445,11448,11855,11871,11878,12529,12532,12536,12542,12558,12561,12565,12570,12573,13056,13066,13084,13095,13098,13208,13212,13215,13222,13673,13689,13698,13702,13707,13717,13723,13892,13895,13920,13926,13932,15234,15240,15442,15445,15572,15578,15692,15698,15715,16087,16103,16106,16109,16112,16116,16122,16138,16141,16152,16158,16162,16187,16190,16234,16254,16277,16280,16502,16507,16722,16725,16729,16732,16735,16792,16799,16810,16823,17013,17023,17027,17032,17037,17044,17070,17073,17079,17091,17177,17180,17186,17189,17233,17239,17242,17245,17434,17437,17441,17444,17447,17451,17456,17459,17465,17473,17477,17480,17483,17536,17546,17553,17574,17577,17644,17653,17657,17666,17746,17750,17753,18037,18040,18043,18341,18344,18350,18353,18356,18360,18366,18382,18385,18388,18391,18395,18401,18407,18416,18743,18747,18750,18754,18761,18935,18939,18946,19090,19093,19097,19100,19421,19426,19430,19435,19450,19462,19465,19599,19606,19617,19626,19916,19925,19928,20075,20082,20085,20088],{"icon":5501,"text":10297,"to":10298,"target":8432},"En savoir plus sur useFetch","https:\u002F\u002Fyoutu.be\u002Fb1S5os65Urs",[381,10300,10302],{"id":10301},"page-des-modules","Page des Modules",[10,10304,10305,10306,10311],{},"Les modules sont ",[14,10307,10310],{"href":10308,"rel":10309},"https:\u002F\u002Fnuxt.com\u002Fmodules",[18],"des modules Nuxt",". Ils seront liés aux thèmes et seront utilisés pour filtrer les thèmes. Imaginez que vous recherchez un thème avec un bon SEO et précisément un sitemap, vous pourrez filtrer les thèmes en fonction des modules qu'ils utilisent. C'est l'objectif de la fonctionnalité des modules. Cela aidera également l'utilisateur à comprendre comment le thème est construit et quelles sont les fonctionnalités.",[10,10313,10314,10315,10318],{},"La première chose que je fais est d'ajouter une nouvelle page ",[103,10316,10317],{},"pages\u002Fadmin\u002Fmodules.vue",". Ensuite, j'ajoute le tableau pour afficher les modules et les métadonnées de la page pour utiliser la mise en page admin et le middleware admin. C'est similaire aux pages des catégories et des utilisateurs, donc je ne vais pas le détailler ici.",[6078,10320,10322],{"id":10321},"remplir-la-base-de-données","Remplir la Base de Données",[10,10324,10325,10326,10333],{},"La question principale est : comment remplir la base de données avec les modules ? Pour les catégories, je les crée manuellement. Cela convient car il n'y a pas beaucoup de catégories et il n'y a pas de moyen d'obtenir une liste prédéfinie, mais pour les modules, c'est différent. Les modules sont créés par l'équipe principale ou la communauté et il y en a beaucoup. De plus, la liste n'est pas statique et peut changer au fil du temps. Donc, je dois trouver une API pour récupérer les modules. Heureusement, Nuxt a une API pour obtenir la liste des modules : ",[14,10327,10330],{"href":10328,"rel":10329},"https:\u002F\u002Fapi.nuxt.com\u002Fmodules",[18],[103,10331,10332],{},"api.nuxt.com\u002Fmodules",". Cette API n'est pas documentée mais elle est publique et je peux l'utiliser.",[10,10335,10336],{},"Grâce à cette API, je pourrai récupérer les données, les assainir pour ne garder que les informations utiles et les stocker dans la base de données. Je vais créer un point de terminaison, accessible uniquement à l'admin, pour récupérer les modules et les stocker dans la base de données. Un deuxième point de terminaison sera disponible pour récupérer les modules depuis la base de données. Cela sera utile pour le frontend afin d'afficher les modules.",[6078,10338,10340],{"id":10339},"récupération-de-lapi-externe","Récupération de l'API Externe",[10,10342,10343,10344,287,10347,10354],{},"Je crée un point de terminaison ",[103,10345,10346],{},"server\u002Fapi\u002Fmodules\u002Ffetch.post.ts",[29,10348,10349,10350,10353],{},"hors stream, j'ai trouvé que nommer ce point de terminaison ",[103,10351,10352],{},"sync"," était une meilleure idée",") pour récupérer les modules depuis l'API externe et les stocker dans la base de données.",[10,10356,10357,10358,10360,10361,2754],{},"Tout d'abord, j'ajoute l'utilitaire ",[103,10359,9837],{}," pour sécuriser le point de terminaison. Ensuite, je récupère les modules depuis l'API externe en utilisant la fonction globale ",[103,10362,10363],{},"$fetch",[96,10365,10367],{"className":261,"code":10366,"language":263,"meta":101,"style":101},"export default defineEventHandler(async (event) => {\n  await requireAdminUser(event)\n\n  const data = await $fetch('https:\u002F\u002Fapi.nuxt.com\u002Fmodules')\n})\n",[103,10368,10369,10391,10403,10407,10429],{"__ignoreMap":101},[106,10370,10371,10373,10375,10377,10379,10381,10383,10385,10387,10389],{"class":108,"line":109},[106,10372,271],{"class":270},[106,10374,274],{"class":270},[106,10376,278],{"class":277},[106,10378,281],{"class":224},[106,10380,284],{"class":132},[106,10382,287],{"class":112},[106,10384,291],{"class":290},[106,10386,294],{"class":112},[106,10388,297],{"class":132},[106,10390,300],{"class":112},[106,10392,10393,10395,10397,10399,10401],{"class":108,"line":123},[106,10394,2348],{"class":270},[106,10396,9904],{"class":277},[106,10398,281],{"class":116},[106,10400,291],{"class":224},[106,10402,324],{"class":116},[106,10404,10405],{"class":108,"line":162},[106,10406,330],{"emptyLinePlaceholder":329},[106,10408,10409,10411,10413,10415,10417,10419,10421,10423,10425,10427],{"class":108,"line":200},[106,10410,305],{"class":132},[106,10412,964],{"class":224},[106,10414,311],{"class":112},[106,10416,314],{"class":270},[106,10418,1405],{"class":277},[106,10420,281],{"class":116},[106,10422,754],{"class":112},[106,10424,10328],{"class":142},[106,10426,754],{"class":112},[106,10428,324],{"class":116},[106,10430,10431,10433],{"class":108,"line":221},[106,10432,366],{"class":112},[106,10434,324],{"class":224},[10,10436,10437,10438,7187,10440,7187,10443,7190,10445,10448],{},"Les données retournées par l'API contiennent beaucoup de données dont je n'ai pas besoin. Je n'ai besoin que du ",[103,10439,3858],{},[103,10441,10442],{},"repo",[103,10444,2484],{},[103,10446,10447],{},"icon"," des modules. Donc, j'assainis les données pour ne garder que les informations utiles :",[96,10450,10452],{"className":261,"code":10451,"language":263,"meta":101,"style":101},"const modules = data.modules\n  .map(({ name, type, icon, repo }) => ({ name, type, icon, repo }))\n  .filter(({ type }) => type === 'official' || type === 'community')\n",[103,10453,10454,10470,10525],{"__ignoreMap":101},[106,10455,10456,10458,10461,10463,10465,10467],{"class":108,"line":109},[106,10457,1252],{"class":132},[106,10459,10460],{"class":224}," modules ",[106,10462,136],{"class":112},[106,10464,964],{"class":224},[106,10466,52],{"class":112},[106,10468,10469],{"class":224},"modules\n",[106,10471,10472,10475,10478,10480,10482,10484,10486,10488,10490,10493,10495,10498,10500,10502,10504,10506,10508,10510,10512,10514,10516,10518,10521,10523],{"class":108,"line":123},[106,10473,10474],{"class":112},"  .",[106,10476,10477],{"class":277},"map",[106,10479,281],{"class":224},[106,10481,6137],{"class":112},[106,10483,3391],{"class":290},[106,10485,355],{"class":112},[106,10487,171],{"class":290},[106,10489,355],{"class":112},[106,10491,10492],{"class":290}," icon",[106,10494,355],{"class":112},[106,10496,10497],{"class":290}," repo",[106,10499,5687],{"class":112},[106,10501,297],{"class":132},[106,10503,287],{"class":224},[106,10505,1285],{"class":112},[106,10507,3391],{"class":224},[106,10509,355],{"class":112},[106,10511,171],{"class":224},[106,10513,355],{"class":112},[106,10515,10492],{"class":224},[106,10517,355],{"class":112},[106,10519,10520],{"class":224}," repo ",[106,10522,366],{"class":112},[106,10524,1127],{"class":224},[106,10526,10527,10529,10532,10534,10536,10538,10540,10542,10545,10548,10550,10553,10555,10557,10559,10561,10563,10566,10568],{"class":108,"line":162},[106,10528,10474],{"class":112},[106,10530,10531],{"class":277},"filter",[106,10533,281],{"class":224},[106,10535,6137],{"class":112},[106,10537,171],{"class":290},[106,10539,5687],{"class":112},[106,10541,297],{"class":132},[106,10543,10544],{"class":224}," type ",[106,10546,10547],{"class":112},"===",[106,10549,425],{"class":112},[106,10551,10552],{"class":142},"official",[106,10554,754],{"class":112},[106,10556,9970],{"class":112},[106,10558,10544],{"class":224},[106,10560,10547],{"class":112},[106,10562,425],{"class":112},[106,10564,10565],{"class":142},"community",[106,10567,754],{"class":112},[106,10569,324],{"class":224},[10,10571,10572,10573,10576,10577,10579],{},"Je choisis de ne pas garder le type ",[103,10574,10575],{},"third party"," car il peut s'agir d'un module de n'importe qui et je ne connais pas la qualité de ces modules. La clé ",[103,10578,10442],{}," n'était pas prévue mais je pense que c'est une bonne idée de l'avoir. Je pourrais l'utiliser pour ajouter un lien vers le dépôt GitHub du module sur une page de thème.",[10,10581,10582,10583,10586,10587,2754],{},"Maintenant que j'ai mes modules, je dois les stocker dans la base de données, et je dois également créer la table pour les stocker. Dans le fichier ",[103,10584,10585],{},"server\u002Fdatabase\u002Fschema.ts",", je crée la table ",[103,10588,6894],{},[96,10590,10592],{"className":261,"code":10591,"language":263,"meta":101,"style":101},"export const modules = sqliteTable('modules', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  name: text('name').notNull().unique(),\n  repo: text('repo').notNull().unique(),\n  type: text('type', { enum: ['official', 'community'] }).notNull(),\n  icon: text('icon'),\n})\n",[103,10593,10594,10619,10660,10695,10728,10784,10804],{"__ignoreMap":101},[106,10595,10596,10598,10600,10602,10604,10607,10609,10611,10613,10615,10617],{"class":108,"line":109},[106,10597,271],{"class":270},[106,10599,1775],{"class":132},[106,10601,10460],{"class":224},[106,10603,136],{"class":112},[106,10605,10606],{"class":277}," sqliteTable",[106,10608,281],{"class":224},[106,10610,754],{"class":112},[106,10612,6894],{"class":142},[106,10614,754],{"class":112},[106,10616,355],{"class":112},[106,10618,300],{"class":112},[106,10620,10621,10623,10625,10628,10630,10632,10634,10636,10638,10640,10643,10645,10647,10650,10652,10654,10656,10658],{"class":108,"line":123},[106,10622,9400],{"class":116},[106,10624,496],{"class":112},[106,10626,10627],{"class":277}," integer",[106,10629,281],{"class":224},[106,10631,754],{"class":112},[106,10633,7100],{"class":142},[106,10635,754],{"class":112},[106,10637,294],{"class":224},[106,10639,52],{"class":112},[106,10641,10642],{"class":277},"primaryKey",[106,10644,281],{"class":224},[106,10646,1285],{"class":112},[106,10648,10649],{"class":116}," autoIncrement",[106,10651,496],{"class":112},[106,10653,5653],{"class":5652},[106,10655,419],{"class":112},[106,10657,294],{"class":224},[106,10659,509],{"class":112},[106,10661,10662,10664,10666,10669,10671,10673,10675,10677,10679,10681,10684,10686,10688,10691,10693],{"class":108,"line":162},[106,10663,1272],{"class":116},[106,10665,496],{"class":112},[106,10667,10668],{"class":277}," text",[106,10670,281],{"class":224},[106,10672,754],{"class":112},[106,10674,3858],{"class":142},[106,10676,754],{"class":112},[106,10678,294],{"class":224},[106,10680,52],{"class":112},[106,10682,10683],{"class":277},"notNull",[106,10685,506],{"class":224},[106,10687,52],{"class":112},[106,10689,10690],{"class":277},"unique",[106,10692,506],{"class":224},[106,10694,509],{"class":112},[106,10696,10697,10700,10702,10704,10706,10708,10710,10712,10714,10716,10718,10720,10722,10724,10726],{"class":108,"line":200},[106,10698,10699],{"class":116},"  repo",[106,10701,496],{"class":112},[106,10703,10668],{"class":277},[106,10705,281],{"class":224},[106,10707,754],{"class":112},[106,10709,10442],{"class":142},[106,10711,754],{"class":112},[106,10713,294],{"class":224},[106,10715,52],{"class":112},[106,10717,10683],{"class":277},[106,10719,506],{"class":224},[106,10721,52],{"class":112},[106,10723,10690],{"class":277},[106,10725,506],{"class":224},[106,10727,509],{"class":112},[106,10729,10730,10733,10735,10737,10739,10741,10743,10745,10747,10749,10752,10754,10756,10758,10760,10762,10764,10766,10768,10770,10772,10774,10776,10778,10780,10782],{"class":108,"line":221},[106,10731,10732],{"class":116},"  type",[106,10734,496],{"class":112},[106,10736,10668],{"class":277},[106,10738,281],{"class":224},[106,10740,754],{"class":112},[106,10742,2484],{"class":142},[106,10744,754],{"class":112},[106,10746,355],{"class":112},[106,10748,413],{"class":112},[106,10750,10751],{"class":116}," enum",[106,10753,496],{"class":112},[106,10755,2538],{"class":224},[106,10757,754],{"class":112},[106,10759,10552],{"class":142},[106,10761,754],{"class":112},[106,10763,355],{"class":112},[106,10765,425],{"class":112},[106,10767,10565],{"class":142},[106,10769,754],{"class":112},[106,10771,5341],{"class":224},[106,10773,366],{"class":112},[106,10775,294],{"class":224},[106,10777,52],{"class":112},[106,10779,10683],{"class":277},[106,10781,506],{"class":224},[106,10783,509],{"class":112},[106,10785,10786,10788,10790,10792,10794,10796,10798,10800,10802],{"class":108,"line":228},[106,10787,9429],{"class":116},[106,10789,496],{"class":112},[106,10791,10668],{"class":277},[106,10793,281],{"class":224},[106,10795,754],{"class":112},[106,10797,10447],{"class":142},[106,10799,754],{"class":112},[106,10801,294],{"class":224},[106,10803,509],{"class":112},[106,10805,10806,10808],{"class":108,"line":238},[106,10807,366],{"class":112},[106,10809,324],{"class":224},[1830,10811,10812],{},[10,10813,10814,10817],{},[106,10815,10816],{},"!NOTE","\nN'oubliez pas d'exécuter la commande drizzle pour générer la migration SQL.",[10,10819,10820],{},"Dans le point de terminaison, il est maintenant possible d'insérer les modules dans la base de données :",[96,10822,10824],{"className":261,"code":10823,"language":263,"meta":101,"style":101},"await useDrizzle().insert(tables.modules).values(modules).execute()\n",[103,10825,10826],{"__ignoreMap":101},[106,10827,10828,10831,10833,10835,10837,10839,10842,10844,10847,10849,10851,10854,10856,10858],{"class":108,"line":109},[106,10829,10830],{"class":270},"await",[106,10832,7070],{"class":277},[106,10834,506],{"class":224},[106,10836,52],{"class":112},[106,10838,7264],{"class":277},[106,10840,10841],{"class":224},"(tables",[106,10843,52],{"class":112},[106,10845,10846],{"class":224},"modules)",[106,10848,52],{"class":112},[106,10850,7279],{"class":277},[106,10852,10853],{"class":224},"(modules)",[106,10855,52],{"class":112},[106,10857,7330],{"class":277},[106,10859,2366],{"class":224},[10,10861,10862,10863,10866,10867,10872],{},"MAIS, surprise, cela ",[22,10864,10865],{},"ne fonctionne pas",". Je ne le savais pas, mais le ",[14,10868,10871],{"href":10869,"rel":10870},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fd1\u002Fplatform\u002Flimits\u002F",[18],"Cloudflare D1 a une limite (ou plusieurs)"," sur les paramètres liés à 100. En réalité, cette limite existe parce qu'ils utilisent SQLite en arrière-plan, ce qui a cette limite pour des raisons de sécurité.",[10,10874,10875,10876,10879,10880,10883,10884,10887,10888,10891],{},"Qu'est-ce qu'un paramètre lié ? C'est un paramètre dans une requête SQL qui est remplacé par une valeur lorsque la requête est exécutée. Par exemple, dans la requête ",[103,10877,10878],{},"SELECT * FROM users WHERE id = ?",", le ",[103,10881,10882],{},"?"," est un paramètre lié. Dans le cas de la requête d'insertion ",[103,10885,10886],{},"INSERT INTO users (name, email) VALUES (?, ?)",", les paramètres liés sont les valeurs à insérer dans la table. La limite est de 100 paramètres liés. Attention, ",[22,10889,10890],{},"une ligne n'est pas un paramètre lié",", un paramètre lié est une valeur unique, ce qui signifie que si vous insérez une ligne avec 10 colonnes, cela comptera comme 10 paramètres liés. Il y a 42 modules avec 4 colonnes, donc 168 paramètres liés.",[10,10893,10894],{},"C'est un vrai problème car je devrai diviser l'insertion en plusieurs requêtes et un worker est limité dans le temps, donc s'il fonctionne trop longtemps, le worker pourrait échouer.",[96,10896,10898],{"className":261,"code":10897,"language":263,"meta":101,"style":101},"const columnsPerModule = Object.keys(modules[0]).length\nconst insertPerLoop = Math.floor(100 \u002F columnsPerModule)\nconst loops = Math.ceil(modules.length \u002F insertPerLoop)\n\nfor (let loop = 0; loop \u003C loops; loop++) {\n  const values = modules.slice(loop * insertPerLoop, (loop + 1) * insertPerLoop)\n  await useDrizzle().insert(tables.modules).values(values).onConflictDoNothing({ target: tables.modules.repo }).execute()\n}\n",[103,10899,10900,10930,10958,10987,10991,11030,11078,11143],{"__ignoreMap":101},[106,10901,10902,10904,10907,10909,10912,10914,10917,10920,10922,10925,10927],{"class":108,"line":109},[106,10903,1252],{"class":132},[106,10905,10906],{"class":224}," columnsPerModule ",[106,10908,136],{"class":112},[106,10910,10911],{"class":224}," Object",[106,10913,52],{"class":112},[106,10915,10916],{"class":277},"keys",[106,10918,10919],{"class":224},"(modules[",[106,10921,782],{"class":358},[106,10923,10924],{"class":224},"])",[106,10926,52],{"class":112},[106,10928,10929],{"class":224},"length\n",[106,10931,10932,10934,10937,10939,10942,10944,10947,10949,10952,10955],{"class":108,"line":123},[106,10933,1252],{"class":132},[106,10935,10936],{"class":224}," insertPerLoop ",[106,10938,136],{"class":112},[106,10940,10941],{"class":224}," Math",[106,10943,52],{"class":112},[106,10945,10946],{"class":277},"floor",[106,10948,281],{"class":224},[106,10950,10951],{"class":358},"100",[106,10953,10954],{"class":112}," \u002F",[106,10956,10957],{"class":224}," columnsPerModule)\n",[106,10959,10960,10962,10965,10967,10969,10971,10974,10977,10979,10982,10984],{"class":108,"line":162},[106,10961,1252],{"class":132},[106,10963,10964],{"class":224}," loops ",[106,10966,136],{"class":112},[106,10968,10941],{"class":224},[106,10970,52],{"class":112},[106,10972,10973],{"class":277},"ceil",[106,10975,10976],{"class":224},"(modules",[106,10978,52],{"class":112},[106,10980,10981],{"class":224},"length ",[106,10983,5763],{"class":112},[106,10985,10986],{"class":224}," insertPerLoop)\n",[106,10988,10989],{"class":108,"line":200},[106,10990,330],{"emptyLinePlaceholder":329},[106,10992,10993,10995,10997,11000,11003,11005,11008,11011,11013,11015,11018,11020,11023,11026,11028],{"class":108,"line":221},[106,10994,5276],{"class":270},[106,10996,287],{"class":224},[106,10998,10999],{"class":132},"let",[106,11001,11002],{"class":224}," loop ",[106,11004,136],{"class":112},[106,11006,11007],{"class":358}," 0",[106,11009,11010],{"class":112},";",[106,11012,11002],{"class":224},[106,11014,113],{"class":112},[106,11016,11017],{"class":224}," loops",[106,11019,11010],{"class":112},[106,11021,11022],{"class":224}," loop",[106,11024,11025],{"class":112},"++",[106,11027,713],{"class":224},[106,11029,488],{"class":112},[106,11031,11032,11034,11037,11039,11042,11044,11047,11049,11052,11054,11057,11059,11061,11063,11066,11069,11071,11074,11076],{"class":108,"line":228},[106,11033,305],{"class":132},[106,11035,11036],{"class":224}," values",[106,11038,311],{"class":112},[106,11040,11041],{"class":224}," modules",[106,11043,52],{"class":112},[106,11045,11046],{"class":277},"slice",[106,11048,281],{"class":116},[106,11050,11051],{"class":224},"loop",[106,11053,2440],{"class":112},[106,11055,11056],{"class":224}," insertPerLoop",[106,11058,355],{"class":112},[106,11060,287],{"class":116},[106,11062,11051],{"class":224},[106,11064,11065],{"class":112}," +",[106,11067,11068],{"class":358}," 1",[106,11070,713],{"class":116},[106,11072,11073],{"class":112},"*",[106,11075,11056],{"class":224},[106,11077,324],{"class":116},[106,11079,11080,11082,11084,11086,11088,11090,11092,11094,11096,11098,11100,11102,11104,11106,11108,11110,11112,11115,11117,11119,11121,11123,11125,11127,11129,11131,11133,11135,11137,11139,11141],{"class":108,"line":238},[106,11081,2348],{"class":270},[106,11083,7070],{"class":277},[106,11085,506],{"class":116},[106,11087,52],{"class":112},[106,11089,7264],{"class":277},[106,11091,281],{"class":116},[106,11093,7155],{"class":224},[106,11095,52],{"class":112},[106,11097,6894],{"class":224},[106,11099,294],{"class":116},[106,11101,52],{"class":112},[106,11103,7279],{"class":277},[106,11105,281],{"class":116},[106,11107,7279],{"class":224},[106,11109,294],{"class":116},[106,11111,52],{"class":112},[106,11113,11114],{"class":277},"onConflictDoNothing",[106,11116,281],{"class":116},[106,11118,1285],{"class":112},[106,11120,3086],{"class":116},[106,11122,496],{"class":112},[106,11124,7091],{"class":224},[106,11126,52],{"class":112},[106,11128,6894],{"class":224},[106,11130,52],{"class":112},[106,11132,10442],{"class":224},[106,11134,419],{"class":112},[106,11136,294],{"class":116},[106,11138,52],{"class":112},[106,11140,7330],{"class":277},[106,11142,2366],{"class":116},[106,11144,11145],{"class":108,"line":248},[106,11146,1558],{"class":112},[10,11148,11149,11150,11152],{},"Pour avoir une insertion dynamique en cas de changement du nombre de colonnes, je calcule le nombre maximum de lignes que je peux insérer dans une seule requête. Ensuite, je boucle sur les modules et insère le nombre maximum de lignes dans chaque requête. J'utilise également la méthode ",[103,11151,11114],{}," pour éviter d'insérer le même module deux fois si je fais fonctionner le point de terminaison plusieurs fois.",[10,11154,11155],{},[29,11156,11157],{},"J'ai essayé cela dans un environnement de production, et tout fonctionne bien.",[10,11159,11160],{},"Je choisis de retourner un code de statut 204 lorsque les modules sont récupérés. Si tout est correct, je peux rafraîchir la liste des modules dans l'interface utilisateur pour voir les nouveaux modules.",[6078,11162,11164],{"id":11163},"servir-les-modules","Servir les Modules",[10,11166,11167,11168,11171],{},"Cette partie est exactement la même que le point de terminaison d'index des catégories et des utilisateurs. Je crée un point de terminaison ",[103,11169,11170],{},"server\u002Fapi\u002Fmodules\u002Findex.get.ts"," pour obtenir les modules de la base de données :",[96,11173,11175],{"className":261,"code":11174,"language":263,"meta":101,"style":101},"export default defineEventHandler(async () => {\n  const modules = await useDrizzle().select({\n    id: tables.modules.id,\n    name: tables.modules.name,\n    type: tables.modules.type,\n    icon: tables.modules.icon,\n  }).from(tables.modules)\n\n  return modules\n})\n",[103,11176,11177,11195,11217,11235,11253,11272,11291,11311,11315,11322],{"__ignoreMap":101},[106,11178,11179,11181,11183,11185,11187,11189,11191,11193],{"class":108,"line":109},[106,11180,271],{"class":270},[106,11182,274],{"class":270},[106,11184,278],{"class":277},[106,11186,281],{"class":224},[106,11188,284],{"class":132},[106,11190,7052],{"class":112},[106,11192,297],{"class":132},[106,11194,300],{"class":112},[106,11196,11197,11199,11201,11203,11205,11207,11209,11211,11213,11215],{"class":108,"line":123},[106,11198,305],{"class":132},[106,11200,11041],{"class":224},[106,11202,311],{"class":112},[106,11204,314],{"class":270},[106,11206,7070],{"class":277},[106,11208,506],{"class":116},[106,11210,52],{"class":112},[106,11212,7077],{"class":277},[106,11214,281],{"class":116},[106,11216,488],{"class":112},[106,11218,11219,11221,11223,11225,11227,11229,11231,11233],{"class":108,"line":162},[106,11220,7086],{"class":116},[106,11222,496],{"class":112},[106,11224,7091],{"class":224},[106,11226,52],{"class":112},[106,11228,6894],{"class":224},[106,11230,52],{"class":112},[106,11232,7100],{"class":224},[106,11234,509],{"class":112},[106,11236,11237,11239,11241,11243,11245,11247,11249,11251],{"class":108,"line":200},[106,11238,493],{"class":116},[106,11240,496],{"class":112},[106,11242,7091],{"class":224},[106,11244,52],{"class":112},[106,11246,6894],{"class":224},[106,11248,52],{"class":112},[106,11250,3858],{"class":224},[106,11252,509],{"class":112},[106,11254,11255,11258,11260,11262,11264,11266,11268,11270],{"class":108,"line":221},[106,11256,11257],{"class":116},"    type",[106,11259,496],{"class":112},[106,11261,7091],{"class":224},[106,11263,52],{"class":112},[106,11265,6894],{"class":224},[106,11267,52],{"class":112},[106,11269,2484],{"class":224},[106,11271,509],{"class":112},[106,11273,11274,11277,11279,11281,11283,11285,11287,11289],{"class":108,"line":228},[106,11275,11276],{"class":116},"    icon",[106,11278,496],{"class":112},[106,11280,7091],{"class":224},[106,11282,52],{"class":112},[106,11284,6894],{"class":224},[106,11286,52],{"class":112},[106,11288,10447],{"class":224},[106,11290,509],{"class":112},[106,11292,11293,11295,11297,11299,11301,11303,11305,11307,11309],{"class":108,"line":238},[106,11294,514],{"class":112},[106,11296,294],{"class":116},[106,11298,52],{"class":112},[106,11300,7150],{"class":277},[106,11302,281],{"class":116},[106,11304,7155],{"class":224},[106,11306,52],{"class":112},[106,11308,6894],{"class":224},[106,11310,324],{"class":116},[106,11312,11313],{"class":108,"line":248},[106,11314,330],{"emptyLinePlaceholder":329},[106,11316,11317,11319],{"class":108,"line":534},[106,11318,345],{"class":270},[106,11320,11321],{"class":224}," modules\n",[106,11323,11324,11326],{"class":108,"line":539},[106,11325,366],{"class":112},[106,11327,324],{"class":224},[10,11329,11330],{},"Rien de nouveau ici, je sélectionne simplement les modules depuis la base de données et les retourne.",[381,11332,5430],{"id":11333},"conclusion-1",[10,11335,11336],{},"Tout cela nous donne ce résultat :",[10,11338,11339],{},[5535,11340],{"alt":10302,"src":11341},"\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmodules-page.png",[10,11343,11344],{},"Ce stream était différent des autres. D'abord, j'ai maintenant un nouveau micro et le son est bien meilleur qu'avant !",[10,11346,11347],{},"Deuxièmement, j'ai rencontré un problème avec la limite D1 (ce qui pourrait entraîner un besoin de repenser le projet, espérons que ce ne sera pas le cas) sur les paramètres liés. C'est une bonne leçon pour moi, et j'espère que cela sera utile pour vous.",[54,11349,11351],{"id":11350},"factorisation-de-la-gestion-des-utilisateurs","Factorisation de la Gestion des Utilisateurs",[10,11353,11354],{},[29,11355,11356],{},"Cette partie a été réalisée hors stream car elle n'était pas intéressante à voir et je n'étais pas sûr de la direction que je voulais prendre.",[10,11358,11359],{},"En écrivant une partie de cet article, je me demande si stocker les données des utilisateurs était une bonne idée. Ne pourrait-il pas être mieux ? Cette question vient de la façon dont je gère le point de terminaison d'authentification.",[10,11361,11362,11363,11368,11369],{},"En fait, lorsqu'un utilisateur se connecte, j'écris toujours les données de l'utilisateur dans la base de données pour être sûr qu'elles sont à jour. La principale préoccupation est qu'une écriture est beaucoup plus coûteuse qu'une lecture. Pour trouver des idées, j'ai lu le ",[14,11364,11367],{"href":11365,"rel":11366},"https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-todos-edge",[18],"projet de liste de tâches Nuxt d'Atinux",". Dans ce projet, il ne stocke pas les données des utilisateurs. Lorsque l'utilisateur se connecte, il ne stocke que l'ID de l'utilisateur dans le cookie. Lorsque l'utilisateur crée une tâche, il lui suffit de lire l'ID de l'utilisateur depuis le cookie pour savoir qui est l'utilisateur. C'est très intelligent et simple. Je vais faire de même. ",[29,11370,11371],{},"Premièrement à travers.",[10,11373,11374],{},"Analysons si cette méthode est possible pour le projet Orion. Pour savoir si c'est possible, je dois répondre à quelques questions :",[6045,11376,11377,11380,11383],{},[5918,11378,11379],{},"Puis-je bannir ou promouvoir un utilisateur sans stocker les données de l'utilisateur ?",[5918,11381,11382],{},"Un utilisateur peut-il créer des données sans stocker les données de l'utilisateur ?",[5918,11384,11385],{},"Puis-je afficher les données des utilisateurs sans stocker les données de l'utilisateur ?",[10,11387,11388],{},"Pour la première question, c'est oui. Je peux imaginer un système où vous entrez le login d'un utilisateur, et lorsque l'utilisateur se connecte, je rechercherai dans la base de données si l'entrée existe et obtiendrai les données pertinentes. Ce n'est pas la meilleure méthode mais c'est possible.",[10,11390,11391,11392],{},"Pour la deuxième question, c'est oui. Puisque les données sont dans le cookie, je peux facilement savoir qui est l'utilisateur et créer des données en utilisant cette information. ",[29,11393,11394],{},"Et c'est ainsi que ça fonctionne en fait. Je ne récupère pas l'utilisateur pour chaque requête.",[10,11396,11397,11398,11401,11402,11405,11406,11408,11409],{},"Pour la troisième question, c'est non. Imaginez que je veuille afficher le modèle créé par l'utilisateur 'X'. Dans notre schéma SQL, nous avons une colonne ",[103,11399,11400],{},"user_id"," dans la table ",[103,11403,11404],{},"templates",". Si je ne stocke pas les données de l'utilisateur, je ne pourrai pas savoir qui est l'utilisateur 'X', associé à ",[103,11407,11400],{},". Je devrai stocker les données de l'utilisateur dans la base de données. ",[29,11410,11411],{},"Cela pourrait être possible avec une base de données non relationnelle mais ce n'est pas le cas ici. Nous pourrions aussi imaginer un système où l'utilisateur est sauvegardé lorsqu'il crée un modèle. C'est possible mais cela ajoutera beaucoup de complexité. Au final, cette réponse et celle à la question une compliquent le système pour aucun réel gain.",[10,11413,11414],{},"Maintenant que nous savons que nous devons stocker les utilisateurs dans la base de données, que pouvons-nous améliorer ?",[5915,11416,11417,11420,11423,11432,11442],{},[5918,11418,11419],{},"Sauvegarder les données des utilisateurs seulement si nécessaire. Par exemple, je pourrais d'abord rechercher l'utilisateur avant de sauvegarder ses données lorsque l'utilisateur se connecte.",[5918,11421,11422],{},"Sauvegarder l'email de l'utilisateur. Cela sera utile pour le système de feedback.",[5918,11424,11425,11426,11428,11429,52],{},"Sauvegarder le nom de l'utilisateur et renommer la colonne ",[103,11427,8828],{}," en ",[103,11430,11431],{},"login",[5918,11433,11434,11435,11438,11439,52],{},"Ajouter une colonne ",[103,11436,11437],{},"created_at"," et une colonne ",[103,11440,11441],{},"updated_at",[5918,11443,11444],{},"Mettre à jour les données stockées dans le cookie.",[10,11446,11447],{},"Avec tous ces points, je mets également à jour l'interface utilisateur en conséquence. Il n'y a rien de difficile ici ; il s'agit principalement de renommer et d'ajouter des champs dans le schéma.",[96,11449,11451],{"className":261,"code":11450,"language":263,"meta":101,"style":101},"export const users = sqliteTable('users', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  githubId: integer('github_id').notNull().unique(),\n  email: text('email').notNull().unique(),\n  login: text('login').notNull().unique(),\n  name: text('name'),\n  avatarUrl: text('avatar_url').notNull(),\n  roleType: text('role_type', { enum: ['admin', 'creator'] }).default('creator'),\n  createdAt: text('created_at').notNull().$defaultFn(() => sql`(current_timestamp)`),\n  updatedAt: text('updated_at').notNull().$defaultFn(() => sql`(current_timestamp)`).$onUpdateFn(() => sql`(current_timestamp)`),\n})\n",[103,11452,11453,11477,11515,11549,11583,11616,11636,11664,11730,11781,11849],{"__ignoreMap":101},[106,11454,11455,11457,11459,11461,11463,11465,11467,11469,11471,11473,11475],{"class":108,"line":109},[106,11456,271],{"class":270},[106,11458,1775],{"class":132},[106,11460,9090],{"class":224},[106,11462,136],{"class":112},[106,11464,10606],{"class":277},[106,11466,281],{"class":224},[106,11468,754],{"class":112},[106,11470,6882],{"class":142},[106,11472,754],{"class":112},[106,11474,355],{"class":112},[106,11476,300],{"class":112},[106,11478,11479,11481,11483,11485,11487,11489,11491,11493,11495,11497,11499,11501,11503,11505,11507,11509,11511,11513],{"class":108,"line":123},[106,11480,9400],{"class":116},[106,11482,496],{"class":112},[106,11484,10627],{"class":277},[106,11486,281],{"class":224},[106,11488,754],{"class":112},[106,11490,7100],{"class":142},[106,11492,754],{"class":112},[106,11494,294],{"class":224},[106,11496,52],{"class":112},[106,11498,10642],{"class":277},[106,11500,281],{"class":224},[106,11502,1285],{"class":112},[106,11504,10649],{"class":116},[106,11506,496],{"class":112},[106,11508,5653],{"class":5652},[106,11510,419],{"class":112},[106,11512,294],{"class":224},[106,11514,509],{"class":112},[106,11516,11517,11520,11522,11524,11526,11528,11531,11533,11535,11537,11539,11541,11543,11545,11547],{"class":108,"line":162},[106,11518,11519],{"class":116},"  githubId",[106,11521,496],{"class":112},[106,11523,10627],{"class":277},[106,11525,281],{"class":224},[106,11527,754],{"class":112},[106,11529,11530],{"class":142},"github_id",[106,11532,754],{"class":112},[106,11534,294],{"class":224},[106,11536,52],{"class":112},[106,11538,10683],{"class":277},[106,11540,506],{"class":224},[106,11542,52],{"class":112},[106,11544,10690],{"class":277},[106,11546,506],{"class":224},[106,11548,509],{"class":112},[106,11550,11551,11554,11556,11558,11560,11562,11565,11567,11569,11571,11573,11575,11577,11579,11581],{"class":108,"line":200},[106,11552,11553],{"class":116},"  email",[106,11555,496],{"class":112},[106,11557,10668],{"class":277},[106,11559,281],{"class":224},[106,11561,754],{"class":112},[106,11563,11564],{"class":142},"email",[106,11566,754],{"class":112},[106,11568,294],{"class":224},[106,11570,52],{"class":112},[106,11572,10683],{"class":277},[106,11574,506],{"class":224},[106,11576,52],{"class":112},[106,11578,10690],{"class":277},[106,11580,506],{"class":224},[106,11582,509],{"class":112},[106,11584,11585,11588,11590,11592,11594,11596,11598,11600,11602,11604,11606,11608,11610,11612,11614],{"class":108,"line":221},[106,11586,11587],{"class":116},"  login",[106,11589,496],{"class":112},[106,11591,10668],{"class":277},[106,11593,281],{"class":224},[106,11595,754],{"class":112},[106,11597,11431],{"class":142},[106,11599,754],{"class":112},[106,11601,294],{"class":224},[106,11603,52],{"class":112},[106,11605,10683],{"class":277},[106,11607,506],{"class":224},[106,11609,52],{"class":112},[106,11611,10690],{"class":277},[106,11613,506],{"class":224},[106,11615,509],{"class":112},[106,11617,11618,11620,11622,11624,11626,11628,11630,11632,11634],{"class":108,"line":228},[106,11619,1272],{"class":116},[106,11621,496],{"class":112},[106,11623,10668],{"class":277},[106,11625,281],{"class":224},[106,11627,754],{"class":112},[106,11629,3858],{"class":142},[106,11631,754],{"class":112},[106,11633,294],{"class":224},[106,11635,509],{"class":112},[106,11637,11638,11641,11643,11645,11647,11649,11652,11654,11656,11658,11660,11662],{"class":108,"line":238},[106,11639,11640],{"class":116},"  avatarUrl",[106,11642,496],{"class":112},[106,11644,10668],{"class":277},[106,11646,281],{"class":224},[106,11648,754],{"class":112},[106,11650,11651],{"class":142},"avatar_url",[106,11653,754],{"class":112},[106,11655,294],{"class":224},[106,11657,52],{"class":112},[106,11659,10683],{"class":277},[106,11661,506],{"class":224},[106,11663,509],{"class":112},[106,11665,11666,11669,11671,11673,11675,11677,11680,11682,11684,11686,11688,11690,11692,11694,11696,11698,11700,11702,11705,11707,11709,11711,11713,11715,11718,11720,11722,11724,11726,11728],{"class":108,"line":248},[106,11667,11668],{"class":116},"  roleType",[106,11670,496],{"class":112},[106,11672,10668],{"class":277},[106,11674,281],{"class":224},[106,11676,754],{"class":112},[106,11678,11679],{"class":142},"role_type",[106,11681,754],{"class":112},[106,11683,355],{"class":112},[106,11685,413],{"class":112},[106,11687,10751],{"class":116},[106,11689,496],{"class":112},[106,11691,2538],{"class":224},[106,11693,754],{"class":112},[106,11695,7408],{"class":142},[106,11697,754],{"class":112},[106,11699,355],{"class":112},[106,11701,425],{"class":112},[106,11703,11704],{"class":142},"creator",[106,11706,754],{"class":112},[106,11708,5341],{"class":224},[106,11710,366],{"class":112},[106,11712,294],{"class":224},[106,11714,52],{"class":112},[106,11716,11717],{"class":277},"default",[106,11719,281],{"class":224},[106,11721,754],{"class":112},[106,11723,11704],{"class":142},[106,11725,754],{"class":112},[106,11727,294],{"class":224},[106,11729,509],{"class":112},[106,11731,11732,11735,11737,11739,11741,11743,11745,11747,11749,11751,11753,11755,11757,11760,11762,11764,11766,11769,11772,11775,11777,11779],{"class":108,"line":534},[106,11733,11734],{"class":116},"  createdAt",[106,11736,496],{"class":112},[106,11738,10668],{"class":277},[106,11740,281],{"class":224},[106,11742,754],{"class":112},[106,11744,11437],{"class":142},[106,11746,754],{"class":112},[106,11748,294],{"class":224},[106,11750,52],{"class":112},[106,11752,10683],{"class":277},[106,11754,506],{"class":224},[106,11756,52],{"class":112},[106,11758,11759],{"class":277},"$defaultFn",[106,11761,281],{"class":224},[106,11763,506],{"class":112},[106,11765,297],{"class":132},[106,11767,11768],{"class":277}," sql",[106,11770,11771],{"class":112},"`",[106,11773,11774],{"class":142},"(current_timestamp)",[106,11776,11771],{"class":112},[106,11778,294],{"class":224},[106,11780,509],{"class":112},[106,11782,11783,11786,11788,11790,11792,11794,11796,11798,11800,11802,11804,11806,11808,11810,11812,11814,11816,11818,11820,11822,11824,11826,11828,11831,11833,11835,11837,11839,11841,11843,11845,11847],{"class":108,"line":539},[106,11784,11785],{"class":116},"  updatedAt",[106,11787,496],{"class":112},[106,11789,10668],{"class":277},[106,11791,281],{"class":224},[106,11793,754],{"class":112},[106,11795,11441],{"class":142},[106,11797,754],{"class":112},[106,11799,294],{"class":224},[106,11801,52],{"class":112},[106,11803,10683],{"class":277},[106,11805,506],{"class":224},[106,11807,52],{"class":112},[106,11809,11759],{"class":277},[106,11811,281],{"class":224},[106,11813,506],{"class":112},[106,11815,297],{"class":132},[106,11817,11768],{"class":277},[106,11819,11771],{"class":112},[106,11821,11774],{"class":142},[106,11823,11771],{"class":112},[106,11825,294],{"class":224},[106,11827,52],{"class":112},[106,11829,11830],{"class":277},"$onUpdateFn",[106,11832,281],{"class":224},[106,11834,506],{"class":112},[106,11836,297],{"class":132},[106,11838,11768],{"class":277},[106,11840,11771],{"class":112},[106,11842,11774],{"class":142},[106,11844,11771],{"class":112},[106,11846,294],{"class":224},[106,11848,509],{"class":112},[106,11850,11851,11853],{"class":108,"line":556},[106,11852,366],{"class":112},[106,11854,324],{"class":224},[10,11856,11857,11858,72,11860,11862,11863,11866,11867,11870],{},"Les fonctions ",[103,11859,11759],{},[103,11861,11830],{}," sont utilisées pour définir la valeur par défaut de la colonne. Dans le cas de ",[103,11864,11865],{},"createdAt",", la valeur par défaut est l'horodatage actuel. Dans le cas de ",[103,11868,11869],{},"updatedAt",", la valeur par défaut est l'horodatage actuel, et la valeur est mise à jour lorsque la ligne est mise à jour. C'est entièrement automatique et géré par Drizzle.",[10,11872,11873,11874,11877],{},"Je mets également à jour le ",[103,11875,11876],{},"server\u002Froutes\u002Fauth\u002Fgithub.ts"," pour stocker les données de l'utilisateur seulement si nécessaire :",[96,11879,11881],{"className":261,"code":11880,"language":263,"meta":101,"style":101},"export default oauth.githubEventHandler({\n  config: {\n    emailRequired: true,\n  },\n  async onSuccess(event, result) {\n    const { user: ghUser } = result\n\n    const githubId = ghUser.id\n\n    let user: User | undefined\n\n    user = await useDrizzle().select().from(tables.users).where(eq(tables.users.githubId, githubId)).get()\n\n    \u002F**\n     * Si l'utilisateur n'est pas dans la base de données ou si ses données ont changé, mettre à jour les données de l'utilisateur.\n     * Une écriture est plus coûteuse qu'une lecture, donc nous écrivons seulement si nécessaire.\n     *\u002F\n    if (!user || userDataChanged(user, ghUser)) {\n      user = await useDrizzle().insert(tables.users).values({\n        githubId,\n        login: ghUser.login,\n        email: ghUser.email,\n        name: ghUser.name,\n        avatarUrl: ghUser.avatar_url,\n      }).onConflictDoUpdate({\n        target: tables.users.githubId,\n        set: {\n          login: ghUser.login,\n          email: ghUser.email,\n          name: ghUser.name,\n          avatarUrl: ghUser.avatar_url,\n        },\n      }).returning().get()\n    }\n\n    \u002F**\n     * Définir seulement les données nécessaires dans la session.\n     *\u002F\n    await setUserSession(event, {\n      user: {\n        id: user.id!,\n        login: ghUser.login,\n        email: ghUser.email,\n        name: ghUser.name,\n        avatarUrl: ghUser.avatar_url,\n        roleType: user.roleType || 'creator',\n      },\n    })\n\n    return sendRedirect(event, '\u002Fportal')\n  },\n})\n",[103,11882,11883,11899,11907,11917,11921,11939,11959,11963,11979,11983,12001,12005,12070,12074,12078,12083,12088,12092,12120,12154,12160,12175,12190,12205,12220,12235,12254,12263,12278,12293,12308,12323,12327,12346,12350,12354,12358,12363,12367,12381,12389,12405,12419,12433,12447,12461,12484,12488,12494,12498,12519,12523],{"__ignoreMap":101},[106,11884,11885,11887,11889,11891,11893,11895,11897],{"class":108,"line":109},[106,11886,271],{"class":270},[106,11888,274],{"class":270},[106,11890,5624],{"class":224},[106,11892,52],{"class":112},[106,11894,5629],{"class":277},[106,11896,281],{"class":224},[106,11898,488],{"class":112},[106,11900,11901,11903,11905],{"class":108,"line":123},[106,11902,5638],{"class":116},[106,11904,496],{"class":112},[106,11906,300],{"class":112},[106,11908,11909,11911,11913,11915],{"class":108,"line":162},[106,11910,5647],{"class":116},[106,11912,496],{"class":112},[106,11914,5653],{"class":5652},[106,11916,509],{"class":112},[106,11918,11919],{"class":108,"line":200},[106,11920,5660],{"class":112},[106,11922,11923,11925,11927,11929,11931,11933,11935,11937],{"class":108,"line":221},[106,11924,5665],{"class":132},[106,11926,5668],{"class":116},[106,11928,281],{"class":112},[106,11930,291],{"class":290},[106,11932,355],{"class":112},[106,11934,638],{"class":290},[106,11936,294],{"class":112},[106,11938,300],{"class":112},[106,11940,11941,11943,11945,11947,11949,11952,11954,11956],{"class":108,"line":228},[106,11942,1379],{"class":132},[106,11944,413],{"class":112},[106,11946,5679],{"class":116},[106,11948,496],{"class":112},[106,11950,11951],{"class":224}," ghUser",[106,11953,419],{"class":112},[106,11955,311],{"class":112},[106,11957,11958],{"class":224}," result\n",[106,11960,11961],{"class":108,"line":238},[106,11962,330],{"emptyLinePlaceholder":329},[106,11964,11965,11967,11970,11972,11974,11976],{"class":108,"line":248},[106,11966,1379],{"class":132},[106,11968,11969],{"class":224}," githubId",[106,11971,311],{"class":112},[106,11973,11951],{"class":224},[106,11975,52],{"class":112},[106,11977,11978],{"class":224},"id\n",[106,11980,11981],{"class":108,"line":534},[106,11982,330],{"emptyLinePlaceholder":329},[106,11984,11985,11988,11990,11992,11995,11998],{"class":108,"line":539},[106,11986,11987],{"class":132},"    let",[106,11989,5679],{"class":224},[106,11991,496],{"class":112},[106,11993,11994],{"class":1041}," User",[106,11996,11997],{"class":112}," |",[106,11999,12000],{"class":1041}," undefined\n",[106,12002,12003],{"class":108,"line":556},[106,12004,330],{"emptyLinePlaceholder":329},[106,12006,12007,12010,12012,12014,12016,12018,12020,12022,12024,12026,12028,12030,12032,12034,12036,12038,12040,12042,12044,12046,12048,12050,12052,12054,12056,12058,12060,12062,12064,12066,12068],{"class":108,"line":759},[106,12008,12009],{"class":224},"    user",[106,12011,311],{"class":112},[106,12013,314],{"class":270},[106,12015,7070],{"class":277},[106,12017,506],{"class":116},[106,12019,52],{"class":112},[106,12021,7077],{"class":277},[106,12023,506],{"class":116},[106,12025,52],{"class":112},[106,12027,7150],{"class":277},[106,12029,281],{"class":116},[106,12031,7155],{"class":224},[106,12033,52],{"class":112},[106,12035,6882],{"class":224},[106,12037,294],{"class":116},[106,12039,52],{"class":112},[106,12041,8623],{"class":277},[106,12043,281],{"class":116},[106,12045,8628],{"class":277},[106,12047,281],{"class":116},[106,12049,7155],{"class":224},[106,12051,52],{"class":112},[106,12053,6882],{"class":224},[106,12055,52],{"class":112},[106,12057,5727],{"class":224},[106,12059,355],{"class":112},[106,12061,11969],{"class":224},[106,12063,8652],{"class":116},[106,12065,52],{"class":112},[106,12067,2333],{"class":277},[106,12069,2366],{"class":116},[106,12071,12072],{"class":108,"line":795},[106,12073,330],{"emptyLinePlaceholder":329},[106,12075,12076],{"class":108,"line":803},[106,12077,6209],{"class":335},[106,12079,12080],{"class":108,"line":809},[106,12081,12082],{"class":335},"     * Si l'utilisateur n'est pas dans la base de données ou si ses données ont changé, mettre à jour les données de l'utilisateur.\n",[106,12084,12085],{"class":108,"line":814},[106,12086,12087],{"class":335},"     * Une écriture est plus coûteuse qu'une lecture, donc nous écrivons seulement si nécessaire.\n",[106,12089,12090],{"class":108,"line":830},[106,12091,6219],{"class":335},[106,12093,12094,12096,12098,12100,12102,12104,12107,12109,12111,12113,12115,12118],{"class":108,"line":835},[106,12095,1031],{"class":270},[106,12097,287],{"class":116},[106,12099,702],{"class":112},[106,12101,9967],{"class":224},[106,12103,9970],{"class":112},[106,12105,12106],{"class":277}," userDataChanged",[106,12108,281],{"class":116},[106,12110,9967],{"class":224},[106,12112,355],{"class":112},[106,12114,11951],{"class":224},[106,12116,12117],{"class":116},")) ",[106,12119,488],{"class":112},[106,12121,12122,12124,12126,12128,12130,12132,12134,12136,12138,12140,12142,12144,12146,12148,12150,12152],{"class":108,"line":840},[106,12123,5709],{"class":224},[106,12125,311],{"class":112},[106,12127,314],{"class":270},[106,12129,7070],{"class":277},[106,12131,506],{"class":116},[106,12133,52],{"class":112},[106,12135,7264],{"class":277},[106,12137,281],{"class":116},[106,12139,7155],{"class":224},[106,12141,52],{"class":112},[106,12143,6882],{"class":224},[106,12145,294],{"class":116},[106,12147,52],{"class":112},[106,12149,7279],{"class":277},[106,12151,281],{"class":116},[106,12153,488],{"class":112},[106,12155,12156,12158],{"class":108,"line":845},[106,12157,5718],{"class":224},[106,12159,509],{"class":112},[106,12161,12162,12165,12167,12169,12171,12173],{"class":108,"line":862},[106,12163,12164],{"class":116},"        login",[106,12166,496],{"class":112},[106,12168,11951],{"class":224},[106,12170,52],{"class":112},[106,12172,11431],{"class":224},[106,12174,509],{"class":112},[106,12176,12177,12180,12182,12184,12186,12188],{"class":108,"line":1469},[106,12178,12179],{"class":116},"        email",[106,12181,496],{"class":112},[106,12183,11951],{"class":224},[106,12185,52],{"class":112},[106,12187,11564],{"class":224},[106,12189,509],{"class":112},[106,12191,12192,12195,12197,12199,12201,12203],{"class":108,"line":1486},[106,12193,12194],{"class":116},"        name",[106,12196,496],{"class":112},[106,12198,11951],{"class":224},[106,12200,52],{"class":112},[106,12202,3858],{"class":224},[106,12204,509],{"class":112},[106,12206,12207,12210,12212,12214,12216,12218],{"class":108,"line":1497},[106,12208,12209],{"class":116},"        avatarUrl",[106,12211,496],{"class":112},[106,12213,11951],{"class":224},[106,12215,52],{"class":112},[106,12217,11651],{"class":224},[106,12219,509],{"class":112},[106,12221,12222,12224,12226,12228,12231,12233],{"class":108,"line":1508},[106,12223,1113],{"class":112},[106,12225,294],{"class":116},[106,12227,52],{"class":112},[106,12229,12230],{"class":277},"onConflictDoUpdate",[106,12232,281],{"class":116},[106,12234,488],{"class":112},[106,12236,12237,12240,12242,12244,12246,12248,12250,12252],{"class":108,"line":1523},[106,12238,12239],{"class":116},"        target",[106,12241,496],{"class":112},[106,12243,7091],{"class":224},[106,12245,52],{"class":112},[106,12247,6882],{"class":224},[106,12249,52],{"class":112},[106,12251,5727],{"class":224},[106,12253,509],{"class":112},[106,12255,12256,12259,12261],{"class":108,"line":1538},[106,12257,12258],{"class":116},"        set",[106,12260,496],{"class":112},[106,12262,300],{"class":112},[106,12264,12265,12268,12270,12272,12274,12276],{"class":108,"line":1545},[106,12266,12267],{"class":116},"          login",[106,12269,496],{"class":112},[106,12271,11951],{"class":224},[106,12273,52],{"class":112},[106,12275,11431],{"class":224},[106,12277,509],{"class":112},[106,12279,12280,12283,12285,12287,12289,12291],{"class":108,"line":1550},[106,12281,12282],{"class":116},"          email",[106,12284,496],{"class":112},[106,12286,11951],{"class":224},[106,12288,52],{"class":112},[106,12290,11564],{"class":224},[106,12292,509],{"class":112},[106,12294,12295,12298,12300,12302,12304,12306],{"class":108,"line":1555},[106,12296,12297],{"class":116},"          name",[106,12299,496],{"class":112},[106,12301,11951],{"class":224},[106,12303,52],{"class":112},[106,12305,3858],{"class":224},[106,12307,509],{"class":112},[106,12309,12310,12313,12315,12317,12319,12321],{"class":108,"line":1561},[106,12311,12312],{"class":116},"          avatarUrl",[106,12314,496],{"class":112},[106,12316,11951],{"class":224},[106,12318,52],{"class":112},[106,12320,11651],{"class":224},[106,12322,509],{"class":112},[106,12324,12325],{"class":108,"line":1570},[106,12326,6578],{"class":112},[106,12328,12329,12331,12333,12335,12338,12340,12342,12344],{"class":108,"line":1575},[106,12330,1113],{"class":112},[106,12332,294],{"class":116},[106,12334,52],{"class":112},[106,12336,12337],{"class":277},"returning",[106,12339,506],{"class":116},[106,12341,52],{"class":112},[106,12343,2333],{"class":277},[106,12345,2366],{"class":116},[106,12347,12348],{"class":108,"line":1584},[106,12349,1120],{"class":112},[106,12351,12352],{"class":108,"line":1605},[106,12353,330],{"emptyLinePlaceholder":329},[106,12355,12356],{"class":108,"line":1648},[106,12357,6209],{"class":335},[106,12359,12360],{"class":108,"line":1667},[106,12361,12362],{"class":335},"     * Définir seulement les données nécessaires dans la session.\n",[106,12364,12365],{"class":108,"line":1672},[106,12366,6219],{"class":335},[106,12368,12369,12371,12373,12375,12377,12379],{"class":108,"line":1681},[106,12370,1402],{"class":270},[106,12372,5696],{"class":277},[106,12374,281],{"class":116},[106,12376,291],{"class":224},[106,12378,355],{"class":112},[106,12380,300],{"class":112},[106,12382,12383,12385,12387],{"class":108,"line":1690},[106,12384,5709],{"class":116},[106,12386,496],{"class":112},[106,12388,300],{"class":112},[106,12390,12391,12394,12396,12398,12400,12402],{"class":108,"line":6491},[106,12392,12393],{"class":116},"        id",[106,12395,496],{"class":112},[106,12397,5679],{"class":224},[106,12399,52],{"class":112},[106,12401,7100],{"class":224},[106,12403,12404],{"class":112},"!,\n",[106,12406,12407,12409,12411,12413,12415,12417],{"class":108,"line":6510},[106,12408,12164],{"class":116},[106,12410,496],{"class":112},[106,12412,11951],{"class":224},[106,12414,52],{"class":112},[106,12416,11431],{"class":224},[106,12418,509],{"class":112},[106,12420,12421,12423,12425,12427,12429,12431],{"class":108,"line":6527},[106,12422,12179],{"class":116},[106,12424,496],{"class":112},[106,12426,11951],{"class":224},[106,12428,52],{"class":112},[106,12430,11564],{"class":224},[106,12432,509],{"class":112},[106,12434,12435,12437,12439,12441,12443,12445],{"class":108,"line":6533},[106,12436,12194],{"class":116},[106,12438,496],{"class":112},[106,12440,11951],{"class":224},[106,12442,52],{"class":112},[106,12444,3858],{"class":224},[106,12446,509],{"class":112},[106,12448,12449,12451,12453,12455,12457,12459],{"class":108,"line":6549},[106,12450,12209],{"class":116},[106,12452,496],{"class":112},[106,12454,11951],{"class":224},[106,12456,52],{"class":112},[106,12458,11651],{"class":224},[106,12460,509],{"class":112},[106,12462,12463,12466,12468,12470,12472,12474,12476,12478,12480,12482],{"class":108,"line":6559},[106,12464,12465],{"class":116},"        roleType",[106,12467,496],{"class":112},[106,12469,5679],{"class":224},[106,12471,52],{"class":112},[106,12473,9054],{"class":224},[106,12475,9970],{"class":112},[106,12477,425],{"class":112},[106,12479,11704],{"class":142},[106,12481,754],{"class":112},[106,12483,509],{"class":112},[106,12485,12486],{"class":108,"line":6575},[106,12487,5734],{"class":112},[106,12489,12490,12492],{"class":108,"line":6581},[106,12491,798],{"class":112},[106,12493,324],{"class":116},[106,12495,12496],{"class":108,"line":6586},[106,12497,330],{"emptyLinePlaceholder":329},[106,12499,12500,12502,12504,12506,12508,12510,12512,12515,12517],{"class":108,"line":6592},[106,12501,5749],{"class":270},[106,12503,5752],{"class":277},[106,12505,281],{"class":116},[106,12507,291],{"class":224},[106,12509,355],{"class":112},[106,12511,425],{"class":112},[106,12513,12514],{"class":142},"\u002Fportal",[106,12516,754],{"class":112},[106,12518,324],{"class":116},[106,12520,12521],{"class":108,"line":6597},[106,12522,5660],{"class":112},[106,12524,12525,12527],{"class":108,"line":6614},[106,12526,366],{"class":112},[106,12528,324],{"class":224},[10,12530,12531],{},"Avec cette nouvelle méthode de gestion des données des utilisateurs, je réduis le nombre d'écritures dans la base de données tout en mettant à jour les données des utilisateurs lorsque nécessaire.",[54,12533,12535],{"id":12534},"création-dun-formulaire-pour-les-modèles","Création d'un formulaire pour les modèles",[10,12537,12538],{},[5535,12539],{"alt":12540,"src":12541},"Vignette de mon sixième stream en direct.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-19.png",[10,12543,12544],{},[29,12545,12546,6828,12549,6832,12552,5558,12555,52],{},[14,12547,5546],{"href":5513,"rel":12548},[18],[14,12550,5552],{"href":5550,"rel":12551},[18],[14,12553,5557],{"href":5503,"rel":12554},[18],[14,12556,5562],{"href":5513,"rel":12557},[18],[10,12559,12560],{},"Depuis le début de la série, je travaille sur l'interface d'administration de l'application web. Aujourd'hui, il est temps de créer le formulaire pour ajouter un nouveau modèle. Ce formulaire sera utilisé par les créateurs pour ajouter leurs modèles ou thèmes à la plateforme. Pour rappel, Orion est une collection de modèles alimentée par la communauté pour votre prochain projet, des pages de destination aux applications web complètes.",[381,12562,12564],{"id":12563},"schéma-du-modèle","Schéma du modèle",[10,12566,12567],{},[29,12568,12569],{},"N'oubliez pas que les choses évoluent et que le schéma n'est pas définitif.",[10,12571,12572],{},"Étant donné que la fonctionnalité de modèle est importante, je l'ai divisée en plusieurs streams en direct. La première étape consiste à créer le formulaire le plus simple mais fonctionnel possible et à ajouter des fonctionnalités au fil du temps. C'est pourquoi le schéma ci-dessus n'est pas exactement le même que celui que nous avons dessiné dans le schéma SQL (pas de modules, pas d'images et pas de markdown, pour l'instant).",[96,12574,12576],{"className":261,"code":12575,"language":263,"meta":101,"style":101},"export const templates = sqliteTable('templates', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  hash: text('hash').notNull().unique(),\n  slug: text('slug').notNull(),\n  title: text('title').notNull(),\n  status: text('status', { enum: ['submitted', 'refused', 'validated'] }).notNull().default('submitted'),\n  paidStatus: text('paid_status', { enum: ['free', 'paid'] }).notNull().default('free'),\n  liveUrl: text('live_url'),\n  accessUrl: text('access_url').notNull(),\n  description: text('description').notNull(),\n  userId: integer('user_id').notNull().references(() => users.id),\n  categoryId: integer('category_id').notNull().references(() => categories.id),\n})\n",[103,12577,12578,12603,12641,12675,12702,12730,12811,12883,12905,12933,12961,13006,13050],{"__ignoreMap":101},[106,12579,12580,12582,12584,12587,12589,12591,12593,12595,12597,12599,12601],{"class":108,"line":109},[106,12581,271],{"class":270},[106,12583,1775],{"class":132},[106,12585,12586],{"class":224}," templates ",[106,12588,136],{"class":112},[106,12590,10606],{"class":277},[106,12592,281],{"class":224},[106,12594,754],{"class":112},[106,12596,11404],{"class":142},[106,12598,754],{"class":112},[106,12600,355],{"class":112},[106,12602,300],{"class":112},[106,12604,12605,12607,12609,12611,12613,12615,12617,12619,12621,12623,12625,12627,12629,12631,12633,12635,12637,12639],{"class":108,"line":123},[106,12606,9400],{"class":116},[106,12608,496],{"class":112},[106,12610,10627],{"class":277},[106,12612,281],{"class":224},[106,12614,754],{"class":112},[106,12616,7100],{"class":142},[106,12618,754],{"class":112},[106,12620,294],{"class":224},[106,12622,52],{"class":112},[106,12624,10642],{"class":277},[106,12626,281],{"class":224},[106,12628,1285],{"class":112},[106,12630,10649],{"class":116},[106,12632,496],{"class":112},[106,12634,5653],{"class":5652},[106,12636,419],{"class":112},[106,12638,294],{"class":224},[106,12640,509],{"class":112},[106,12642,12643,12646,12648,12650,12652,12654,12657,12659,12661,12663,12665,12667,12669,12671,12673],{"class":108,"line":162},[106,12644,12645],{"class":116},"  hash",[106,12647,496],{"class":112},[106,12649,10668],{"class":277},[106,12651,281],{"class":224},[106,12653,754],{"class":112},[106,12655,12656],{"class":142},"hash",[106,12658,754],{"class":112},[106,12660,294],{"class":224},[106,12662,52],{"class":112},[106,12664,10683],{"class":277},[106,12666,506],{"class":224},[106,12668,52],{"class":112},[106,12670,10690],{"class":277},[106,12672,506],{"class":224},[106,12674,509],{"class":112},[106,12676,12677,12680,12682,12684,12686,12688,12690,12692,12694,12696,12698,12700],{"class":108,"line":200},[106,12678,12679],{"class":116},"  slug",[106,12681,496],{"class":112},[106,12683,10668],{"class":277},[106,12685,281],{"class":224},[106,12687,754],{"class":112},[106,12689,7120],{"class":142},[106,12691,754],{"class":112},[106,12693,294],{"class":224},[106,12695,52],{"class":112},[106,12697,10683],{"class":277},[106,12699,506],{"class":224},[106,12701,509],{"class":112},[106,12703,12704,12707,12709,12711,12713,12715,12718,12720,12722,12724,12726,12728],{"class":108,"line":221},[106,12705,12706],{"class":116},"  title",[106,12708,496],{"class":112},[106,12710,10668],{"class":277},[106,12712,281],{"class":224},[106,12714,754],{"class":112},[106,12716,12717],{"class":142},"title",[106,12719,754],{"class":112},[106,12721,294],{"class":224},[106,12723,52],{"class":112},[106,12725,10683],{"class":277},[106,12727,506],{"class":224},[106,12729,509],{"class":112},[106,12731,12732,12735,12737,12739,12741,12743,12746,12748,12750,12752,12754,12756,12758,12760,12763,12765,12767,12769,12772,12774,12776,12778,12781,12783,12785,12787,12789,12791,12793,12795,12797,12799,12801,12803,12805,12807,12809],{"class":108,"line":228},[106,12733,12734],{"class":116},"  status",[106,12736,496],{"class":112},[106,12738,10668],{"class":277},[106,12740,281],{"class":224},[106,12742,754],{"class":112},[106,12744,12745],{"class":142},"status",[106,12747,754],{"class":112},[106,12749,355],{"class":112},[106,12751,413],{"class":112},[106,12753,10751],{"class":116},[106,12755,496],{"class":112},[106,12757,2538],{"class":224},[106,12759,754],{"class":112},[106,12761,12762],{"class":142},"submitted",[106,12764,754],{"class":112},[106,12766,355],{"class":112},[106,12768,425],{"class":112},[106,12770,12771],{"class":142},"refused",[106,12773,754],{"class":112},[106,12775,355],{"class":112},[106,12777,425],{"class":112},[106,12779,12780],{"class":142},"validated",[106,12782,754],{"class":112},[106,12784,5341],{"class":224},[106,12786,366],{"class":112},[106,12788,294],{"class":224},[106,12790,52],{"class":112},[106,12792,10683],{"class":277},[106,12794,506],{"class":224},[106,12796,52],{"class":112},[106,12798,11717],{"class":277},[106,12800,281],{"class":224},[106,12802,754],{"class":112},[106,12804,12762],{"class":142},[106,12806,754],{"class":112},[106,12808,294],{"class":224},[106,12810,509],{"class":112},[106,12812,12813,12816,12818,12820,12822,12824,12827,12829,12831,12833,12835,12837,12839,12841,12844,12846,12848,12850,12853,12855,12857,12859,12861,12863,12865,12867,12869,12871,12873,12875,12877,12879,12881],{"class":108,"line":238},[106,12814,12815],{"class":116},"  paidStatus",[106,12817,496],{"class":112},[106,12819,10668],{"class":277},[106,12821,281],{"class":224},[106,12823,754],{"class":112},[106,12825,12826],{"class":142},"paid_status",[106,12828,754],{"class":112},[106,12830,355],{"class":112},[106,12832,413],{"class":112},[106,12834,10751],{"class":116},[106,12836,496],{"class":112},[106,12838,2538],{"class":224},[106,12840,754],{"class":112},[106,12842,12843],{"class":142},"free",[106,12845,754],{"class":112},[106,12847,355],{"class":112},[106,12849,425],{"class":112},[106,12851,12852],{"class":142},"paid",[106,12854,754],{"class":112},[106,12856,5341],{"class":224},[106,12858,366],{"class":112},[106,12860,294],{"class":224},[106,12862,52],{"class":112},[106,12864,10683],{"class":277},[106,12866,506],{"class":224},[106,12868,52],{"class":112},[106,12870,11717],{"class":277},[106,12872,281],{"class":224},[106,12874,754],{"class":112},[106,12876,12843],{"class":142},[106,12878,754],{"class":112},[106,12880,294],{"class":224},[106,12882,509],{"class":112},[106,12884,12885,12888,12890,12892,12894,12896,12899,12901,12903],{"class":108,"line":248},[106,12886,12887],{"class":116},"  liveUrl",[106,12889,496],{"class":112},[106,12891,10668],{"class":277},[106,12893,281],{"class":224},[106,12895,754],{"class":112},[106,12897,12898],{"class":142},"live_url",[106,12900,754],{"class":112},[106,12902,294],{"class":224},[106,12904,509],{"class":112},[106,12906,12907,12910,12912,12914,12916,12918,12921,12923,12925,12927,12929,12931],{"class":108,"line":534},[106,12908,12909],{"class":116},"  accessUrl",[106,12911,496],{"class":112},[106,12913,10668],{"class":277},[106,12915,281],{"class":224},[106,12917,754],{"class":112},[106,12919,12920],{"class":142},"access_url",[106,12922,754],{"class":112},[106,12924,294],{"class":224},[106,12926,52],{"class":112},[106,12928,10683],{"class":277},[106,12930,506],{"class":224},[106,12932,509],{"class":112},[106,12934,12935,12938,12940,12942,12944,12946,12949,12951,12953,12955,12957,12959],{"class":108,"line":539},[106,12936,12937],{"class":116},"  description",[106,12939,496],{"class":112},[106,12941,10668],{"class":277},[106,12943,281],{"class":224},[106,12945,754],{"class":112},[106,12947,12948],{"class":142},"description",[106,12950,754],{"class":112},[106,12952,294],{"class":224},[106,12954,52],{"class":112},[106,12956,10683],{"class":277},[106,12958,506],{"class":224},[106,12960,509],{"class":112},[106,12962,12963,12966,12968,12970,12972,12974,12976,12978,12980,12982,12984,12986,12988,12991,12993,12995,12997,12999,13001,13004],{"class":108,"line":556},[106,12964,12965],{"class":116},"  userId",[106,12967,496],{"class":112},[106,12969,10627],{"class":277},[106,12971,281],{"class":224},[106,12973,754],{"class":112},[106,12975,11400],{"class":142},[106,12977,754],{"class":112},[106,12979,294],{"class":224},[106,12981,52],{"class":112},[106,12983,10683],{"class":277},[106,12985,506],{"class":224},[106,12987,52],{"class":112},[106,12989,12990],{"class":277},"references",[106,12992,281],{"class":224},[106,12994,506],{"class":112},[106,12996,297],{"class":132},[106,12998,8757],{"class":224},[106,13000,52],{"class":112},[106,13002,13003],{"class":224},"id)",[106,13005,509],{"class":112},[106,13007,13008,13011,13013,13015,13017,13019,13022,13024,13026,13028,13030,13032,13034,13036,13038,13040,13042,13044,13046,13048],{"class":108,"line":759},[106,13009,13010],{"class":116},"  categoryId",[106,13012,496],{"class":112},[106,13014,10627],{"class":277},[106,13016,281],{"class":224},[106,13018,754],{"class":112},[106,13020,13021],{"class":142},"category_id",[106,13023,754],{"class":112},[106,13025,294],{"class":224},[106,13027,52],{"class":112},[106,13029,10683],{"class":277},[106,13031,506],{"class":224},[106,13033,52],{"class":112},[106,13035,12990],{"class":277},[106,13037,281],{"class":224},[106,13039,506],{"class":112},[106,13041,297],{"class":132},[106,13043,7063],{"class":224},[106,13045,52],{"class":112},[106,13047,13003],{"class":224},[106,13049,509],{"class":112},[106,13051,13052,13054],{"class":108,"line":795},[106,13053,366],{"class":112},[106,13055,324],{"class":224},[10,13057,13058,13059,13061,13062,13065],{},"La colonne ",[103,13060,12656],{}," est un petit ID que je stockerai dans la base de données pour créer des URL comme Notion ou Dev.to : ",[103,13063,13064],{},"\u003Cslug>-\u003Chash>",". Cela est utile pour avoir une URL lisible, à la fois pour les humains et pour le SEO, mais comme deux modèles peuvent avoir le même nom, ou le nom d'un modèle peut changer, j'ai besoin d'un ID unique.",[10,13067,13068,13069,13071,13072,13074,13075,13078,13079,13081,13082,52],{},"Pour la première fois, nous créons une relation entre deux tables : une relation un-à-plusieurs entre la table ",[103,13070,6882],{}," et la table ",[103,13073,11404],{},". Cette relation est utilisée pour savoir qui est le créateur du modèle. La colonne ",[103,13076,13077],{},"userId"," est une clé étrangère qui fait référence à la colonne ",[103,13080,7100],{}," de la table ",[103,13083,6882],{},[10,13085,13058,13086,13089,13090,13081,13092,13094],{},[103,13087,13088],{},"categoryId"," est également une clé étrangère qui fait référence à la colonne ",[103,13091,7100],{},[103,13093,6888],{},". Cette relation est utilisée pour savoir dans quelle catégorie se trouve le modèle.",[10,13096,13097],{},"Afin de pouvoir récupérer les modèles d'un utilisateur ou d'une catégorie, je dois également définir la relation dans l'autre sens. Grâce à Drizzle, c'est assez facile.",[96,13099,13101],{"className":261,"code":13100,"language":263,"meta":101,"style":101},"export const usersRelations = relations(users, ({ many }) => ({\n  templates: many(templates),\n}))\n\nexport const categoriesRelations = relations(categories, ({ many }) => ({\n  templates: many(templates),\n}))\n",[103,13102,13103,13136,13150,13156,13160,13190,13202],{"__ignoreMap":101},[106,13104,13105,13107,13109,13112,13114,13117,13120,13122,13125,13128,13130,13132,13134],{"class":108,"line":109},[106,13106,271],{"class":270},[106,13108,1775],{"class":132},[106,13110,13111],{"class":224}," usersRelations ",[106,13113,136],{"class":112},[106,13115,13116],{"class":277}," relations",[106,13118,13119],{"class":224},"(users",[106,13121,355],{"class":112},[106,13123,13124],{"class":112}," ({",[106,13126,13127],{"class":290}," many",[106,13129,5687],{"class":112},[106,13131,297],{"class":132},[106,13133,287],{"class":224},[106,13135,488],{"class":112},[106,13137,13138,13141,13143,13145,13148],{"class":108,"line":123},[106,13139,13140],{"class":116},"  templates",[106,13142,496],{"class":112},[106,13144,13127],{"class":277},[106,13146,13147],{"class":224},"(templates)",[106,13149,509],{"class":112},[106,13151,13152,13154],{"class":108,"line":162},[106,13153,366],{"class":112},[106,13155,1127],{"class":224},[106,13157,13158],{"class":108,"line":200},[106,13159,330],{"emptyLinePlaceholder":329},[106,13161,13162,13164,13166,13169,13171,13173,13176,13178,13180,13182,13184,13186,13188],{"class":108,"line":221},[106,13163,271],{"class":270},[106,13165,1775],{"class":132},[106,13167,13168],{"class":224}," categoriesRelations ",[106,13170,136],{"class":112},[106,13172,13116],{"class":277},[106,13174,13175],{"class":224},"(categories",[106,13177,355],{"class":112},[106,13179,13124],{"class":112},[106,13181,13127],{"class":290},[106,13183,5687],{"class":112},[106,13185,297],{"class":132},[106,13187,287],{"class":224},[106,13189,488],{"class":112},[106,13191,13192,13194,13196,13198,13200],{"class":108,"line":228},[106,13193,13140],{"class":116},[106,13195,496],{"class":112},[106,13197,13127],{"class":277},[106,13199,13147],{"class":224},[106,13201,509],{"class":112},[106,13203,13204,13206],{"class":108,"line":238},[106,13205,366],{"class":112},[106,13207,1127],{"class":224},[381,13209,13211],{"id":13210},"formulaire-de-modèle","Formulaire de modèle",[10,13213,13214],{},"Pendant cette phase, je crée le formulaire sur l'UI, exactement comme je l'ai fait pour les catégories. Je définis l'état, le schéma et l'action au moment de la soumission !",[10,13216,13217,13218,13221],{},"Maintenant qu'un utilisateur est capable de remplir un formulaire, j'ai besoin d'un point de terminaison pour enregistrer les données dans la base de données. Je crée le point de terminaison ",[103,13219,13220],{},"server\u002Fapi\u002Ftemplates\u002Findex.post.ts"," pour enregistrer le modèle dans la base de données :",[96,13223,13225],{"className":261,"code":13224,"language":263,"meta":101,"style":101},"import { number, object, string, enum as zEnum } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const { user } = await requireUserSession(event)\n\n  const body = await readValidatedBody(event, object({\n    title: string(),\n    paidStatus: zEnum(['free', 'paid']),\n    categoryId: number(),\n    liveUrl: string().optional(),\n    accessUrl: string(),\n    description: string().max(1000),\n  }).parse)\n\n  await useDrizzle().insert(tables.templates).values({\n    hash: useHash(),\n    slug: useSlugify(body.title),\n    title: body.title,\n    categoryId: body.categoryId,\n    paidStatus: body.paidStatus,\n    liveUrl: body.liveUrl,\n    accessUrl: body.accessUrl,\n    description: body.description,\n    userId: user.id,\n  }).execute()\n\n  return sendNoContent(event, 201)\n})\n",[103,13226,13227,13263,13267,13289,13312,13316,13340,13352,13382,13395,13415,13428,13453,13465,13469,13499,13513,13533,13547,13561,13576,13591,13606,13620,13635,13647,13651,13667],{"__ignoreMap":101},[106,13228,13229,13231,13233,13236,13238,13240,13242,13244,13246,13248,13250,13253,13255,13257,13259,13261],{"class":108,"line":109},[106,13230,410],{"class":270},[106,13232,413],{"class":112},[106,13234,13235],{"class":224}," number",[106,13237,355],{"class":112},[106,13239,2257],{"class":224},[106,13241,355],{"class":112},[106,13243,3985],{"class":224},[106,13245,355],{"class":112},[106,13247,10751],{"class":224},[106,13249,3099],{"class":270},[106,13251,13252],{"class":224}," zEnum",[106,13254,419],{"class":112},[106,13256,422],{"class":270},[106,13258,425],{"class":112},[106,13260,428],{"class":142},[106,13262,431],{"class":112},[106,13264,13265],{"class":108,"line":123},[106,13266,330],{"emptyLinePlaceholder":329},[106,13268,13269,13271,13273,13275,13277,13279,13281,13283,13285,13287],{"class":108,"line":162},[106,13270,271],{"class":270},[106,13272,274],{"class":270},[106,13274,278],{"class":277},[106,13276,281],{"class":224},[106,13278,284],{"class":132},[106,13280,287],{"class":112},[106,13282,291],{"class":290},[106,13284,294],{"class":112},[106,13286,297],{"class":132},[106,13288,300],{"class":112},[106,13290,13291,13293,13295,13297,13299,13301,13303,13306,13308,13310],{"class":108,"line":200},[106,13292,305],{"class":132},[106,13294,413],{"class":112},[106,13296,5679],{"class":224},[106,13298,419],{"class":112},[106,13300,311],{"class":112},[106,13302,314],{"class":270},[106,13304,13305],{"class":277}," requireUserSession",[106,13307,281],{"class":116},[106,13309,291],{"class":224},[106,13311,324],{"class":116},[106,13313,13314],{"class":108,"line":221},[106,13315,330],{"emptyLinePlaceholder":329},[106,13317,13318,13320,13322,13324,13326,13328,13330,13332,13334,13336,13338],{"class":108,"line":228},[106,13319,305],{"class":132},[106,13321,308],{"class":224},[106,13323,311],{"class":112},[106,13325,314],{"class":270},[106,13327,470],{"class":277},[106,13329,281],{"class":116},[106,13331,291],{"class":224},[106,13333,355],{"class":112},[106,13335,2257],{"class":277},[106,13337,281],{"class":116},[106,13339,488],{"class":112},[106,13341,13342,13344,13346,13348,13350],{"class":108,"line":238},[106,13343,7822],{"class":116},[106,13345,496],{"class":112},[106,13347,3985],{"class":277},[106,13349,506],{"class":116},[106,13351,509],{"class":112},[106,13353,13354,13357,13359,13361,13364,13366,13368,13370,13372,13374,13376,13378,13380],{"class":108,"line":248},[106,13355,13356],{"class":116},"    paidStatus",[106,13358,496],{"class":112},[106,13360,13252],{"class":277},[106,13362,13363],{"class":116},"([",[106,13365,754],{"class":112},[106,13367,12843],{"class":142},[106,13369,754],{"class":112},[106,13371,355],{"class":112},[106,13373,425],{"class":112},[106,13375,12852],{"class":142},[106,13377,754],{"class":112},[106,13379,10924],{"class":116},[106,13381,509],{"class":112},[106,13383,13384,13387,13389,13391,13393],{"class":108,"line":534},[106,13385,13386],{"class":116},"    categoryId",[106,13388,496],{"class":112},[106,13390,13235],{"class":277},[106,13392,506],{"class":116},[106,13394,509],{"class":112},[106,13396,13397,13400,13402,13404,13406,13408,13411,13413],{"class":108,"line":539},[106,13398,13399],{"class":116},"    liveUrl",[106,13401,496],{"class":112},[106,13403,3985],{"class":277},[106,13405,506],{"class":116},[106,13407,52],{"class":112},[106,13409,13410],{"class":277},"optional",[106,13412,506],{"class":116},[106,13414,509],{"class":112},[106,13416,13417,13420,13422,13424,13426],{"class":108,"line":556},[106,13418,13419],{"class":116},"    accessUrl",[106,13421,496],{"class":112},[106,13423,3985],{"class":277},[106,13425,506],{"class":116},[106,13427,509],{"class":112},[106,13429,13430,13433,13435,13437,13439,13441,13444,13446,13449,13451],{"class":108,"line":759},[106,13431,13432],{"class":116},"    description",[106,13434,496],{"class":112},[106,13436,3985],{"class":277},[106,13438,506],{"class":116},[106,13440,52],{"class":112},[106,13442,13443],{"class":277},"max",[106,13445,281],{"class":116},[106,13447,13448],{"class":358},"1000",[106,13450,294],{"class":116},[106,13452,509],{"class":112},[106,13454,13455,13457,13459,13461,13463],{"class":108,"line":795},[106,13456,514],{"class":112},[106,13458,294],{"class":116},[106,13460,52],{"class":112},[106,13462,521],{"class":224},[106,13464,324],{"class":116},[106,13466,13467],{"class":108,"line":803},[106,13468,330],{"emptyLinePlaceholder":329},[106,13470,13471,13473,13475,13477,13479,13481,13483,13485,13487,13489,13491,13493,13495,13497],{"class":108,"line":809},[106,13472,2348],{"class":270},[106,13474,7070],{"class":277},[106,13476,506],{"class":116},[106,13478,52],{"class":112},[106,13480,7264],{"class":277},[106,13482,281],{"class":116},[106,13484,7155],{"class":224},[106,13486,52],{"class":112},[106,13488,11404],{"class":224},[106,13490,294],{"class":116},[106,13492,52],{"class":112},[106,13494,7279],{"class":277},[106,13496,281],{"class":116},[106,13498,488],{"class":112},[106,13500,13501,13504,13506,13509,13511],{"class":108,"line":814},[106,13502,13503],{"class":116},"    hash",[106,13505,496],{"class":112},[106,13507,13508],{"class":277}," useHash",[106,13510,506],{"class":116},[106,13512,509],{"class":112},[106,13514,13515,13517,13519,13521,13523,13525,13527,13529,13531],{"class":108,"line":830},[106,13516,7107],{"class":116},[106,13518,496],{"class":112},[106,13520,7292],{"class":277},[106,13522,281],{"class":116},[106,13524,7297],{"class":224},[106,13526,52],{"class":112},[106,13528,12717],{"class":224},[106,13530,294],{"class":116},[106,13532,509],{"class":112},[106,13534,13535,13537,13539,13541,13543,13545],{"class":108,"line":835},[106,13536,7822],{"class":116},[106,13538,496],{"class":112},[106,13540,308],{"class":224},[106,13542,52],{"class":112},[106,13544,12717],{"class":224},[106,13546,509],{"class":112},[106,13548,13549,13551,13553,13555,13557,13559],{"class":108,"line":840},[106,13550,13386],{"class":116},[106,13552,496],{"class":112},[106,13554,308],{"class":224},[106,13556,52],{"class":112},[106,13558,13088],{"class":224},[106,13560,509],{"class":112},[106,13562,13563,13565,13567,13569,13571,13574],{"class":108,"line":845},[106,13564,13356],{"class":116},[106,13566,496],{"class":112},[106,13568,308],{"class":224},[106,13570,52],{"class":112},[106,13572,13573],{"class":224},"paidStatus",[106,13575,509],{"class":112},[106,13577,13578,13580,13582,13584,13586,13589],{"class":108,"line":862},[106,13579,13399],{"class":116},[106,13581,496],{"class":112},[106,13583,308],{"class":224},[106,13585,52],{"class":112},[106,13587,13588],{"class":224},"liveUrl",[106,13590,509],{"class":112},[106,13592,13593,13595,13597,13599,13601,13604],{"class":108,"line":1469},[106,13594,13419],{"class":116},[106,13596,496],{"class":112},[106,13598,308],{"class":224},[106,13600,52],{"class":112},[106,13602,13603],{"class":224},"accessUrl",[106,13605,509],{"class":112},[106,13607,13608,13610,13612,13614,13616,13618],{"class":108,"line":1486},[106,13609,13432],{"class":116},[106,13611,496],{"class":112},[106,13613,308],{"class":224},[106,13615,52],{"class":112},[106,13617,12948],{"class":224},[106,13619,509],{"class":112},[106,13621,13622,13625,13627,13629,13631,13633],{"class":108,"line":1497},[106,13623,13624],{"class":116},"    userId",[106,13626,496],{"class":112},[106,13628,5679],{"class":224},[106,13630,52],{"class":112},[106,13632,7100],{"class":224},[106,13634,509],{"class":112},[106,13636,13637,13639,13641,13643,13645],{"class":108,"line":1508},[106,13638,514],{"class":112},[106,13640,294],{"class":116},[106,13642,52],{"class":112},[106,13644,7330],{"class":277},[106,13646,2366],{"class":116},[106,13648,13649],{"class":108,"line":1523},[106,13650,330],{"emptyLinePlaceholder":329},[106,13652,13653,13655,13657,13659,13661,13663,13665],{"class":108,"line":1538},[106,13654,345],{"class":270},[106,13656,348],{"class":277},[106,13658,281],{"class":116},[106,13660,291],{"class":224},[106,13662,355],{"class":112},[106,13664,359],{"class":358},[106,13666,324],{"class":116},[106,13668,13669,13671],{"class":108,"line":1545},[106,13670,366],{"class":112},[106,13672,324],{"class":224},[10,13674,13675,13676,13679,13680,13683,13684,11401,13686,13688],{},"Assez similaire au point de terminaison pour créer une nouvelle catégorie. ",[22,13677,13678],{},"Une différence"," est l'utilisation de l'utilitaire ",[103,13681,13682],{},"requireUserSession"," pour s'assurer que la demande provient d'un utilisateur authentifié et pour obtenir les données de l'utilisateur. J'utilise les données pour remplir la colonne ",[103,13685,13077],{},[103,13687,11404],{},". Bien sûr, je valide les données reçues du client à l'aide de Zod.",[10,13690,563,13691,13694,13695,52],{},[103,13692,13693],{},"useHash"," est un petit wrapper autour du package ",[103,13696,13697],{},"nanoid",[381,13699,13701],{"id":13700},"modèles-dans-linterface-dadministration","Modèles dans l'Interface d'Administration",[10,13703,13704],{},[29,13705,13706],{},"Pendant cette partie, j'ai rencontré un bug important. Lisez attentivement la partie suivante, cela pourrait vous faire gagner beaucoup de temps si vous prévoyez également d'utiliser Drizzle sur Cloudflare.",[10,13708,13709,13710,13713,13714],{},"Les utilisateurs sont capables de créer des modèles, c'est bien. Pour les surveiller et les approuver, je dois créer une page dans l'interface d'administration pour gérer les modèles. Cette page affichera les modèles dans un tableau et permettra à l'administrateur de les approuver ou de les refuser. ",[29,13711,13712],{},"Je n'implémente que la vue, pas l'action."," La tâche est simple, c'est comme toute autre page dans l'interface d'administration. ",[29,13715,13716],{},"Si seulement je savais...",[10,13718,13719,13720,2754],{},"Tout d'abord, créons le point de terminaison pour obtenir les modèles depuis la base de données. Je crée le point de terminaison ",[103,13721,13722],{},"server\u002Fapi\u002Ftemplates\u002Findex.get.ts",[96,13724,13726],{"className":261,"code":13725,"language":263,"meta":101,"style":101},"export default defineEventHandler(async () => {\n  const templates = await useDrizzle()\n    .select()\n    .from(tables.templates)\n    .leftJoin(tables.users, eq(tables.templates.userId, tables.users.id))\n    .leftJoin(tables.categories, eq(tables.templates.categoryId, tables.categories.id))\n\n  return templates\n})\n",[103,13727,13728,13746,13761,13769,13785,13831,13875,13879,13886],{"__ignoreMap":101},[106,13729,13730,13732,13734,13736,13738,13740,13742,13744],{"class":108,"line":109},[106,13731,271],{"class":270},[106,13733,274],{"class":270},[106,13735,278],{"class":277},[106,13737,281],{"class":224},[106,13739,284],{"class":132},[106,13741,7052],{"class":112},[106,13743,297],{"class":132},[106,13745,300],{"class":112},[106,13747,13748,13750,13753,13755,13757,13759],{"class":108,"line":123},[106,13749,305],{"class":132},[106,13751,13752],{"class":224}," templates",[106,13754,311],{"class":112},[106,13756,314],{"class":270},[106,13758,7070],{"class":277},[106,13760,2366],{"class":116},[106,13762,13763,13765,13767],{"class":108,"line":162},[106,13764,2818],{"class":112},[106,13766,7077],{"class":277},[106,13768,2366],{"class":116},[106,13770,13771,13773,13775,13777,13779,13781,13783],{"class":108,"line":200},[106,13772,2818],{"class":112},[106,13774,7150],{"class":277},[106,13776,281],{"class":116},[106,13778,7155],{"class":224},[106,13780,52],{"class":112},[106,13782,11404],{"class":224},[106,13784,324],{"class":116},[106,13786,13787,13789,13792,13794,13796,13798,13800,13802,13805,13807,13809,13811,13813,13815,13817,13819,13821,13823,13825,13827,13829],{"class":108,"line":221},[106,13788,2818],{"class":112},[106,13790,13791],{"class":277},"leftJoin",[106,13793,281],{"class":116},[106,13795,7155],{"class":224},[106,13797,52],{"class":112},[106,13799,6882],{"class":224},[106,13801,355],{"class":112},[106,13803,13804],{"class":277}," eq",[106,13806,281],{"class":116},[106,13808,7155],{"class":224},[106,13810,52],{"class":112},[106,13812,11404],{"class":224},[106,13814,52],{"class":112},[106,13816,13077],{"class":224},[106,13818,355],{"class":112},[106,13820,7091],{"class":224},[106,13822,52],{"class":112},[106,13824,6882],{"class":224},[106,13826,52],{"class":112},[106,13828,7100],{"class":224},[106,13830,1127],{"class":116},[106,13832,13833,13835,13837,13839,13841,13843,13845,13847,13849,13851,13853,13855,13857,13859,13861,13863,13865,13867,13869,13871,13873],{"class":108,"line":228},[106,13834,2818],{"class":112},[106,13836,13791],{"class":277},[106,13838,281],{"class":116},[106,13840,7155],{"class":224},[106,13842,52],{"class":112},[106,13844,6888],{"class":224},[106,13846,355],{"class":112},[106,13848,13804],{"class":277},[106,13850,281],{"class":116},[106,13852,7155],{"class":224},[106,13854,52],{"class":112},[106,13856,11404],{"class":224},[106,13858,52],{"class":112},[106,13860,13088],{"class":224},[106,13862,355],{"class":112},[106,13864,7091],{"class":224},[106,13866,52],{"class":112},[106,13868,6888],{"class":224},[106,13870,52],{"class":112},[106,13872,7100],{"class":224},[106,13874,1127],{"class":116},[106,13876,13877],{"class":108,"line":238},[106,13878,330],{"emptyLinePlaceholder":329},[106,13880,13881,13883],{"class":108,"line":248},[106,13882,345],{"class":270},[106,13884,13885],{"class":224}," templates\n",[106,13887,13888,13890],{"class":108,"line":534},[106,13889,366],{"class":112},[106,13891,324],{"class":224},[10,13893,13894],{},"Ce point de terminaison récupère tous les modèles de la base de données et les remplit à l'aide d'une jointure et de la relation que nous avons définie plus tôt dans le schéma.",[10,13896,13897,13898,13901,13902,13904,13905,13071,13907,13909,13910,13912,13913,13915,13916,1715,13918,52],{},"Une ",[103,13899,13900],{},"jointure"," est une opération SQL qui combine les lignes de deux ou plusieurs tables en fonction d'une colonne liée entre elles. Dans ce cas, je fais une jointure de la table ",[103,13903,11404],{}," avec la table ",[103,13906,6882],{},[103,13908,6888],{},". La méthode ",[103,13911,13791],{}," est utilisée pour conserver toutes les lignes de la table ",[103,13914,11404],{},", même s'il n'y a pas de correspondance dans les tables ",[103,13917,6882],{},[103,13919,6888],{},[10,13921,13922],{},[5535,13923],{"alt":13924,"src":13925},"Une explication schématique de l'opération de jointure.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fleft-join.png",[10,13927,13928,13929,13931],{},"Maintenant que j'ai les données, je peux les afficher sur l'UI. J'utilise le composant ",[103,13930,7738],{}," de Nuxt UI Pro pour afficher les modèles dans un tableau :",[96,13933,13935],{"className":98,"code":13934,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst columns = [{\n  key: 'id',\n  label: '#',\n}, {\n  key: 'title',\n  label: 'Title',\n  sortable: true,\n}, {\n  key: 'status',\n  label: 'Status',\n  sortable: true,\n}, {\n  key: 'paidStatus',\n  label: 'Paid Status',\n  sortable: true,\n}, {\n  key: 'liveUrl',\n  label: 'Live URL',\n}, {\n  key: 'accessUrl',\n  label: 'Access URL',\n}, {\n  key: 'description',\n  label: 'Description',\n}, {\n  key: 'category',\n  label: 'Category',\n}, {\n  key: 'createdBy',\n  label: 'Created By',\n}]\n\nconst { data: templates, refresh, pending } = await useFetch('\u002Fapi\u002Ftemplates', {\n  deep: false,\n  lazy: true,\n  default: () => [],\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUTable\n    :columns=\"columns\"\n    :rows=\"templates\"\n    :loading=\"pending\"\n  >\n    \u003Ctemplate #liveUrl-data=\"{ row }\">\n      \u003CUButton\n        v-if=\"row.liveUrl\"\n        variant=\"link\"\n        target=\"_blank\"\n        :to=\"row.liveUrl\"\n        class=\"flex flex-row items-center gap-1\"\n      >\n        \u003Cspan>{{ row.liveUrl }}\u003C\u002Fspan>\n\n        \u003Cspan class=\"i-heroicons-arrow-top-right-on-square-16-solid inline-block h-4 w-4\" \u002F>\n      \u003C\u002FUButton>\n      \u003Cspan v-else> - \u003C\u002Fspan>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #accessUrl-data=\"{ row }\">\n      \u003CUButton\n        variant=\"link\"\n        target=\"_blank\"\n        :to=\"row.accessUrl\"\n        class=\"flex flex-row items-center gap-1\"\n      >\n        \u003Cspan>{{ row.accessUrl }}\u003C\u002Fspan>\n\n        \u003Cspan class=\"i-heroicons-arrow-top-right-on-square-16-solid inline-block h-4 w-4\" \u002F>\n      \u003C\u002FUButton>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #paidStatus-data=\"{ row }\">\n      \u003Ctemplate v-if=\"row.paidStatus === 'free'\">\n        \u003CUBadge\n          color=\"green\"\n          variant=\"subtle\"\n        >\n          Free\n        \u003C\u002FUBadge>\n      \u003C\u002Ftemplate>\n      \u003Ctemplate v-else-if=\"row.paidStatus === 'paid'\">\n        \u003CUBadge\n          color=\"yellow\"\n          variant=\"subtle\"\n        >\n          Paid\n        \u003C\u002FUBadge>\n      \u003C\u002Ftemplate>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #createdBy-data=\"{ row }\">\n      \u003Cdiv class=\"flex flex-row items-center gap-2\">\n        \u003Cimg\n          :src=\"row.user.avatarUrl\"\n          alt=\"avatar\"\n          class=\"h-6 w-6 rounded-full\"\n        >\n        \u003Cspan>{{ row.user.name ?? row.user.login }}\u003C\u002Fspan>\n      \u003C\u002Fdiv>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #category-data=\"{ row }\">\n      {{ row.category.name }}\n    \u003C\u002Ftemplate>\n  \u003C\u002FUTable>\n\u003C\u002Ftemplate>\n",[103,13936,13937,13957,13969,13983,13997,14003,14017,14032,14042,14048,14062,14077,14087,14093,14107,14122,14132,14138,14152,14167,14173,14187,14202,14208,14222,14237,14243,14258,14273,14279,14294,14309,14315,14319,14361,14371,14381,14396,14402,14410,14414,14422,14429,14444,14458,14472,14476,14501,14507,14524,14538,14550,14570,14584,14589,14606,14610,14630,14638,14658,14666,14691,14697,14709,14721,14739,14751,14755,14772,14776,14794,14802,14810,14835,14866,14874,14889,14903,14909,14915,14924,14933,14963,14970,14984,14997,15002,15008,15017,15026,15035,15061,15081,15089,15104,15119,15134,15139,15157,15166,15175,15201,15207,15216,15225],{"__ignoreMap":101},[106,13938,13939,13941,13943,13945,13947,13949,13951,13953,13955],{"class":108,"line":109},[106,13940,113],{"class":112},[106,13942,1189],{"class":116},[106,13944,1192],{"class":132},[106,13946,136],{"class":112},[106,13948,139],{"class":112},[106,13950,263],{"class":142},[106,13952,139],{"class":112},[106,13954,1203],{"class":132},[106,13956,120],{"class":112},[106,13958,13959,13961,13963,13965,13967],{"class":108,"line":123},[106,13960,1252],{"class":132},[106,13962,7529],{"class":224},[106,13964,136],{"class":112},[106,13966,2538],{"class":224},[106,13968,488],{"class":112},[106,13970,13971,13973,13975,13977,13979,13981],{"class":108,"line":162},[106,13972,7540],{"class":116},[106,13974,496],{"class":112},[106,13976,425],{"class":112},[106,13978,7100],{"class":142},[106,13980,754],{"class":112},[106,13982,509],{"class":112},[106,13984,13985,13987,13989,13991,13993,13995],{"class":108,"line":200},[106,13986,7555],{"class":116},[106,13988,496],{"class":112},[106,13990,425],{"class":112},[106,13992,7562],{"class":142},[106,13994,754],{"class":112},[106,13996,509],{"class":112},[106,13998,13999,14001],{"class":108,"line":221},[106,14000,7569],{"class":112},[106,14002,300],{"class":112},[106,14004,14005,14007,14009,14011,14013,14015],{"class":108,"line":228},[106,14006,7540],{"class":116},[106,14008,496],{"class":112},[106,14010,425],{"class":112},[106,14012,12717],{"class":142},[106,14014,754],{"class":112},[106,14016,509],{"class":112},[106,14018,14019,14021,14023,14025,14028,14030],{"class":108,"line":238},[106,14020,7555],{"class":116},[106,14022,496],{"class":112},[106,14024,425],{"class":112},[106,14026,14027],{"class":142},"Title",[106,14029,754],{"class":112},[106,14031,509],{"class":112},[106,14033,14034,14036,14038,14040],{"class":108,"line":248},[106,14035,7604],{"class":116},[106,14037,496],{"class":112},[106,14039,5653],{"class":5652},[106,14041,509],{"class":112},[106,14043,14044,14046],{"class":108,"line":534},[106,14045,7569],{"class":112},[106,14047,300],{"class":112},[106,14049,14050,14052,14054,14056,14058,14060],{"class":108,"line":539},[106,14051,7540],{"class":116},[106,14053,496],{"class":112},[106,14055,425],{"class":112},[106,14057,12745],{"class":142},[106,14059,754],{"class":112},[106,14061,509],{"class":112},[106,14063,14064,14066,14068,14070,14073,14075],{"class":108,"line":556},[106,14065,7555],{"class":116},[106,14067,496],{"class":112},[106,14069,425],{"class":112},[106,14071,14072],{"class":142},"Status",[106,14074,754],{"class":112},[106,14076,509],{"class":112},[106,14078,14079,14081,14083,14085],{"class":108,"line":759},[106,14080,7604],{"class":116},[106,14082,496],{"class":112},[106,14084,5653],{"class":5652},[106,14086,509],{"class":112},[106,14088,14089,14091],{"class":108,"line":795},[106,14090,7569],{"class":112},[106,14092,300],{"class":112},[106,14094,14095,14097,14099,14101,14103,14105],{"class":108,"line":803},[106,14096,7540],{"class":116},[106,14098,496],{"class":112},[106,14100,425],{"class":112},[106,14102,13573],{"class":142},[106,14104,754],{"class":112},[106,14106,509],{"class":112},[106,14108,14109,14111,14113,14115,14118,14120],{"class":108,"line":809},[106,14110,7555],{"class":116},[106,14112,496],{"class":112},[106,14114,425],{"class":112},[106,14116,14117],{"class":142},"Paid Status",[106,14119,754],{"class":112},[106,14121,509],{"class":112},[106,14123,14124,14126,14128,14130],{"class":108,"line":814},[106,14125,7604],{"class":116},[106,14127,496],{"class":112},[106,14129,5653],{"class":5652},[106,14131,509],{"class":112},[106,14133,14134,14136],{"class":108,"line":830},[106,14135,7569],{"class":112},[106,14137,300],{"class":112},[106,14139,14140,14142,14144,14146,14148,14150],{"class":108,"line":835},[106,14141,7540],{"class":116},[106,14143,496],{"class":112},[106,14145,425],{"class":112},[106,14147,13588],{"class":142},[106,14149,754],{"class":112},[106,14151,509],{"class":112},[106,14153,14154,14156,14158,14160,14163,14165],{"class":108,"line":840},[106,14155,7555],{"class":116},[106,14157,496],{"class":112},[106,14159,425],{"class":112},[106,14161,14162],{"class":142},"Live URL",[106,14164,754],{"class":112},[106,14166,509],{"class":112},[106,14168,14169,14171],{"class":108,"line":845},[106,14170,7569],{"class":112},[106,14172,300],{"class":112},[106,14174,14175,14177,14179,14181,14183,14185],{"class":108,"line":862},[106,14176,7540],{"class":116},[106,14178,496],{"class":112},[106,14180,425],{"class":112},[106,14182,13603],{"class":142},[106,14184,754],{"class":112},[106,14186,509],{"class":112},[106,14188,14189,14191,14193,14195,14198,14200],{"class":108,"line":1469},[106,14190,7555],{"class":116},[106,14192,496],{"class":112},[106,14194,425],{"class":112},[106,14196,14197],{"class":142},"Access URL",[106,14199,754],{"class":112},[106,14201,509],{"class":112},[106,14203,14204,14206],{"class":108,"line":1486},[106,14205,7569],{"class":112},[106,14207,300],{"class":112},[106,14209,14210,14212,14214,14216,14218,14220],{"class":108,"line":1497},[106,14211,7540],{"class":116},[106,14213,496],{"class":112},[106,14215,425],{"class":112},[106,14217,12948],{"class":142},[106,14219,754],{"class":112},[106,14221,509],{"class":112},[106,14223,14224,14226,14228,14230,14233,14235],{"class":108,"line":1508},[106,14225,7555],{"class":116},[106,14227,496],{"class":112},[106,14229,425],{"class":112},[106,14231,14232],{"class":142},"Description",[106,14234,754],{"class":112},[106,14236,509],{"class":112},[106,14238,14239,14241],{"class":108,"line":1523},[106,14240,7569],{"class":112},[106,14242,300],{"class":112},[106,14244,14245,14247,14249,14251,14254,14256],{"class":108,"line":1538},[106,14246,7540],{"class":116},[106,14248,496],{"class":112},[106,14250,425],{"class":112},[106,14252,14253],{"class":142},"category",[106,14255,754],{"class":112},[106,14257,509],{"class":112},[106,14259,14260,14262,14264,14266,14269,14271],{"class":108,"line":1545},[106,14261,7555],{"class":116},[106,14263,496],{"class":112},[106,14265,425],{"class":112},[106,14267,14268],{"class":142},"Category",[106,14270,754],{"class":112},[106,14272,509],{"class":112},[106,14274,14275,14277],{"class":108,"line":1550},[106,14276,7569],{"class":112},[106,14278,300],{"class":112},[106,14280,14281,14283,14285,14287,14290,14292],{"class":108,"line":1555},[106,14282,7540],{"class":116},[106,14284,496],{"class":112},[106,14286,425],{"class":112},[106,14288,14289],{"class":142},"createdBy",[106,14291,754],{"class":112},[106,14293,509],{"class":112},[106,14295,14296,14298,14300,14302,14305,14307],{"class":108,"line":1561},[106,14297,7555],{"class":116},[106,14299,496],{"class":112},[106,14301,425],{"class":112},[106,14303,14304],{"class":142},"Created By",[106,14306,754],{"class":112},[106,14308,509],{"class":112},[106,14310,14311,14313],{"class":108,"line":1570},[106,14312,366],{"class":112},[106,14314,3725],{"class":224},[106,14316,14317],{"class":108,"line":1575},[106,14318,330],{"emptyLinePlaceholder":329},[106,14320,14321,14323,14325,14327,14329,14331,14333,14336,14338,14340,14342,14344,14346,14348,14350,14352,14355,14357,14359],{"class":108,"line":1584},[106,14322,1252],{"class":132},[106,14324,413],{"class":112},[106,14326,964],{"class":116},[106,14328,496],{"class":112},[106,14330,13752],{"class":224},[106,14332,355],{"class":112},[106,14334,14335],{"class":224}," refresh",[106,14337,355],{"class":112},[106,14339,7671],{"class":224},[106,14341,366],{"class":112},[106,14343,311],{"class":112},[106,14345,314],{"class":270},[106,14347,7680],{"class":277},[106,14349,281],{"class":224},[106,14351,754],{"class":112},[106,14353,14354],{"class":142},"\u002Fapi\u002Ftemplates",[106,14356,754],{"class":112},[106,14358,355],{"class":112},[106,14360,300],{"class":112},[106,14362,14363,14365,14367,14369],{"class":108,"line":1605},[106,14364,7698],{"class":116},[106,14366,496],{"class":112},[106,14368,7703],{"class":5652},[106,14370,509],{"class":112},[106,14372,14373,14375,14377,14379],{"class":108,"line":1648},[106,14374,10225],{"class":116},[106,14376,496],{"class":112},[106,14378,5653],{"class":5652},[106,14380,509],{"class":112},[106,14382,14383,14385,14387,14389,14391,14394],{"class":108,"line":1667},[106,14384,10239],{"class":277},[106,14386,496],{"class":112},[106,14388,7052],{"class":112},[106,14390,297],{"class":132},[106,14392,14393],{"class":224}," []",[106,14395,509],{"class":112},[106,14397,14398,14400],{"class":108,"line":1672},[106,14399,366],{"class":112},[106,14401,324],{"class":224},[106,14403,14404,14406,14408],{"class":108,"line":1681},[106,14405,251],{"class":112},[106,14407,1189],{"class":116},[106,14409,120],{"class":112},[106,14411,14412],{"class":108,"line":1690},[106,14413,330],{"emptyLinePlaceholder":329},[106,14415,14416,14418,14420],{"class":108,"line":6491},[106,14417,113],{"class":112},[106,14419,117],{"class":116},[106,14421,120],{"class":112},[106,14423,14424,14426],{"class":108,"line":6510},[106,14425,126],{"class":112},[106,14427,14428],{"class":116},"UTable\n",[106,14430,14431,14434,14436,14438,14440,14442],{"class":108,"line":6527},[106,14432,14433],{"class":112},"    :",[106,14435,7743],{"class":132},[106,14437,136],{"class":112},[106,14439,139],{"class":112},[106,14441,7743],{"class":224},[106,14443,7832],{"class":112},[106,14445,14446,14448,14450,14452,14454,14456],{"class":108,"line":6533},[106,14447,14433],{"class":112},[106,14449,7756],{"class":132},[106,14451,136],{"class":112},[106,14453,139],{"class":112},[106,14455,11404],{"class":224},[106,14457,7832],{"class":112},[106,14459,14460,14462,14464,14466,14468,14470],{"class":108,"line":6549},[106,14461,14433],{"class":112},[106,14463,7769],{"class":132},[106,14465,136],{"class":112},[106,14467,139],{"class":112},[106,14469,7776],{"class":224},[106,14471,7832],{"class":112},[106,14473,14474],{"class":108,"line":6559},[106,14475,7837],{"class":112},[106,14477,14478,14480,14482,14484,14487,14489,14491,14493,14495,14497,14499],{"class":108,"line":6575},[106,14479,165],{"class":112},[106,14481,117],{"class":116},[106,14483,7846],{"class":112},[106,14485,14486],{"class":132},"liveUrl-data",[106,14488,136],{"class":112},[106,14490,139],{"class":112},[106,14492,1285],{"class":112},[106,14494,9218],{"class":224},[106,14496,366],{"class":112},[106,14498,139],{"class":112},[106,14500,120],{"class":112},[106,14502,14503,14505],{"class":108,"line":6581},[106,14504,3376],{"class":112},[106,14506,7858],{"class":116},[106,14508,14509,14512,14514,14516,14518,14520,14522],{"class":108,"line":6586},[106,14510,14511],{"class":270},"        v-if",[106,14513,136],{"class":112},[106,14515,139],{"class":112},[106,14517,9256],{"class":224},[106,14519,52],{"class":112},[106,14521,13588],{"class":224},[106,14523,7832],{"class":112},[106,14525,14526,14529,14531,14533,14536],{"class":108,"line":6592},[106,14527,14528],{"class":132},"        variant",[106,14530,136],{"class":112},[106,14532,139],{"class":112},[106,14534,14535],{"class":142},"link",[106,14537,7832],{"class":112},[106,14539,14540,14542,14544,14546,14548],{"class":108,"line":6597},[106,14541,12239],{"class":132},[106,14543,136],{"class":112},[106,14545,139],{"class":112},[106,14547,8432],{"class":142},[106,14549,7832],{"class":112},[106,14551,14552,14555,14558,14560,14562,14564,14566,14568],{"class":108,"line":6614},[106,14553,14554],{"class":112},"        :",[106,14556,14557],{"class":132},"to",[106,14559,136],{"class":112},[106,14561,139],{"class":112},[106,14563,9256],{"class":224},[106,14565,52],{"class":112},[106,14567,13588],{"class":224},[106,14569,7832],{"class":112},[106,14571,14572,14575,14577,14579,14582],{"class":108,"line":6619},[106,14573,14574],{"class":132},"        class",[106,14576,136],{"class":112},[106,14578,139],{"class":112},[106,14580,14581],{"class":142},"flex flex-row items-center gap-1",[106,14583,7832],{"class":112},[106,14585,14586],{"class":108,"line":6624},[106,14587,14588],{"class":112},"      >\n",[106,14590,14591,14593,14595,14597,14600,14602,14604],{"class":108,"line":6630},[106,14592,9202],{"class":112},[106,14594,106],{"class":116},[106,14596,3308],{"class":112},[106,14598,14599],{"class":224},"{{ row.liveUrl }}",[106,14601,251],{"class":112},[106,14603,106],{"class":116},[106,14605,120],{"class":112},[106,14607,14608],{"class":108,"line":6635},[106,14609,330],{"emptyLinePlaceholder":329},[106,14611,14612,14614,14616,14619,14621,14623,14626,14628],{"class":108,"line":6666},[106,14613,9202],{"class":112},[106,14615,106],{"class":116},[106,14617,14618],{"class":132}," class",[106,14620,136],{"class":112},[106,14622,139],{"class":112},[106,14624,14625],{"class":142},"i-heroicons-arrow-top-right-on-square-16-solid inline-block h-4 w-4",[106,14627,139],{"class":112},[106,14629,6988],{"class":112},[106,14631,14632,14634,14636],{"class":108,"line":6676},[106,14633,3439],{"class":112},[106,14635,8358],{"class":116},[106,14637,120],{"class":112},[106,14639,14640,14642,14644,14647,14649,14652,14654,14656],{"class":108,"line":6701},[106,14641,3376],{"class":112},[106,14643,106],{"class":116},[106,14645,14646],{"class":132}," v-else",[106,14648,3308],{"class":112},[106,14650,14651],{"class":224}," - ",[106,14653,251],{"class":112},[106,14655,106],{"class":116},[106,14657,120],{"class":112},[106,14659,14660,14662,14664],{"class":108,"line":6706},[106,14661,231],{"class":112},[106,14663,117],{"class":116},[106,14665,120],{"class":112},[106,14667,14668,14670,14672,14674,14677,14679,14681,14683,14685,14687,14689],{"class":108,"line":6713},[106,14669,165],{"class":112},[106,14671,117],{"class":116},[106,14673,7846],{"class":112},[106,14675,14676],{"class":132},"accessUrl-data",[106,14678,136],{"class":112},[106,14680,139],{"class":112},[106,14682,1285],{"class":112},[106,14684,9218],{"class":224},[106,14686,366],{"class":112},[106,14688,139],{"class":112},[106,14690,120],{"class":112},[106,14692,14693,14695],{"class":108,"line":6718},[106,14694,3376],{"class":112},[106,14696,7858],{"class":116},[106,14698,14699,14701,14703,14705,14707],{"class":108,"line":6723},[106,14700,14528],{"class":132},[106,14702,136],{"class":112},[106,14704,139],{"class":112},[106,14706,14535],{"class":142},[106,14708,7832],{"class":112},[106,14710,14711,14713,14715,14717,14719],{"class":108,"line":6729},[106,14712,12239],{"class":132},[106,14714,136],{"class":112},[106,14716,139],{"class":112},[106,14718,8432],{"class":142},[106,14720,7832],{"class":112},[106,14722,14723,14725,14727,14729,14731,14733,14735,14737],{"class":108,"line":6735},[106,14724,14554],{"class":112},[106,14726,14557],{"class":132},[106,14728,136],{"class":112},[106,14730,139],{"class":112},[106,14732,9256],{"class":224},[106,14734,52],{"class":112},[106,14736,13603],{"class":224},[106,14738,7832],{"class":112},[106,14740,14741,14743,14745,14747,14749],{"class":108,"line":6741},[106,14742,14574],{"class":132},[106,14744,136],{"class":112},[106,14746,139],{"class":112},[106,14748,14581],{"class":142},[106,14750,7832],{"class":112},[106,14752,14753],{"class":108,"line":6747},[106,14754,14588],{"class":112},[106,14756,14757,14759,14761,14763,14766,14768,14770],{"class":108,"line":6752},[106,14758,9202],{"class":112},[106,14760,106],{"class":116},[106,14762,3308],{"class":112},[106,14764,14765],{"class":224},"{{ row.accessUrl }}",[106,14767,251],{"class":112},[106,14769,106],{"class":116},[106,14771,120],{"class":112},[106,14773,14774],{"class":108,"line":6767},[106,14775,330],{"emptyLinePlaceholder":329},[106,14777,14778,14780,14782,14784,14786,14788,14790,14792],{"class":108,"line":6774},[106,14779,9202],{"class":112},[106,14781,106],{"class":116},[106,14783,14618],{"class":132},[106,14785,136],{"class":112},[106,14787,139],{"class":112},[106,14789,14625],{"class":142},[106,14791,139],{"class":112},[106,14793,6988],{"class":112},[106,14795,14796,14798,14800],{"class":108,"line":6782},[106,14797,3439],{"class":112},[106,14799,8358],{"class":116},[106,14801,120],{"class":112},[106,14803,14804,14806,14808],{"class":108,"line":6789},[106,14805,231],{"class":112},[106,14807,117],{"class":116},[106,14809,120],{"class":112},[106,14811,14812,14814,14816,14818,14821,14823,14825,14827,14829,14831,14833],{"class":108,"line":6796},[106,14813,165],{"class":112},[106,14815,117],{"class":116},[106,14817,7846],{"class":112},[106,14819,14820],{"class":132},"paidStatus-data",[106,14822,136],{"class":112},[106,14824,139],{"class":112},[106,14826,1285],{"class":112},[106,14828,9218],{"class":224},[106,14830,366],{"class":112},[106,14832,139],{"class":112},[106,14834,120],{"class":112},[106,14836,14838,14840,14842,14845,14847,14849,14851,14853,14856,14858,14860,14862,14864],{"class":108,"line":14837},74,[106,14839,3376],{"class":112},[106,14841,117],{"class":116},[106,14843,14844],{"class":270}," v-if",[106,14846,136],{"class":112},[106,14848,139],{"class":112},[106,14850,9256],{"class":224},[106,14852,52],{"class":112},[106,14854,14855],{"class":224},"paidStatus ",[106,14857,10547],{"class":112},[106,14859,425],{"class":112},[106,14861,12843],{"class":142},[106,14863,9290],{"class":112},[106,14865,120],{"class":112},[106,14867,14869,14871],{"class":108,"line":14868},75,[106,14870,9202],{"class":112},[106,14872,14873],{"class":116},"UBadge\n",[106,14875,14877,14880,14882,14884,14887],{"class":108,"line":14876},76,[106,14878,14879],{"class":132},"          color",[106,14881,136],{"class":112},[106,14883,139],{"class":112},[106,14885,14886],{"class":142},"green",[106,14888,7832],{"class":112},[106,14890,14892,14895,14897,14899,14901],{"class":108,"line":14891},77,[106,14893,14894],{"class":132},"          variant",[106,14896,136],{"class":112},[106,14898,139],{"class":112},[106,14900,9242],{"class":142},[106,14902,7832],{"class":112},[106,14904,14906],{"class":108,"line":14905},78,[106,14907,14908],{"class":112},"        >\n",[106,14910,14912],{"class":108,"line":14911},79,[106,14913,14914],{"class":224},"          Free\n",[106,14916,14918,14920,14922],{"class":108,"line":14917},80,[106,14919,9311],{"class":112},[106,14921,9232],{"class":116},[106,14923,120],{"class":112},[106,14925,14927,14929,14931],{"class":108,"line":14926},81,[106,14928,3439],{"class":112},[106,14930,117],{"class":116},[106,14932,120],{"class":112},[106,14934,14936,14938,14940,14943,14945,14947,14949,14951,14953,14955,14957,14959,14961],{"class":108,"line":14935},82,[106,14937,3376],{"class":112},[106,14939,117],{"class":116},[106,14941,14942],{"class":270}," v-else-if",[106,14944,136],{"class":112},[106,14946,139],{"class":112},[106,14948,9256],{"class":224},[106,14950,52],{"class":112},[106,14952,14855],{"class":224},[106,14954,10547],{"class":112},[106,14956,425],{"class":112},[106,14958,12852],{"class":142},[106,14960,9290],{"class":112},[106,14962,120],{"class":112},[106,14964,14966,14968],{"class":108,"line":14965},83,[106,14967,9202],{"class":112},[106,14969,14873],{"class":116},[106,14971,14973,14975,14977,14979,14982],{"class":108,"line":14972},84,[106,14974,14879],{"class":132},[106,14976,136],{"class":112},[106,14978,139],{"class":112},[106,14980,14981],{"class":142},"yellow",[106,14983,7832],{"class":112},[106,14985,14987,14989,14991,14993,14995],{"class":108,"line":14986},85,[106,14988,14894],{"class":132},[106,14990,136],{"class":112},[106,14992,139],{"class":112},[106,14994,9242],{"class":142},[106,14996,7832],{"class":112},[106,14998,15000],{"class":108,"line":14999},86,[106,15001,14908],{"class":112},[106,15003,15005],{"class":108,"line":15004},87,[106,15006,15007],{"class":224},"          Paid\n",[106,15009,15011,15013,15015],{"class":108,"line":15010},88,[106,15012,9311],{"class":112},[106,15014,9232],{"class":116},[106,15016,120],{"class":112},[106,15018,15020,15022,15024],{"class":108,"line":15019},89,[106,15021,3439],{"class":112},[106,15023,117],{"class":116},[106,15025,120],{"class":112},[106,15027,15029,15031,15033],{"class":108,"line":15028},90,[106,15030,231],{"class":112},[106,15032,117],{"class":116},[106,15034,120],{"class":112},[106,15036,15038,15040,15042,15044,15047,15049,15051,15053,15055,15057,15059],{"class":108,"line":15037},91,[106,15039,165],{"class":112},[106,15041,117],{"class":116},[106,15043,7846],{"class":112},[106,15045,15046],{"class":132},"createdBy-data",[106,15048,136],{"class":112},[106,15050,139],{"class":112},[106,15052,1285],{"class":112},[106,15054,9218],{"class":224},[106,15056,366],{"class":112},[106,15058,139],{"class":112},[106,15060,120],{"class":112},[106,15062,15064,15066,15068,15070,15072,15074,15077,15079],{"class":108,"line":15063},92,[106,15065,3376],{"class":112},[106,15067,3296],{"class":116},[106,15069,14618],{"class":132},[106,15071,136],{"class":112},[106,15073,139],{"class":112},[106,15075,15076],{"class":142},"flex flex-row items-center gap-2",[106,15078,139],{"class":112},[106,15080,120],{"class":112},[106,15082,15084,15086],{"class":108,"line":15083},93,[106,15085,9202],{"class":112},[106,15087,15088],{"class":116},"img\n",[106,15090,15092,15095,15097,15099,15102],{"class":108,"line":15091},94,[106,15093,15094],{"class":132},"          :src",[106,15096,136],{"class":112},[106,15098,139],{"class":112},[106,15100,15101],{"class":142},"row.user.avatarUrl",[106,15103,7832],{"class":112},[106,15105,15107,15110,15112,15114,15117],{"class":108,"line":15106},95,[106,15108,15109],{"class":132},"          alt",[106,15111,136],{"class":112},[106,15113,139],{"class":112},[106,15115,15116],{"class":142},"avatar",[106,15118,7832],{"class":112},[106,15120,15122,15125,15127,15129,15132],{"class":108,"line":15121},96,[106,15123,15124],{"class":132},"          class",[106,15126,136],{"class":112},[106,15128,139],{"class":112},[106,15130,15131],{"class":142},"h-6 w-6 rounded-full",[106,15133,7832],{"class":112},[106,15135,15137],{"class":108,"line":15136},97,[106,15138,14908],{"class":112},[106,15140,15142,15144,15146,15148,15151,15153,15155],{"class":108,"line":15141},98,[106,15143,9202],{"class":112},[106,15145,106],{"class":116},[106,15147,3308],{"class":112},[106,15149,15150],{"class":224},"{{ row.user.name ?? row.user.login }}",[106,15152,251],{"class":112},[106,15154,106],{"class":116},[106,15156,120],{"class":112},[106,15158,15160,15162,15164],{"class":108,"line":15159},99,[106,15161,3439],{"class":112},[106,15163,3296],{"class":116},[106,15165,120],{"class":112},[106,15167,15169,15171,15173],{"class":108,"line":15168},100,[106,15170,231],{"class":112},[106,15172,117],{"class":116},[106,15174,120],{"class":112},[106,15176,15178,15180,15182,15184,15187,15189,15191,15193,15195,15197,15199],{"class":108,"line":15177},101,[106,15179,165],{"class":112},[106,15181,117],{"class":116},[106,15183,7846],{"class":112},[106,15185,15186],{"class":132},"category-data",[106,15188,136],{"class":112},[106,15190,139],{"class":112},[106,15192,1285],{"class":112},[106,15194,9218],{"class":224},[106,15196,366],{"class":112},[106,15198,139],{"class":112},[106,15200,120],{"class":112},[106,15202,15204],{"class":108,"line":15203},102,[106,15205,15206],{"class":224},"      {{ row.category.name }}\n",[106,15208,15210,15212,15214],{"class":108,"line":15209},103,[106,15211,231],{"class":112},[106,15213,117],{"class":116},[106,15215,120],{"class":112},[106,15217,15219,15221,15223],{"class":108,"line":15218},104,[106,15220,241],{"class":112},[106,15222,7738],{"class":116},[106,15224,120],{"class":112},[106,15226,15228,15230,15232],{"class":108,"line":15227},105,[106,15229,251],{"class":112},[106,15231,117],{"class":116},[106,15233,120],{"class":112},[10,15235,15236,15239],{},[22,15237,15238],{},"Mais cela ne fonctionne pas."," Étant habitué à Adonis, j'étais familiarisé avec un objet comme celui-ci :",[96,15241,15245],{"className":15242,"code":15243,"language":15244,"meta":101,"style":101},"language-json shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","{\n  \"id\": 1,\n  \"title\": \"My template\",\n  \"status\": \"submitted\",\n  \"liveUrl\": \"https:\u002F\u002Fexample.com\",\n  \"user\": {\n    \"id\": 1,\n    \"name\": \"John Doe\",\n    \"avatarUrl\": \"https:\u002F\u002Fexample.com\u002Favatar.jpg\"\n  },\n  \"category\": {\n    \"id\": 1,\n    \"name\": \"Landing Page\"\n  }\n}\n","json",[103,15246,15247,15251,15266,15286,15304,15323,15335,15350,15369,15387,15391,15403,15417,15434,15438],{"__ignoreMap":101},[106,15248,15249],{"class":108,"line":109},[106,15250,488],{"class":112},[106,15252,15253,15256,15258,15260,15262,15264],{"class":108,"line":123},[106,15254,15255],{"class":112},"  \"",[106,15257,7100],{"class":132},[106,15259,139],{"class":112},[106,15261,496],{"class":112},[106,15263,11068],{"class":358},[106,15265,509],{"class":112},[106,15267,15268,15270,15272,15274,15276,15279,15282,15284],{"class":108,"line":162},[106,15269,15255],{"class":112},[106,15271,12717],{"class":132},[106,15273,139],{"class":112},[106,15275,496],{"class":112},[106,15277,15278],{"class":112}," \"",[106,15280,15281],{"class":142},"My template",[106,15283,139],{"class":112},[106,15285,509],{"class":112},[106,15287,15288,15290,15292,15294,15296,15298,15300,15302],{"class":108,"line":200},[106,15289,15255],{"class":112},[106,15291,12745],{"class":132},[106,15293,139],{"class":112},[106,15295,496],{"class":112},[106,15297,15278],{"class":112},[106,15299,12762],{"class":142},[106,15301,139],{"class":112},[106,15303,509],{"class":112},[106,15305,15306,15308,15310,15312,15314,15316,15319,15321],{"class":108,"line":221},[106,15307,15255],{"class":112},[106,15309,13588],{"class":132},[106,15311,139],{"class":112},[106,15313,496],{"class":112},[106,15315,15278],{"class":112},[106,15317,15318],{"class":142},"https:\u002F\u002Fexample.com",[106,15320,139],{"class":112},[106,15322,509],{"class":112},[106,15324,15325,15327,15329,15331,15333],{"class":108,"line":228},[106,15326,15255],{"class":112},[106,15328,9967],{"class":132},[106,15330,139],{"class":112},[106,15332,496],{"class":112},[106,15334,300],{"class":112},[106,15336,15337,15340,15342,15344,15346,15348],{"class":108,"line":238},[106,15338,15339],{"class":112},"    \"",[106,15341,7100],{"class":1041},[106,15343,139],{"class":112},[106,15345,496],{"class":112},[106,15347,11068],{"class":358},[106,15349,509],{"class":112},[106,15351,15352,15354,15356,15358,15360,15362,15365,15367],{"class":108,"line":248},[106,15353,15339],{"class":112},[106,15355,3858],{"class":1041},[106,15357,139],{"class":112},[106,15359,496],{"class":112},[106,15361,15278],{"class":112},[106,15363,15364],{"class":142},"John Doe",[106,15366,139],{"class":112},[106,15368,509],{"class":112},[106,15370,15371,15373,15376,15378,15380,15382,15385],{"class":108,"line":534},[106,15372,15339],{"class":112},[106,15374,15375],{"class":1041},"avatarUrl",[106,15377,139],{"class":112},[106,15379,496],{"class":112},[106,15381,15278],{"class":112},[106,15383,15384],{"class":142},"https:\u002F\u002Fexample.com\u002Favatar.jpg",[106,15386,7832],{"class":112},[106,15388,15389],{"class":108,"line":539},[106,15390,5660],{"class":112},[106,15392,15393,15395,15397,15399,15401],{"class":108,"line":556},[106,15394,15255],{"class":112},[106,15396,14253],{"class":132},[106,15398,139],{"class":112},[106,15400,496],{"class":112},[106,15402,300],{"class":112},[106,15404,15405,15407,15409,15411,15413,15415],{"class":108,"line":759},[106,15406,15339],{"class":112},[106,15408,7100],{"class":1041},[106,15410,139],{"class":112},[106,15412,496],{"class":112},[106,15414,11068],{"class":358},[106,15416,509],{"class":112},[106,15418,15419,15421,15423,15425,15427,15429,15432],{"class":108,"line":795},[106,15420,15339],{"class":112},[106,15422,3858],{"class":1041},[106,15424,139],{"class":112},[106,15426,496],{"class":112},[106,15428,15278],{"class":112},[106,15430,15431],{"class":142},"Landing Page",[106,15433,7832],{"class":112},[106,15435,15436],{"class":108,"line":803},[106,15437,806],{"class":112},[106,15439,15440],{"class":108,"line":809},[106,15441,1558],{"class":112},[10,15443,15444],{},"Mais j'ai reçu un objet comme celui-ci :",[96,15446,15448],{"className":15242,"code":15447,"language":15244,"meta":101,"style":101},"{\n  \"template\": {\n    \"id\": 1,\n    \"title\": \"My template\",\n    \"status\": \"submitted\",\n    \"liveUrl\": \"https:\u002F\u002Fexample.com\"\n  },\n  \"user\": {\n  },\n  \"category\": {\n  }\n}\n",[103,15449,15450,15454,15466,15480,15498,15516,15532,15536,15548,15552,15564,15568],{"__ignoreMap":101},[106,15451,15452],{"class":108,"line":109},[106,15453,488],{"class":112},[106,15455,15456,15458,15460,15462,15464],{"class":108,"line":123},[106,15457,15255],{"class":112},[106,15459,117],{"class":132},[106,15461,139],{"class":112},[106,15463,496],{"class":112},[106,15465,300],{"class":112},[106,15467,15468,15470,15472,15474,15476,15478],{"class":108,"line":162},[106,15469,15339],{"class":112},[106,15471,7100],{"class":1041},[106,15473,139],{"class":112},[106,15475,496],{"class":112},[106,15477,11068],{"class":358},[106,15479,509],{"class":112},[106,15481,15482,15484,15486,15488,15490,15492,15494,15496],{"class":108,"line":200},[106,15483,15339],{"class":112},[106,15485,12717],{"class":1041},[106,15487,139],{"class":112},[106,15489,496],{"class":112},[106,15491,15278],{"class":112},[106,15493,15281],{"class":142},[106,15495,139],{"class":112},[106,15497,509],{"class":112},[106,15499,15500,15502,15504,15506,15508,15510,15512,15514],{"class":108,"line":221},[106,15501,15339],{"class":112},[106,15503,12745],{"class":1041},[106,15505,139],{"class":112},[106,15507,496],{"class":112},[106,15509,15278],{"class":112},[106,15511,12762],{"class":142},[106,15513,139],{"class":112},[106,15515,509],{"class":112},[106,15517,15518,15520,15522,15524,15526,15528,15530],{"class":108,"line":228},[106,15519,15339],{"class":112},[106,15521,13588],{"class":1041},[106,15523,139],{"class":112},[106,15525,496],{"class":112},[106,15527,15278],{"class":112},[106,15529,15318],{"class":142},[106,15531,7832],{"class":112},[106,15533,15534],{"class":108,"line":238},[106,15535,5660],{"class":112},[106,15537,15538,15540,15542,15544,15546],{"class":108,"line":248},[106,15539,15255],{"class":112},[106,15541,9967],{"class":132},[106,15543,139],{"class":112},[106,15545,496],{"class":112},[106,15547,300],{"class":112},[106,15549,15550],{"class":108,"line":534},[106,15551,5660],{"class":112},[106,15553,15554,15556,15558,15560,15562],{"class":108,"line":539},[106,15555,15255],{"class":112},[106,15557,14253],{"class":132},[106,15559,139],{"class":112},[106,15561,496],{"class":112},[106,15563,300],{"class":112},[106,15565,15566],{"class":108,"line":556},[106,15567,806],{"class":112},[106,15569,15570],{"class":108,"line":759},[106,15571,1558],{"class":112},[10,15573,15574,15575],{},"et quelle surprise de voir que les clés et les valeurs étaient échangées entre elles. ",[29,15576,15577],{},"Difficile à expliquer mais voyez l'exemple.",[96,15579,15581],{"className":15242,"code":15580,"language":15244,"meta":101,"style":101},"{\n  \"template\": {\n    \"id\": \"My template\",\n    \"status\": \"https:\u002F\u002Fexample.com\"\n  },\n  \"user\": {\n    \"id\": \"John Doe\",\n    \"login\": \"https:\u002F\u002Favatars.github.com\u002Fu\u002F1\"\n  }\n}\n",[103,15582,15583,15587,15599,15617,15633,15637,15649,15667,15684,15688],{"__ignoreMap":101},[106,15584,15585],{"class":108,"line":109},[106,15586,488],{"class":112},[106,15588,15589,15591,15593,15595,15597],{"class":108,"line":123},[106,15590,15255],{"class":112},[106,15592,117],{"class":132},[106,15594,139],{"class":112},[106,15596,496],{"class":112},[106,15598,300],{"class":112},[106,15600,15601,15603,15605,15607,15609,15611,15613,15615],{"class":108,"line":162},[106,15602,15339],{"class":112},[106,15604,7100],{"class":1041},[106,15606,139],{"class":112},[106,15608,496],{"class":112},[106,15610,15278],{"class":112},[106,15612,15281],{"class":142},[106,15614,139],{"class":112},[106,15616,509],{"class":112},[106,15618,15619,15621,15623,15625,15627,15629,15631],{"class":108,"line":200},[106,15620,15339],{"class":112},[106,15622,12745],{"class":1041},[106,15624,139],{"class":112},[106,15626,496],{"class":112},[106,15628,15278],{"class":112},[106,15630,15318],{"class":142},[106,15632,7832],{"class":112},[106,15634,15635],{"class":108,"line":221},[106,15636,5660],{"class":112},[106,15638,15639,15641,15643,15645,15647],{"class":108,"line":228},[106,15640,15255],{"class":112},[106,15642,9967],{"class":132},[106,15644,139],{"class":112},[106,15646,496],{"class":112},[106,15648,300],{"class":112},[106,15650,15651,15653,15655,15657,15659,15661,15663,15665],{"class":108,"line":238},[106,15652,15339],{"class":112},[106,15654,7100],{"class":1041},[106,15656,139],{"class":112},[106,15658,496],{"class":112},[106,15660,15278],{"class":112},[106,15662,15364],{"class":142},[106,15664,139],{"class":112},[106,15666,509],{"class":112},[106,15668,15669,15671,15673,15675,15677,15679,15682],{"class":108,"line":248},[106,15670,15339],{"class":112},[106,15672,11431],{"class":1041},[106,15674,139],{"class":112},[106,15676,496],{"class":112},[106,15678,15278],{"class":112},[106,15680,15681],{"class":142},"https:\u002F\u002Favatars.github.com\u002Fu\u002F1",[106,15683,7832],{"class":112},[106,15685,15686],{"class":108,"line":534},[106,15687,806],{"class":112},[106,15689,15690],{"class":108,"line":539},[106,15691,1558],{"class":112},[10,15693,15694,15695],{},"Très difficile de manipuler les données maintenant ! ",[29,15696,15697],{},"L'ID devrait être un nombre et le login ne devrait définitivement pas être une URL.",[10,15699,15700,15701,72,15705,15709,15710,15714],{},"Après une longue recherche, j'ai trouvé deux problèmes : ",[14,15702,15703],{"href":15703,"rel":15704},"https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworkers-sdk\u002Fissues\u002F3160",[18],[14,15706,15707],{"href":15707,"rel":15708},"https:\u002F\u002Fgithub.com\u002Fdrizzle-team\u002Fdrizzle-orm\u002Fissues\u002F555",[18]," expliquant pourquoi cela ne fonctionne pas. Lorsque deux tables ont le même nom de colonne, ",[15711,15712,15713],"del",{},"Drizzle et"," Cloudflare sont incapables de les résoudre correctement et mélangent les données. La solution consiste à sélectionner explicitement les colonnes et à renommer les colonnes similaires.",[96,15716,15718],{"className":261,"code":15717,"language":263,"meta":101,"style":101},"const templates = await useDrizzle().select({\n  id: tables.templates.id,\n  title: tables.templates.title,\n  description: tables.templates.description,\n  status: tables.templates.status,\n  paidStatus: tables.templates.paidStatus,\n  liveUrl: tables.templates.liveUrl,\n  accessUrl: tables.templates.accessUrl,\n  user: {\n    name: tables.users.name,\n    login: tables.users.login,\n    avatarUrl: tables.users.avatarUrl,\n  },\n  category: {\n    name: sql\u003Cstring>`${tables.categories.name}`.as('c_name'),\n  },\n}).from(tables.templates).leftJoin(tables.users, eq(tables.templates.userId, tables.users.id)).leftJoin(tables.categories, eq(tables.templates.categoryId, tables.categories.id))\n",[103,15719,15720,15742,15760,15778,15796,15814,15832,15850,15868,15877,15895,15914,15933,15937,15946,15992,15996],{"__ignoreMap":101},[106,15721,15722,15724,15726,15728,15730,15732,15734,15736,15738,15740],{"class":108,"line":109},[106,15723,1252],{"class":132},[106,15725,12586],{"class":224},[106,15727,136],{"class":112},[106,15729,314],{"class":270},[106,15731,7070],{"class":277},[106,15733,506],{"class":224},[106,15735,52],{"class":112},[106,15737,7077],{"class":277},[106,15739,281],{"class":224},[106,15741,488],{"class":112},[106,15743,15744,15746,15748,15750,15752,15754,15756,15758],{"class":108,"line":123},[106,15745,9400],{"class":116},[106,15747,496],{"class":112},[106,15749,7091],{"class":224},[106,15751,52],{"class":112},[106,15753,11404],{"class":224},[106,15755,52],{"class":112},[106,15757,7100],{"class":224},[106,15759,509],{"class":112},[106,15761,15762,15764,15766,15768,15770,15772,15774,15776],{"class":108,"line":162},[106,15763,12706],{"class":116},[106,15765,496],{"class":112},[106,15767,7091],{"class":224},[106,15769,52],{"class":112},[106,15771,11404],{"class":224},[106,15773,52],{"class":112},[106,15775,12717],{"class":224},[106,15777,509],{"class":112},[106,15779,15780,15782,15784,15786,15788,15790,15792,15794],{"class":108,"line":200},[106,15781,12937],{"class":116},[106,15783,496],{"class":112},[106,15785,7091],{"class":224},[106,15787,52],{"class":112},[106,15789,11404],{"class":224},[106,15791,52],{"class":112},[106,15793,12948],{"class":224},[106,15795,509],{"class":112},[106,15797,15798,15800,15802,15804,15806,15808,15810,15812],{"class":108,"line":221},[106,15799,12734],{"class":116},[106,15801,496],{"class":112},[106,15803,7091],{"class":224},[106,15805,52],{"class":112},[106,15807,11404],{"class":224},[106,15809,52],{"class":112},[106,15811,12745],{"class":224},[106,15813,509],{"class":112},[106,15815,15816,15818,15820,15822,15824,15826,15828,15830],{"class":108,"line":228},[106,15817,12815],{"class":116},[106,15819,496],{"class":112},[106,15821,7091],{"class":224},[106,15823,52],{"class":112},[106,15825,11404],{"class":224},[106,15827,52],{"class":112},[106,15829,13573],{"class":224},[106,15831,509],{"class":112},[106,15833,15834,15836,15838,15840,15842,15844,15846,15848],{"class":108,"line":238},[106,15835,12887],{"class":116},[106,15837,496],{"class":112},[106,15839,7091],{"class":224},[106,15841,52],{"class":112},[106,15843,11404],{"class":224},[106,15845,52],{"class":112},[106,15847,13588],{"class":224},[106,15849,509],{"class":112},[106,15851,15852,15854,15856,15858,15860,15862,15864,15866],{"class":108,"line":248},[106,15853,12909],{"class":116},[106,15855,496],{"class":112},[106,15857,7091],{"class":224},[106,15859,52],{"class":112},[106,15861,11404],{"class":224},[106,15863,52],{"class":112},[106,15865,13603],{"class":224},[106,15867,509],{"class":112},[106,15869,15870,15873,15875],{"class":108,"line":534},[106,15871,15872],{"class":116},"  user",[106,15874,496],{"class":112},[106,15876,300],{"class":112},[106,15878,15879,15881,15883,15885,15887,15889,15891,15893],{"class":108,"line":539},[106,15880,493],{"class":116},[106,15882,496],{"class":112},[106,15884,7091],{"class":224},[106,15886,52],{"class":112},[106,15888,6882],{"class":224},[106,15890,52],{"class":112},[106,15892,3858],{"class":224},[106,15894,509],{"class":112},[106,15896,15897,15900,15902,15904,15906,15908,15910,15912],{"class":108,"line":556},[106,15898,15899],{"class":116},"    login",[106,15901,496],{"class":112},[106,15903,7091],{"class":224},[106,15905,52],{"class":112},[106,15907,6882],{"class":224},[106,15909,52],{"class":112},[106,15911,11431],{"class":224},[106,15913,509],{"class":112},[106,15915,15916,15919,15921,15923,15925,15927,15929,15931],{"class":108,"line":759},[106,15917,15918],{"class":116},"    avatarUrl",[106,15920,496],{"class":112},[106,15922,7091],{"class":224},[106,15924,52],{"class":112},[106,15926,6882],{"class":224},[106,15928,52],{"class":112},[106,15930,15375],{"class":224},[106,15932,509],{"class":112},[106,15934,15935],{"class":108,"line":795},[106,15936,5660],{"class":112},[106,15938,15939,15942,15944],{"class":108,"line":803},[106,15940,15941],{"class":116},"  category",[106,15943,496],{"class":112},[106,15945,300],{"class":112},[106,15947,15948,15950,15952,15954,15956,15958,15960,15963,15965,15967,15969,15971,15973,15975,15977,15979,15981,15983,15986,15988,15990],{"class":108,"line":809},[106,15949,493],{"class":116},[106,15951,496],{"class":112},[106,15953,11768],{"class":277},[106,15955,113],{"class":112},[106,15957,503],{"class":1041},[106,15959,3308],{"class":112},[106,15961,15962],{"class":112},"`${",[106,15964,7155],{"class":224},[106,15966,52],{"class":112},[106,15968,6888],{"class":224},[106,15970,52],{"class":112},[106,15972,3858],{"class":224},[106,15974,6696],{"class":112},[106,15976,52],{"class":112},[106,15978,3659],{"class":277},[106,15980,281],{"class":224},[106,15982,754],{"class":112},[106,15984,15985],{"class":142},"c_name",[106,15987,754],{"class":112},[106,15989,294],{"class":224},[106,15991,509],{"class":112},[106,15993,15994],{"class":108,"line":814},[106,15995,5660],{"class":112},[106,15997,15998,16000,16002,16004,16006,16008,16010,16013,16015,16017,16019,16021,16023,16025,16027,16029,16031,16033,16035,16037,16039,16041,16043,16045,16047,16050,16052,16054,16056,16058,16060,16062,16064,16066,16068,16070,16072,16074,16076,16078,16080,16082,16084],{"class":108,"line":830},[106,15999,366],{"class":112},[106,16001,294],{"class":224},[106,16003,52],{"class":112},[106,16005,7150],{"class":277},[106,16007,10841],{"class":224},[106,16009,52],{"class":112},[106,16011,16012],{"class":224},"templates)",[106,16014,52],{"class":112},[106,16016,13791],{"class":277},[106,16018,10841],{"class":224},[106,16020,52],{"class":112},[106,16022,6882],{"class":224},[106,16024,355],{"class":112},[106,16026,13804],{"class":277},[106,16028,10841],{"class":224},[106,16030,52],{"class":112},[106,16032,11404],{"class":224},[106,16034,52],{"class":112},[106,16036,13077],{"class":224},[106,16038,355],{"class":112},[106,16040,7091],{"class":224},[106,16042,52],{"class":112},[106,16044,6882],{"class":224},[106,16046,52],{"class":112},[106,16048,16049],{"class":224},"id))",[106,16051,52],{"class":112},[106,16053,13791],{"class":277},[106,16055,10841],{"class":224},[106,16057,52],{"class":112},[106,16059,6888],{"class":224},[106,16061,355],{"class":112},[106,16063,13804],{"class":277},[106,16065,10841],{"class":224},[106,16067,52],{"class":112},[106,16069,11404],{"class":224},[106,16071,52],{"class":112},[106,16073,13088],{"class":224},[106,16075,355],{"class":112},[106,16077,7091],{"class":224},[106,16079,52],{"class":112},[106,16081,6888],{"class":224},[106,16083,52],{"class":112},[106,16085,16086],{"class":224},"id))\n",[10,16088,16089,16092,16093,11428,16095,16097,16098,72,16100,16102],{},[103,16090,16091],{},"sql\u003Cstring>'${tables.categories.name}'.as('c_name')"," est le code important. Je renomme la colonne ",[103,16094,3858],{},[103,16096,15985],{}," pour éviter le conflit. J'utilise également cette syntaxe pour formater l'objet avec les clés ",[103,16099,9967],{},[103,16101,14253],{},". Maintenant, tout fonctionne comme prévu.",[381,16104,5430],{"id":16105},"conclusion-2",[10,16107,16108],{},"Travailler sur le côté client, et pas seulement sur la partie admin, rend le projet encore plus concret. Il reste encore beaucoup de travail sur le formulaire de modèle comme le markdown ou l'image, mais c'est un très bon début.",[10,16110,16111],{},"J'apprécie vraiment ce projet et j'espère que vous aussi.",[54,16113,16115],{"id":16114},"poursuivre-le-modèle-de-formulaire","Poursuivre le modèle de formulaire",[10,16117,16118],{},[5535,16119],{"alt":16120,"src":16121},"Vignette de mon huitième direct.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-20.png",[10,16123,16124],{},[29,16125,16126,6828,16129,5553,16132,5558,16135,52],{},[14,16127,5546],{"href":5513,"rel":16128},[18],[14,16130,5552],{"href":5550,"rel":16131},[18],[14,16133,5557],{"href":5503,"rel":16134},[18],[14,16136,5562],{"href":5513,"rel":16137},[18],[10,16139,16140],{},"Ce direct était court, donc j'essaie de faire des choses intéressantes. Je fais principalement trois choses :",[5915,16142,16143,16146,16149],{},[5918,16144,16145],{},"Créer la page de modèle de l'émission",[5918,16147,16148],{},"Gérer le markdown côté serveur pour la description du modèle",[5918,16150,16151],{},"Ajouter un éditeur de markdown pour la description du modèle",[10,16153,16154,16155],{},"Voyons comment ça se passe. ",[29,16156,16157],{},"Pendant le direct, ça se passe bien mais depuis, j'ai complètement réécrit la partie éditeur de markdown.",[381,16159,16161],{"id":16160},"page-de-modèle-de-lémission","Page de modèle de l'émission",[10,16163,16164,16165,16168,16169,16171,16172,16174,16175,16177,16178,16180,16181,16183,16184,16186],{},"Cet élément était davantage lié à l'utilisation de l'URL qu'au design de la page. En effet, l'URL fonctionnera comme Notion ou Dev.to : ",[103,16166,16167],{},"\u002F\u003Cslug>-\u003Chash>"," où le ",[103,16170,7120],{}," est le nom du modèle et le ",[103,16173,12656],{}," est un identifiant unique. Le ",[103,16176,7120],{}," est utilisé pour le SEO et le ",[103,16179,12656],{}," est utilisé pour obtenir le modèle. Le ",[103,16182,7120],{}," n'est pas unique et peut changer avec le temps. Le ",[103,16185,12656],{}," est unique et sert à obtenir le modèle.",[10,16188,16189],{},"Sur la page, nous commençons par obtenir les paramètres de la route :",[96,16191,16193],{"className":261,"code":16192,"language":263,"meta":101,"style":101},"const route = useRoute()\nconst slug = route.params.slug \u002F\u002F \u003Cslug>-\u003Chash>\n",[103,16194,16195,16209],{"__ignoreMap":101},[106,16196,16197,16199,16202,16204,16207],{"class":108,"line":109},[106,16198,1252],{"class":132},[106,16200,16201],{"class":224}," route ",[106,16203,136],{"class":112},[106,16205,16206],{"class":277}," useRoute",[106,16208,2366],{"class":224},[106,16210,16211,16213,16216,16218,16221,16223,16226,16228,16231],{"class":108,"line":123},[106,16212,1252],{"class":132},[106,16214,16215],{"class":224}," slug ",[106,16217,136],{"class":112},[106,16219,16220],{"class":224}," route",[106,16222,52],{"class":112},[106,16224,16225],{"class":224},"params",[106,16227,52],{"class":112},[106,16229,16230],{"class":224},"slug ",[106,16232,16233],{"class":335},"\u002F\u002F \u003Cslug>-\u003Chash>\n",[10,16235,16236,16237,16239,16240,16242,16243,16245,16246,16248,16249,16251,16252,2754],{},"La variable ",[103,16238,7120],{}," contient à la fois le ",[103,16241,7120],{}," du modèle et le ",[103,16244,12656],{},". Nous savons que le hash fait exactement 12 caractères donc nous pouvons découper le ",[103,16247,7120],{}," pour obtenir le ",[103,16250,7120],{}," et le ",[103,16253,12656],{},[96,16255,16257],{"className":261,"code":16256,"language":263,"meta":101,"style":101},"slug.slice(-12)\n",[103,16258,16259],{"__ignoreMap":101},[106,16260,16261,16263,16265,16267,16269,16272,16275],{"class":108,"line":109},[106,16262,7120],{"class":224},[106,16264,52],{"class":112},[106,16266,11046],{"class":277},[106,16268,281],{"class":224},[106,16270,16271],{"class":112},"-",[106,16273,16274],{"class":358},"12",[106,16276,324],{"class":224},[10,16278,16279],{},"Enfin, nous pouvons faire une requête au backend pour récupérer le modèle. Tout cela nous donne ce code :",[96,16281,16283],{"className":98,"code":16282,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst route = useRoute()\nconst hash = computed(() => {\n  return (route.params.slug as string).slice(-12)\n})\nconst { data: template } = await useFetch(`\u002Fapi\u002Ftemplates\u002F${hash.value}`, {\n  deep: false,\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Carticle\n    v-if=\"template\"\n  >\n    {{ template }}\n  \u003C\u002Farticle>\n\u003C\u002Ftemplate>\n",[103,16284,16285,16305,16317,16337,16372,16378,16420,16430,16436,16444,16448,16456,16463,16476,16480,16485,16494],{"__ignoreMap":101},[106,16286,16287,16289,16291,16293,16295,16297,16299,16301,16303],{"class":108,"line":109},[106,16288,113],{"class":112},[106,16290,1189],{"class":116},[106,16292,1192],{"class":132},[106,16294,136],{"class":112},[106,16296,139],{"class":112},[106,16298,263],{"class":142},[106,16300,139],{"class":112},[106,16302,1203],{"class":132},[106,16304,120],{"class":112},[106,16306,16307,16309,16311,16313,16315],{"class":108,"line":123},[106,16308,1252],{"class":132},[106,16310,16201],{"class":224},[106,16312,136],{"class":112},[106,16314,16206],{"class":277},[106,16316,2366],{"class":224},[106,16318,16319,16321,16324,16326,16329,16331,16333,16335],{"class":108,"line":162},[106,16320,1252],{"class":132},[106,16322,16323],{"class":224}," hash ",[106,16325,136],{"class":112},[106,16327,16328],{"class":277}," computed",[106,16330,281],{"class":224},[106,16332,506],{"class":112},[106,16334,297],{"class":132},[106,16336,300],{"class":112},[106,16338,16339,16341,16343,16346,16348,16350,16352,16354,16356,16358,16360,16362,16364,16366,16368,16370],{"class":108,"line":200},[106,16340,345],{"class":270},[106,16342,287],{"class":116},[106,16344,16345],{"class":224},"route",[106,16347,52],{"class":112},[106,16349,16225],{"class":224},[106,16351,52],{"class":112},[106,16353,7120],{"class":224},[106,16355,3099],{"class":270},[106,16357,3985],{"class":1041},[106,16359,294],{"class":116},[106,16361,52],{"class":112},[106,16363,11046],{"class":277},[106,16365,281],{"class":116},[106,16367,16271],{"class":112},[106,16369,16274],{"class":358},[106,16371,324],{"class":116},[106,16373,16374,16376],{"class":108,"line":221},[106,16375,366],{"class":112},[106,16377,324],{"class":224},[106,16379,16380,16382,16384,16386,16388,16391,16393,16395,16397,16399,16401,16403,16406,16408,16410,16412,16414,16416,16418],{"class":108,"line":228},[106,16381,1252],{"class":132},[106,16383,413],{"class":112},[106,16385,964],{"class":116},[106,16387,496],{"class":112},[106,16389,16390],{"class":224}," template ",[106,16392,366],{"class":112},[106,16394,311],{"class":112},[106,16396,314],{"class":270},[106,16398,7680],{"class":277},[106,16400,281],{"class":224},[106,16402,11771],{"class":112},[106,16404,16405],{"class":142},"\u002Fapi\u002Ftemplates\u002F",[106,16407,6690],{"class":112},[106,16409,12656],{"class":224},[106,16411,52],{"class":112},[106,16413,9764],{"class":224},[106,16415,6696],{"class":112},[106,16417,355],{"class":112},[106,16419,300],{"class":112},[106,16421,16422,16424,16426,16428],{"class":108,"line":238},[106,16423,7698],{"class":116},[106,16425,496],{"class":112},[106,16427,7703],{"class":5652},[106,16429,509],{"class":112},[106,16431,16432,16434],{"class":108,"line":248},[106,16433,366],{"class":112},[106,16435,324],{"class":224},[106,16437,16438,16440,16442],{"class":108,"line":534},[106,16439,251],{"class":112},[106,16441,1189],{"class":116},[106,16443,120],{"class":112},[106,16445,16446],{"class":108,"line":539},[106,16447,330],{"emptyLinePlaceholder":329},[106,16449,16450,16452,16454],{"class":108,"line":556},[106,16451,113],{"class":112},[106,16453,117],{"class":116},[106,16455,120],{"class":112},[106,16457,16458,16460],{"class":108,"line":759},[106,16459,126],{"class":112},[106,16461,16462],{"class":116},"article\n",[106,16464,16465,16468,16470,16472,16474],{"class":108,"line":795},[106,16466,16467],{"class":132},"    v-if",[106,16469,136],{"class":112},[106,16471,139],{"class":112},[106,16473,117],{"class":142},[106,16475,7832],{"class":112},[106,16477,16478],{"class":108,"line":803},[106,16479,7837],{"class":112},[106,16481,16482],{"class":108,"line":809},[106,16483,16484],{"class":224},"    {{ template }}\n",[106,16486,16487,16489,16492],{"class":108,"line":814},[106,16488,241],{"class":112},[106,16490,16491],{"class":116},"article",[106,16493,120],{"class":112},[106,16495,16496,16498,16500],{"class":108,"line":830},[106,16497,251],{"class":112},[106,16499,117],{"class":116},[106,16501,120],{"class":112},[10,16503,16504,16505,2754],{},"La partie serveur n'est pas si compliquée puisque nous obtenons seulement le hash à partir du paramètre de route et recherchons le modèle en utilisant une clause ",[103,16506,8623],{},[96,16508,16510],{"className":261,"code":16509,"language":263,"meta":101,"style":101},"import { object, string } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const params = await getValidatedRouterParams(event, object({\n    hash: string().length(12),\n  }).parse)\n\n  const { hash } = params\n\n  const template = await useDrizzle().select().from(tables.templates).where(eq(tables.templates.hash, hash))\n\n  return template\n})\n",[103,16511,16512,16534,16538,16560,16585,16608,16620,16624,16640,16644,16705,16709,16716],{"__ignoreMap":101},[106,16513,16514,16516,16518,16520,16522,16524,16526,16528,16530,16532],{"class":108,"line":109},[106,16515,410],{"class":270},[106,16517,413],{"class":112},[106,16519,2257],{"class":224},[106,16521,355],{"class":112},[106,16523,3985],{"class":224},[106,16525,419],{"class":112},[106,16527,422],{"class":270},[106,16529,425],{"class":112},[106,16531,428],{"class":142},[106,16533,431],{"class":112},[106,16535,16536],{"class":108,"line":123},[106,16537,330],{"emptyLinePlaceholder":329},[106,16539,16540,16542,16544,16546,16548,16550,16552,16554,16556,16558],{"class":108,"line":162},[106,16541,271],{"class":270},[106,16543,274],{"class":270},[106,16545,278],{"class":277},[106,16547,281],{"class":224},[106,16549,284],{"class":132},[106,16551,287],{"class":112},[106,16553,291],{"class":290},[106,16555,294],{"class":112},[106,16557,297],{"class":132},[106,16559,300],{"class":112},[106,16561,16562,16564,16566,16568,16570,16573,16575,16577,16579,16581,16583],{"class":108,"line":200},[106,16563,305],{"class":132},[106,16565,8645],{"class":224},[106,16567,311],{"class":112},[106,16569,314],{"class":270},[106,16571,16572],{"class":277}," getValidatedRouterParams",[106,16574,281],{"class":116},[106,16576,291],{"class":224},[106,16578,355],{"class":112},[106,16580,2257],{"class":277},[106,16582,281],{"class":116},[106,16584,488],{"class":112},[106,16586,16587,16589,16591,16593,16595,16597,16600,16602,16604,16606],{"class":108,"line":221},[106,16588,13503],{"class":116},[106,16590,496],{"class":112},[106,16592,3985],{"class":277},[106,16594,506],{"class":116},[106,16596,52],{"class":112},[106,16598,16599],{"class":277},"length",[106,16601,281],{"class":116},[106,16603,16274],{"class":358},[106,16605,294],{"class":116},[106,16607,509],{"class":112},[106,16609,16610,16612,16614,16616,16618],{"class":108,"line":228},[106,16611,514],{"class":112},[106,16613,294],{"class":116},[106,16615,52],{"class":112},[106,16617,521],{"class":224},[106,16619,324],{"class":116},[106,16621,16622],{"class":108,"line":238},[106,16623,330],{"emptyLinePlaceholder":329},[106,16625,16626,16628,16630,16633,16635,16637],{"class":108,"line":248},[106,16627,305],{"class":132},[106,16629,413],{"class":112},[106,16631,16632],{"class":224}," hash",[106,16634,419],{"class":112},[106,16636,311],{"class":112},[106,16638,16639],{"class":224}," params\n",[106,16641,16642],{"class":108,"line":534},[106,16643,330],{"emptyLinePlaceholder":329},[106,16645,16646,16648,16651,16653,16655,16657,16659,16661,16663,16665,16667,16669,16671,16673,16675,16677,16679,16681,16683,16685,16687,16689,16691,16693,16695,16697,16699,16701,16703],{"class":108,"line":539},[106,16647,305],{"class":132},[106,16649,16650],{"class":224}," template",[106,16652,311],{"class":112},[106,16654,314],{"class":270},[106,16656,7070],{"class":277},[106,16658,506],{"class":116},[106,16660,52],{"class":112},[106,16662,7077],{"class":277},[106,16664,506],{"class":116},[106,16666,52],{"class":112},[106,16668,7150],{"class":277},[106,16670,281],{"class":116},[106,16672,7155],{"class":224},[106,16674,52],{"class":112},[106,16676,11404],{"class":224},[106,16678,294],{"class":116},[106,16680,52],{"class":112},[106,16682,8623],{"class":277},[106,16684,281],{"class":116},[106,16686,8628],{"class":277},[106,16688,281],{"class":116},[106,16690,7155],{"class":224},[106,16692,52],{"class":112},[106,16694,11404],{"class":224},[106,16696,52],{"class":112},[106,16698,12656],{"class":224},[106,16700,355],{"class":112},[106,16702,16632],{"class":224},[106,16704,1127],{"class":116},[106,16706,16707],{"class":108,"line":556},[106,16708,330],{"emptyLinePlaceholder":329},[106,16710,16711,16713],{"class":108,"line":759},[106,16712,345],{"class":270},[106,16714,16715],{"class":224}," template\n",[106,16717,16718,16720],{"class":108,"line":795},[106,16719,366],{"class":112},[106,16721,324],{"class":224},[10,16723,16724],{},"Plus tard, nous devrons joindre les données de l'utilisateur et les données de la catégorie pour avoir un modèle complet.",[381,16726,16728],{"id":16727},"markdown-côté-serveur","Markdown côté serveur",[10,16730,16731],{},"L'idée de recevoir du markdown du client est de le nettoyer facilement pour éviter les injections HTML.",[10,16733,16734],{},"Imaginez si le client envoie ce code HTML :",[96,16736,16740],{"className":16737,"code":16738,"language":16739,"meta":101,"style":101},"language-html shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u003Cp> Hello World!\u003C\u002Fp>\n\u003Cscript> console.log('hacked') \u003C\u002Fscript>\n","html",[103,16741,16742,16759],{"__ignoreMap":101},[106,16743,16744,16746,16748,16750,16753,16755,16757],{"class":108,"line":109},[106,16745,113],{"class":112},[106,16747,10],{"class":116},[106,16749,3308],{"class":112},[106,16751,16752],{"class":224}," Hello World!",[106,16754,251],{"class":112},[106,16756,10],{"class":116},[106,16758,120],{"class":112},[106,16760,16761,16763,16765,16767,16770,16772,16775,16777,16779,16782,16784,16786,16788,16790],{"class":108,"line":123},[106,16762,113],{"class":112},[106,16764,1189],{"class":116},[106,16766,3308],{"class":112},[106,16768,16769],{"class":224}," console",[106,16771,52],{"class":112},[106,16773,16774],{"class":277},"log",[106,16776,281],{"class":224},[106,16778,754],{"class":112},[106,16780,16781],{"class":142},"hacked",[106,16783,754],{"class":112},[106,16785,713],{"class":224},[106,16787,251],{"class":112},[106,16789,1189],{"class":116},[106,16791,120],{"class":112},[10,16793,16794,16795,16798],{},"Si le serveur stocke ce contenu dans la base de données et l'affiche ensuite en utilisant la directive ",[103,16796,16797],{},"v-html",", chaque utilisateur qui verra la page exécutera le script. C'est un réel problème de sécurité.",[10,16800,16801,16802,16805,16806,16809],{},"En recevant du markdown du client, je peux facilement le transformer en utilisant ",[103,16803,16804],{},"markdown-it"," et stocker le ",[22,16807,16808],{},"HTML nettoyé"," dans la base de données. Le HTML nettoyé est du HTML qui ne contient aucun script ou balise dangereuse.",[10,16811,16812,16813,16815,16816,16818,16819,16822],{},"Pour ce faire, je dois installer ",[103,16814,16804],{}," et mettre à jour le point de terminaison pour créer un modèle ",[103,16817,13220],{},". J'ajoute également une nouvelle colonne ",[103,16820,16821],{},"descriptionHTML"," au schéma pour stocker le HTML nettoyé. Stocker le HTML évite de le faire chaque fois que nous rendons la page.",[96,16824,16826],{"className":261,"code":16825,"language":263,"meta":101,"style":101},"await useDrizzle().insert(tables.templates).values({\n  hash: useHash(),\n  slug: useSlugify(body.title),\n  title: body.title,\n  categoryId: body.categoryId,\n  paidStatus: body.paidStatus,\n  liveUrl: body.liveUrl,\n  accessUrl: body.accessUrl,\n  description: body.description,\n  descriptionHTML: useMarkdown(body.description),\n  userId: user.id,\n}).execute()\n",[103,16827,16828,16854,16866,16884,16898,16912,16926,16940,16954,16968,16987,17001],{"__ignoreMap":101},[106,16829,16830,16832,16834,16836,16838,16840,16842,16844,16846,16848,16850,16852],{"class":108,"line":109},[106,16831,10830],{"class":270},[106,16833,7070],{"class":277},[106,16835,506],{"class":224},[106,16837,52],{"class":112},[106,16839,7264],{"class":277},[106,16841,10841],{"class":224},[106,16843,52],{"class":112},[106,16845,16012],{"class":224},[106,16847,52],{"class":112},[106,16849,7279],{"class":277},[106,16851,281],{"class":224},[106,16853,488],{"class":112},[106,16855,16856,16858,16860,16862,16864],{"class":108,"line":123},[106,16857,12645],{"class":116},[106,16859,496],{"class":112},[106,16861,13508],{"class":277},[106,16863,506],{"class":224},[106,16865,509],{"class":112},[106,16867,16868,16870,16872,16874,16877,16879,16882],{"class":108,"line":162},[106,16869,12679],{"class":116},[106,16871,496],{"class":112},[106,16873,7292],{"class":277},[106,16875,16876],{"class":224},"(body",[106,16878,52],{"class":112},[106,16880,16881],{"class":224},"title)",[106,16883,509],{"class":112},[106,16885,16886,16888,16890,16892,16894,16896],{"class":108,"line":200},[106,16887,12706],{"class":116},[106,16889,496],{"class":112},[106,16891,308],{"class":224},[106,16893,52],{"class":112},[106,16895,12717],{"class":224},[106,16897,509],{"class":112},[106,16899,16900,16902,16904,16906,16908,16910],{"class":108,"line":221},[106,16901,13010],{"class":116},[106,16903,496],{"class":112},[106,16905,308],{"class":224},[106,16907,52],{"class":112},[106,16909,13088],{"class":224},[106,16911,509],{"class":112},[106,16913,16914,16916,16918,16920,16922,16924],{"class":108,"line":228},[106,16915,12815],{"class":116},[106,16917,496],{"class":112},[106,16919,308],{"class":224},[106,16921,52],{"class":112},[106,16923,13573],{"class":224},[106,16925,509],{"class":112},[106,16927,16928,16930,16932,16934,16936,16938],{"class":108,"line":238},[106,16929,12887],{"class":116},[106,16931,496],{"class":112},[106,16933,308],{"class":224},[106,16935,52],{"class":112},[106,16937,13588],{"class":224},[106,16939,509],{"class":112},[106,16941,16942,16944,16946,16948,16950,16952],{"class":108,"line":248},[106,16943,12909],{"class":116},[106,16945,496],{"class":112},[106,16947,308],{"class":224},[106,16949,52],{"class":112},[106,16951,13603],{"class":224},[106,16953,509],{"class":112},[106,16955,16956,16958,16960,16962,16964,16966],{"class":108,"line":534},[106,16957,12937],{"class":116},[106,16959,496],{"class":112},[106,16961,308],{"class":224},[106,16963,52],{"class":112},[106,16965,12948],{"class":224},[106,16967,509],{"class":112},[106,16969,16970,16973,16975,16978,16980,16982,16985],{"class":108,"line":539},[106,16971,16972],{"class":116},"  descriptionHTML",[106,16974,496],{"class":112},[106,16976,16977],{"class":277}," useMarkdown",[106,16979,16876],{"class":224},[106,16981,52],{"class":112},[106,16983,16984],{"class":224},"description)",[106,16986,509],{"class":112},[106,16988,16989,16991,16993,16995,16997,16999],{"class":108,"line":556},[106,16990,12965],{"class":116},[106,16992,496],{"class":112},[106,16994,5679],{"class":224},[106,16996,52],{"class":112},[106,16998,7100],{"class":224},[106,17000,509],{"class":112},[106,17002,17003,17005,17007,17009,17011],{"class":108,"line":759},[106,17004,366],{"class":112},[106,17006,294],{"class":224},[106,17008,52],{"class":112},[106,17010,7330],{"class":277},[106,17012,2366],{"class":224},[10,17014,17015,17016,17019,17020,17022],{},"La fonction ",[103,17017,17018],{},"useMarkdown"," est une fonction simple qui utilise ",[103,17021,16804],{}," pour transformer le markdown en HTML.",[381,17024,17026],{"id":17025},"éditeur-de-markdown","Éditeur de Markdown",[10,17028,17029],{},[29,17030,17031],{},"Cette partie est très expérimentale et pourrait changer à l'avenir. Il s'agit d'une première implémentation.",[10,17033,17034],{},[29,17035,17036],{},"En écrivant cette partie, j'ai fait quelques recherches supplémentaires et tout va changer. Je pense que c'est encore intéressant de voir le processus.",[10,17038,17039,17040,17043],{},"Avant de commencer, je dois faire un choix. Quel éditeur de markdown devrais-je utiliser ? ",[29,17041,17042],{},"Rappelez-vous que nous venons de mettre en place un point de terminaison serveur pour gérer le markdown."," J'ai de nombreuses options :",[5915,17045,17046,17049,17052,17055,17058,17061,17064,17067],{},[5918,17047,17048],{},"SimpleMDE",[5918,17050,17051],{},"EasyMDE",[5918,17053,17054],{},"TinyMDE",[5918,17056,17057],{},"Editor.js",[5918,17059,17060],{},"Quill",[5918,17062,17063],{},"Trix",[5918,17065,17066],{},"CKEditor",[5918,17068,17069],{},"TinyMCE",[10,17071,17072],{},"C'est beaucoup d'options. Mais, quels sont mes besoins ? J'ai besoin d'un éditeur très simple qui peut retourner du markdown. Ça a l'air facile, non ?",[10,17074,17075,17076],{},"Editor.js, CKEditor et TinyMCE sont des éditeurs avancés donc je ne les utiliserai pas. SimpleMDE est une bonne option mais trop vieux. Il n'a pas reçu de mises à jour depuis 6 ans. SimpleMDE et TinyMDE sont des forks de SimpleMDE. EasyMDE est puissant, simple et retourne du markdown. C'est une bonne option. TinyMDE n'a pas assez d'options de personnalisation. Trix et Quill ne sont pas des éditeurs de markdown. Ce sont des éditeurs de texte enrichi donc ils ne produisent pas de markdown. Je vais opter pour EasyMDE. ",[29,17077,17078],{},"Pour l'instant.",[10,17080,17081,17082,393,17085],{},"Nous pouvons donc maintenant installer le package et l'utiliser. L'utilisation est un peu délicate. Étant un package uniquement côté client, nous ne pouvons pas l'exécuter ni l'importer côté serveur. Pour l'utiliser, nous allons l'importer dynamiquement dans un hook ",[103,17083,17084],{},"onMounted",[29,17086,17087,17090],{},[103,17088,17089],{},"onBeforeMount"," est encore mieux !",[96,17092,17094],{"className":261,"code":17093,"language":263,"meta":101,"style":101},"onMounted(async () => {\n  const EasyMDE = await import('easymde').then(m => m.default)\n  const easymde = new EasyMDE()\n})\n",[103,17095,17096,17110,17156,17171],{"__ignoreMap":101},[106,17097,17098,17100,17102,17104,17106,17108],{"class":108,"line":109},[106,17099,17084],{"class":277},[106,17101,281],{"class":224},[106,17103,284],{"class":132},[106,17105,7052],{"class":112},[106,17107,297],{"class":132},[106,17109,300],{"class":112},[106,17111,17112,17114,17117,17119,17121,17124,17126,17128,17131,17133,17135,17137,17140,17142,17145,17147,17150,17152,17154],{"class":108,"line":123},[106,17113,305],{"class":132},[106,17115,17116],{"class":224}," EasyMDE",[106,17118,311],{"class":112},[106,17120,314],{"class":270},[106,17122,17123],{"class":112}," import",[106,17125,281],{"class":116},[106,17127,754],{"class":112},[106,17129,17130],{"class":142},"easymde",[106,17132,754],{"class":112},[106,17134,294],{"class":116},[106,17136,52],{"class":112},[106,17138,17139],{"class":277},"then",[106,17141,281],{"class":116},[106,17143,17144],{"class":290},"m",[106,17146,297],{"class":132},[106,17148,17149],{"class":224}," m",[106,17151,52],{"class":112},[106,17153,11717],{"class":224},[106,17155,324],{"class":116},[106,17157,17158,17160,17163,17165,17167,17169],{"class":108,"line":162},[106,17159,305],{"class":132},[106,17161,17162],{"class":224}," easymde",[106,17164,311],{"class":112},[106,17166,3113],{"class":112},[106,17168,17116],{"class":277},[106,17170,2366],{"class":116},[106,17172,17173,17175],{"class":108,"line":200},[106,17174,366],{"class":112},[106,17176,324],{"class":224},[10,17178,17179],{},"Et ça fonctionne ? Oui, mais je pense que nous avons complètement oublié d'importer le style !",[10,17181,17182],{},[5535,17183],{"alt":17184,"src":17185},"EasyMDE sans style","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-without-style.png",[10,17187,17188],{},"Je pense que nous avons oublié d'ajouter le style pour EasyMDE. Grâce à Vite et son chargeur CSS, nous pouvons importer directement le fichier CSS dans notre script.",[96,17190,17192],{"className":98,"code":17191,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nimport 'easymde\u002Fdist\u002Feasymde.min.css'\n\u003C\u002Fscript>\n",[103,17193,17194,17214,17225],{"__ignoreMap":101},[106,17195,17196,17198,17200,17202,17204,17206,17208,17210,17212],{"class":108,"line":109},[106,17197,113],{"class":112},[106,17199,1189],{"class":116},[106,17201,1192],{"class":132},[106,17203,136],{"class":112},[106,17205,139],{"class":112},[106,17207,263],{"class":142},[106,17209,139],{"class":112},[106,17211,1203],{"class":132},[106,17213,120],{"class":112},[106,17215,17216,17218,17220,17223],{"class":108,"line":123},[106,17217,410],{"class":270},[106,17219,425],{"class":112},[106,17221,17222],{"class":142},"easymde\u002Fdist\u002Feasymde.min.css",[106,17224,431],{"class":112},[106,17226,17227,17229,17231],{"class":108,"line":162},[106,17228,251],{"class":112},[106,17230,1189],{"class":116},[106,17232,120],{"class":112},[10,17234,17235],{},[5535,17236],{"alt":17237,"src":17238},"EasyMDE avec style","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-with-style.png",[10,17240,17241],{},"Ok, ce n'est pas parfait et j'aurai du travail à faire, tant sur les styles que sur les icônes. Mais c'est un bon début. La seule partie que je n'aime pas est le symbole markdown. Je préférerais ne pas les avoir parce que ce n'est pas convivial (tout le monde pourrait devoir créer un modèle).",[10,17243,17244],{},"Mais de toute façon, c'est un bon début et je peux maintenant facilement écouter les changements de l'éditeur et mettre à jour la description.",[96,17246,17248],{"className":98,"code":17247,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst description = ref\u003Cstring>('')\nonMounted(async () => {\n  const EasyMDE = await import('easymde').then(m => m.default)\n  const easymde = new EasyMDE()\n  easyMDE.codemirror.on('change', () => {\n    description.value = easyMDE.value()\n  })\n})\n\u003C\u002Fscript>\n",[103,17249,17250,17270,17295,17309,17349,17363,17395,17414,17420,17426],{"__ignoreMap":101},[106,17251,17252,17254,17256,17258,17260,17262,17264,17266,17268],{"class":108,"line":109},[106,17253,113],{"class":112},[106,17255,1189],{"class":116},[106,17257,1192],{"class":132},[106,17259,136],{"class":112},[106,17261,139],{"class":112},[106,17263,263],{"class":142},[106,17265,139],{"class":112},[106,17267,1203],{"class":132},[106,17269,120],{"class":112},[106,17271,17272,17274,17277,17279,17282,17284,17286,17288,17290,17293],{"class":108,"line":123},[106,17273,1252],{"class":132},[106,17275,17276],{"class":224}," description ",[106,17278,136],{"class":112},[106,17280,17281],{"class":277}," ref",[106,17283,113],{"class":112},[106,17285,503],{"class":1041},[106,17287,3308],{"class":112},[106,17289,281],{"class":224},[106,17291,17292],{"class":112},"''",[106,17294,324],{"class":224},[106,17296,17297,17299,17301,17303,17305,17307],{"class":108,"line":162},[106,17298,17084],{"class":277},[106,17300,281],{"class":224},[106,17302,284],{"class":132},[106,17304,7052],{"class":112},[106,17306,297],{"class":132},[106,17308,300],{"class":112},[106,17310,17311,17313,17315,17317,17319,17321,17323,17325,17327,17329,17331,17333,17335,17337,17339,17341,17343,17345,17347],{"class":108,"line":200},[106,17312,305],{"class":132},[106,17314,17116],{"class":224},[106,17316,311],{"class":112},[106,17318,314],{"class":270},[106,17320,17123],{"class":112},[106,17322,281],{"class":116},[106,17324,754],{"class":112},[106,17326,17130],{"class":142},[106,17328,754],{"class":112},[106,17330,294],{"class":116},[106,17332,52],{"class":112},[106,17334,17139],{"class":277},[106,17336,281],{"class":116},[106,17338,17144],{"class":290},[106,17340,297],{"class":132},[106,17342,17149],{"class":224},[106,17344,52],{"class":112},[106,17346,11717],{"class":224},[106,17348,324],{"class":116},[106,17350,17351,17353,17355,17357,17359,17361],{"class":108,"line":221},[106,17352,305],{"class":132},[106,17354,17162],{"class":224},[106,17356,311],{"class":112},[106,17358,3113],{"class":112},[106,17360,17116],{"class":277},[106,17362,2366],{"class":116},[106,17364,17365,17368,17370,17373,17375,17378,17380,17382,17385,17387,17389,17391,17393],{"class":108,"line":228},[106,17366,17367],{"class":224},"  easyMDE",[106,17369,52],{"class":112},[106,17371,17372],{"class":224},"codemirror",[106,17374,52],{"class":112},[106,17376,17377],{"class":277},"on",[106,17379,281],{"class":116},[106,17381,754],{"class":112},[106,17383,17384],{"class":142},"change",[106,17386,754],{"class":112},[106,17388,355],{"class":112},[106,17390,7052],{"class":112},[106,17392,297],{"class":132},[106,17394,300],{"class":112},[106,17396,17397,17399,17401,17403,17405,17408,17410,17412],{"class":108,"line":238},[106,17398,13432],{"class":224},[106,17400,52],{"class":112},[106,17402,9764],{"class":224},[106,17404,311],{"class":112},[106,17406,17407],{"class":224}," easyMDE",[106,17409,52],{"class":112},[106,17411,9764],{"class":277},[106,17413,2366],{"class":116},[106,17415,17416,17418],{"class":108,"line":248},[106,17417,514],{"class":112},[106,17419,324],{"class":116},[106,17421,17422,17424],{"class":108,"line":534},[106,17423,366],{"class":112},[106,17425,324],{"class":224},[106,17427,17428,17430,17432],{"class":108,"line":539},[106,17429,251],{"class":112},[106,17431,1189],{"class":116},[106,17433,120],{"class":112},[10,17435,17436],{},"Maintenant, je peux envoyer la description au serveur et elle sera transformée en HTML !",[381,17438,17440],{"id":17439},"quelle-est-la-prochaine-étape-pour-le-formulaire","Quelle est la prochaine étape pour le formulaire ?",[10,17442,17443],{},"Le formulaire est loin d'être terminé mais il progresse bien. Je vais ajouter le support pour les images et les modules dans le prochain stream.",[10,17445,17446],{},"Le projet Orion est presque prêt pour une version alpha et je suis impatient de voir comment la communauté l'utilisera et découvrir le contenu qu'ils partageront.",[54,17448,17450],{"id":17449},"remplacer-léditeur-markdown","Remplacer l'éditeur Markdown",[10,17452,17453],{},[29,17454,17455],{},"Cette partie a été réalisée hors-stream parce que je n'étais pas satisfait de l'éditeur EasyMDE mais je ne savais pas quoi faire.",[10,17457,17458],{},"Après y avoir réfléchi, j'ai réalisé que l'utilisation de Markdown, pour une simple description et qui peut être utilisée par des utilisateurs non techniques, n'est pas la meilleure idée. De plus, l'éditeur EasyMDE utilise CodeMirror qui est lent et beaucoup trop lourd pour un simple éditeur.",[10,17460,17461,17462],{},"Maintenant que nous savons que l'éditeur EasyMDE n'est pas la meilleure solution pour nos besoins, nous devons en trouver un nouveau. Nous avons besoin d'un éditeur de texte enrichi simple et il y a deux options principales : Quill et Trix. Dans ce cas, Quill semble être plus simple donc je vais opter pour lui. ",[29,17463,17464],{},"Je ne détaillerai pas l'installation de Quill, c'est assez simple.",[10,17466,17467,17468,17470,17471,52],{},"Comme Quill est un éditeur de texte enrichi, il ne génère pas de markdown mais du HTML. Je dois donc mettre à jour le point de terminaison pour stocker le HTML au lieu du markdown. Je dois également mettre à jour le schéma pour supprimer la colonne ",[103,17469,12948],{}," et ajouter une colonne ",[103,17472,16821],{},[381,17474,17476],{"id":17475},"mise-à-jour-du-point-de-terminaison","Mise à jour du point de terminaison",[10,17478,17479],{},"Pourquoi dois-je mettre à jour le point de terminaison ? Quill renvoie du HTML déjà nettoyé, donc je pourrais le stocker directement dans la base de données. Oui mais non. Nous construisons une API et il est impossible de faire confiance au client, même à notre propre client. N'importe qui peut modifier le HTML ou utiliser un client HTTP pour envoyer une requête au serveur avec du HTML malveillant. Je dois donc nettoyer le HTML sur le serveur.",[10,17481,17482],{},"Par exemple, voici du HTML malveillant :",[96,17484,17486],{"className":16737,"code":17485,"language":16739,"meta":101,"style":101},"\u003Cp> Bonjour le monde ! \u003C\u002Fp>\n\u003Cscript> console.log('piraté') \u003C\u002Fscript>\n",[103,17487,17488,17505],{"__ignoreMap":101},[106,17489,17490,17492,17494,17496,17499,17501,17503],{"class":108,"line":109},[106,17491,113],{"class":112},[106,17493,10],{"class":116},[106,17495,3308],{"class":112},[106,17497,17498],{"class":224}," Bonjour le monde ! ",[106,17500,251],{"class":112},[106,17502,10],{"class":116},[106,17504,120],{"class":112},[106,17506,17507,17509,17511,17513,17515,17517,17519,17521,17523,17526,17528,17530,17532,17534],{"class":108,"line":123},[106,17508,113],{"class":112},[106,17510,1189],{"class":116},[106,17512,3308],{"class":112},[106,17514,16769],{"class":224},[106,17516,52],{"class":112},[106,17518,16774],{"class":277},[106,17520,281],{"class":224},[106,17522,754],{"class":112},[106,17524,17525],{"class":142},"piraté",[106,17527,754],{"class":112},[106,17529,713],{"class":224},[106,17531,251],{"class":112},[106,17533,1189],{"class":116},[106,17535,120],{"class":112},[10,17537,17538,17539,17541,17542,17545],{},"Si vous stockez ce HTML dans la base de données et l'affichez en utilisant la directive ",[103,17540,16797],{},", chaque utilisateur qui verra la page exécutera le script. C'est un vrai problème de sécurité. Nous ne pouvons pas utiliser ",[103,17543,17544],{},"{{ html }}"," parce que Vue échappera le HTML donc nettoyer notre contenu est la seule solution.",[10,17547,17548,17549,17552],{},"Je vais utiliser le paquet ",[103,17550,17551],{},"sanitize-html"," pour nettoyer le HTML. Après avoir nettoyé avec ce paquet, le HTML sera :",[96,17554,17556],{"className":16737,"code":17555,"language":16739,"meta":101,"style":101},"\u003Cp> Bonjour le monde ! \u003C\u002Fp>\n",[103,17557,17558],{"__ignoreMap":101},[106,17559,17560,17562,17564,17566,17568,17570,17572],{"class":108,"line":109},[106,17561,113],{"class":112},[106,17563,10],{"class":116},[106,17565,3308],{"class":112},[106,17567,17498],{"class":224},[106,17569,251],{"class":112},[106,17571,10],{"class":116},[106,17573,120],{"class":112},[10,17575,17576],{},"C'est parfait !",[96,17578,17580],{"className":261,"code":17579,"language":263,"meta":101,"style":101},"await useDrizzle().insert(tables.templates).values({\n  description: body.description ? sanitizeHtml(body.description) : null,\n})\n",[103,17581,17582,17608,17638],{"__ignoreMap":101},[106,17583,17584,17586,17588,17590,17592,17594,17596,17598,17600,17602,17604,17606],{"class":108,"line":109},[106,17585,10830],{"class":270},[106,17587,7070],{"class":277},[106,17589,506],{"class":224},[106,17591,52],{"class":112},[106,17593,7264],{"class":277},[106,17595,10841],{"class":224},[106,17597,52],{"class":112},[106,17599,16012],{"class":224},[106,17601,52],{"class":112},[106,17603,7279],{"class":277},[106,17605,281],{"class":224},[106,17607,488],{"class":112},[106,17609,17610,17612,17614,17616,17618,17621,17623,17626,17628,17630,17633,17635],{"class":108,"line":123},[106,17611,12937],{"class":116},[106,17613,496],{"class":112},[106,17615,308],{"class":224},[106,17617,52],{"class":112},[106,17619,17620],{"class":224},"description ",[106,17622,10882],{"class":112},[106,17624,17625],{"class":277}," sanitizeHtml",[106,17627,16876],{"class":224},[106,17629,52],{"class":112},[106,17631,17632],{"class":224},"description) ",[106,17634,496],{"class":112},[106,17636,17637],{"class":112}," null,\n",[106,17639,17640,17642],{"class":108,"line":162},[106,17641,366],{"class":112},[106,17643,324],{"class":224},[10,17645,17015,17646,17649,17650,17652],{},[103,17647,17648],{},"sanitizeHtml"," est l'exportation par défaut du paquet ",[103,17651,17551],{},". Facile, non ?",[381,17654,17656],{"id":17655},"mise-à-jour-du-schéma","Mise à jour du schéma",[10,17658,17659,17660,17662,17663,17665],{},"Maintenant, je dois mettre à jour le schéma pour supprimer la colonne ",[103,17661,16821],{}," puisque la ",[103,17664,12948],{}," est maintenant par défaut en HTML. Il est important de savoir que si l'utilisateur doit modifier son modèle, le HTML sera utilisé par l'éditeur Quill.",[96,17667,17670],{"className":17668,"code":17669,"language":263,"meta":101,"style":101},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight has-diff","export const templates = sqliteTable('templates', {\n  description: text('description'),\n  descriptionHTML: text('description_html'), \u002F\u002F [!code --]\n})\n",[103,17671,17672,17696,17716,17740],{"__ignoreMap":101},[106,17673,17674,17676,17678,17680,17682,17684,17686,17688,17690,17692,17694],{"class":108,"line":109},[106,17675,271],{"class":270},[106,17677,1775],{"class":132},[106,17679,12586],{"class":224},[106,17681,136],{"class":112},[106,17683,10606],{"class":277},[106,17685,281],{"class":224},[106,17687,754],{"class":112},[106,17689,11404],{"class":142},[106,17691,754],{"class":112},[106,17693,355],{"class":112},[106,17695,300],{"class":112},[106,17697,17698,17700,17702,17704,17706,17708,17710,17712,17714],{"class":108,"line":123},[106,17699,12937],{"class":116},[106,17701,496],{"class":112},[106,17703,10668],{"class":277},[106,17705,281],{"class":224},[106,17707,754],{"class":112},[106,17709,12948],{"class":142},[106,17711,754],{"class":112},[106,17713,294],{"class":224},[106,17715,509],{"class":112},[106,17717,17721,17723,17725,17727,17729,17731,17734,17736,17738],{"class":17718,"line":162},[108,17719,17720],"diff","remove",[106,17722,16972],{"class":116},[106,17724,496],{"class":112},[106,17726,10668],{"class":277},[106,17728,281],{"class":224},[106,17730,754],{"class":112},[106,17732,17733],{"class":142},"description_html",[106,17735,754],{"class":112},[106,17737,294],{"class":224},[106,17739,355],{"class":112},[106,17741,17742,17744],{"class":108,"line":200},[106,17743,366],{"class":112},[106,17745,324],{"class":224},[381,17747,17749],{"id":17748},"mise-à-jour-de-linterface-utilisateur","Mise à jour de l'interface utilisateur",[10,17751,17752],{},"L'utilisation de Quill n'est pas si différente de EasyMDE. Je charge paresseusement le paquet Quill, crée une nouvelle instance de celui-ci et écoute les changements. Je n'oublie pas d'importer le fichier CSS.",[96,17754,17756],{"className":98,"code":17755,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nimport 'quill\u002Fdist\u002Fquill.snow.css'\n\nonBeforeMount(async () => {\n  const Quill = await import('quill').then(m => m.default)\n  quill.value = new Quill('#editor', {\n    theme: 'snow',\n  })\n  quill.value?.on('text-change', (_, __, source) => {\n    state.description = quill.value?.root.innerHTML ?? ''\n  })\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv id=\"editor\" \u002F>\n\u003C\u002Ftemplate>\n",[103,17757,17758,17778,17789,17793,17807,17849,17877,17893,17899,17943,17977,17983,17989,17997,18001,18009,18029],{"__ignoreMap":101},[106,17759,17760,17762,17764,17766,17768,17770,17772,17774,17776],{"class":108,"line":109},[106,17761,113],{"class":112},[106,17763,1189],{"class":116},[106,17765,1192],{"class":132},[106,17767,136],{"class":112},[106,17769,139],{"class":112},[106,17771,263],{"class":142},[106,17773,139],{"class":112},[106,17775,1203],{"class":132},[106,17777,120],{"class":112},[106,17779,17780,17782,17784,17787],{"class":108,"line":123},[106,17781,410],{"class":270},[106,17783,425],{"class":112},[106,17785,17786],{"class":142},"quill\u002Fdist\u002Fquill.snow.css",[106,17788,431],{"class":112},[106,17790,17791],{"class":108,"line":162},[106,17792,330],{"emptyLinePlaceholder":329},[106,17794,17795,17797,17799,17801,17803,17805],{"class":108,"line":200},[106,17796,17089],{"class":277},[106,17798,281],{"class":224},[106,17800,284],{"class":132},[106,17802,7052],{"class":112},[106,17804,297],{"class":132},[106,17806,300],{"class":112},[106,17808,17809,17811,17814,17816,17818,17820,17822,17824,17827,17829,17831,17833,17835,17837,17839,17841,17843,17845,17847],{"class":108,"line":221},[106,17810,305],{"class":132},[106,17812,17813],{"class":224}," Quill",[106,17815,311],{"class":112},[106,17817,314],{"class":270},[106,17819,17123],{"class":112},[106,17821,281],{"class":116},[106,17823,754],{"class":112},[106,17825,17826],{"class":142},"quill",[106,17828,754],{"class":112},[106,17830,294],{"class":116},[106,17832,52],{"class":112},[106,17834,17139],{"class":277},[106,17836,281],{"class":116},[106,17838,17144],{"class":290},[106,17840,297],{"class":132},[106,17842,17149],{"class":224},[106,17844,52],{"class":112},[106,17846,11717],{"class":224},[106,17848,324],{"class":116},[106,17850,17851,17854,17856,17858,17860,17862,17864,17866,17868,17871,17873,17875],{"class":108,"line":228},[106,17852,17853],{"class":224},"  quill",[106,17855,52],{"class":112},[106,17857,9764],{"class":224},[106,17859,311],{"class":112},[106,17861,3113],{"class":112},[106,17863,17813],{"class":277},[106,17865,281],{"class":116},[106,17867,754],{"class":112},[106,17869,17870],{"class":142},"#editor",[106,17872,754],{"class":112},[106,17874,355],{"class":112},[106,17876,300],{"class":112},[106,17878,17879,17882,17884,17886,17889,17891],{"class":108,"line":238},[106,17880,17881],{"class":116},"    theme",[106,17883,496],{"class":112},[106,17885,425],{"class":112},[106,17887,17888],{"class":142},"snow",[106,17890,754],{"class":112},[106,17892,509],{"class":112},[106,17894,17895,17897],{"class":108,"line":248},[106,17896,514],{"class":112},[106,17898,324],{"class":116},[106,17900,17901,17903,17905,17907,17909,17911,17913,17915,17918,17920,17922,17924,17927,17929,17932,17934,17937,17939,17941],{"class":108,"line":534},[106,17902,17853],{"class":224},[106,17904,52],{"class":112},[106,17906,9764],{"class":224},[106,17908,9767],{"class":112},[106,17910,17377],{"class":277},[106,17912,281],{"class":116},[106,17914,754],{"class":112},[106,17916,17917],{"class":142},"text-change",[106,17919,754],{"class":112},[106,17921,355],{"class":112},[106,17923,287],{"class":112},[106,17925,17926],{"class":290},"_",[106,17928,355],{"class":112},[106,17930,17931],{"class":290}," __",[106,17933,355],{"class":112},[106,17935,17936],{"class":290}," source",[106,17938,294],{"class":112},[106,17940,297],{"class":132},[106,17942,300],{"class":112},[106,17944,17945,17948,17950,17952,17954,17957,17959,17961,17963,17966,17968,17971,17974],{"class":108,"line":539},[106,17946,17947],{"class":224},"    state",[106,17949,52],{"class":112},[106,17951,12948],{"class":224},[106,17953,311],{"class":112},[106,17955,17956],{"class":224}," quill",[106,17958,52],{"class":112},[106,17960,9764],{"class":224},[106,17962,9767],{"class":112},[106,17964,17965],{"class":224},"root",[106,17967,52],{"class":112},[106,17969,17970],{"class":224},"innerHTML",[106,17972,17973],{"class":112}," ??",[106,17975,17976],{"class":112}," ''\n",[106,17978,17979,17981],{"class":108,"line":556},[106,17980,514],{"class":112},[106,17982,324],{"class":116},[106,17984,17985,17987],{"class":108,"line":759},[106,17986,366],{"class":112},[106,17988,324],{"class":224},[106,17990,17991,17993,17995],{"class":108,"line":795},[106,17992,251],{"class":112},[106,17994,1189],{"class":116},[106,17996,120],{"class":112},[106,17998,17999],{"class":108,"line":803},[106,18000,330],{"emptyLinePlaceholder":329},[106,18002,18003,18005,18007],{"class":108,"line":809},[106,18004,113],{"class":112},[106,18006,117],{"class":116},[106,18008,120],{"class":112},[106,18010,18011,18013,18015,18018,18020,18022,18025,18027],{"class":108,"line":814},[106,18012,126],{"class":112},[106,18014,3296],{"class":116},[106,18016,18017],{"class":132}," id",[106,18019,136],{"class":112},[106,18021,139],{"class":112},[106,18023,18024],{"class":142},"editor",[106,18026,139],{"class":112},[106,18028,6988],{"class":112},[106,18030,18031,18033,18035],{"class":108,"line":830},[106,18032,251],{"class":112},[106,18034,117],{"class":116},[106,18036,120],{"class":112},[10,18038,18039],{},"Bien sûr, je dois ajouter quelques styles personnalisés à l'éditeur pour qu'il s'intègre plus naturellement dans le design.",[10,18041,18042],{},"Une dernière chose. J'utilise ceci dans un formulaire Nuxt UI. Dans ce formulaire, chaque élément est validé par rapport au schéma. Cela me permet d'afficher un message d'erreur si la description est vide ou trop longue. Le problème est que ce n'est pas intégré dans l'éditeur Quill, il ne déclenche aucune validation. Grâce à l'API du formulaire Nuxt UI, je peux facilement valider le contenu de l'éditeur Quill à chaque changement.",[96,18044,18046],{"className":98,"code":18045,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst form = ref()\n\nonBeforeMount(async () => {\n  quill.value?.on('text-change', (_, __, source) => {\n    state.description = quill.value?.root.innerHTML ?? ''\n    form.value.validate('description', { silent: true })\n  })\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUFormGroup\n    label=\"Description\"\n    name=\"description\"\n    :hint=\"`${state.description?.length || 0}\u002F2000 characters`\"\n  >\n    \u003Cdiv id=\"editor\" \u002F>\n  \u003C\u002FUFormGroup>\n\u003C\u002Ftemplate>\n",[103,18047,18048,18068,18081,18085,18099,18139,18167,18204,18210,18216,18224,18228,18236,18243,18256,18268,18303,18307,18325,18333],{"__ignoreMap":101},[106,18049,18050,18052,18054,18056,18058,18060,18062,18064,18066],{"class":108,"line":109},[106,18051,113],{"class":112},[106,18053,1189],{"class":116},[106,18055,1192],{"class":132},[106,18057,136],{"class":112},[106,18059,139],{"class":112},[106,18061,263],{"class":142},[106,18063,139],{"class":112},[106,18065,1203],{"class":132},[106,18067,120],{"class":112},[106,18069,18070,18072,18075,18077,18079],{"class":108,"line":123},[106,18071,1252],{"class":132},[106,18073,18074],{"class":224}," form ",[106,18076,136],{"class":112},[106,18078,17281],{"class":277},[106,18080,2366],{"class":224},[106,18082,18083],{"class":108,"line":162},[106,18084,330],{"emptyLinePlaceholder":329},[106,18086,18087,18089,18091,18093,18095,18097],{"class":108,"line":200},[106,18088,17089],{"class":277},[106,18090,281],{"class":224},[106,18092,284],{"class":132},[106,18094,7052],{"class":112},[106,18096,297],{"class":132},[106,18098,300],{"class":112},[106,18100,18101,18103,18105,18107,18109,18111,18113,18115,18117,18119,18121,18123,18125,18127,18129,18131,18133,18135,18137],{"class":108,"line":221},[106,18102,17853],{"class":224},[106,18104,52],{"class":112},[106,18106,9764],{"class":224},[106,18108,9767],{"class":112},[106,18110,17377],{"class":277},[106,18112,281],{"class":116},[106,18114,754],{"class":112},[106,18116,17917],{"class":142},[106,18118,754],{"class":112},[106,18120,355],{"class":112},[106,18122,287],{"class":112},[106,18124,17926],{"class":290},[106,18126,355],{"class":112},[106,18128,17931],{"class":290},[106,18130,355],{"class":112},[106,18132,17936],{"class":290},[106,18134,294],{"class":112},[106,18136,297],{"class":132},[106,18138,300],{"class":112},[106,18140,18141,18143,18145,18147,18149,18151,18153,18155,18157,18159,18161,18163,18165],{"class":108,"line":228},[106,18142,17947],{"class":224},[106,18144,52],{"class":112},[106,18146,12948],{"class":224},[106,18148,311],{"class":112},[106,18150,17956],{"class":224},[106,18152,52],{"class":112},[106,18154,9764],{"class":224},[106,18156,9767],{"class":112},[106,18158,17965],{"class":224},[106,18160,52],{"class":112},[106,18162,17970],{"class":224},[106,18164,17973],{"class":112},[106,18166,17976],{"class":112},[106,18168,18169,18172,18174,18176,18178,18181,18183,18185,18187,18189,18191,18193,18196,18198,18200,18202],{"class":108,"line":238},[106,18170,18171],{"class":224},"    form",[106,18173,52],{"class":112},[106,18175,9764],{"class":224},[106,18177,52],{"class":112},[106,18179,18180],{"class":277},"validate",[106,18182,281],{"class":116},[106,18184,754],{"class":112},[106,18186,12948],{"class":142},[106,18188,754],{"class":112},[106,18190,355],{"class":112},[106,18192,413],{"class":112},[106,18194,18195],{"class":116}," silent",[106,18197,496],{"class":112},[106,18199,5653],{"class":5652},[106,18201,419],{"class":112},[106,18203,324],{"class":116},[106,18205,18206,18208],{"class":108,"line":248},[106,18207,514],{"class":112},[106,18209,324],{"class":116},[106,18211,18212,18214],{"class":108,"line":534},[106,18213,366],{"class":112},[106,18215,324],{"class":224},[106,18217,18218,18220,18222],{"class":108,"line":539},[106,18219,251],{"class":112},[106,18221,1189],{"class":116},[106,18223,120],{"class":112},[106,18225,18226],{"class":108,"line":556},[106,18227,330],{"emptyLinePlaceholder":329},[106,18229,18230,18232,18234],{"class":108,"line":759},[106,18231,113],{"class":112},[106,18233,117],{"class":116},[106,18235,120],{"class":112},[106,18237,18238,18240],{"class":108,"line":795},[106,18239,126],{"class":112},[106,18241,18242],{"class":116},"UFormGroup\n",[106,18244,18245,18248,18250,18252,18254],{"class":108,"line":803},[106,18246,18247],{"class":132},"    label",[106,18249,136],{"class":112},[106,18251,139],{"class":112},[106,18253,14232],{"class":142},[106,18255,7832],{"class":112},[106,18257,18258,18260,18262,18264,18266],{"class":108,"line":809},[106,18259,493],{"class":132},[106,18261,136],{"class":112},[106,18263,139],{"class":112},[106,18265,12948],{"class":142},[106,18267,7832],{"class":112},[106,18269,18270,18272,18275,18277,18280,18282,18284,18286,18288,18290,18293,18295,18297,18300],{"class":108,"line":814},[106,18271,14433],{"class":112},[106,18273,18274],{"class":132},"hint",[106,18276,136],{"class":112},[106,18278,18279],{"class":112},"\"`${",[106,18281,1395],{"class":224},[106,18283,52],{"class":112},[106,18285,12948],{"class":224},[106,18287,9767],{"class":112},[106,18289,10981],{"class":224},[106,18291,18292],{"class":112},"||",[106,18294,11007],{"class":358},[106,18296,366],{"class":112},[106,18298,18299],{"class":142},"\u002F2000 characters",[106,18301,18302],{"class":112},"`\"\n",[106,18304,18305],{"class":108,"line":830},[106,18306,7837],{"class":112},[106,18308,18309,18311,18313,18315,18317,18319,18321,18323],{"class":108,"line":835},[106,18310,165],{"class":112},[106,18312,3296],{"class":116},[106,18314,18017],{"class":132},[106,18316,136],{"class":112},[106,18318,139],{"class":112},[106,18320,18024],{"class":142},[106,18322,139],{"class":112},[106,18324,6988],{"class":112},[106,18326,18327,18329,18331],{"class":108,"line":840},[106,18328,241],{"class":112},[106,18330,8293],{"class":116},[106,18332,120],{"class":112},[106,18334,18335,18337,18339],{"class":108,"line":845},[106,18336,251],{"class":112},[106,18338,117],{"class":116},[106,18340,120],{"class":112},[10,18342,18343],{},"Maintenant, l'éditeur Quill est complètement intégré dans le formulaire et un message d'erreur s'affichera si la description est vide ou trop longue.",[10,18345,18346],{},[5535,18347],{"alt":18348,"src":18349},"L'éditeur de texte enrichi personnalisé pour s'intégrer pleinement dans le formulaire.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Frich-text-editor.png",[381,18351,5430],{"id":18352},"conclusion-3",[10,18354,18355],{},"Je suis vraiment content de ce changement. Depuis des années, je me demandais comment installer et utiliser un éditeur de texte enrichi et maintenant je sais. Vous pouvez utiliser le markdown pour les utilisateurs techniques, le parser sur le serveur et stocker le HTML pour simplifier une obtention, ou utiliser un éditeur de texte enrichi pour les utilisateurs non techniques et nettoyer le HTML sur le serveur. C'est une bonne leçon pour moi et j'espère qu'elle vous sera utile.",[54,18357,18359],{"id":18358},"ajout-de-modules-au-formulaire-de-modèle","Ajout de modules au formulaire de modèle",[10,18361,18362],{},[5535,18363],{"alt":18364,"src":18365},"Vignette de mon neuvième live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-28.png",[10,18367,18368],{},[29,18369,18370,6828,18373,6832,18376,5558,18379,52],{},[14,18371,5546],{"href":5513,"rel":18372},[18],[14,18374,5552],{"href":5550,"rel":18375},[18],[14,18377,5557],{"href":5503,"rel":18378},[18],[14,18380,5562],{"href":5513,"rel":18381},[18],[10,18383,18384],{},"Pendant ce live stream, j'ai ajouté la possibilité d'ajouter des modules à un modèle dans le formulaire. Cette fonctionnalité est importante car elle permettra aux utilisateurs de filtrer les modèles par modules.",[10,18386,18387],{},"J'ai choisi de le faire dans un stream séparé car c'est une relation de plusieurs à plusieurs et un peu plus complexe que les autres parties. Je m'attendais à le faire en 1 heure, mais cela m'a pris 2 heures avec l'aide du chat. En effet, j'étais bloqué sur la requête SQL pour récupérer un modèle peuplé avec ses modules, et la documentation de Drizzle n'était pas claire à ce sujet.",[10,18389,18390],{},"Plongeons dans le code et comprenons comment fonctionne le concept des relations ORM de Drizzle.",[381,18392,18394],{"id":18393},"schéma","Schéma",[10,18396,18397,18398],{},"Le créateur peut ajouter plusieurs modules à un modèle. En même temps, un module peut être utilisé par plusieurs modèles. La relation est une relation de plusieurs à plusieurs. Pour gérer cela, je dois créer une nouvelle table, appelée table de pivot, pour stocker la relation entre les modèles et les modules. Elle agit comme un lien entre les deux tables pour les connecter. ",[29,18399,18400],{},"Nous utilisons une table de pivot au lieu d'un tableau de modules dans le modèle parce que c'est plus efficace et c'est la façon SQL de le faire.",[10,18402,18403],{},[5535,18404],{"alt":18405,"src":18406},"Relation de plusieurs à plusieurs entre les modèles et les modules","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmany-to-many-relation.png",[10,18408,18409,18410,18412,18413,52],{},"Le schéma est simplifié pour montrer uniquement la relation entre les modèles et les modules. À partir de la table ",[103,18411,11404],{},", vous pouvez récupérer tous les modules associés et inversement grâce à la table ",[103,18414,18415],{},"templates_modules",[96,18417,18419],{"className":261,"code":18418,"language":263,"meta":101,"style":101},"export const templates = sqliteTable('templates', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  title: text('title').notNull(),\n})\n\nexport const modules = sqliteTable('modules', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  name: text('name').notNull().unique(),\n})\n\nexport const modulesToTemplates = sqliteTable('modulesToTemplates', {\n  moduleId: integer('module_id').notNull().references(() => modules.id),\n  templateId: integer('template_id').notNull().references(() => templates.id),\n})\n",[103,18420,18421,18445,18483,18509,18515,18519,18543,18581,18613,18619,18623,18649,18693,18737],{"__ignoreMap":101},[106,18422,18423,18425,18427,18429,18431,18433,18435,18437,18439,18441,18443],{"class":108,"line":109},[106,18424,271],{"class":270},[106,18426,1775],{"class":132},[106,18428,12586],{"class":224},[106,18430,136],{"class":112},[106,18432,10606],{"class":277},[106,18434,281],{"class":224},[106,18436,754],{"class":112},[106,18438,11404],{"class":142},[106,18440,754],{"class":112},[106,18442,355],{"class":112},[106,18444,300],{"class":112},[106,18446,18447,18449,18451,18453,18455,18457,18459,18461,18463,18465,18467,18469,18471,18473,18475,18477,18479,18481],{"class":108,"line":123},[106,18448,9400],{"class":116},[106,18450,496],{"class":112},[106,18452,10627],{"class":277},[106,18454,281],{"class":224},[106,18456,754],{"class":112},[106,18458,7100],{"class":142},[106,18460,754],{"class":112},[106,18462,294],{"class":224},[106,18464,52],{"class":112},[106,18466,10642],{"class":277},[106,18468,281],{"class":224},[106,18470,1285],{"class":112},[106,18472,10649],{"class":116},[106,18474,496],{"class":112},[106,18476,5653],{"class":5652},[106,18478,419],{"class":112},[106,18480,294],{"class":224},[106,18482,509],{"class":112},[106,18484,18485,18487,18489,18491,18493,18495,18497,18499,18501,18503,18505,18507],{"class":108,"line":162},[106,18486,12706],{"class":116},[106,18488,496],{"class":112},[106,18490,10668],{"class":277},[106,18492,281],{"class":224},[106,18494,754],{"class":112},[106,18496,12717],{"class":142},[106,18498,754],{"class":112},[106,18500,294],{"class":224},[106,18502,52],{"class":112},[106,18504,10683],{"class":277},[106,18506,506],{"class":224},[106,18508,509],{"class":112},[106,18510,18511,18513],{"class":108,"line":200},[106,18512,366],{"class":112},[106,18514,324],{"class":224},[106,18516,18517],{"class":108,"line":221},[106,18518,330],{"emptyLinePlaceholder":329},[106,18520,18521,18523,18525,18527,18529,18531,18533,18535,18537,18539,18541],{"class":108,"line":228},[106,18522,271],{"class":270},[106,18524,1775],{"class":132},[106,18526,10460],{"class":224},[106,18528,136],{"class":112},[106,18530,10606],{"class":277},[106,18532,281],{"class":224},[106,18534,754],{"class":112},[106,18536,6894],{"class":142},[106,18538,754],{"class":112},[106,18540,355],{"class":112},[106,18542,300],{"class":112},[106,18544,18545,18547,18549,18551,18553,18555,18557,18559,18561,18563,18565,18567,18569,18571,18573,18575,18577,18579],{"class":108,"line":238},[106,18546,9400],{"class":116},[106,18548,496],{"class":112},[106,18550,10627],{"class":277},[106,18552,281],{"class":224},[106,18554,754],{"class":112},[106,18556,7100],{"class":142},[106,18558,754],{"class":112},[106,18560,294],{"class":224},[106,18562,52],{"class":112},[106,18564,10642],{"class":277},[106,18566,281],{"class":224},[106,18568,1285],{"class":112},[106,18570,10649],{"class":116},[106,18572,496],{"class":112},[106,18574,5653],{"class":5652},[106,18576,419],{"class":112},[106,18578,294],{"class":224},[106,18580,509],{"class":112},[106,18582,18583,18585,18587,18589,18591,18593,18595,18597,18599,18601,18603,18605,18607,18609,18611],{"class":108,"line":248},[106,18584,1272],{"class":116},[106,18586,496],{"class":112},[106,18588,10668],{"class":277},[106,18590,281],{"class":224},[106,18592,754],{"class":112},[106,18594,3858],{"class":142},[106,18596,754],{"class":112},[106,18598,294],{"class":224},[106,18600,52],{"class":112},[106,18602,10683],{"class":277},[106,18604,506],{"class":224},[106,18606,52],{"class":112},[106,18608,10690],{"class":277},[106,18610,506],{"class":224},[106,18612,509],{"class":112},[106,18614,18615,18617],{"class":108,"line":534},[106,18616,366],{"class":112},[106,18618,324],{"class":224},[106,18620,18621],{"class":108,"line":539},[106,18622,330],{"emptyLinePlaceholder":329},[106,18624,18625,18627,18629,18632,18634,18636,18638,18640,18643,18645,18647],{"class":108,"line":556},[106,18626,271],{"class":270},[106,18628,1775],{"class":132},[106,18630,18631],{"class":224}," modulesToTemplates ",[106,18633,136],{"class":112},[106,18635,10606],{"class":277},[106,18637,281],{"class":224},[106,18639,754],{"class":112},[106,18641,18642],{"class":142},"modulesToTemplates",[106,18644,754],{"class":112},[106,18646,355],{"class":112},[106,18648,300],{"class":112},[106,18650,18651,18654,18656,18658,18660,18662,18665,18667,18669,18671,18673,18675,18677,18679,18681,18683,18685,18687,18689,18691],{"class":108,"line":759},[106,18652,18653],{"class":116},"  moduleId",[106,18655,496],{"class":112},[106,18657,10627],{"class":277},[106,18659,281],{"class":224},[106,18661,754],{"class":112},[106,18663,18664],{"class":142},"module_id",[106,18666,754],{"class":112},[106,18668,294],{"class":224},[106,18670,52],{"class":112},[106,18672,10683],{"class":277},[106,18674,506],{"class":224},[106,18676,52],{"class":112},[106,18678,12990],{"class":277},[106,18680,281],{"class":224},[106,18682,506],{"class":112},[106,18684,297],{"class":132},[106,18686,11041],{"class":224},[106,18688,52],{"class":112},[106,18690,13003],{"class":224},[106,18692,509],{"class":112},[106,18694,18695,18698,18700,18702,18704,18706,18709,18711,18713,18715,18717,18719,18721,18723,18725,18727,18729,18731,18733,18735],{"class":108,"line":795},[106,18696,18697],{"class":116},"  templateId",[106,18699,496],{"class":112},[106,18701,10627],{"class":277},[106,18703,281],{"class":224},[106,18705,754],{"class":112},[106,18707,18708],{"class":142},"template_id",[106,18710,754],{"class":112},[106,18712,294],{"class":224},[106,18714,52],{"class":112},[106,18716,10683],{"class":277},[106,18718,506],{"class":224},[106,18720,52],{"class":112},[106,18722,12990],{"class":277},[106,18724,281],{"class":224},[106,18726,506],{"class":112},[106,18728,297],{"class":132},[106,18730,13752],{"class":224},[106,18732,52],{"class":112},[106,18734,13003],{"class":224},[106,18736,509],{"class":112},[106,18738,18739,18741],{"class":108,"line":803},[106,18740,366],{"class":112},[106,18742,324],{"class":224},[381,18744,18746],{"id":18745},"formulaire-de-modules","Formulaire de modules",[10,18748,18749],{},"Dans cette partie, je vais ajouter les modules au formulaire, du côté client au côté serveur.",[6078,18751,18753],{"id":18752},"ajout-de-modules-au-modèle","Ajout de modules au modèle",[10,18755,18756,18757,18760],{},"Dans l'interface utilisateur, gérer les modules est assez simple. Je récupère les modules du serveur et les affiche dans un ",[103,18758,18759],{},"USelectMenu"," avec une prop multiple.",[96,18762,18764],{"className":98,"code":18763,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst { data: modules } = await useFetch('\u002Fapi\u002Fmodules', {\n  deep: false,\n  default: () => [],\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUSelectMenu\n    :options=\"modules\"\n    multiple\n    value-attribute=\"id\"\n    option-attribute=\"name\"\n  \u002F>\n\u003C\u002Ftemplate>\n",[103,18765,18766,18786,18819,18829,18843,18849,18857,18861,18869,18876,18891,18896,18909,18922,18927],{"__ignoreMap":101},[106,18767,18768,18770,18772,18774,18776,18778,18780,18782,18784],{"class":108,"line":109},[106,18769,113],{"class":112},[106,18771,1189],{"class":116},[106,18773,1192],{"class":132},[106,18775,136],{"class":112},[106,18777,139],{"class":112},[106,18779,263],{"class":142},[106,18781,139],{"class":112},[106,18783,1203],{"class":132},[106,18785,120],{"class":112},[106,18787,18788,18790,18792,18794,18796,18798,18800,18802,18804,18806,18808,18810,18813,18815,18817],{"class":108,"line":123},[106,18789,1252],{"class":132},[106,18791,413],{"class":112},[106,18793,964],{"class":116},[106,18795,496],{"class":112},[106,18797,10460],{"class":224},[106,18799,366],{"class":112},[106,18801,311],{"class":112},[106,18803,314],{"class":270},[106,18805,7680],{"class":277},[106,18807,281],{"class":224},[106,18809,754],{"class":112},[106,18811,18812],{"class":142},"\u002Fapi\u002Fmodules",[106,18814,754],{"class":112},[106,18816,355],{"class":112},[106,18818,300],{"class":112},[106,18820,18821,18823,18825,18827],{"class":108,"line":162},[106,18822,7698],{"class":116},[106,18824,496],{"class":112},[106,18826,7703],{"class":5652},[106,18828,509],{"class":112},[106,18830,18831,18833,18835,18837,18839,18841],{"class":108,"line":200},[106,18832,10239],{"class":277},[106,18834,496],{"class":112},[106,18836,7052],{"class":112},[106,18838,297],{"class":132},[106,18840,14393],{"class":224},[106,18842,509],{"class":112},[106,18844,18845,18847],{"class":108,"line":221},[106,18846,366],{"class":112},[106,18848,324],{"class":224},[106,18850,18851,18853,18855],{"class":108,"line":228},[106,18852,251],{"class":112},[106,18854,1189],{"class":116},[106,18856,120],{"class":112},[106,18858,18859],{"class":108,"line":238},[106,18860,330],{"emptyLinePlaceholder":329},[106,18862,18863,18865,18867],{"class":108,"line":248},[106,18864,113],{"class":112},[106,18866,117],{"class":116},[106,18868,120],{"class":112},[106,18870,18871,18873],{"class":108,"line":534},[106,18872,126],{"class":112},[106,18874,18875],{"class":116},"USelectMenu\n",[106,18877,18878,18880,18883,18885,18887,18889],{"class":108,"line":539},[106,18879,14433],{"class":112},[106,18881,18882],{"class":132},"options",[106,18884,136],{"class":112},[106,18886,139],{"class":112},[106,18888,6894],{"class":224},[106,18890,7832],{"class":112},[106,18892,18893],{"class":108,"line":556},[106,18894,18895],{"class":132},"    multiple\n",[106,18897,18898,18901,18903,18905,18907],{"class":108,"line":759},[106,18899,18900],{"class":132},"    value-attribute",[106,18902,136],{"class":112},[106,18904,139],{"class":112},[106,18906,7100],{"class":142},[106,18908,7832],{"class":112},[106,18910,18911,18914,18916,18918,18920],{"class":108,"line":795},[106,18912,18913],{"class":132},"    option-attribute",[106,18915,136],{"class":112},[106,18917,139],{"class":112},[106,18919,3858],{"class":142},[106,18921,7832],{"class":112},[106,18923,18924],{"class":108,"line":803},[106,18925,18926],{"class":112},"  \u002F>\n",[106,18928,18929,18931,18933],{"class":108,"line":809},[106,18930,251],{"class":112},[106,18932,117],{"class":116},[106,18934,120],{"class":112},[6078,18936,18938],{"id":18937},"validation-des-modules","Validation des modules",[10,18940,18941,18942,18945],{},"Dans le validateur, j'ajoute un nouveau champ ",[103,18943,18944],{},"moduleIds"," qui vérifie que les modules sont un tableau d'entiers.",[96,18947,18949],{"className":261,"code":18948,"language":263,"meta":101,"style":101},"import { array, number, object, string } from 'zod'\n\nexport const createTemplateValidator = object({\n  title: string({ message: 'Requis' }),\n  moduleIds: array(number()).max(6, { message: 'Max 6 modules' }).optional(),\n})\n",[103,18950,18951,18982,18986,19003,19031,19084],{"__ignoreMap":101},[106,18952,18953,18955,18957,18960,18962,18964,18966,18968,18970,18972,18974,18976,18978,18980],{"class":108,"line":109},[106,18954,410],{"class":270},[106,18956,413],{"class":112},[106,18958,18959],{"class":224}," array",[106,18961,355],{"class":112},[106,18963,13235],{"class":224},[106,18965,355],{"class":112},[106,18967,2257],{"class":224},[106,18969,355],{"class":112},[106,18971,3985],{"class":224},[106,18973,419],{"class":112},[106,18975,422],{"class":270},[106,18977,425],{"class":112},[106,18979,428],{"class":142},[106,18981,431],{"class":112},[106,18983,18984],{"class":108,"line":123},[106,18985,330],{"emptyLinePlaceholder":329},[106,18987,18988,18990,18992,18995,18997,18999,19001],{"class":108,"line":162},[106,18989,271],{"class":270},[106,18991,1775],{"class":132},[106,18993,18994],{"class":224}," createTemplateValidator ",[106,18996,136],{"class":112},[106,18998,2257],{"class":277},[106,19000,281],{"class":224},[106,19002,488],{"class":112},[106,19004,19005,19007,19009,19011,19013,19015,19017,19019,19021,19023,19025,19027,19029],{"class":108,"line":200},[106,19006,12706],{"class":116},[106,19008,496],{"class":112},[106,19010,3985],{"class":277},[106,19012,281],{"class":224},[106,19014,1285],{"class":112},[106,19016,1288],{"class":116},[106,19018,496],{"class":112},[106,19020,425],{"class":112},[106,19022,8062],{"class":142},[106,19024,754],{"class":112},[106,19026,419],{"class":112},[106,19028,294],{"class":224},[106,19030,509],{"class":112},[106,19032,19033,19036,19038,19040,19042,19045,19048,19050,19052,19054,19057,19059,19061,19063,19065,19067,19070,19072,19074,19076,19078,19080,19082],{"class":108,"line":221},[106,19034,19035],{"class":116},"  moduleIds",[106,19037,496],{"class":112},[106,19039,18959],{"class":277},[106,19041,281],{"class":224},[106,19043,19044],{"class":277},"number",[106,19046,19047],{"class":224},"())",[106,19049,52],{"class":112},[106,19051,13443],{"class":277},[106,19053,281],{"class":224},[106,19055,19056],{"class":358},"6",[106,19058,355],{"class":112},[106,19060,413],{"class":112},[106,19062,1288],{"class":116},[106,19064,496],{"class":112},[106,19066,425],{"class":112},[106,19068,19069],{"class":142},"Max 6 modules",[106,19071,754],{"class":112},[106,19073,419],{"class":112},[106,19075,294],{"class":224},[106,19077,52],{"class":112},[106,19079,13410],{"class":277},[106,19081,506],{"class":224},[106,19083,509],{"class":112},[106,19085,19086,19088],{"class":108,"line":228},[106,19087,366],{"class":112},[106,19089,324],{"class":224},[10,19091,19092],{},"J'ai arbitrairement fixé le nombre maximum de modules à 6. C'est un nombre que je pense suffisant pour un modèle.",[6078,19094,19096],{"id":19095},"enregistrement-des-modules","Enregistrement des modules",[10,19098,19099],{},"Enfin, je mets à jour le point de terminaison pour enregistrer le modèle et les modules associés. Je commence par enregistrer le modèle, puis les modules dans la table de pivot.",[96,19101,19103],{"className":261,"code":19102,"language":263,"meta":101,"style":101},"import { createTemplateValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  const { user } = await requireUserSession(event)\n\n  const body = await readValidatedBody(event, createTemplateValidator.parse)\n\n  const [template] = await useDrizzle().insert(tables.templates).values({\n    title: body.title,\n  }).returning({\n    id: tables.templates.id,\n  })\n\n  if (body.moduleIds)\n    await useDrizzle().insert(tables.modulesToTemplates).values(body.moduleIds.map(id => ({ moduleId: id, templateId: template.id }))).execute()\n\n  return sendNoContent(event, 201)\n})\n",[103,19104,19105,19124,19128,19150,19172,19176,19202,19206,19246,19260,19274,19292,19298,19302,19316,19395,19399,19415],{"__ignoreMap":101},[106,19106,19107,19109,19111,19114,19116,19118,19120,19122],{"class":108,"line":109},[106,19108,410],{"class":270},[106,19110,413],{"class":112},[106,19112,19113],{"class":224}," createTemplateValidator",[106,19115,419],{"class":112},[106,19117,422],{"class":270},[106,19119,425],{"class":112},[106,19121,1963],{"class":142},[106,19123,431],{"class":112},[106,19125,19126],{"class":108,"line":123},[106,19127,330],{"emptyLinePlaceholder":329},[106,19129,19130,19132,19134,19136,19138,19140,19142,19144,19146,19148],{"class":108,"line":162},[106,19131,271],{"class":270},[106,19133,274],{"class":270},[106,19135,278],{"class":277},[106,19137,281],{"class":224},[106,19139,284],{"class":132},[106,19141,287],{"class":112},[106,19143,291],{"class":290},[106,19145,294],{"class":112},[106,19147,297],{"class":132},[106,19149,300],{"class":112},[106,19151,19152,19154,19156,19158,19160,19162,19164,19166,19168,19170],{"class":108,"line":200},[106,19153,305],{"class":132},[106,19155,413],{"class":112},[106,19157,5679],{"class":224},[106,19159,419],{"class":112},[106,19161,311],{"class":112},[106,19163,314],{"class":270},[106,19165,13305],{"class":277},[106,19167,281],{"class":116},[106,19169,291],{"class":224},[106,19171,324],{"class":116},[106,19173,19174],{"class":108,"line":221},[106,19175,330],{"emptyLinePlaceholder":329},[106,19177,19178,19180,19182,19184,19186,19188,19190,19192,19194,19196,19198,19200],{"class":108,"line":228},[106,19179,305],{"class":132},[106,19181,308],{"class":224},[106,19183,311],{"class":112},[106,19185,314],{"class":270},[106,19187,470],{"class":277},[106,19189,281],{"class":116},[106,19191,291],{"class":224},[106,19193,355],{"class":112},[106,19195,19113],{"class":224},[106,19197,52],{"class":112},[106,19199,521],{"class":224},[106,19201,324],{"class":116},[106,19203,19204],{"class":108,"line":238},[106,19205,330],{"emptyLinePlaceholder":329},[106,19207,19208,19210,19212,19214,19216,19218,19220,19222,19224,19226,19228,19230,19232,19234,19236,19238,19240,19242,19244],{"class":108,"line":248},[106,19209,305],{"class":132},[106,19211,2538],{"class":112},[106,19213,117],{"class":224},[106,19215,785],{"class":112},[106,19217,311],{"class":112},[106,19219,314],{"class":270},[106,19221,7070],{"class":277},[106,19223,506],{"class":116},[106,19225,52],{"class":112},[106,19227,7264],{"class":277},[106,19229,281],{"class":116},[106,19231,7155],{"class":224},[106,19233,52],{"class":112},[106,19235,11404],{"class":224},[106,19237,294],{"class":116},[106,19239,52],{"class":112},[106,19241,7279],{"class":277},[106,19243,281],{"class":116},[106,19245,488],{"class":112},[106,19247,19248,19250,19252,19254,19256,19258],{"class":108,"line":534},[106,19249,7822],{"class":116},[106,19251,496],{"class":112},[106,19253,308],{"class":224},[106,19255,52],{"class":112},[106,19257,12717],{"class":224},[106,19259,509],{"class":112},[106,19261,19262,19264,19266,19268,19270,19272],{"class":108,"line":539},[106,19263,514],{"class":112},[106,19265,294],{"class":116},[106,19267,52],{"class":112},[106,19269,12337],{"class":277},[106,19271,281],{"class":116},[106,19273,488],{"class":112},[106,19275,19276,19278,19280,19282,19284,19286,19288,19290],{"class":108,"line":556},[106,19277,7086],{"class":116},[106,19279,496],{"class":112},[106,19281,7091],{"class":224},[106,19283,52],{"class":112},[106,19285,11404],{"class":224},[106,19287,52],{"class":112},[106,19289,7100],{"class":224},[106,19291,509],{"class":112},[106,19293,19294,19296],{"class":108,"line":759},[106,19295,514],{"class":112},[106,19297,324],{"class":116},[106,19299,19300],{"class":108,"line":795},[106,19301,330],{"emptyLinePlaceholder":329},[106,19303,19304,19306,19308,19310,19312,19314],{"class":108,"line":803},[106,19305,697],{"class":270},[106,19307,287],{"class":116},[106,19309,7297],{"class":224},[106,19311,52],{"class":112},[106,19313,18944],{"class":224},[106,19315,324],{"class":116},[106,19317,19318,19320,19322,19324,19326,19328,19330,19332,19334,19336,19338,19340,19342,19344,19346,19348,19350,19352,19354,19356,19358,19360,19362,19364,19367,19369,19371,19373,19376,19378,19380,19382,19384,19386,19389,19391,19393],{"class":108,"line":809},[106,19319,1402],{"class":270},[106,19321,7070],{"class":277},[106,19323,506],{"class":116},[106,19325,52],{"class":112},[106,19327,7264],{"class":277},[106,19329,281],{"class":116},[106,19331,7155],{"class":224},[106,19333,52],{"class":112},[106,19335,18642],{"class":224},[106,19337,294],{"class":116},[106,19339,52],{"class":112},[106,19341,7279],{"class":277},[106,19343,281],{"class":116},[106,19345,7297],{"class":224},[106,19347,52],{"class":112},[106,19349,18944],{"class":224},[106,19351,52],{"class":112},[106,19353,10477],{"class":277},[106,19355,281],{"class":116},[106,19357,7100],{"class":290},[106,19359,297],{"class":132},[106,19361,287],{"class":116},[106,19363,1285],{"class":112},[106,19365,19366],{"class":116}," moduleId",[106,19368,496],{"class":112},[106,19370,18017],{"class":224},[106,19372,355],{"class":112},[106,19374,19375],{"class":116}," templateId",[106,19377,496],{"class":112},[106,19379,16650],{"class":224},[106,19381,52],{"class":112},[106,19383,7100],{"class":224},[106,19385,419],{"class":112},[106,19387,19388],{"class":116},")))",[106,19390,52],{"class":112},[106,19392,7330],{"class":277},[106,19394,2366],{"class":116},[106,19396,19397],{"class":108,"line":814},[106,19398,330],{"emptyLinePlaceholder":329},[106,19400,19401,19403,19405,19407,19409,19411,19413],{"class":108,"line":830},[106,19402,345],{"class":270},[106,19404,348],{"class":277},[106,19406,281],{"class":116},[106,19408,291],{"class":224},[106,19410,355],{"class":112},[106,19412,359],{"class":358},[106,19414,324],{"class":116},[106,19416,19417,19419],{"class":108,"line":835},[106,19418,366],{"class":112},[106,19420,324],{"class":224},[10,19422,19423],{},[29,19424,19425],{},"Je dois admettre que je ne suis pas très confiant avec cette partie. Je devrai vérifier s'il y a une meilleure façon de le faire (en utilisant une transaction ou une fonctionnalité intégrée de Drizzle).",[381,19427,19429],{"id":19428},"remplir-le-modèle-avec-des-modules","Remplir le modèle avec des modules",[10,19431,19432],{},[29,19433,19434],{},"C'est là que les choses deviennent difficiles.",[10,19436,19437,19438,19440,19441,13071,19443,19445,19446,52],{},"Au départ, j'avais une requête SQL personnalisée qui joignait la table ",[103,19439,11404],{},", la table ",[103,19442,6882],{},[103,19444,6888],{},". Vous pouvez en savoir plus à ce sujet dans la ",[14,19447,19449],{"href":19448},"#creating-a-form-for-templates","partie précédente",[10,19451,19452,19453,19455,19456,19458,19459,19461],{},"Pour lier la table ",[103,19454,6894],{},", j'ai essayé d'ajouter une jointure avec la table ",[103,19457,18642],{}," puis la table ",[103,19460,6894],{},". Le problème que je rencontre est comment grouper les modules par modèle pour produire un tableau de modules pour chaque modèle.",[10,19463,19464],{},"Quelque chose comme :",[96,19466,19468],{"className":15242,"code":19467,"language":15244,"meta":101,"style":101},"{\n  \"id\": 1,\n  \"title\": \"Mon modèle\",\n  \"modules\": [\n    { \"id\": 1, \"name\": \"Module 1\" },\n    { \"id\": 2, \"name\": \"Module 2\" }\n  ]\n}\n",[103,19469,19470,19474,19488,19507,19520,19555,19590,19595],{"__ignoreMap":101},[106,19471,19472],{"class":108,"line":109},[106,19473,488],{"class":112},[106,19475,19476,19478,19480,19482,19484,19486],{"class":108,"line":123},[106,19477,15255],{"class":112},[106,19479,7100],{"class":132},[106,19481,139],{"class":112},[106,19483,496],{"class":112},[106,19485,11068],{"class":358},[106,19487,509],{"class":112},[106,19489,19490,19492,19494,19496,19498,19500,19503,19505],{"class":108,"line":162},[106,19491,15255],{"class":112},[106,19493,12717],{"class":132},[106,19495,139],{"class":112},[106,19497,496],{"class":112},[106,19499,15278],{"class":112},[106,19501,19502],{"class":142},"Mon modèle",[106,19504,139],{"class":112},[106,19506,509],{"class":112},[106,19508,19509,19511,19513,19515,19517],{"class":108,"line":200},[106,19510,15255],{"class":112},[106,19512,6894],{"class":132},[106,19514,139],{"class":112},[106,19516,496],{"class":112},[106,19518,19519],{"class":112}," [\n",[106,19521,19522,19525,19527,19529,19531,19533,19535,19537,19539,19541,19543,19545,19547,19550,19552],{"class":108,"line":221},[106,19523,19524],{"class":112},"    {",[106,19526,15278],{"class":112},[106,19528,7100],{"class":1041},[106,19530,139],{"class":112},[106,19532,496],{"class":112},[106,19534,11068],{"class":358},[106,19536,355],{"class":112},[106,19538,15278],{"class":112},[106,19540,3858],{"class":1041},[106,19542,139],{"class":112},[106,19544,496],{"class":112},[106,19546,15278],{"class":112},[106,19548,19549],{"class":142},"Module 1",[106,19551,139],{"class":112},[106,19553,19554],{"class":112}," },\n",[106,19556,19557,19559,19561,19563,19565,19567,19570,19572,19574,19576,19578,19580,19582,19585,19587],{"class":108,"line":228},[106,19558,19524],{"class":112},[106,19560,15278],{"class":112},[106,19562,7100],{"class":1041},[106,19564,139],{"class":112},[106,19566,496],{"class":112},[106,19568,19569],{"class":358}," 2",[106,19571,355],{"class":112},[106,19573,15278],{"class":112},[106,19575,3858],{"class":1041},[106,19577,139],{"class":112},[106,19579,496],{"class":112},[106,19581,15278],{"class":112},[106,19583,19584],{"class":142},"Module 2",[106,19586,139],{"class":112},[106,19588,19589],{"class":112}," }\n",[106,19591,19592],{"class":108,"line":238},[106,19593,19594],{"class":112},"  ]\n",[106,19596,19597],{"class":108,"line":248},[106,19598,1558],{"class":112},[10,19600,19601,19602,19605],{},"J'ai trouvé la fonction SQLite ",[103,19603,19604],{},"group_concat"," qui peut être utilisée pour concaténer les modules. J'étais un peu déçu de devoir le faire manuellement car Drizzle se présente comme un ORM. Je m'attendais à avoir un moyen de le faire facilement.",[10,19607,19608,19609,19616],{},"MAIS, le chat Twitch est venu à la rescousse et m'a suggéré d'utiliser la ",[14,19610,19613],{"href":19611,"rel":19612},"https:\u002F\u002Form.drizzle.team\u002Fdocs\u002Frqb#include-relations",[18],[103,19614,19615],{},"clé with de la méthode findMany",". J'ai essayé, et cela n'a pas vraiment fonctionné. Cloudflare se plaignait d'une relation manquante. J'étais un peu perdu.",[10,19618,19619,19620,19622,19623,19625],{},"J'ai fini par lire attentivement la documentation sur la page de la relation de plusieurs à plusieurs, et j'ai compris que je devais définir explicitement chaque relation entre chaque table. En d'autres termes, je dois dire à Drizzle comment joindre les tables. Un modèle a plusieurs modules en utilisant la table ",[103,19621,18642],{},". Un module a plusieurs modèles en utilisant la table ",[103,19624,18642],{},". C'est ainsi que l'on définit la relation de plusieurs à plusieurs.",[96,19627,19629],{"className":261,"code":19628,"language":263,"meta":101,"style":101},"\u002F\u002F Un modèle a une relation de plusieurs à plusieurs\nexport const templatesRelations = relations(templates, ({ one, many }) => ({\n  \u002F\u002F Lié à la table de pivot et non directement à la table des modules\n  modules: many(modulesToTemplates),\n}))\n\n\u002F\u002F La table de pivot a 2 relations\nexport const modulesToTemplatesRelations = relations(modulesToTemplates, ({ one }) => ({\n  module: one(modules, {\n    fields: [modulesToTemplates.moduleId],\n    references: [modules.id],\n  }),\n  template: one(templates, {\n    fields: [modulesToTemplates.templateId],\n    references: [templates.id],\n  }),\n}))\n\n\u002F\u002F Un module a une relation de plusieurs à plusieurs\nexport const modulesRelations = relations(modules, ({ one, many }) => ({\n  \u002F\u002F Lié à la table de pivot et non directement à la table des modèles\n  templates: many(modulesToTemplates),\n}))\n",[103,19630,19631,19636,19671,19676,19690,19696,19700,19705,19735,19750,19767,19784,19792,19807,19822,19837,19845,19851,19855,19860,19893,19898,19910],{"__ignoreMap":101},[106,19632,19633],{"class":108,"line":109},[106,19634,19635],{"class":335},"\u002F\u002F Un modèle a une relation de plusieurs à plusieurs\n",[106,19637,19638,19640,19642,19645,19647,19649,19652,19654,19656,19659,19661,19663,19665,19667,19669],{"class":108,"line":123},[106,19639,271],{"class":270},[106,19641,1775],{"class":132},[106,19643,19644],{"class":224}," templatesRelations ",[106,19646,136],{"class":112},[106,19648,13116],{"class":277},[106,19650,19651],{"class":224},"(templates",[106,19653,355],{"class":112},[106,19655,13124],{"class":112},[106,19657,19658],{"class":290}," one",[106,19660,355],{"class":112},[106,19662,13127],{"class":290},[106,19664,5687],{"class":112},[106,19666,297],{"class":132},[106,19668,287],{"class":224},[106,19670,488],{"class":112},[106,19672,19673],{"class":108,"line":162},[106,19674,19675],{"class":335},"  \u002F\u002F Lié à la table de pivot et non directement à la table des modules\n",[106,19677,19678,19681,19683,19685,19688],{"class":108,"line":200},[106,19679,19680],{"class":116},"  modules",[106,19682,496],{"class":112},[106,19684,13127],{"class":277},[106,19686,19687],{"class":224},"(modulesToTemplates)",[106,19689,509],{"class":112},[106,19691,19692,19694],{"class":108,"line":221},[106,19693,366],{"class":112},[106,19695,1127],{"class":224},[106,19697,19698],{"class":108,"line":228},[106,19699,330],{"emptyLinePlaceholder":329},[106,19701,19702],{"class":108,"line":238},[106,19703,19704],{"class":335},"\u002F\u002F La table de pivot a 2 relations\n",[106,19706,19707,19709,19711,19714,19716,19718,19721,19723,19725,19727,19729,19731,19733],{"class":108,"line":248},[106,19708,271],{"class":270},[106,19710,1775],{"class":132},[106,19712,19713],{"class":224}," modulesToTemplatesRelations ",[106,19715,136],{"class":112},[106,19717,13116],{"class":277},[106,19719,19720],{"class":224},"(modulesToTemplates",[106,19722,355],{"class":112},[106,19724,13124],{"class":112},[106,19726,19658],{"class":290},[106,19728,5687],{"class":112},[106,19730,297],{"class":132},[106,19732,287],{"class":224},[106,19734,488],{"class":112},[106,19736,19737,19740,19742,19744,19746,19748],{"class":108,"line":534},[106,19738,19739],{"class":116},"  module",[106,19741,496],{"class":112},[106,19743,19658],{"class":277},[106,19745,10976],{"class":224},[106,19747,355],{"class":112},[106,19749,300],{"class":112},[106,19751,19752,19755,19757,19760,19762,19765],{"class":108,"line":539},[106,19753,19754],{"class":116},"    fields",[106,19756,496],{"class":112},[106,19758,19759],{"class":224}," [modulesToTemplates",[106,19761,52],{"class":112},[106,19763,19764],{"class":224},"moduleId]",[106,19766,509],{"class":112},[106,19768,19769,19772,19774,19777,19779,19782],{"class":108,"line":556},[106,19770,19771],{"class":116},"    references",[106,19773,496],{"class":112},[106,19775,19776],{"class":224}," [modules",[106,19778,52],{"class":112},[106,19780,19781],{"class":224},"id]",[106,19783,509],{"class":112},[106,19785,19786,19788,19790],{"class":108,"line":759},[106,19787,514],{"class":112},[106,19789,294],{"class":224},[106,19791,509],{"class":112},[106,19793,19794,19797,19799,19801,19803,19805],{"class":108,"line":795},[106,19795,19796],{"class":116},"  template",[106,19798,496],{"class":112},[106,19800,19658],{"class":277},[106,19802,19651],{"class":224},[106,19804,355],{"class":112},[106,19806,300],{"class":112},[106,19808,19809,19811,19813,19815,19817,19820],{"class":108,"line":803},[106,19810,19754],{"class":116},[106,19812,496],{"class":112},[106,19814,19759],{"class":224},[106,19816,52],{"class":112},[106,19818,19819],{"class":224},"templateId]",[106,19821,509],{"class":112},[106,19823,19824,19826,19828,19831,19833,19835],{"class":108,"line":809},[106,19825,19771],{"class":116},[106,19827,496],{"class":112},[106,19829,19830],{"class":224}," [templates",[106,19832,52],{"class":112},[106,19834,19781],{"class":224},[106,19836,509],{"class":112},[106,19838,19839,19841,19843],{"class":108,"line":814},[106,19840,514],{"class":112},[106,19842,294],{"class":224},[106,19844,509],{"class":112},[106,19846,19847,19849],{"class":108,"line":830},[106,19848,366],{"class":112},[106,19850,1127],{"class":224},[106,19852,19853],{"class":108,"line":835},[106,19854,330],{"emptyLinePlaceholder":329},[106,19856,19857],{"class":108,"line":840},[106,19858,19859],{"class":335},"\u002F\u002F Un module a une relation de plusieurs à plusieurs\n",[106,19861,19862,19864,19866,19869,19871,19873,19875,19877,19879,19881,19883,19885,19887,19889,19891],{"class":108,"line":845},[106,19863,271],{"class":270},[106,19865,1775],{"class":132},[106,19867,19868],{"class":224}," modulesRelations ",[106,19870,136],{"class":112},[106,19872,13116],{"class":277},[106,19874,10976],{"class":224},[106,19876,355],{"class":112},[106,19878,13124],{"class":112},[106,19880,19658],{"class":290},[106,19882,355],{"class":112},[106,19884,13127],{"class":290},[106,19886,5687],{"class":112},[106,19888,297],{"class":132},[106,19890,287],{"class":224},[106,19892,488],{"class":112},[106,19894,19895],{"class":108,"line":862},[106,19896,19897],{"class":335},"  \u002F\u002F Lié à la table de pivot et non directement à la table des modèles\n",[106,19899,19900,19902,19904,19906,19908],{"class":108,"line":1469},[106,19901,13140],{"class":116},[106,19903,496],{"class":112},[106,19905,13127],{"class":277},[106,19907,19687],{"class":224},[106,19909,509],{"class":112},[106,19911,19912,19914],{"class":108,"line":1486},[106,19913,366],{"class":112},[106,19915,1127],{"class":224},[10,19917,19918,19919,19924],{},"Cette partie est super importante. Dans un ORM comme ",[14,19920,19923],{"href":19921,"rel":19922},"https:\u002F\u002Flucid.adonisjs.com",[18],"Lucid",", l'utilisation d'une table de pivot est transparente. Avec Drizzle, vous devez définir la relation entre chaque table. C'est un peu plus verbeux, mais c'est aussi plus explicite.",[10,19926,19927],{},"Enfin, je peux mettre à jour le point de terminaison pour récupérer les modèles avec leurs modules :",[96,19929,19931],{"className":261,"code":19930,"language":263,"meta":101,"style":101},"export default defineEventHandler(async () => {\n  const templates = await useDrizzle().query.templates.findMany({\n    with: {\n      category: true,\n      creator: true,\n      modules: {\n        with: {\n          module: true\n        },\n      },\n    },\n  })\n\n  return templates\n})\n",[103,19932,19933,19951,19982,19991,20002,20013,20022,20031,20040,20044,20048,20053,20059,20063,20069],{"__ignoreMap":101},[106,19934,19935,19937,19939,19941,19943,19945,19947,19949],{"class":108,"line":109},[106,19936,271],{"class":270},[106,19938,274],{"class":270},[106,19940,278],{"class":277},[106,19942,281],{"class":224},[106,19944,284],{"class":132},[106,19946,7052],{"class":112},[106,19948,297],{"class":132},[106,19950,300],{"class":112},[106,19952,19953,19955,19957,19959,19961,19963,19965,19967,19969,19971,19973,19975,19978,19980],{"class":108,"line":123},[106,19954,305],{"class":132},[106,19956,13752],{"class":224},[106,19958,311],{"class":112},[106,19960,314],{"class":270},[106,19962,7070],{"class":277},[106,19964,506],{"class":116},[106,19966,52],{"class":112},[106,19968,6250],{"class":224},[106,19970,52],{"class":112},[106,19972,11404],{"class":224},[106,19974,52],{"class":112},[106,19976,19977],{"class":277},"findMany",[106,19979,281],{"class":116},[106,19981,488],{"class":112},[106,19983,19984,19987,19989],{"class":108,"line":162},[106,19985,19986],{"class":116},"    with",[106,19988,496],{"class":112},[106,19990,300],{"class":112},[106,19992,19993,19996,19998,20000],{"class":108,"line":200},[106,19994,19995],{"class":116},"      category",[106,19997,496],{"class":112},[106,19999,5653],{"class":5652},[106,20001,509],{"class":112},[106,20003,20004,20007,20009,20011],{"class":108,"line":221},[106,20005,20006],{"class":116},"      creator",[106,20008,496],{"class":112},[106,20010,5653],{"class":5652},[106,20012,509],{"class":112},[106,20014,20015,20018,20020],{"class":108,"line":228},[106,20016,20017],{"class":116},"      modules",[106,20019,496],{"class":112},[106,20021,300],{"class":112},[106,20023,20024,20027,20029],{"class":108,"line":238},[106,20025,20026],{"class":116},"        with",[106,20028,496],{"class":112},[106,20030,300],{"class":112},[106,20032,20033,20036,20038],{"class":108,"line":248},[106,20034,20035],{"class":116},"          module",[106,20037,496],{"class":112},[106,20039,7609],{"class":5652},[106,20041,20042],{"class":108,"line":534},[106,20043,6578],{"class":112},[106,20045,20046],{"class":108,"line":539},[106,20047,5734],{"class":112},[106,20049,20050],{"class":108,"line":556},[106,20051,20052],{"class":112},"    },\n",[106,20054,20055,20057],{"class":108,"line":759},[106,20056,514],{"class":112},[106,20058,324],{"class":116},[106,20060,20061],{"class":108,"line":795},[106,20062,330],{"emptyLinePlaceholder":329},[106,20064,20065,20067],{"class":108,"line":803},[106,20066,345],{"class":270},[106,20068,13885],{"class":224},[106,20070,20071,20073],{"class":108,"line":809},[106,20072,366],{"class":112},[106,20074,324],{"class":224},[10,20076,20077,20078,20081],{},"L'utilisation de ",[103,20079,20080],{},"with",", une fois que tout est correctement défini, rend les choses tellement plus faciles ; j'adore vraiment ça.",[381,20083,5430],{"id":20084},"conclusion-4",[10,20086,20087],{},"Dans mon esprit, cette partie était facile, et en 30 minutes, c'était fait. J'avais déjà fait quelque chose de similaire en utilisant Lucid d'Adonis. En réalité, ce n'était pas vraiment difficile, mais trouver la bonne partie de la documentation de Drizzle puis la comprendre était bien plus difficile que prévu. Cela est largement dû au fait que Drizzle n'abstrait pas SQL mais agit comme un petit wrapper, et tout doit être explicite.",[10,20089,20090],{},"Je suis heureux de l'avoir fait, j'ai beaucoup appris, et un grand merci au chat pour leur aide.",[5454,20092,20093],{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}",{"title":101,"searchDepth":123,"depth":123,"links":20095},[20096,20097],{"id":5530,"depth":123,"text":5531},{"id":5831,"depth":123,"text":5832},"2024-05-01","Cela fait des mois que je veux commencer à streamer sur Twitch pour le plaisir de partager des choses et de coder en direct. J'ai finalement sauté le pas.",{},"\u002Ffr\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey","---\ncontentId: 2349bbcd-1535-4f33-8da7-299f46af18b9\ntitle: \"Créer une App Nuxt Full-Stack : une Aventure sur Twitch\"\ndescription: Cela fait des mois que je veux commencer à streamer sur Twitch pour le plaisir de partager des choses et de coder en direct. J'ai finalement sauté le pas.\ndate: 2024-05-01\n---\n\n**Je l'ai enfin fait !**\n\nAprès des mois à y penser, j'ai enfin lancé mon premier stream sur Twitch, le 28 avril 2024. C'est une étape très importante pour moi car j'adore partager mes connaissances et parler de ce que je fais.\n\n\u003Cdiv class=\"grid grid-cols-1 md:grid-cols-2 gap-4\">\n  \u003CCalloutFollowMeOnTwitch \u002F>\n  \u003CCallout icon=\"i-simple-icons-youtube\" text=\"Voir les rediffusions\" href=\"https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes\"  \u002F>\n  \u003CCallout icon=\"i-simple-icons-x\" text=\"Ne manquez rien\" href=\"https:\u002F\u002Fx.com\u002Fsoubiran_\"  \u002F>\n  \u003CCallout icon=\"i-simple-icons-github\" text=\"L'application Full-Stack\" href=\"https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion\"  \u002F>\n\u003C\u002Fdiv>\n\nQuand j'étais à l'école, j'organisais des cours pour expliquer la programmation à mes camarades. J'ai aussi rejoint une communauté pour expliquer les choses sur les ordinateurs, j'ai donné quelques conférences, et récemment, j'ai commencé à écrire des articles sur mon blog.\n\n**Twitch est juste une nouvelle façon de continuer à faire ce que j'aime !**\n\nDans les semaines à venir, je partagerai avec vous mon parcours pour construire une application Full-Stack Nuxt, déployée sur Cloudflare, appelée Orion. J'expliquerai comment je l'ai construite, les choix que j'ai faits, et les problèmes que j'ai rencontrés. Mon objectif à travers cet article est de vous donner une vue honnête et réaliste des avantages et des inconvénients de la création d'une application Full-Stack en edge avec Nuxt en mai 2024, à travers le processus de construction d'une application réelle.\n\nDans cet article, je partagerai avec vous chaque étape de cette première série de streams. Si vous ne parlez pas français (car les streams sont en français), si vous avez manqué un stream, ou si vous préférez simplement lire, cet article est fait pour vous !\n\n## Découverte de NuxtHub et Début de l'Application Full-Stack Nuxt\n\n![Vignette de mon premier live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-04-28.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application Full-Stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir la rediffusion sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nC'était mon premier stream, et pour être honnête, j'étais un peu stressé. Le moment où vous devez passer de la scène \"Starting Soon\" à la caméra est difficile. Mais une fois que j'ai commencé à parler, tout s'est bien passé. _Cependant, cela ne veut pas dire que c'est le meilleur stream que vous verrez jamais. Nous allons nous améliorer !_\n\n![Moi, pas très à l'aise devant la caméra.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-04-28-me.png)\n\nTout d'abord, je présente [NuxtHub](https:\u002F\u002Fhub.nuxt.com). C'est une plateforme développée par l'équipe Nuxt pour faciliter le déploiement et la gestion des applications Nuxt déployées sur Cloudflare. En quelques clics, vous pouvez déployer votre application Nuxt sur Cloudflare. C'est vraiment magique ! Grâce à un module Nuxt, vous pourrez développer votre application localement et ensuite la déployer sans modifier votre code. Tout, de la base de données au stockage du blog, fonctionne parfaitement dans les deux environnements.\n\nJ'ai essayé de déployer un modèle depuis NuxtHub pour montrer à quel point il est facile d'utiliser la plateforme. J'ai choisi le modèle Atinotes. Malheureusement, cette application produit un bundle de plus de 1 Mo, la limite pour le niveau gratuit, donc le déploiement échoue. Dommage pour une démonstration. _Après cet échec, j'ai pensé à un moyen de prévenir cela et d'informer les utilisateurs si un modèle nécessite un plan payant ou non. [J'ai soulevé un problème](https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F13) qui a été rapidement corrigé !_. La démo attendra plus tard.\n\nAprès cet effet de démonstration, nous avons commencé à coder l'application Full-Stack Nuxt en ajoutant l'authentification. Pour ce faire, nous utilisons le module [`nuxt-auth-utils`](https:\u002F\u002Fgithub.com\u002Fatinux\u002Fnuxt-auth-utils) de [Sébastien Chopin](https:\u002F\u002Fgithub.com\u002FAtinux). Avec quelques lignes de code, nous ajoutons l'authentification. Voici le code que nous avons utilisé :\n\n```ts\nexport default oauth.githubEventHandler({\n  config: {\n    emailRequired: true,\n  },\n  async onSuccess(event, { user, tokens }) {\n    await setUserSession(event, {\n      user: {\n        githubId: user.githubId,\n      },\n    })\n\n    return sendRedirect(event, '\u002F')\n  },\n})\n```\n\nC'est vraiment simple et ça fonctionne à merveille, bien intégré dans une application Nuxt.\n\nPour terminer le stream, j'ai rapidement configuré NuxtHub dans mon application pour montrer à quel point il est facile de jouer avec une base de données. J'ai essayé d'utiliser SQL brut, mais la recette pour migrer la base de données ne fonctionnait pas. _[J'ai soulevé un problème](https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F15)_ à ce sujet et il est maintenant corrigé ! Après ce deuxième échec, j'ai décidé de commencer à utiliser Drizzle ORM, un ORM simple mais puissant compatible avec les workers de Cloudflare. Dans tous les cas, j'aurais dû utiliser Drizzle car gérer tout avec SQL brut est trop de travail.\n\nPendant le live, quelqu'un a demandé comment appliquer les migrations à la base de données de production. Je dois admettre que je ne connaissais pas la réponse et rien dans la documentation n'était clair à ce sujet. _[J'ai soulevé un problème](https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F14)_ à ce sujet, qui a reçu une réponse. _Il faut déployer votre application puis démarrer le développement local avec une connexion distante pour exécuter les migrations_ Clairement, c'est loin d'être idéal pour le moment, mais avec les prochaines [tâches Nitro](https:\u002F\u002Fnitro.unjs.io\u002Fguide\u002Ftasks), cela devrait être plus facile dans un avenir proche. _Je l'espère vraiment._\n\nGlobalement, je suis tellement heureux d'avoir commencé ce stream. Même si ce n'est pas encore parfait, commencer est déjà un grand pas en avant, et je sais que les futurs streams ne feront que devenir plus faciles à commencer.\n\n_J'étais tellement stressé que je suis allé très vite dans mes explications, donc j'ai décidé que le prochain stream serait plus bavard pour réexpliquer différents concepts._\n\nLe projet est open-source et disponible sur GitHub à \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F> ! Donnez-lui une étoile si vous l'aimez ! ⭐\n\n## Discussion sur Cloudflare, NuxtHub et GitHub OAuth\n\n![Vignette de mon deuxième live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-02.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application Full-Stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir la rediffusion sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nJ'étais frustré par mon premier live stream. Avec le stress, je n'ai pas expliqué des concepts comme ce qu'est Cloudflare, ce qu'est l'edge et comment NuxtHub est lié à Cloudflare. Mais je sais qu'il est important d'expliquer ces concepts puisque ce sont des technologies nouvelles et que tout le monde ne les connaît pas.\n\nÀ StrasbourgJS ou Devoxx, j'ai parlé d'UnJS et des problèmes résolus par l'écosystème. Beaucoup de personnes, pendant ou après, m'ont demandé ce qu'est l'edge. Qu'est-ce que c'est ? Pourquoi en avons-nous besoin ? D'où cela vient-il ?\n\n\u003CCalloutReadMore id=\"a1291894-3de4-444e-b49b-a5dec98f3e27\" \u002F>\n\n\u003CCalloutReadMore id=\"f3e97a6a-ce47-42b8-8493-06eb37f8e964\" \u002F>\n\nLe but de mes streams est de tester cette nouvelle technologie, et avec ces retours, il était nécessaire d'expliquer ces concepts.\n\n### Cloudflare en un coup d'œil\n\nCloudflare est une entreprise qui propose de nombreux services pour rendre le web plus rapide et plus sécurisé, comme une solution zéro-trust, un WAF, un CDN, un DNS, une protection contre les DDoS, et plus encore, pour les entreprises comme pour les particuliers.\n\nLeur produit le plus connu est le proxy inverse. Cloudflare agit comme le point d'entrée principal et unique pour votre trafic. Ensuite, il dirigera, en interne, le trafic vers le bon serveur en utilisant le DNS. Cela permet de cacher votre IP serveur, de mettre en cache les ressources statiques et de protéger votre serveur contre les attaques DDoS.\n\n![Schéma du proxy inverse.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Freverse-proxy.png)\n\nPour fournir ces services, ils ont construit un réseau de centres de données à travers le monde pour être le plus proche possible des utilisateurs afin de servir le contenu mis en cache plus rapidement et de pouvoir atténuer les attaques. Cela s'appelle le réseau edge. Il est à la frontière entre les utilisateurs et le web, pour lequel il constitue la passerelle.\n\nEn septembre 2017, [ils ont annoncé Cloudflare Workers](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-workers). Pour comprendre l'objectif à l'époque, citons l'article :\n\n> Que faire si vous souhaitez équilibrer la charge avec un algorithme d'affinité personnalisé ? Que faire si les règles de mise en cache HTTP standard ne sont pas tout à fait adaptées et que vous avez besoin de logique personnalisée pour améliorer votre taux de cache ? Que faire si vous voulez écrire des règles WAF personnalisées adaptées à votre application ?\n>\n> **Vous voulez écrire du code**\n\nLes Workers ont été lancés pour permettre aux développeurs de personnaliser l'infrastructure selon leurs besoins. La solution Cloudflare Workers est construite sur l'API Service Worker, une norme dans le navigateur pour intercepter et modifier les requêtes et les réponses.\n\nDepuis lors, ils ont construit une plateforme complète pour les développeurs autour des workers :\n\n\u003C!-- TODO: créer une grille de composants (et documenter : éléments fortement liés, meilleure présentation qu'une simple liste) -->\n\n- [Workers KV, magasin de clés-valeurs sans serveur](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-workers-kv)\n- [D1, base de données SQL sans serveur](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-d1)\n- [R2, stockage d'objets sans serveur](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-r2-object-storage)\n- [Queues, files d'attente de tâches sans serveur](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-queues)\n- [Workers AI, inférence GPU sans serveur](https:\u002F\u002Fblog.cloudflare.com\u002Fworkers-ai)\n\nAvec tous ces services, il est maintenant (théoriquement, et c'est le but de cette série de répondre à la question) possible de construire une application entièrement sur le réseau mondial de Cloudflare.\n\n### NuxtHub\n\n![Page d'accueil de NuxtHub.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-home-page.png)\n\nDepuis la version 3 de Nuxt, l'équipe pousse l'edge. Nitro, la partie serveur de Nuxt, peut être déployée partout, sur les plateformes actuelles et futures, et est optimisée pour l'edge. H3, le serveur HTTP sous-jacent, se réveille en moins de 2 ms, et la sortie de build de Nitro est inférieure à 1 Mo, modules Node inclus.\n\nNuxtHub est la continuité de cet effort. D'abord, c'est une plateforme pour déployer des applications Nuxt sur Cloudflare. C'est un déploiement en deux clics. Vous connectez votre compte Cloudflare car c'est construit sur Cloudflare, vous choisissez un modèle ou un dépôt, vous cliquez sur déployer, et c'est tout. _Votre application est en direct sur l'edge._ On a l'impression que c'est de la magie.\n\n![Modèles NuxtHub pouvant être déployés en deux clics.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-deploy.png)\n\nNuxtHub est également un module pour votre application Nuxt afin d'ajouter des fonctionnalités d'accès aux données. Localement, vous pourrez jouer instantanément avec une base de données, un magasin KV, et même un stockage d'objets. En production, vous n'avez pas besoin de changer votre code, il fonctionnera de la même manière. C'est clairement la véritable magie de NuxtHub, alimentée par [`workerd`](https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworker).\n\n![Schéma de NuxtHub (de hub.nuxt.com).](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-schema.png)\n\nIl y a aussi une fonctionnalité permettant à un environnement de développement local d'interagir avec les données distantes. Pas sûr du cas d'utilisation, mais c'est définitivement intéressant.\n\nMême si la plateforme est encore nouvelle, annoncée lors de Vue Amsterdam 2024, elle est déjà très prometteuse. L'équipe est très réactive, et la plateforme évolue rapidement. Je suis très impatient de voir ce que l'avenir réserve à NuxtHub, mais il y a quelques indices dans la documentation :\n\n> Nous prévoyons de fournir une expérience backend complète pour les applications Nuxt à travers divers packages @nuxthub.\n>\n> - @nuxthub\u002Fcore : Package principal pour fournir des fonctionnalités de stockage\n> - @nuxthub\u002Fauth : Ajouter l'authentification pour la gestion des utilisateurs (bientôt)\n> - @nuxthub\u002Femail : Envoyer des e-mails transactionnels à vos utilisateurs (bientôt)\n> - @nuxthub\u002Fanalytics : Comprendre votre trafic et suivre les événements au sein de votre application et API (bientôt)\n> - @nuxthub\u002F... : Vous le nommez !\n\n### GitHub OAuth\n\nLors du précédent live stream, j'ai ajouté l'authentification, en utilisant `nuxt-auth-utils` à Orion, l'application Full-Stack Nuxt. J'ai utilisé GitHub OAuth, et je pense qu'il est important de voir comment cela fonctionne en coulisses.\n\nPendant le live, nous avons plongé dans le code, et j'ai expliqué comment cela fonctionne. Voici un schéma pour vous aider à comprendre le processus :\n\n![Schéma GitHub OAuth.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Foauth-github.png)\n\n1. L'utilisateur clique sur le bouton de connexion, qui pointe vers `\u002Fauth\u002Fgithub`.\n2. Le serveur envoie une redirection vers la page OAuth de GitHub.\n3. L'utilisateur accepte l'autorisation de GitHub.\n4. GitHub redirige l'utilisateur vers l'URL de rappel de l'application avec un code.\n5. Le serveur échange le code contre des tokens.\n6. Le serveur utilise les tokens pour obtenir les informations de l'utilisateur.\n7. Le serveur stocke l'utilisateur dans une session.\n8. Le serveur redirige l'utilisateur vers la page d'accueil.\n\nDans ces étapes, le serveur est Nitro, et nous ne parlons pas des erreurs possibles. Il y a de nombreuses erreurs possibles comme le refus de l'autorisation par l'utilisateur, un code invalide, des tokens invalides, l'utilisateur non trouvé, etc. Chaque erreur doit être gérée pour offrir une bonne expérience utilisateur.\n\n#### Code du gestionnaire OAuth GitHub\n\nVoyons maintenant le code de `nuxt-auth-utils` pour comprendre comment il fonctionne.\n\nPour simplifier, j'ai retiré certaines parties du code qui ne sont pas nécessaires à la compréhension du processus, et je me suis concentré sur les étapes principales. Vous pouvez trouver le code complet dans le [dépôt GitHub](https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-auth-utils).\n\n```ts\n\u002F**\n * Gestionnaire d'événements OAuth GitHub.\n *\n * _Le code est simplifié pour l'article et ne fonctionne pas tel quel._\n *\u002F\nexport function githubEventHandler({ config, onSuccess }: OAuthConfig\u003COAuthGitHubConfig>) {\n  return eventHandler(async (event: H3Event) => {\n    config = { \u002F** ... *\u002F}\n\n    \u002F**\n     * Récupérer la requête depuis l'événement.\n     *\u002F\n    const query = getQuery(event)\n\n    if (query.error) {\n      \u002F\u002F Gérer l'erreur\n      return\n    }\n\n    if (!query.code) {\n      \u002F**\n       * Rediriger vers la page OAuth GitHub s'il n'y a pas de code dans la requête.\n       * Si l'utilisateur accepte l'autorisation, GitHub redirigera l'utilisateur vers l'application avec le code.\n       * Le code est un code unique à échanger contre les tokens.\n       *\u002F\n      const redirectUrl = getRequestURL(event).href\n      return sendRedirect(\n        event,\n        withQuery(config.authorizationURL as string, {\n          client_id: config.clientId,\n          redirect_uri: redirectUrl,\n          scope: config.scope.join(' '),\n          ...config.authorizationParams,\n        }),\n      )\n    }\n\n    \u002F**\n     * Utiliser le code pour obtenir les tokens.\n     *\u002F\n    const tokens: any = await $fetch(\n      config.tokenURL as string,\n      {\n        method: 'POST',\n        body: {\n          code: query.code,\n        },\n      },\n    )\n\n    const accessToken = tokens.access_token\n\n    \u002F**\n     * Utiliser le token d'accès pour obtenir les informations de l'utilisateur.\n     *\u002F\n    const user: any = await ofetch('https:\u002F\u002Fapi.github.com\u002Fuser', {\n      headers: {\n        Authorization: `token ${accessToken}`,\n      },\n    })\n\n    \u002F**\n     * Appeler le callback onSuccess avec l'utilisateur et les tokens.\n     *\n     * L'OAuth est terminé, et l'utilisateur peut maintenant être authentifié avec notre système.\n     * Avec Nitro, nous stockons l'utilisateur dans une session au sein d'un cookie.\n     *\u002F\n    return onSuccess(event, {\n      user,\n      tokens,\n    })\n  })\n}\n```\n\nComme on peut s'y attendre, le code est assez simple, et nous reconnaissons facilement les étapes que nous avons vues dans le schéma.\n\n### Conclusion\n\nJe n'ai pas codé durant ce live. Je voulais expliquer de nombreux concepts importants pour aider chacun à comprendre le contexte du projet Orion. J'espère que c'était clair et que vous avez appris quelque chose de nouveau.\n\nLe projet est open-source et disponible sur GitHub à \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F> ! Donnez-lui une étoile si vous l'aimez ! ⭐\n\n## Schéma SQL et Premiers Éléments de l'Interface Admin d'Orion\n\n![Vignette de mon troisième live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-03.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application full-stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir le replay sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nDans ce troisième stream, j'ai créé le schéma SQL pour la base de données. J'ai ensuite configuré Nuxt UI et Nuxt UI Pro pour créer la mise en page de l'administration et le premier formulaire pour les catégories. Cet article est un résumé du stream et du travail réalisé.\n\n### Schéma SQL\n\nQu'est-ce qu'un schéma SQL et pourquoi ai-je créé ce schéma SQL ? Ce sont deux bonnes questions.\n\nUn schéma SQL est un moyen d'organiser la base de données. C'est une feuille de route sur la manière dont la base de données sera structurée. Il est donc très utile de réfléchir à cela avant de commencer à coder. Il n'est pas nécessaire qu'il soit parfait, mais il aide à itérer sur la structure de la base de données, à éviter les erreurs et à avoir une vision claire du projet. _Je pense personnellement que c'est un indispensable lorsqu'on commence un nouveau projet._\n\nAvant d'écrire quoi que ce soit pour le projet Orion, j'ai donc créé le schéma SQL.\n\n![Schéma SQL](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fsql-schema.png)\n\nPour construire ce schéma, j'ai utilisé [drawSQL](https:\u002F\u002Fdrawsql.app\u002F). C'est une application web pour créer des schémas SQL avec une interface utilisateur agréable. Elle est très facile à utiliser et gratuite pour les petits projets. Je la recommande.\n\nLa base de données pour le projet Orion, l'application full-stack Nuxt, est simple et courante pour ce type de projet.\n\n- Une table `users` pour stocker les utilisateurs de l'application. Stocker les utilisateurs est utile pour l'interface admin, pour bannir un utilisateur ou pour donner des droits d'administrateur.\n- Une table `categories` pour stocker les catégories du thème comme blog, e-commerce, documentation, etc. C'est une table simple avec un nom et un slug. Le slug est utilisé pour l'URL de la catégorie.\n- Une table `modules` pour stocker les modules du thème. La table aura un nom, un slug et un type (officiel, communauté). Cela sera utile pour filtrer les thèmes par modules.\n- Une table `themes` pour stocker le contenu principal de l'application. Elle aura de nombreuses colonnes, mais nous verrons cela dans un autre stream.\n- Une table `feedbacks` pour stocker les retours des administrateurs sur un thème. En effet, un thème doit être évalué avant d'être publié et les retours seront utilisés pour indiquer au créateur ce qu'il doit améliorer.\n\nCe schéma est un bon point de départ pour clarifier les choses et savoir où commencer, mais je sais déjà qu'il évoluera.\n\n### Nuxt UI et Nuxt UI Pro\n\nNuxt UI est une bibliothèque de composants créée par l'équipe de Nuxt. Initialement, c'était un outil interne pour leurs propres projets comme [Volta](https:\u002F\u002Fvolta.net). En mai 2023, ils ont décidé de [le rendre open-source](https:\u002F\u002Fgithub.com\u002Fnuxt\u002Fui\u002Freleases\u002Ftag\u002Fv2.0.0) et de le rendre disponible gratuitement pour tout le monde. _Aujourd'hui, Nuxt UI est mon choix de prédilection pour construire des interfaces utilisateur avec Nuxt car il est simple, beau et pourtant très personnalisable._\n\nNuxt UI Pro est construit sur Nuxt UI et fournit des composants liés aux mises en page, comme une page, un hero, un en-tête et des mises en page complètes comme un tableau de bord, un blog, une documentation, etc. C'est un produit payant, mais il en vaut la peine. _Je l'utilise pour Orion, l'application full-stack, car il fait gagner du temps et est magnifique._ Le mieux, c'est qu'il est gratuit pendant le développement (le processus de build nécessitera une clé), donc vous pouvez l'essayer avant de l'acheter.\n\nPendant le stream, j'ai commencé par installer Nuxt UI et Nuxt UI Pro. J'ai ensuite créé la mise en page pour l'interface d'administration et la page pour les catégories. Voyons le code.\n\n### Mise en Page Admin\n\nAvec Nuxt UI Pro, cette partie a été très facile. Il n'y a pas encore de navbar ni de sidebar. _L'objectif est d'ajouter des fonctionnalités et des composants étape par étape, selon les besoins, et d'éviter de trop anticiper._ Voici le code pour la mise en page `layouts\u002Fadmin.vue`.\n\n```vue\n\u003Ctemplate>\n  \u003CUDashboardLayout>\n    \u003Cslot \u002F>\n  \u003C\u002FUDashboardLayout>\n\u003C\u002Ftemplate>\n```\n\n### Page des Catégories\n\nAvant de développer la vue, j'ai créé l'endpoint API pour obtenir et mettre à jour les catégories.\n\n![Résultat de la mise en page et de la page pour les catégories.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Flayout-and-page-for-categories.png)\n\n#### API des Catégories\n\nGrâce à Nitro et Drizzle (configuré dans le premier stream), c'est très simple. Dans le fichier `server\u002Fapi\u002Fcategories\u002Findex.get.ts` (le `.get.` est utilisé pour spécifier le verbe HTTP), j'ai écrit le code suivant :\n\n```ts\nexport default defineEventHandler(async () => {\n  const categories = await useDrizzle().select({\n    id: tables.categories.id,\n    slug: tables.categories.slug,\n    name: tables.categories.name\n  }).from(tables.categories)\n\n  return categories\n})\n```\n\nL'utilitaire `useDrizzle` est un wrapper autour de l'ORM Drizzle. Ce code renverra toutes les catégories avec seulement les champs `id`, `slug`, et `name`. _C'est une bonne pratique de ne renvoyer que les champs dont vous avez besoin pour éviter d'envoyer trop de données._\n\nEnsuite, j'ai créé l'endpoint pour créer une nouvelle catégorie, et c'est le même processus. J'ai créé le fichier `server\u002Fapi\u002Fcategories\u002Findex.post.ts` avec le code suivant :\n\n```ts\nexport default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  await useDrizzle().insert(tables.categories).values({\n    slug: useSlugify(body.name),\n    name: body.name,\n  }).execute()\n\n  return body\n})\n```\n\nDans ce code, il n'y a pas de vérification de sécurité, et chaque utilisateur peut créer une catégorie. Ce n'est clairement pas un cas d'utilisation pour notre application, mais la vérification sera ajoutée plus tard. En même temps, je n'ai pas vérifié le corps de la requête. Il est important de valider les données reçues d'un client pour éviter les problèmes de sécurité. **Ce sera le sujet du prochain stream**.\n\n## Contenu des Catégories\n\nLa page des catégories est construite avec Nuxt UI Pro, ce qui rend le code très simple.\n\n```vue\n\u003Cscript setup lang=\"ts\">\ndefinePageMeta({\n  layout: 'admin'\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUDashboardPage>\n    \u003CUDashboardPanel grow>\n      \u003C!-- Contenu -->\n    \u003C\u002FUDashboardPanel>\n  \u003C\u002FUDashboardPage>\n\u003C\u002Ftemplate>\n```\n\nDans le `UDashboardPanel`, nous pouvons ajouter un en-tête avec le titre de la page, un bouton pour créer une nouvelle catégorie, et un tableau pour afficher les catégories.\n\nCommençons par ajouter le tableau dans le `UDashboardPanel` :\n\n```vue\n\u003Cscript setup lang=\"ts\">\nconst columns = [{\n  key: 'id',\n  label: '#'\n}, {\n  key: 'name',\n  label: 'Nom',\n  sortable: true\n}, {\n  key: 'slug',\n  label: 'Slug',\n}]\n\nconst { data: categories, pending } = await useFetch('\u002Fapi\u002Fcategories', {\n  deep: false,\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUTable :columns=\"columns\" :rows=\"categories\" :loading=\"pending\" \u002F>\n\u003C\u002Ftemplate>\n```\n\nC'est difficile d'être plus simple ! _Merci à Nuxt UI pour ce travail !_\n\nEnsuite, ajoutons l'en-tête avec le titre et le bouton pour créer une nouvelle catégorie :\n\n```vue\n\u003Ctemplate>\n  \u003CUDashboardNavbar\n    title=\"Catégories\"\n  >\n    \u003Ctemplate #right>\n      \u003CUButton\n        label=\"Nouvelle catégorie\"\n        trailing-icon=\"i-heroicons-plus\"\n        color=\"gray\"\n      \u002F>\n    \u003C\u002Ftemplate>\n  \u003C\u002FUDashboardNavbar>\n\u003C\u002Ftemplate>\n```\n\nToujours très facile. Le bouton sera utilisé pour ouvrir un modal avec un formulaire pour créer une nouvelle catégorie. J'ai commencé à créer le formulaire mais de manière très simple :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nimport type { FormSubmitEvent } from '#ui\u002Ftypes'\nimport type { output } from 'zod'\nimport { object, string } from 'zod'\n\nconst schema = object({\n  name: string({ message: 'Requis' }),\n})\n\ntype Schema = output\u003Ctypeof schema>\n\nconst state = reactive({\n  name: undefined,\n})\n\nasync function onSubmit(event: FormSubmitEvent\u003CSchema>) {\n  await $fetch('\u002Fapi\u002Fcategories', {\n    method: 'POST',\n    body: event.data,\n  })\n}\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUForm :schema=\"schema\" :state=\"state\" @submit=\"onSubmit\">\n    \u003CUFormGroup label=\"Nom\" name=\"name\">\n      \u003CUInput v-model=\"state.name\" \u002F>\n    \u003C\u002FUFormGroup>\n\n    \u003CUButton type=\"submit\">\n      Soumettre\n    \u003C\u002FUButton>\n  \u003C\u002FUForm>\n\u003C\u002Ftemplate>\n```\n\nJ'utilise le formulaire tel qu'expliqué dans la documentation de Nuxt UI. Je n'ai pas vraiment choisi Yup pour la validation mais c'était la première option proposée. _Nous verrons si c'est un bon choix à l'avenir._ Dans la méthode `onSubmit`, je fais simplement une requête `fetch` à l'API pour créer une nouvelle catégorie. Dans le prochain stream, nous l'améliorerons en gérant les états de succès et d'erreur.\n\nCe composant est placé dans un modal en utilisant le composant `UModal` et en écoutant l'événement de clic sur le bouton pour ouvrir le modal.\n\n_Un point sur lequel je ne suis pas sûr est la gestion de la réponse de l'API. Devrais-je retourner la nouvelle catégorie et l'ajouter dans le tableau, ou devrais-je rafraîchir le tableau ? Pour le moment, j'ai opté pour la seconde solution._\n\nUtiliser Nuxt UI m'aide à construire l'interface admin rapidement, sans avoir besoin de composants personnalisés.\n\n\u003CCallout icon=\"i-simple-icons-github\" text=\"Voir le code source\" to=\"https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion\" target=\"_blank\" \u002F>\n\n### Fin du Stream\n\nC'était définitivement un bon stream. Le projet avance sans trop de difficultés et c'est un plaisir de travailler dessus. J'attends avec impatience le prochain stream pour continuer à travailler sur l'interface admin et les formulaires. Nuxt UI et Nuxt UI Pro sont des changeurs de jeu : code simple, facile à utiliser, et je peux me concentrer sur les fonctionnalités de l'application.\n\nLe projet est open-source et disponible sur GitHub à \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F> ! Donnez-lui une étoile si vous l'aimez ! ⭐\n\n## Continuons l'Interface Admin\n\n![Thumbnail de mon quatrième live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-04.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application full-stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir la rediffusion sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nLors du stream précédent, j'ai commencé à créer la page des catégories. J'ai mis en place les endpoints API pour obtenir et créer des catégories, et j'ai construit l'interface utilisateur pour afficher les catégories et le formulaire pour en créer une nouvelle. Dans ce stream, j'ai continué à travailler sur l'interface admin en ajoutant le formulaire de modification de catégorie et en créant une nouvelle page pour gérer les utilisateurs. J'ai également ajouté le bouncer pour sécuriser l'accès à la partie admin de l'application, tant pour le frontend que pour le backend. Voyons les travaux réalisés en détail.\n\n### Formulaire de Modification de Catégorie\n\nCette partie était très similaire au formulaire de création de catégorie. Au lieu de soumettre les données du formulaire à l'API pour créer une nouvelle catégorie, j'ai changé l'endpoint pour mettre à jour une catégorie existante.\n\nJ'ai créé le formulaire dans un nouveau composant `components\u002Fcategories\u002FCategoryEditForm.vue` (et renommé le précédent formulaire en `CategoryCreateForm.vue`). J'ai également créé un nouvel endpoint `server\u002Fapi\u002Fcategories\u002F[id].put.ts` pour mettre à jour une catégorie. Cet endpoint met à jour une catégorie en utilisant l'`id` dans l'URL et le corps de la requête :\n\n```ts\nexport default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  await useDrizzle().update(tables.categories).set({\n    slug: useSlugify(body.name),\n    name: body.name,\n  }).where(eq(tables.categories.id, params.id)).execute()\n\n  return sendNoContent(event, 200)\n})\n```\n\n_Le code de statut 200 pourrait ne pas être le meilleur choix (204 est mieux pour un `PUT`), mais je réfléchis encore à savoir si je devrais retourner la catégorie mise à jour ou non._\n\nJ'ai également tenté de valider le corps de la requête en utilisant Yup, car c'était l'outil utilisé dans le frontend. Cependant, j'ai rapidement réalisé qu'il n'était pas possible de caster une chaîne en nombre. Je sais que myzod peut le faire, alors je l'ai essayé. J'ai rencontré [un problème](https:\u002F\u002Fgithub.com\u002Funjs\u002Fh3\u002Fissues\u002F745) et j'ai donc utilisé Zod. _J'ai déplacé le frontend vers Zod en dehors du stream._\n\n### Page des Utilisateurs\n\nEn réalité, lorsqu'un utilisateur se connecte, les données associées de GitHub sont stockées dans la base de données (_en écrivant cette partie, je me demande si cela est nécessaire car cela signifie qu'une connexion est une opération d'écriture, ce qui est plus coûteux qu'une lecture_) et dans la session. Ce comportement est utile pour promouvoir l'utilisateur au statut d'admin ou pour bannir un utilisateur. Mais pour le moment, il n'y a pas de moyen de gérer les utilisateurs. Donc, j'ai créé une nouvelle page pour gérer les utilisateurs. Comme la page des catégories, la page affichera les utilisateurs dans un tableau. Cependant, il ne sera pas possible de créer ou de mettre à jour un utilisateur pour le moment.\n\nPour récupérer les utilisateurs depuis la base de données, j'ai créé l'endpoint API `server\u002Fapi\u002Fusers\u002Findex.get.ts` :\n\n```ts\nexport default defineEventHandler(async (event) => {\n  const users = await useDrizzle().select({\n    id: tables.users.id,\n    githubId: tables.users.githubId,\n    username: tables.users.username,\n    roleType: tables.users.roleType\n  }).from(tables.users)\n\n  return users\n})\n```\n\nPour les afficher, j'utilise le composant `UTable` de Nuxt UI Pro. Le code est très similaire à celui de la page des catégories :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst columns = [{\n  key: 'id',\n  label: '#'\n}, {\n  key: 'githubId',\n  label: 'GitHub ID',\n}, {\n  key: 'username',\n  label: 'Nom d\\'utilisateur',\n  sortable: true\n}, {\n  key: 'roleType',\n  label: 'Type de rôle'\n}]\nconst { data: users } = await useFetch('\u002Fapi\u002Fusers', {\n  deep: false,\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUDashboardPage>\n    \u003CUDashboardPanel grow>\n      \u003CUTable :columns=\"columns\" :rows=\"users\">\n        \u003Ctemplate #roleType-data=\"{ row }\">\n          \u003CUBadge variant=\"subtle\" :color=\"row.roleType === 'admin' ? 'amber' : 'primary'\">\n            {{ row.roleType }}\n          \u003C\u002FUBadge>\n        \u003C\u002Ftemplate>\n      \u003C\u002FUTable>\n    \u003C\u002FUDashboardPanel>\n  \u003C\u002FUDashboardPage>\n\u003C\u002Ftemplate>\n```\n\nComme prévu, le code est très similaire à celui de la page des catégories.\n\nMaintenant que nous avons deux pages, une pour les catégories et une pour les utilisateurs, j'ai ajouté une barre latérale pour naviguer entre elles. Rien de compliqué ici, j'ai simplement utilisé le composant `UDashboardSidebar` de Nuxt UI Pro et ajouté les liens vers les pages des catégories et des utilisateurs :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst links = [{\n  id: 'categories',\n  label: 'Catégories',\n  icon: 'i-heroicons-tag',\n  to: '\u002Fadmin\u002Fcategories',\n  tooltip: {\n    text: 'Catégories',\n  }\n}, {\n  id: 'users',\n  label: 'Utilisateurs',\n  icon: 'i-heroicons-user-group',\n  to: '\u002Fadmin\u002Fusers',\n  tooltip: {\n    text: 'Utilisateurs',\n  }\n}]\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUDashboardLayout>\n    \u003CUDashboardPanel>\n      \u003CUDashboardSidebar>\n        \u003CUDashboardSidebarLinks :links=\"links\" \u002F>\n      \u003C\u002FUDashboardSidebar>\n    \u003C\u002FUDashboardPanel>\n\n    \u003Cslot \u002F>\n  \u003C\u002FUDashboardLayout>\n\u003C\u002Ftemplate>\n```\n\n## Sécuriser la Partie Administration\n\nLors de la construction de la partie administration d'une application, il est crucial de sécuriser et de restreindre l'accès, tant pour le frontend que pour le backend. Cela signifie que seuls les utilisateurs autorisés peuvent visualiser et manipuler les données.\n\nPour restreindre l'accès à la partie UI de l'administration, c'est-à-dire le frontend, j'ai utilisé un middleware. Il s'agit d'une fonction qui s'exécute avant la navigation vers la page et peut être utilisée pour vérifier si l'utilisateur est authentifié et dispose des bonnes permissions. Dans ce cas, j'ai créé un middleware `middleware\u002Fadmin.ts` pour vérifier si l'utilisateur est authentifié et a le rôle d'admin :\n\n```ts\nexport default defineNuxtRouteMiddleware(() => {\n  const { user } = useUserSession()\n\n  const isAdmin = user.value?.roleType === 'admin'\n\n  if (!isAdmin)\n    return redirectTo('\u002F')\n})\n```\n\n_Le middleware doit être utilisé sur les pages nécessitant une protection en utilisant `definePageMeta`._\n\nJe récupère l'utilisateur depuis la session en utilisant `useUserSession` et vérifie si l'utilisateur a le rôle d'admin. Sinon, je redirige l'utilisateur vers la page d'accueil.\n\nLe backend est beaucoup plus critique que le frontend, mais il n'est pas plus difficile à sécuriser. Pour ce faire, j'ai créé un utilitaire nommé `requireAdminUser` dans le fichier `server\u002Futils\u002Fsession.ts` :\n\n```ts\nimport type { UserSessionRequired } from '#auth-utils'\nimport type { H3Event } from 'h3'\n\nexport async function requireAdminUser(event: H3Event): Promise\u003CUserSessionRequired> {\n  const userSession = await getUserSession(event)\n\n  if (!userSession.user || userSession.user.roleType !== 'admin') {\n    throw createError({\n      statusCode: 401,\n      message: 'Unauthorized',\n    })\n  }\n\n  return userSession as UserSessionRequired\n}\n```\n\nCette fonction vérifie si l'utilisateur est authentifié et possède le rôle d'admin. Sinon, elle renvoie une erreur avec un code de statut 401. Cette fonction est utilisée dans les endpoints API pour les sécuriser :\n\n```ts\nexport default defineEventHandler(async (event) => {\n  await requireAdminUser(event)\n\n  \u002F\u002F Code pour gérer la requête\n})\n```\n\nAvec ces deux fonctions, le frontend et le backend de la partie admin sont sécurisés. Seuls les utilisateurs authentifiés avec le rôle d'admin peuvent accéder à la partie administration de l'application.\n\n### À Venir\n\nCe stream a été très productif et a mis en place des éléments importants pour l'avenir. Le prochain stream sera consacré aux modules et au créateur de thèmes. J'espère vraiment pouvoir mettre cela en production bientôt. Je suis vraiment impatient de voir les retours de la communauté.\n\nLe projet est open-source et disponible sur GitHub à \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F> ! Donnez-lui une étoile si vous l'aimez ! ⭐\n\n## Récupérer une API externe pour les modules\n\n![Vignette de mon cinquième direct.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-18.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application full-stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir le replay sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nAvant de continuer et d'implémenter la page des modules, j'ai décidé de modifier le comportement de `useFetch` sur les pages actuelles des Catégories et des Utilisateurs. En effet, le bloc composable `useFetch` bloque la navigation côté client jusqu'à ce que les données soient récupérées. Cela donne la sensation d'une application lente. Pour éviter cela, je peux utiliser le drapeau `lazy`. Côté serveur, cela ne change rien et la page sera toujours servie avec les données. Côté client, la navigation sera instantanée et j'afficherai un squelette pendant que les données sont en cours de récupération en utilisant le `pending` ref.\n\n```ts\nconst { data, pending } = await useFetch('\u002Fapi\u002Fcategories', {\n  deep: false,\n  lazy: true, \u002F\u002F \u003C= Ne bloque pas la navigation\n  default: () => []\n})\n```\n\nLe `pending` ref est un booléen qui indique si les données sont en cours de récupération ou non. Je le passe simplement aux props `loading` du composant `UTable` de Nuxt UI Pro et _voilà_ ! L'utilisateur verra un loader pendant que les données sont en cours de récupération et la navigation sera instantanée.\n\n\u003Cdiv flex=\"~ col\" md=\"flex-row\" gap-x-2>\n  \u003Cfigure>\n    \u003Cimg src=\"https:\u002F\u002Fimages.soubiran.dev\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fblocking-fetch.gif\" alt=\"Sans drapeau\" loading=\"lazy\"\u002F>\n    \u003Cfigcaption>Sans drapeau lazy (blocage de la navigation)\u003C\u002Ffigcaption>\n  \u003C\u002Ffigure>\n  \u003Cfigure>\n    \u003Cimg src=\"https:\u002F\u002Fimages.soubiran.dev\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Flazy-fetch.gif\" alt=\"Avec drapeau lazy\" loading=\"lazy\" \u002F>\n    \u003Cfigcaption>Avec drapeau lazy (navigation instantanée)\u003C\u002Ffigcaption>\n  \u003C\u002Ffigure>\n\u003C\u002Fdiv>\n\n\u003CCallout icon=\"i-simple-icons-youtube\" text=\"En savoir plus sur useFetch\" to=\"https:\u002F\u002Fyoutu.be\u002Fb1S5os65Urs\" target=\"_blank\" \u002F>\n\n### Page des Modules\n\nLes modules sont [des modules Nuxt](https:\u002F\u002Fnuxt.com\u002Fmodules). Ils seront liés aux thèmes et seront utilisés pour filtrer les thèmes. Imaginez que vous recherchez un thème avec un bon SEO et précisément un sitemap, vous pourrez filtrer les thèmes en fonction des modules qu'ils utilisent. C'est l'objectif de la fonctionnalité des modules. Cela aidera également l'utilisateur à comprendre comment le thème est construit et quelles sont les fonctionnalités.\n\nLa première chose que je fais est d'ajouter une nouvelle page `pages\u002Fadmin\u002Fmodules.vue`. Ensuite, j'ajoute le tableau pour afficher les modules et les métadonnées de la page pour utiliser la mise en page admin et le middleware admin. C'est similaire aux pages des catégories et des utilisateurs, donc je ne vais pas le détailler ici.\n\n#### Remplir la Base de Données\n\nLa question principale est : comment remplir la base de données avec les modules ? Pour les catégories, je les crée manuellement. Cela convient car il n'y a pas beaucoup de catégories et il n'y a pas de moyen d'obtenir une liste prédéfinie, mais pour les modules, c'est différent. Les modules sont créés par l'équipe principale ou la communauté et il y en a beaucoup. De plus, la liste n'est pas statique et peut changer au fil du temps. Donc, je dois trouver une API pour récupérer les modules. Heureusement, Nuxt a une API pour obtenir la liste des modules : [`api.nuxt.com\u002Fmodules`](https:\u002F\u002Fapi.nuxt.com\u002Fmodules). Cette API n'est pas documentée mais elle est publique et je peux l'utiliser.\n\nGrâce à cette API, je pourrai récupérer les données, les assainir pour ne garder que les informations utiles et les stocker dans la base de données. Je vais créer un point de terminaison, accessible uniquement à l'admin, pour récupérer les modules et les stocker dans la base de données. Un deuxième point de terminaison sera disponible pour récupérer les modules depuis la base de données. Cela sera utile pour le frontend afin d'afficher les modules.\n\n#### Récupération de l'API Externe\n\nJe crée un point de terminaison `server\u002Fapi\u002Fmodules\u002Ffetch.post.ts` (_hors stream, j'ai trouvé que nommer ce point de terminaison `sync` était une meilleure idée_) pour récupérer les modules depuis l'API externe et les stocker dans la base de données.\n\nTout d'abord, j'ajoute l'utilitaire `requireAdminUser` pour sécuriser le point de terminaison. Ensuite, je récupère les modules depuis l'API externe en utilisant la fonction globale `$fetch` :\n\n```ts\nexport default defineEventHandler(async (event) => {\n  await requireAdminUser(event)\n\n  const data = await $fetch('https:\u002F\u002Fapi.nuxt.com\u002Fmodules')\n})\n```\n\nLes données retournées par l'API contiennent beaucoup de données dont je n'ai pas besoin. Je n'ai besoin que du `name`, `repo`, `type`, et `icon` des modules. Donc, j'assainis les données pour ne garder que les informations utiles :\n\n```ts\nconst modules = data.modules\n  .map(({ name, type, icon, repo }) => ({ name, type, icon, repo }))\n  .filter(({ type }) => type === 'official' || type === 'community')\n```\n\nJe choisis de ne pas garder le type `third party` car il peut s'agir d'un module de n'importe qui et je ne connais pas la qualité de ces modules. La clé `repo` n'était pas prévue mais je pense que c'est une bonne idée de l'avoir. Je pourrais l'utiliser pour ajouter un lien vers le dépôt GitHub du module sur une page de thème.\n\nMaintenant que j'ai mes modules, je dois les stocker dans la base de données, et je dois également créer la table pour les stocker. Dans le fichier `server\u002Fdatabase\u002Fschema.ts`, je crée la table `modules` :\n\n```ts\nexport const modules = sqliteTable('modules', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  name: text('name').notNull().unique(),\n  repo: text('repo').notNull().unique(),\n  type: text('type', { enum: ['official', 'community'] }).notNull(),\n  icon: text('icon'),\n})\n```\n\n> [!NOTE]\n> N'oubliez pas d'exécuter la commande drizzle pour générer la migration SQL.\n\nDans le point de terminaison, il est maintenant possible d'insérer les modules dans la base de données :\n\n```ts\nawait useDrizzle().insert(tables.modules).values(modules).execute()\n```\n\nMAIS, surprise, cela **ne fonctionne pas**. Je ne le savais pas, mais le [Cloudflare D1 a une limite (ou plusieurs)](https:\u002F\u002Fdevelopers.cloudflare.com\u002Fd1\u002Fplatform\u002Flimits\u002F) sur les paramètres liés à 100. En réalité, cette limite existe parce qu'ils utilisent SQLite en arrière-plan, ce qui a cette limite pour des raisons de sécurité.\n\nQu'est-ce qu'un paramètre lié ? C'est un paramètre dans une requête SQL qui est remplacé par une valeur lorsque la requête est exécutée. Par exemple, dans la requête `SELECT * FROM users WHERE id = ?`, le `?` est un paramètre lié. Dans le cas de la requête d'insertion `INSERT INTO users (name, email) VALUES (?, ?)`, les paramètres liés sont les valeurs à insérer dans la table. La limite est de 100 paramètres liés. Attention, **une ligne n'est pas un paramètre lié**, un paramètre lié est une valeur unique, ce qui signifie que si vous insérez une ligne avec 10 colonnes, cela comptera comme 10 paramètres liés. Il y a 42 modules avec 4 colonnes, donc 168 paramètres liés.\n\nC'est un vrai problème car je devrai diviser l'insertion en plusieurs requêtes et un worker est limité dans le temps, donc s'il fonctionne trop longtemps, le worker pourrait échouer.\n\n```ts\nconst columnsPerModule = Object.keys(modules[0]).length\nconst insertPerLoop = Math.floor(100 \u002F columnsPerModule)\nconst loops = Math.ceil(modules.length \u002F insertPerLoop)\n\nfor (let loop = 0; loop \u003C loops; loop++) {\n  const values = modules.slice(loop * insertPerLoop, (loop + 1) * insertPerLoop)\n  await useDrizzle().insert(tables.modules).values(values).onConflictDoNothing({ target: tables.modules.repo }).execute()\n}\n```\n\nPour avoir une insertion dynamique en cas de changement du nombre de colonnes, je calcule le nombre maximum de lignes que je peux insérer dans une seule requête. Ensuite, je boucle sur les modules et insère le nombre maximum de lignes dans chaque requête. J'utilise également la méthode `onConflictDoNothing` pour éviter d'insérer le même module deux fois si je fais fonctionner le point de terminaison plusieurs fois.\n\n_J'ai essayé cela dans un environnement de production, et tout fonctionne bien._\n\nJe choisis de retourner un code de statut 204 lorsque les modules sont récupérés. Si tout est correct, je peux rafraîchir la liste des modules dans l'interface utilisateur pour voir les nouveaux modules.\n\n#### Servir les Modules\n\nCette partie est exactement la même que le point de terminaison d'index des catégories et des utilisateurs. Je crée un point de terminaison `server\u002Fapi\u002Fmodules\u002Findex.get.ts` pour obtenir les modules de la base de données :\n\n```ts\nexport default defineEventHandler(async () => {\n  const modules = await useDrizzle().select({\n    id: tables.modules.id,\n    name: tables.modules.name,\n    type: tables.modules.type,\n    icon: tables.modules.icon,\n  }).from(tables.modules)\n\n  return modules\n})\n```\n\nRien de nouveau ici, je sélectionne simplement les modules depuis la base de données et les retourne.\n\n### Conclusion\n\nTout cela nous donne ce résultat :\n\n![Page des Modules](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmodules-page.png)\n\nCe stream était différent des autres. D'abord, j'ai maintenant un nouveau micro et le son est bien meilleur qu'avant !\n\nDeuxièmement, j'ai rencontré un problème avec la limite D1 (ce qui pourrait entraîner un besoin de repenser le projet, espérons que ce ne sera pas le cas) sur les paramètres liés. C'est une bonne leçon pour moi, et j'espère que cela sera utile pour vous.\n\n## Factorisation de la Gestion des Utilisateurs\n\n_Cette partie a été réalisée hors stream car elle n'était pas intéressante à voir et je n'étais pas sûr de la direction que je voulais prendre._\n\nEn écrivant une partie de cet article, je me demande si stocker les données des utilisateurs était une bonne idée. Ne pourrait-il pas être mieux ? Cette question vient de la façon dont je gère le point de terminaison d'authentification.\n\nEn fait, lorsqu'un utilisateur se connecte, j'écris toujours les données de l'utilisateur dans la base de données pour être sûr qu'elles sont à jour. La principale préoccupation est qu'une écriture est beaucoup plus coûteuse qu'une lecture. Pour trouver des idées, j'ai lu le [projet de liste de tâches Nuxt d'Atinux](https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-todos-edge). Dans ce projet, il ne stocke pas les données des utilisateurs. Lorsque l'utilisateur se connecte, il ne stocke que l'ID de l'utilisateur dans le cookie. Lorsque l'utilisateur crée une tâche, il lui suffit de lire l'ID de l'utilisateur depuis le cookie pour savoir qui est l'utilisateur. C'est très intelligent et simple. Je vais faire de même. _Premièrement à travers._\n\nAnalysons si cette méthode est possible pour le projet Orion. Pour savoir si c'est possible, je dois répondre à quelques questions :\n\n1. Puis-je bannir ou promouvoir un utilisateur sans stocker les données de l'utilisateur ?\n2. Un utilisateur peut-il créer des données sans stocker les données de l'utilisateur ?\n3. Puis-je afficher les données des utilisateurs sans stocker les données de l'utilisateur ?\n\nPour la première question, c'est oui. Je peux imaginer un système où vous entrez le login d'un utilisateur, et lorsque l'utilisateur se connecte, je rechercherai dans la base de données si l'entrée existe et obtiendrai les données pertinentes. Ce n'est pas la meilleure méthode mais c'est possible.\n\nPour la deuxième question, c'est oui. Puisque les données sont dans le cookie, je peux facilement savoir qui est l'utilisateur et créer des données en utilisant cette information. _Et c'est ainsi que ça fonctionne en fait. Je ne récupère pas l'utilisateur pour chaque requête._\n\nPour la troisième question, c'est non. Imaginez que je veuille afficher le modèle créé par l'utilisateur 'X'. Dans notre schéma SQL, nous avons une colonne `user_id` dans la table `templates`. Si je ne stocke pas les données de l'utilisateur, je ne pourrai pas savoir qui est l'utilisateur 'X', associé à `user_id`. Je devrai stocker les données de l'utilisateur dans la base de données. _Cela pourrait être possible avec une base de données non relationnelle mais ce n'est pas le cas ici. Nous pourrions aussi imaginer un système où l'utilisateur est sauvegardé lorsqu'il crée un modèle. C'est possible mais cela ajoutera beaucoup de complexité. Au final, cette réponse et celle à la question une compliquent le système pour aucun réel gain._\n\nMaintenant que nous savons que nous devons stocker les utilisateurs dans la base de données, que pouvons-nous améliorer ?\n\n- Sauvegarder les données des utilisateurs seulement si nécessaire. Par exemple, je pourrais d'abord rechercher l'utilisateur avant de sauvegarder ses données lorsque l'utilisateur se connecte.\n- Sauvegarder l'email de l'utilisateur. Cela sera utile pour le système de feedback.\n- Sauvegarder le nom de l'utilisateur et renommer la colonne `username` en `login`.\n- Ajouter une colonne `created_at` et une colonne `updated_at`.\n- Mettre à jour les données stockées dans le cookie.\n\nAvec tous ces points, je mets également à jour l'interface utilisateur en conséquence. Il n'y a rien de difficile ici ; il s'agit principalement de renommer et d'ajouter des champs dans le schéma.\n\n```ts\nexport const users = sqliteTable('users', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  githubId: integer('github_id').notNull().unique(),\n  email: text('email').notNull().unique(),\n  login: text('login').notNull().unique(),\n  name: text('name'),\n  avatarUrl: text('avatar_url').notNull(),\n  roleType: text('role_type', { enum: ['admin', 'creator'] }).default('creator'),\n  createdAt: text('created_at').notNull().$defaultFn(() => sql`(current_timestamp)`),\n  updatedAt: text('updated_at').notNull().$defaultFn(() => sql`(current_timestamp)`).$onUpdateFn(() => sql`(current_timestamp)`),\n})\n```\n\nLes fonctions `$defaultFn` et `$onUpdateFn` sont utilisées pour définir la valeur par défaut de la colonne. Dans le cas de `createdAt`, la valeur par défaut est l'horodatage actuel. Dans le cas de `updatedAt`, la valeur par défaut est l'horodatage actuel, et la valeur est mise à jour lorsque la ligne est mise à jour. C'est entièrement automatique et géré par Drizzle.\n\nJe mets également à jour le `server\u002Froutes\u002Fauth\u002Fgithub.ts` pour stocker les données de l'utilisateur seulement si nécessaire :\n\n```ts\nexport default oauth.githubEventHandler({\n  config: {\n    emailRequired: true,\n  },\n  async onSuccess(event, result) {\n    const { user: ghUser } = result\n\n    const githubId = ghUser.id\n\n    let user: User | undefined\n\n    user = await useDrizzle().select().from(tables.users).where(eq(tables.users.githubId, githubId)).get()\n\n    \u002F**\n     * Si l'utilisateur n'est pas dans la base de données ou si ses données ont changé, mettre à jour les données de l'utilisateur.\n     * Une écriture est plus coûteuse qu'une lecture, donc nous écrivons seulement si nécessaire.\n     *\u002F\n    if (!user || userDataChanged(user, ghUser)) {\n      user = await useDrizzle().insert(tables.users).values({\n        githubId,\n        login: ghUser.login,\n        email: ghUser.email,\n        name: ghUser.name,\n        avatarUrl: ghUser.avatar_url,\n      }).onConflictDoUpdate({\n        target: tables.users.githubId,\n        set: {\n          login: ghUser.login,\n          email: ghUser.email,\n          name: ghUser.name,\n          avatarUrl: ghUser.avatar_url,\n        },\n      }).returning().get()\n    }\n\n    \u002F**\n     * Définir seulement les données nécessaires dans la session.\n     *\u002F\n    await setUserSession(event, {\n      user: {\n        id: user.id!,\n        login: ghUser.login,\n        email: ghUser.email,\n        name: ghUser.name,\n        avatarUrl: ghUser.avatar_url,\n        roleType: user.roleType || 'creator',\n      },\n    })\n\n    return sendRedirect(event, '\u002Fportal')\n  },\n})\n```\n\nAvec cette nouvelle méthode de gestion des données des utilisateurs, je réduis le nombre d'écritures dans la base de données tout en mettant à jour les données des utilisateurs lorsque nécessaire.\n\n## Création d'un formulaire pour les modèles\n\n![Vignette de mon sixième stream en direct.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-19.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application full-stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir le replay sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nDepuis le début de la série, je travaille sur l'interface d'administration de l'application web. Aujourd'hui, il est temps de créer le formulaire pour ajouter un nouveau modèle. Ce formulaire sera utilisé par les créateurs pour ajouter leurs modèles ou thèmes à la plateforme. Pour rappel, Orion est une collection de modèles alimentée par la communauté pour votre prochain projet, des pages de destination aux applications web complètes.\n\n### Schéma du modèle\n\n_N'oubliez pas que les choses évoluent et que le schéma n'est pas définitif._\n\nÉtant donné que la fonctionnalité de modèle est importante, je l'ai divisée en plusieurs streams en direct. La première étape consiste à créer le formulaire le plus simple mais fonctionnel possible et à ajouter des fonctionnalités au fil du temps. C'est pourquoi le schéma ci-dessus n'est pas exactement le même que celui que nous avons dessiné dans le schéma SQL (pas de modules, pas d'images et pas de markdown, pour l'instant).\n\n```ts\nexport const templates = sqliteTable('templates', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  hash: text('hash').notNull().unique(),\n  slug: text('slug').notNull(),\n  title: text('title').notNull(),\n  status: text('status', { enum: ['submitted', 'refused', 'validated'] }).notNull().default('submitted'),\n  paidStatus: text('paid_status', { enum: ['free', 'paid'] }).notNull().default('free'),\n  liveUrl: text('live_url'),\n  accessUrl: text('access_url').notNull(),\n  description: text('description').notNull(),\n  userId: integer('user_id').notNull().references(() => users.id),\n  categoryId: integer('category_id').notNull().references(() => categories.id),\n})\n```\n\nLa colonne `hash` est un petit ID que je stockerai dans la base de données pour créer des URL comme Notion ou Dev.to : `\u003Cslug>-\u003Chash>`. Cela est utile pour avoir une URL lisible, à la fois pour les humains et pour le SEO, mais comme deux modèles peuvent avoir le même nom, ou le nom d'un modèle peut changer, j'ai besoin d'un ID unique.\n\nPour la première fois, nous créons une relation entre deux tables : une relation un-à-plusieurs entre la table `users` et la table `templates`. Cette relation est utilisée pour savoir qui est le créateur du modèle. La colonne `userId` est une clé étrangère qui fait référence à la colonne `id` de la table `users`.\n\nLa colonne `categoryId` est également une clé étrangère qui fait référence à la colonne `id` de la table `categories`. Cette relation est utilisée pour savoir dans quelle catégorie se trouve le modèle.\n\nAfin de pouvoir récupérer les modèles d'un utilisateur ou d'une catégorie, je dois également définir la relation dans l'autre sens. Grâce à Drizzle, c'est assez facile.\n\n```ts\nexport const usersRelations = relations(users, ({ many }) => ({\n  templates: many(templates),\n}))\n\nexport const categoriesRelations = relations(categories, ({ many }) => ({\n  templates: many(templates),\n}))\n```\n\n### Formulaire de modèle\n\nPendant cette phase, je crée le formulaire sur l'UI, exactement comme je l'ai fait pour les catégories. Je définis l'état, le schéma et l'action au moment de la soumission !\n\nMaintenant qu'un utilisateur est capable de remplir un formulaire, j'ai besoin d'un point de terminaison pour enregistrer les données dans la base de données. Je crée le point de terminaison `server\u002Fapi\u002Ftemplates\u002Findex.post.ts` pour enregistrer le modèle dans la base de données :\n\n```ts\nimport { number, object, string, enum as zEnum } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const { user } = await requireUserSession(event)\n\n  const body = await readValidatedBody(event, object({\n    title: string(),\n    paidStatus: zEnum(['free', 'paid']),\n    categoryId: number(),\n    liveUrl: string().optional(),\n    accessUrl: string(),\n    description: string().max(1000),\n  }).parse)\n\n  await useDrizzle().insert(tables.templates).values({\n    hash: useHash(),\n    slug: useSlugify(body.title),\n    title: body.title,\n    categoryId: body.categoryId,\n    paidStatus: body.paidStatus,\n    liveUrl: body.liveUrl,\n    accessUrl: body.accessUrl,\n    description: body.description,\n    userId: user.id,\n  }).execute()\n\n  return sendNoContent(event, 201)\n})\n```\n\nAssez similaire au point de terminaison pour créer une nouvelle catégorie. **Une différence** est l'utilisation de l'utilitaire `requireUserSession` pour s'assurer que la demande provient d'un utilisateur authentifié et pour obtenir les données de l'utilisateur. J'utilise les données pour remplir la colonne `userId` dans la table `templates`. Bien sûr, je valide les données reçues du client à l'aide de Zod.\n\nL'utilitaire `useHash` est un petit wrapper autour du package `nanoid`.\n\n### Modèles dans l'Interface d'Administration\n\n_Pendant cette partie, j'ai rencontré un bug important. Lisez attentivement la partie suivante, cela pourrait vous faire gagner beaucoup de temps si vous prévoyez également d'utiliser Drizzle sur Cloudflare._\n\nLes utilisateurs sont capables de créer des modèles, c'est bien. Pour les surveiller et les approuver, je dois créer une page dans l'interface d'administration pour gérer les modèles. Cette page affichera les modèles dans un tableau et permettra à l'administrateur de les approuver ou de les refuser. _Je n'implémente que la vue, pas l'action._ La tâche est simple, c'est comme toute autre page dans l'interface d'administration. _Si seulement je savais..._\n\nTout d'abord, créons le point de terminaison pour obtenir les modèles depuis la base de données. Je crée le point de terminaison `server\u002Fapi\u002Ftemplates\u002Findex.get.ts` :\n\n```ts\nexport default defineEventHandler(async () => {\n  const templates = await useDrizzle()\n    .select()\n    .from(tables.templates)\n    .leftJoin(tables.users, eq(tables.templates.userId, tables.users.id))\n    .leftJoin(tables.categories, eq(tables.templates.categoryId, tables.categories.id))\n\n  return templates\n})\n```\n\nCe point de terminaison récupère tous les modèles de la base de données et les remplit à l'aide d'une jointure et de la relation que nous avons définie plus tôt dans le schéma.\n\nUne `jointure` est une opération SQL qui combine les lignes de deux ou plusieurs tables en fonction d'une colonne liée entre elles. Dans ce cas, je fais une jointure de la table `templates` avec la table `users` et la table `categories`. La méthode `leftJoin` est utilisée pour conserver toutes les lignes de la table `templates`, même s'il n'y a pas de correspondance dans les tables `users` ou `categories`.\n\n![Une explication schématique de l'opération de jointure.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fleft-join.png)\n\nMaintenant que j'ai les données, je peux les afficher sur l'UI. J'utilise le composant `UTable` de Nuxt UI Pro pour afficher les modèles dans un tableau :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst columns = [{\n  key: 'id',\n  label: '#',\n}, {\n  key: 'title',\n  label: 'Title',\n  sortable: true,\n}, {\n  key: 'status',\n  label: 'Status',\n  sortable: true,\n}, {\n  key: 'paidStatus',\n  label: 'Paid Status',\n  sortable: true,\n}, {\n  key: 'liveUrl',\n  label: 'Live URL',\n}, {\n  key: 'accessUrl',\n  label: 'Access URL',\n}, {\n  key: 'description',\n  label: 'Description',\n}, {\n  key: 'category',\n  label: 'Category',\n}, {\n  key: 'createdBy',\n  label: 'Created By',\n}]\n\nconst { data: templates, refresh, pending } = await useFetch('\u002Fapi\u002Ftemplates', {\n  deep: false,\n  lazy: true,\n  default: () => [],\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUTable\n    :columns=\"columns\"\n    :rows=\"templates\"\n    :loading=\"pending\"\n  >\n    \u003Ctemplate #liveUrl-data=\"{ row }\">\n      \u003CUButton\n        v-if=\"row.liveUrl\"\n        variant=\"link\"\n        target=\"_blank\"\n        :to=\"row.liveUrl\"\n        class=\"flex flex-row items-center gap-1\"\n      >\n        \u003Cspan>{{ row.liveUrl }}\u003C\u002Fspan>\n\n        \u003Cspan class=\"i-heroicons-arrow-top-right-on-square-16-solid inline-block h-4 w-4\" \u002F>\n      \u003C\u002FUButton>\n      \u003Cspan v-else> - \u003C\u002Fspan>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #accessUrl-data=\"{ row }\">\n      \u003CUButton\n        variant=\"link\"\n        target=\"_blank\"\n        :to=\"row.accessUrl\"\n        class=\"flex flex-row items-center gap-1\"\n      >\n        \u003Cspan>{{ row.accessUrl }}\u003C\u002Fspan>\n\n        \u003Cspan class=\"i-heroicons-arrow-top-right-on-square-16-solid inline-block h-4 w-4\" \u002F>\n      \u003C\u002FUButton>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #paidStatus-data=\"{ row }\">\n      \u003Ctemplate v-if=\"row.paidStatus === 'free'\">\n        \u003CUBadge\n          color=\"green\"\n          variant=\"subtle\"\n        >\n          Free\n        \u003C\u002FUBadge>\n      \u003C\u002Ftemplate>\n      \u003Ctemplate v-else-if=\"row.paidStatus === 'paid'\">\n        \u003CUBadge\n          color=\"yellow\"\n          variant=\"subtle\"\n        >\n          Paid\n        \u003C\u002FUBadge>\n      \u003C\u002Ftemplate>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #createdBy-data=\"{ row }\">\n      \u003Cdiv class=\"flex flex-row items-center gap-2\">\n        \u003Cimg\n          :src=\"row.user.avatarUrl\"\n          alt=\"avatar\"\n          class=\"h-6 w-6 rounded-full\"\n        >\n        \u003Cspan>{{ row.user.name ?? row.user.login }}\u003C\u002Fspan>\n      \u003C\u002Fdiv>\n    \u003C\u002Ftemplate>\n    \u003Ctemplate #category-data=\"{ row }\">\n      {{ row.category.name }}\n    \u003C\u002Ftemplate>\n  \u003C\u002FUTable>\n\u003C\u002Ftemplate>\n```\n\n**Mais cela ne fonctionne pas.** Étant habitué à Adonis, j'étais familiarisé avec un objet comme celui-ci :\n\n```json\n{\n  \"id\": 1,\n  \"title\": \"My template\",\n  \"status\": \"submitted\",\n  \"liveUrl\": \"https:\u002F\u002Fexample.com\",\n  \"user\": {\n    \"id\": 1,\n    \"name\": \"John Doe\",\n    \"avatarUrl\": \"https:\u002F\u002Fexample.com\u002Favatar.jpg\"\n  },\n  \"category\": {\n    \"id\": 1,\n    \"name\": \"Landing Page\"\n  }\n}\n```\n\nMais j'ai reçu un objet comme celui-ci :\n\n```json\n{\n  \"template\": {\n    \"id\": 1,\n    \"title\": \"My template\",\n    \"status\": \"submitted\",\n    \"liveUrl\": \"https:\u002F\u002Fexample.com\"\n  },\n  \"user\": {\n  },\n  \"category\": {\n  }\n}\n```\n\net quelle surprise de voir que les clés et les valeurs étaient échangées entre elles. _Difficile à expliquer mais voyez l'exemple._\n\n```json\n{\n  \"template\": {\n    \"id\": \"My template\",\n    \"status\": \"https:\u002F\u002Fexample.com\"\n  },\n  \"user\": {\n    \"id\": \"John Doe\",\n    \"login\": \"https:\u002F\u002Favatars.github.com\u002Fu\u002F1\"\n  }\n}\n```\n\nTrès difficile de manipuler les données maintenant ! _L'ID devrait être un nombre et le login ne devrait définitivement pas être une URL._\n\nAprès une longue recherche, j'ai trouvé deux problèmes : https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworkers-sdk\u002Fissues\u002F3160 et https:\u002F\u002Fgithub.com\u002Fdrizzle-team\u002Fdrizzle-orm\u002Fissues\u002F555 expliquant pourquoi cela ne fonctionne pas. Lorsque deux tables ont le même nom de colonne, ~~Drizzle et~~ Cloudflare sont incapables de les résoudre correctement et mélangent les données. La solution consiste à sélectionner explicitement les colonnes et à renommer les colonnes similaires.\n\n```ts\nconst templates = await useDrizzle().select({\n  id: tables.templates.id,\n  title: tables.templates.title,\n  description: tables.templates.description,\n  status: tables.templates.status,\n  paidStatus: tables.templates.paidStatus,\n  liveUrl: tables.templates.liveUrl,\n  accessUrl: tables.templates.accessUrl,\n  user: {\n    name: tables.users.name,\n    login: tables.users.login,\n    avatarUrl: tables.users.avatarUrl,\n  },\n  category: {\n    name: sql\u003Cstring>`${tables.categories.name}`.as('c_name'),\n  },\n}).from(tables.templates).leftJoin(tables.users, eq(tables.templates.userId, tables.users.id)).leftJoin(tables.categories, eq(tables.templates.categoryId, tables.categories.id))\n```\n\n`sql\u003Cstring>'${tables.categories.name}'.as('c_name')` est le code important. Je renomme la colonne `name` en `c_name` pour éviter le conflit. J'utilise également cette syntaxe pour formater l'objet avec les clés `user` et `category`. Maintenant, tout fonctionne comme prévu.\n\n### Conclusion\n\nTravailler sur le côté client, et pas seulement sur la partie admin, rend le projet encore plus concret. Il reste encore beaucoup de travail sur le formulaire de modèle comme le markdown ou l'image, mais c'est un très bon début.\n\nJ'apprécie vraiment ce projet et j'espère que vous aussi.\n\n## Poursuivre le modèle de formulaire\n\n![Vignette de mon huitième direct.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-20.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application full-stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir la rediffusion sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nCe direct était court, donc j'essaie de faire des choses intéressantes. Je fais principalement trois choses :\n\n- Créer la page de modèle de l'émission\n- Gérer le markdown côté serveur pour la description du modèle\n- Ajouter un éditeur de markdown pour la description du modèle\n\nVoyons comment ça se passe. _Pendant le direct, ça se passe bien mais depuis, j'ai complètement réécrit la partie éditeur de markdown._\n\n### Page de modèle de l'émission\n\nCet élément était davantage lié à l'utilisation de l'URL qu'au design de la page. En effet, l'URL fonctionnera comme Notion ou Dev.to : `\u002F\u003Cslug>-\u003Chash>` où le `slug` est le nom du modèle et le `hash` est un identifiant unique. Le `slug` est utilisé pour le SEO et le `hash` est utilisé pour obtenir le modèle. Le `slug` n'est pas unique et peut changer avec le temps. Le `hash` est unique et sert à obtenir le modèle.\n\nSur la page, nous commençons par obtenir les paramètres de la route :\n\n```ts\nconst route = useRoute()\nconst slug = route.params.slug \u002F\u002F \u003Cslug>-\u003Chash>\n```\n\nLa variable `slug` contient à la fois le `slug` du modèle et le `hash`. Nous savons que le hash fait exactement 12 caractères donc nous pouvons découper le `slug` pour obtenir le `slug` et le `hash` :\n\n```ts\nslug.slice(-12)\n```\n\nEnfin, nous pouvons faire une requête au backend pour récupérer le modèle. Tout cela nous donne ce code :\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst route = useRoute()\nconst hash = computed(() => {\n  return (route.params.slug as string).slice(-12)\n})\nconst { data: template } = await useFetch(`\u002Fapi\u002Ftemplates\u002F${hash.value}`, {\n  deep: false,\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Carticle\n    v-if=\"template\"\n  >\n    {{ template }}\n  \u003C\u002Farticle>\n\u003C\u002Ftemplate>\n```\n\nLa partie serveur n'est pas si compliquée puisque nous obtenons seulement le hash à partir du paramètre de route et recherchons le modèle en utilisant une clause `where` :\n\n```ts\nimport { object, string } from 'zod'\n\nexport default defineEventHandler(async (event) => {\n  const params = await getValidatedRouterParams(event, object({\n    hash: string().length(12),\n  }).parse)\n\n  const { hash } = params\n\n  const template = await useDrizzle().select().from(tables.templates).where(eq(tables.templates.hash, hash))\n\n  return template\n})\n```\n\nPlus tard, nous devrons joindre les données de l'utilisateur et les données de la catégorie pour avoir un modèle complet.\n\n### Markdown côté serveur\n\nL'idée de recevoir du markdown du client est de le nettoyer facilement pour éviter les injections HTML.\n\nImaginez si le client envoie ce code HTML :\n\n```html\n\u003Cp> Hello World!\u003C\u002Fp>\n\u003Cscript> console.log('hacked') \u003C\u002Fscript>\n```\n\nSi le serveur stocke ce contenu dans la base de données et l'affiche ensuite en utilisant la directive `v-html`, chaque utilisateur qui verra la page exécutera le script. C'est un réel problème de sécurité.\n\nEn recevant du markdown du client, je peux facilement le transformer en utilisant `markdown-it` et stocker le **HTML nettoyé** dans la base de données. Le HTML nettoyé est du HTML qui ne contient aucun script ou balise dangereuse.\n\nPour ce faire, je dois installer `markdown-it` et mettre à jour le point de terminaison pour créer un modèle `server\u002Fapi\u002Ftemplates\u002Findex.post.ts`. J'ajoute également une nouvelle colonne `descriptionHTML` au schéma pour stocker le HTML nettoyé. Stocker le HTML évite de le faire chaque fois que nous rendons la page.\n\n```ts\nawait useDrizzle().insert(tables.templates).values({\n  hash: useHash(),\n  slug: useSlugify(body.title),\n  title: body.title,\n  categoryId: body.categoryId,\n  paidStatus: body.paidStatus,\n  liveUrl: body.liveUrl,\n  accessUrl: body.accessUrl,\n  description: body.description,\n  descriptionHTML: useMarkdown(body.description),\n  userId: user.id,\n}).execute()\n```\n\nLa fonction `useMarkdown` est une fonction simple qui utilise `markdown-it` pour transformer le markdown en HTML.\n\n### Éditeur de Markdown\n\n_Cette partie est très expérimentale et pourrait changer à l'avenir. Il s'agit d'une première implémentation._\n\n_En écrivant cette partie, j'ai fait quelques recherches supplémentaires et tout va changer. Je pense que c'est encore intéressant de voir le processus._\n\nAvant de commencer, je dois faire un choix. Quel éditeur de markdown devrais-je utiliser ? _Rappelez-vous que nous venons de mettre en place un point de terminaison serveur pour gérer le markdown._ J'ai de nombreuses options :\n\n- SimpleMDE\n- EasyMDE\n- TinyMDE\n- Editor.js\n- Quill\n- Trix\n- CKEditor\n- TinyMCE\n\nC'est beaucoup d'options. Mais, quels sont mes besoins ? J'ai besoin d'un éditeur très simple qui peut retourner du markdown. Ça a l'air facile, non ?\n\nEditor.js, CKEditor et TinyMCE sont des éditeurs avancés donc je ne les utiliserai pas. SimpleMDE est une bonne option mais trop vieux. Il n'a pas reçu de mises à jour depuis 6 ans. SimpleMDE et TinyMDE sont des forks de SimpleMDE. EasyMDE est puissant, simple et retourne du markdown. C'est une bonne option. TinyMDE n'a pas assez d'options de personnalisation. Trix et Quill ne sont pas des éditeurs de markdown. Ce sont des éditeurs de texte enrichi donc ils ne produisent pas de markdown. Je vais opter pour EasyMDE. _Pour l'instant._\n\nNous pouvons donc maintenant installer le package et l'utiliser. L'utilisation est un peu délicate. Étant un package uniquement côté client, nous ne pouvons pas l'exécuter ni l'importer côté serveur. Pour l'utiliser, nous allons l'importer dynamiquement dans un hook `onMounted`. _`onBeforeMount` est encore mieux !_\n\n```ts\nonMounted(async () => {\n  const EasyMDE = await import('easymde').then(m => m.default)\n  const easymde = new EasyMDE()\n})\n```\n\nEt ça fonctionne ? Oui, mais je pense que nous avons complètement oublié d'importer le style !\n\n![EasyMDE sans style](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-without-style.png)\n\nJe pense que nous avons oublié d'ajouter le style pour EasyMDE. Grâce à Vite et son chargeur CSS, nous pouvons importer directement le fichier CSS dans notre script.\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nimport 'easymde\u002Fdist\u002Feasymde.min.css'\n\u003C\u002Fscript>\n```\n\n![EasyMDE avec style](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-with-style.png)\n\nOk, ce n'est pas parfait et j'aurai du travail à faire, tant sur les styles que sur les icônes. Mais c'est un bon début. La seule partie que je n'aime pas est le symbole markdown. Je préférerais ne pas les avoir parce que ce n'est pas convivial (tout le monde pourrait devoir créer un modèle).\n\nMais de toute façon, c'est un bon début et je peux maintenant facilement écouter les changements de l'éditeur et mettre à jour la description.\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst description = ref\u003Cstring>('')\nonMounted(async () => {\n  const EasyMDE = await import('easymde').then(m => m.default)\n  const easymde = new EasyMDE()\n  easyMDE.codemirror.on('change', () => {\n    description.value = easyMDE.value()\n  })\n})\n\u003C\u002Fscript>\n```\n\nMaintenant, je peux envoyer la description au serveur et elle sera transformée en HTML !\n\n### Quelle est la prochaine étape pour le formulaire ?\n\nLe formulaire est loin d'être terminé mais il progresse bien. Je vais ajouter le support pour les images et les modules dans le prochain stream.\n\nLe projet Orion est presque prêt pour une version alpha et je suis impatient de voir comment la communauté l'utilisera et découvrir le contenu qu'ils partageront.\n\n## Remplacer l'éditeur Markdown\n\n_Cette partie a été réalisée hors-stream parce que je n'étais pas satisfait de l'éditeur EasyMDE mais je ne savais pas quoi faire._\n\nAprès y avoir réfléchi, j'ai réalisé que l'utilisation de Markdown, pour une simple description et qui peut être utilisée par des utilisateurs non techniques, n'est pas la meilleure idée. De plus, l'éditeur EasyMDE utilise CodeMirror qui est lent et beaucoup trop lourd pour un simple éditeur.\n\nMaintenant que nous savons que l'éditeur EasyMDE n'est pas la meilleure solution pour nos besoins, nous devons en trouver un nouveau. Nous avons besoin d'un éditeur de texte enrichi simple et il y a deux options principales : Quill et Trix. Dans ce cas, Quill semble être plus simple donc je vais opter pour lui. _Je ne détaillerai pas l'installation de Quill, c'est assez simple._\n\nComme Quill est un éditeur de texte enrichi, il ne génère pas de markdown mais du HTML. Je dois donc mettre à jour le point de terminaison pour stocker le HTML au lieu du markdown. Je dois également mettre à jour le schéma pour supprimer la colonne `description` et ajouter une colonne `descriptionHTML`.\n\n### Mise à jour du point de terminaison\n\nPourquoi dois-je mettre à jour le point de terminaison ? Quill renvoie du HTML déjà nettoyé, donc je pourrais le stocker directement dans la base de données. Oui mais non. Nous construisons une API et il est impossible de faire confiance au client, même à notre propre client. N'importe qui peut modifier le HTML ou utiliser un client HTTP pour envoyer une requête au serveur avec du HTML malveillant. Je dois donc nettoyer le HTML sur le serveur.\n\nPar exemple, voici du HTML malveillant :\n\n```html\n\u003Cp> Bonjour le monde ! \u003C\u002Fp>\n\u003Cscript> console.log('piraté') \u003C\u002Fscript>\n```\n\nSi vous stockez ce HTML dans la base de données et l'affichez en utilisant la directive `v-html`, chaque utilisateur qui verra la page exécutera le script. C'est un vrai problème de sécurité. Nous ne pouvons pas utiliser `{{ html }}` parce que Vue échappera le HTML donc nettoyer notre contenu est la seule solution.\n\nJe vais utiliser le paquet `sanitize-html` pour nettoyer le HTML. Après avoir nettoyé avec ce paquet, le HTML sera :\n\n```html\n\u003Cp> Bonjour le monde ! \u003C\u002Fp>\n```\n\nC'est parfait !\n\n```ts\nawait useDrizzle().insert(tables.templates).values({\n  description: body.description ? sanitizeHtml(body.description) : null,\n})\n```\n\nLa fonction `sanitizeHtml` est l'exportation par défaut du paquet `sanitize-html`. Facile, non ?\n\n### Mise à jour du schéma\n\nMaintenant, je dois mettre à jour le schéma pour supprimer la colonne `descriptionHTML` puisque la `description` est maintenant par défaut en HTML. Il est important de savoir que si l'utilisateur doit modifier son modèle, le HTML sera utilisé par l'éditeur Quill.\n\n```ts\nexport const templates = sqliteTable('templates', {\n  description: text('description'),\n  descriptionHTML: text('description_html'), \u002F\u002F [!code --]\n})\n```\n\n### Mise à jour de l'interface utilisateur\n\nL'utilisation de Quill n'est pas si différente de EasyMDE. Je charge paresseusement le paquet Quill, crée une nouvelle instance de celui-ci et écoute les changements. Je n'oublie pas d'importer le fichier CSS.\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nimport 'quill\u002Fdist\u002Fquill.snow.css'\n\nonBeforeMount(async () => {\n  const Quill = await import('quill').then(m => m.default)\n  quill.value = new Quill('#editor', {\n    theme: 'snow',\n  })\n  quill.value?.on('text-change', (_, __, source) => {\n    state.description = quill.value?.root.innerHTML ?? ''\n  })\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv id=\"editor\" \u002F>\n\u003C\u002Ftemplate>\n```\n\nBien sûr, je dois ajouter quelques styles personnalisés à l'éditeur pour qu'il s'intègre plus naturellement dans le design.\n\nUne dernière chose. J'utilise ceci dans un formulaire Nuxt UI. Dans ce formulaire, chaque élément est validé par rapport au schéma. Cela me permet d'afficher un message d'erreur si la description est vide ou trop longue. Le problème est que ce n'est pas intégré dans l'éditeur Quill, il ne déclenche aucune validation. Grâce à l'API du formulaire Nuxt UI, je peux facilement valider le contenu de l'éditeur Quill à chaque changement.\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst form = ref()\n\nonBeforeMount(async () => {\n  quill.value?.on('text-change', (_, __, source) => {\n    state.description = quill.value?.root.innerHTML ?? ''\n    form.value.validate('description', { silent: true })\n  })\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUFormGroup\n    label=\"Description\"\n    name=\"description\"\n    :hint=\"`${state.description?.length || 0}\u002F2000 characters`\"\n  >\n    \u003Cdiv id=\"editor\" \u002F>\n  \u003C\u002FUFormGroup>\n\u003C\u002Ftemplate>\n```\n\nMaintenant, l'éditeur Quill est complètement intégré dans le formulaire et un message d'erreur s'affichera si la description est vide ou trop longue.\n\n![L'éditeur de texte enrichi personnalisé pour s'intégrer pleinement dans le formulaire.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Frich-text-editor.png)\n\n### Conclusion\n\nJe suis vraiment content de ce changement. Depuis des années, je me demandais comment installer et utiliser un éditeur de texte enrichi et maintenant je sais. Vous pouvez utiliser le markdown pour les utilisateurs techniques, le parser sur le serveur et stocker le HTML pour simplifier une obtention, ou utiliser un éditeur de texte enrichi pour les utilisateurs non techniques et nettoyer le HTML sur le serveur. C'est une bonne leçon pour moi et j'espère qu'elle vous sera utile.\n\n## Ajout de modules au formulaire de modèle\n\n![Vignette de mon neuvième live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-28.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) est une application full-stack construite avec Nuxt et développée en direct [sur Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). Vous pouvez voir le replay sur [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) et le code source est open-source sur [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nPendant ce live stream, j'ai ajouté la possibilité d'ajouter des modules à un modèle dans le formulaire. Cette fonctionnalité est importante car elle permettra aux utilisateurs de filtrer les modèles par modules.\n\nJ'ai choisi de le faire dans un stream séparé car c'est une relation de plusieurs à plusieurs et un peu plus complexe que les autres parties. Je m'attendais à le faire en 1 heure, mais cela m'a pris 2 heures avec l'aide du chat. En effet, j'étais bloqué sur la requête SQL pour récupérer un modèle peuplé avec ses modules, et la documentation de Drizzle n'était pas claire à ce sujet.\n\nPlongeons dans le code et comprenons comment fonctionne le concept des relations ORM de Drizzle.\n\n### Schéma\n\nLe créateur peut ajouter plusieurs modules à un modèle. En même temps, un module peut être utilisé par plusieurs modèles. La relation est une relation de plusieurs à plusieurs. Pour gérer cela, je dois créer une nouvelle table, appelée table de pivot, pour stocker la relation entre les modèles et les modules. Elle agit comme un lien entre les deux tables pour les connecter. _Nous utilisons une table de pivot au lieu d'un tableau de modules dans le modèle parce que c'est plus efficace et c'est la façon SQL de le faire._\n\n![Relation de plusieurs à plusieurs entre les modèles et les modules](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmany-to-many-relation.png)\n\nLe schéma est simplifié pour montrer uniquement la relation entre les modèles et les modules. À partir de la table `templates`, vous pouvez récupérer tous les modules associés et inversement grâce à la table `templates_modules`.\n\n```ts\nexport const templates = sqliteTable('templates', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  title: text('title').notNull(),\n})\n\nexport const modules = sqliteTable('modules', {\n  id: integer('id').primaryKey({ autoIncrement: true }),\n  name: text('name').notNull().unique(),\n})\n\nexport const modulesToTemplates = sqliteTable('modulesToTemplates', {\n  moduleId: integer('module_id').notNull().references(() => modules.id),\n  templateId: integer('template_id').notNull().references(() => templates.id),\n})\n```\n\n### Formulaire de modules\n\nDans cette partie, je vais ajouter les modules au formulaire, du côté client au côté serveur.\n\n#### Ajout de modules au modèle\n\nDans l'interface utilisateur, gérer les modules est assez simple. Je récupère les modules du serveur et les affiche dans un `USelectMenu` avec une prop multiple.\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst { data: modules } = await useFetch('\u002Fapi\u002Fmodules', {\n  deep: false,\n  default: () => [],\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003CUSelectMenu\n    :options=\"modules\"\n    multiple\n    value-attribute=\"id\"\n    option-attribute=\"name\"\n  \u002F>\n\u003C\u002Ftemplate>\n```\n\n#### Validation des modules\n\nDans le validateur, j'ajoute un nouveau champ `moduleIds` qui vérifie que les modules sont un tableau d'entiers.\n\n```ts\nimport { array, number, object, string } from 'zod'\n\nexport const createTemplateValidator = object({\n  title: string({ message: 'Requis' }),\n  moduleIds: array(number()).max(6, { message: 'Max 6 modules' }).optional(),\n})\n```\n\nJ'ai arbitrairement fixé le nombre maximum de modules à 6. C'est un nombre que je pense suffisant pour un modèle.\n\n#### Enregistrement des modules\n\nEnfin, je mets à jour le point de terminaison pour enregistrer le modèle et les modules associés. Je commence par enregistrer le modèle, puis les modules dans la table de pivot.\n\n```ts\nimport { createTemplateValidator } from '~\u002Futils\u002Fvalidators'\n\nexport default defineEventHandler(async (event) => {\n  const { user } = await requireUserSession(event)\n\n  const body = await readValidatedBody(event, createTemplateValidator.parse)\n\n  const [template] = await useDrizzle().insert(tables.templates).values({\n    title: body.title,\n  }).returning({\n    id: tables.templates.id,\n  })\n\n  if (body.moduleIds)\n    await useDrizzle().insert(tables.modulesToTemplates).values(body.moduleIds.map(id => ({ moduleId: id, templateId: template.id }))).execute()\n\n  return sendNoContent(event, 201)\n})\n```\n\n_Je dois admettre que je ne suis pas très confiant avec cette partie. Je devrai vérifier s'il y a une meilleure façon de le faire (en utilisant une transaction ou une fonctionnalité intégrée de Drizzle)._\n\n### Remplir le modèle avec des modules\n\n_C'est là que les choses deviennent difficiles._\n\nAu départ, j'avais une requête SQL personnalisée qui joignait la table `templates`, la table `users` et la table `categories`. Vous pouvez en savoir plus à ce sujet dans la [partie précédente](#creating-a-form-for-templates).\n\nPour lier la table `modules`, j'ai essayé d'ajouter une jointure avec la table `modulesToTemplates` puis la table `modules`. Le problème que je rencontre est comment grouper les modules par modèle pour produire un tableau de modules pour chaque modèle.\n\nQuelque chose comme :\n\n```json\n{\n  \"id\": 1,\n  \"title\": \"Mon modèle\",\n  \"modules\": [\n    { \"id\": 1, \"name\": \"Module 1\" },\n    { \"id\": 2, \"name\": \"Module 2\" }\n  ]\n}\n```\n\nJ'ai trouvé la fonction SQLite `group_concat` qui peut être utilisée pour concaténer les modules. J'étais un peu déçu de devoir le faire manuellement car Drizzle se présente comme un ORM. Je m'attendais à avoir un moyen de le faire facilement.\n\nMAIS, le chat Twitch est venu à la rescousse et m'a suggéré d'utiliser la [`clé with de la méthode findMany`](https:\u002F\u002Form.drizzle.team\u002Fdocs\u002Frqb#include-relations). J'ai essayé, et cela n'a pas vraiment fonctionné. Cloudflare se plaignait d'une relation manquante. J'étais un peu perdu.\n\nJ'ai fini par lire attentivement la documentation sur la page de la relation de plusieurs à plusieurs, et j'ai compris que je devais définir explicitement chaque relation entre chaque table. En d'autres termes, je dois dire à Drizzle comment joindre les tables. Un modèle a plusieurs modules en utilisant la table `modulesToTemplates`. Un module a plusieurs modèles en utilisant la table `modulesToTemplates`. C'est ainsi que l'on définit la relation de plusieurs à plusieurs.\n\n```ts\n\u002F\u002F Un modèle a une relation de plusieurs à plusieurs\nexport const templatesRelations = relations(templates, ({ one, many }) => ({\n  \u002F\u002F Lié à la table de pivot et non directement à la table des modules\n  modules: many(modulesToTemplates),\n}))\n\n\u002F\u002F La table de pivot a 2 relations\nexport const modulesToTemplatesRelations = relations(modulesToTemplates, ({ one }) => ({\n  module: one(modules, {\n    fields: [modulesToTemplates.moduleId],\n    references: [modules.id],\n  }),\n  template: one(templates, {\n    fields: [modulesToTemplates.templateId],\n    references: [templates.id],\n  }),\n}))\n\n\u002F\u002F Un module a une relation de plusieurs à plusieurs\nexport const modulesRelations = relations(modules, ({ one, many }) => ({\n  \u002F\u002F Lié à la table de pivot et non directement à la table des modèles\n  templates: many(modulesToTemplates),\n}))\n```\n\nCette partie est super importante. Dans un ORM comme [Lucid](https:\u002F\u002Flucid.adonisjs.com), l'utilisation d'une table de pivot est transparente. Avec Drizzle, vous devez définir la relation entre chaque table. C'est un peu plus verbeux, mais c'est aussi plus explicite.\n\nEnfin, je peux mettre à jour le point de terminaison pour récupérer les modèles avec leurs modules :\n\n```ts\nexport default defineEventHandler(async () => {\n  const templates = await useDrizzle().query.templates.findMany({\n    with: {\n      category: true,\n      creator: true,\n      modules: {\n        with: {\n          module: true\n        },\n      },\n    },\n  })\n\n  return templates\n})\n```\n\nL'utilisation de `with`, une fois que tout est correctement défini, rend les choses tellement plus faciles ; j'adore vraiment ça.\n\n### Conclusion\n\nDans mon esprit, cette partie était facile, et en 30 minutes, c'était fait. J'avais déjà fait quelque chose de similaire en utilisant Lucid d'Adonis. En réalité, ce n'était pas vraiment difficile, mais trouver la bonne partie de la documentation de Drizzle puis la comprendre était bien plus difficile que prévu. Cela est largement dû au fait que Drizzle n'abstrait pas SQL mais agit comme un petit wrapper, et tout doit être explicite.\n\nJe suis heureux de l'avoir fait, j'ai beaucoup appris, et un grand merci au chat pour leur aide.\n",{"title":5477,"description":20099},{"loc":20101,"images":20105},[20106,20107,20108,20109,20110,20111,20112,20113,20114,20115,20116,20117,20118,20119,20120,20121,20122,20123,20124,20125,20126,20127,20128,20129],{"loc":5538},{"loc":5574},{"loc":5838},{"loc":5885},{"loc":5964},{"loc":5980},{"loc":5996},{"loc":6043},{"loc":6820},{"loc":6862},{"loc":7017},{"loc":8453},{"loc":10140},{"loc":10280},{"loc":10290},{"loc":11341},{"loc":12541},{"loc":13925},{"loc":16121},{"loc":17185},{"loc":17238},{"loc":18349},{"loc":18365},{"loc":18406},"fr\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey","xxRStz1JEhvLivIm0iYslnGqI1b-fM3tw1J7iN1gR24",1790086904484]