[{"data":1,"prerenderedAt":20066},["ShallowReactive",2],{"post-\u002Fposts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation":3,"post-link-en-c21a371e-abdd-4325-b296-dadcb8deec25":5416},{"id":4,"title":5,"body":6,"contentId":5404,"date":5405,"description":5406,"extension":5407,"meta":5408,"navigation":329,"path":5409,"rawbody":5410,"robots":5411,"schemaOrg":5411,"seo":5412,"sitemap":5413,"stem":5414,"__hash__":5415},"posts\u002Fposts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation.md","Nuxt Going Full-Stack: How to Streamline Form Validation?",{"type":7,"value":8,"toc":5398},"minimark",[9,26,32,5394],[10,11,12,13,20,21,25],"p",{},"Since weeks, I'm building a full-stack application called Orion with Nuxt and ",[14,15,19],"a",{"href":16,"rel":17},"https:\u002F\u002Fhub.nuxt.com",[18],"nofollow","NuxtHub",". Throughout this journey, I've been facing a lot of challenges, and one of them is forms validation. In this article, I'll show you how I've managed to ",[22,23,24],"strong",{},"validate forms on both the client and server sides without duplicating"," the validation logic, from simple text fields to complex file uploads with NuxtHub.",[10,27,28],{},[29,30,31],"em",{},"Orion is a community-driven collection of templates for your next project,\nfrom landing pages to complete web applications. NuxtHub is deployment and administration platform for Nuxt, powered by 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},"c21a371e-abdd-4325-b296-dadcb8deec25",[10,37,38],{},[29,39,40,41,44],{},"During this article, I'll talk about ",[14,42,19],{"href":16,"rel":43},[18]," because it provides the primitives to build full-stack application, but even if you don't use it, you can easily follow this article. Forms will be handled without any libraries.",[10,46,47,48],{},"You can browse the example code in ",[49,50,52],"git-hub-link",{"repo":51},"barbapapazes\u002Fvalidate-forms-in-nuxt",".",[54,55,57],"h2",{"id":56},"the-problem","The problem",[10,59,60,61,64,65,68,69,72,73,76],{},"On the web, forms are everywhere. They are the primary way to interact and ask for user input. But on the web, there is an essential rule: ",[22,62,63],{},"never trust the client"," (absolutely never and under no circumstances). This means that ",[22,66,67],{},"every data"," that comes from the client must be ",[22,70,71],{},"verified"," and ",[22,74,75],{},"sanitized"," on the server before being processed or stored in a database. A client can be a browser, a mobile app, an HTTP request, or anything else that can send data to your server, built by you or not.",[10,78,79,80,83,84,87],{},"When you build a traditional MPA (Multi-Page Application), only the server is responsible for the validation. For a SPA (Single-Page Application), both the client and the server are responsible for the validation to provide a better user experience. ",[29,81,82],{},"This is simplified because there is some way to provide a feeling of client-side validation with a traditional MPA, but it's not the subject of this article."," BUT, even if the data is validated on the client side, it is ",[22,85,86],{},"mandatory"," to validate them on the server side.",[54,89,91],{"id":90},"the-simplest-form","The simplest form",[10,93,94],{},"Let's start our journey with the simplest form: a single text input.",[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=\"Name\" required>\n    \u003Cbutton type=\"submit\">\n      Create 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},"Name",[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","      Create 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],{},"Then, on the server side, I can create a route to handle the form submission:",[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 Save in the database...\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 Save in the database...\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],{},"Until now, I don't have any validation. I can send anything to the server, and it will accept it. ",[29,373,374],{},"This is a huge security issue.",[10,376,377],{},[29,378,379],{},"Remember that the way I'm handling the validation is specific to a SPA. A traditional MPA would have added errors to flash messages and redirect back the request.",[381,382,384],"h3",{"id":383},"validating-the-body","Validating the body",[10,386,387,388,393,394,397,398,52],{},"In order to validate the body on the server side, I'm using ",[14,389,392],{"href":390,"rel":391},"https:\u002F\u002Fzod.dev",[18],"Zod",". ",[29,395,396],{},"Zod is a TypeScript-first schema validation with static type inference."," The good part is that h3, the router and web framework that power Nitro, the server layer of Nuxt, provides a utility to validate the body: ",[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 Save in the database...\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],{},"The utility ",[103,565,400],{}," will automatically throw an error if the body doesn't match the schema. For example, if the name is missing, the request will fail.",[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,2727,2730,2737,2987,2990,3425,3432,3435,3528,3532,3541,3723,3727,3730,3736,3739,3918,3921,4215,4218,4224,4567,4574,4836,4846,5190,5196,5368,5372,5388,5391],"callout",{"icon":570,"text":571,"to":572},"i-unjs-h3","Validate Data using H3","https:\u002F\u002Fh3.unjs.io\u002Fexamples\u002Fvalidate-data",[10,574,575],{},"By default, the error will be handled by the server and throw a 500 error, but I can catch the error to return a more user-friendly error message.",[10,577,578,579,582],{},"The first solution is by using the method ",[103,580,581],{},"safeParse"," that will not throw an error but return a result with the error if any.",[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: 'Invalid data',\n      data: result.error.errors[0].message,\n    })\n  }\n\n  const body = result.data\n\n  \u002F\u002F Save in the database...\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},"Invalid data",[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],{},"The second solution is by using the ",[103,871,872],{},"parseAsync"," method to attach a catch to the promise and handle the error manually.",[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: 'Invalid data',\n        data: error.errors[0].message,\n      })\n    }\n  }))\n\n  \u002F\u002F Save in the database...\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],{},"You could wrap the validator with a ",[103,1168,1169],{},"try\u002Fcatch",", but I found it less elegant and readable.",[381,1172,1174],{"id":1173},"validating-the-frontend","Validating the frontend",[10,1176,1177],{},"On the frontend, I can also use Zod to validate the form before sending it to the server.",[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: 'Required' }), \u002F\u002F I can customize the error message shown to the user\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: 'Invalid data',\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=\"Name\" required>\n    \u003Cbutton type=\"submit\">\n      Create 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},"Required",[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 I can customize the error message shown to the user\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],{},"The implementation is very simple and straightforward. I could save the errors in a variable to display them to the user. If you want to go further, I recommend you try the ",[103,1701,1702],{},"Form"," component from ",[14,1705,1708],{"href":1706,"rel":1707},"https:\u002F\u002Fui.nuxt.com\u002F",[18],"Nuxt UI"," or a validation library like ",[14,1711,1714],{"href":1712,"rel":1713},"https:\u002F\u002Fvee-validate.logaretm.com\u002Fv4\u002F",[18],"VeeValidate"," or ",[14,1717,1720],{"href":1718,"rel":1719},"https:\u002F\u002Fformkit.com\u002F",[18],"FormKit",[54,1722,1724],{"id":1723},"lets-refactor","Let's refactor",[10,1726,1727],{},"Now that I have the validation logic on both the client and server sides, let's see if there is some interesting code to refactor.",[10,1729,1730],{},"The first thing I notice is that the validation logic, on the client and server sides, is the same, and that's totally normal. But what happens if I decide to change the validation logic on the server side because my API is used by another application? My frontend could be broken because the validation logic will be different. To avoid this, it could be better to only write the validator once and use it on both sides. What do you think?",[10,1732,1733,1734,1737,1738,1741],{},"I can create a file ",[103,1735,1736],{},"validators.ts"," in the ",[103,1739,1740],{},"utils"," folder of the client part (at the root) of a Nuxt application. This file will contain all the validators for my application and shared between the client and server.",[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: 'Required' }),\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","\nThis file can be used on the server but has been created on the client part so you need to explicitly import everything you need to avoid renderer issues.",[10,1839,1840],{},"This validator contains the message for the error to help the user understand what's wrong.",[10,1842,1843],{},"Thanks to the auto-import feature of Nuxt, I can easily use it on the 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],{},"Then, you can also use it on the server side by importing it:",[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 Save in the database...\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],{},"Now, I can safely change the validation logic without breaking the frontend since they follow the same rules, and I don't have to duplicate the code.",[54,2056,2058],{"id":2057},"handling-files","Handling files",[10,2060,2061,2062,2065],{},"For this part, I'll show you two different ways to validate files on the server. The first one will use Zod and some refinements, and the second one will use the ",[103,2063,2064],{},"ensureBlob"," utility from NuxtHub.",[10,2067,2068],{},[29,2069,2070],{},"This validation is not feature complete and should be improved to handle more cases.",[381,2072,2074],{"id":2073},"with-zod","With Zod",[10,2076,2077],{},"For this second part, I'll show you how to upload a file to the server. First, let's add our input to the form:",[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      Create 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],{},"Then, I can handle the file on the server side, but it's a bit more complex because I send a ",[103,2215,2216],{},"formData"," and h3 does not provide a utility like ",[103,2219,2220],{},"readValidatedFormData",". So, I need to handle it manually.",[10,2223,2224,2225,2227,2228,2231,2232,2235,2236,2238],{},"First, I will read the ",[103,2226,2216],{}," using the ",[103,2229,2230],{},"readFormData"," utility, then I will extract the ",[103,2233,2234],{},"image"," from the ",[103,2237,2216],{}," and validate it using 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: 'Image must be a file' })\n      .refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' })\n      .refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' })\n      .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n  }).parseAsync({ image }).catch((error) => {\n    throw createError({\n      status: 400,\n      message: 'Invalid image',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Save the image to a database or a file system\n\n  return sendNoContent(event, 201)\n})\n",[103,2243,2244,2268,2272,2294,2314,2318,2344,2355,2367,2407,2456,2510,2586,2622,2632,2642,2657,2680,2686,2692,2696,2701,2705,2721],{"__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,2403,2405],{"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},"Image must be a file",[106,2402,754],{"class":112},[106,2404,419],{"class":112},[106,2406,324],{"class":116},[106,2408,2409,2411,2413,2415,2417,2419,2421,2423,2426,2429,2432,2435,2437,2439,2441,2443,2445,2447,2450,2452,2454],{"class":108,"line":539},[106,2410,2371],{"class":112},[106,2412,2374],{"class":277},[106,2414,281],{"class":116},[106,2416,2234],{"class":290},[106,2418,297],{"class":132},[106,2420,2324],{"class":224},[106,2422,52],{"class":112},[106,2424,2425],{"class":224},"size",[106,2427,2428],{"class":112}," \u003C",[106,2430,2431],{"class":358}," 1024",[106,2433,2434],{"class":112}," *",[106,2436,2431],{"class":358},[106,2438,355],{"class":112},[106,2440,413],{"class":112},[106,2442,1288],{"class":116},[106,2444,496],{"class":112},[106,2446,425],{"class":112},[106,2448,2449],{"class":142},"Image must be less than 1MB",[106,2451,754],{"class":112},[106,2453,419],{"class":112},[106,2455,324],{"class":116},[106,2457,2458,2460,2462,2464,2466,2468,2470,2472,2475,2477,2480,2482,2484,2487,2489,2491,2493,2495,2497,2499,2501,2504,2506,2508],{"class":108,"line":556},[106,2459,2371],{"class":112},[106,2461,2374],{"class":277},[106,2463,281],{"class":116},[106,2465,2234],{"class":290},[106,2467,297],{"class":132},[106,2469,2324],{"class":224},[106,2471,52],{"class":112},[106,2473,2474],{"class":224},"type",[106,2476,52],{"class":112},[106,2478,2479],{"class":277},"startsWith",[106,2481,281],{"class":116},[106,2483,754],{"class":112},[106,2485,2486],{"class":142},"image\u002F",[106,2488,754],{"class":112},[106,2490,294],{"class":116},[106,2492,355],{"class":112},[106,2494,413],{"class":112},[106,2496,1288],{"class":116},[106,2498,496],{"class":112},[106,2500,425],{"class":112},[106,2502,2503],{"class":142},"Image must be an image",[106,2505,754],{"class":112},[106,2507,419],{"class":112},[106,2509,324],{"class":116},[106,2511,2512,2514,2516,2518,2520,2522,2525,2527,2530,2532,2534,2536,2539,2541,2543,2545,2548,2550,2552,2554,2557,2559,2561,2563,2565,2567,2569,2571,2573,2575,2577,2580,2582,2584],{"class":108,"line":759},[106,2513,2371],{"class":112},[106,2515,2374],{"class":277},[106,2517,281],{"class":116},[106,2519,2234],{"class":290},[106,2521,297],{"class":132},[106,2523,2524],{"class":116}," [",[106,2526,754],{"class":112},[106,2528,2529],{"class":142},"image\u002Fpng",[106,2531,754],{"class":112},[106,2533,355],{"class":112},[106,2535,425],{"class":112},[106,2537,2538],{"class":142},"image\u002Fjpeg",[106,2540,754],{"class":112},[106,2542,355],{"class":112},[106,2544,425],{"class":112},[106,2546,2547],{"class":142},"image\u002Fjpg",[106,2549,754],{"class":112},[106,2551,785],{"class":116},[106,2553,52],{"class":112},[106,2555,2556],{"class":277},"includes",[106,2558,281],{"class":116},[106,2560,2234],{"class":224},[106,2562,52],{"class":112},[106,2564,2474],{"class":224},[106,2566,294],{"class":116},[106,2568,355],{"class":112},[106,2570,413],{"class":112},[106,2572,1288],{"class":116},[106,2574,496],{"class":112},[106,2576,425],{"class":112},[106,2578,2579],{"class":142},"Image must be a JPEG or PNG",[106,2581,754],{"class":112},[106,2583,419],{"class":112},[106,2585,324],{"class":116},[106,2587,2588,2590,2592,2594,2596,2598,2600,2602,2604,2606,2608,2610,2612,2614,2616,2618,2620],{"class":108,"line":795},[106,2589,514],{"class":112},[106,2591,294],{"class":116},[106,2593,52],{"class":112},[106,2595,872],{"class":277},[106,2597,281],{"class":116},[106,2599,1285],{"class":112},[106,2601,2324],{"class":224},[106,2603,419],{"class":112},[106,2605,294],{"class":116},[106,2607,52],{"class":112},[106,2609,1014],{"class":277},[106,2611,281],{"class":116},[106,2613,281],{"class":112},[106,2615,771],{"class":290},[106,2617,294],{"class":112},[106,2619,297],{"class":132},[106,2621,300],{"class":112},[106,2623,2624,2626,2628,2630],{"class":108,"line":803},[106,2625,720],{"class":270},[106,2627,723],{"class":277},[106,2629,281],{"class":116},[106,2631,488],{"class":112},[106,2633,2634,2636,2638,2640],{"class":108,"line":809},[106,2635,732],{"class":116},[106,2637,496],{"class":112},[106,2639,737],{"class":358},[106,2641,509],{"class":112},[106,2643,2644,2646,2648,2650,2653,2655],{"class":108,"line":814},[106,2645,744],{"class":116},[106,2647,496],{"class":112},[106,2649,425],{"class":112},[106,2651,2652],{"class":142},"Invalid image",[106,2654,754],{"class":112},[106,2656,509],{"class":112},[106,2658,2659,2661,2663,2665,2667,2669,2671,2673,2675,2677],{"class":108,"line":830},[106,2660,762],{"class":116},[106,2662,496],{"class":112},[106,2664,1092],{"class":224},[106,2666,52],{"class":112},[106,2668,776],{"class":224},[106,2670,779],{"class":116},[106,2672,782],{"class":358},[106,2674,785],{"class":116},[106,2676,52],{"class":112},[106,2678,2679],{"class":224},"message\n",[106,2681,2682,2684],{"class":108,"line":835},[106,2683,798],{"class":112},[106,2685,324],{"class":116},[106,2687,2688,2690],{"class":108,"line":840},[106,2689,514],{"class":112},[106,2691,324],{"class":116},[106,2693,2694],{"class":108,"line":845},[106,2695,330],{"emptyLinePlaceholder":329},[106,2697,2698],{"class":108,"line":862},[106,2699,2700],{"class":335},"  \u002F\u002F Save the image to a database or a file system\n",[106,2702,2703],{"class":108,"line":1469},[106,2704,330],{"emptyLinePlaceholder":329},[106,2706,2707,2709,2711,2713,2715,2717,2719],{"class":108,"line":1486},[106,2708,345],{"class":270},[106,2710,348],{"class":277},[106,2712,281],{"class":116},[106,2714,291],{"class":224},[106,2716,355],{"class":112},[106,2718,359],{"class":358},[106,2720,324],{"class":116},[106,2722,2723,2725],{"class":108,"line":1497},[106,2724,366],{"class":112},[106,2726,324],{"class":224},[10,2728,2729],{},"On the client side, I can also validate the file using the same technique as before and by sharing the validator between the client and server sides.",[10,2731,2732,2733,2736],{},"First, I need to move the validator to the ",[103,2734,2735],{},"utils\u002Fvalidators.ts"," file:",[96,2738,2740],{"className":261,"code":2739,"language":263,"meta":101,"style":101},"import { any, object } from 'zod'\n\nexport const createImageValidator = object({\n  image: any().refine(image => image instanceof File, { message: 'Image must be a file' }).refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' }).refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' }).refine(image => ['image\u002Fjpeg', 'image\u002Fpng'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n})\n",[103,2741,2742,2764,2768,2785,2981],{"__ignoreMap":101},[106,2743,2744,2746,2748,2750,2752,2754,2756,2758,2760,2762],{"class":108,"line":109},[106,2745,410],{"class":270},[106,2747,413],{"class":112},[106,2749,2252],{"class":224},[106,2751,355],{"class":112},[106,2753,2257],{"class":224},[106,2755,419],{"class":112},[106,2757,422],{"class":270},[106,2759,425],{"class":112},[106,2761,428],{"class":142},[106,2763,431],{"class":112},[106,2765,2766],{"class":108,"line":123},[106,2767,330],{"emptyLinePlaceholder":329},[106,2769,2770,2772,2774,2777,2779,2781,2783],{"class":108,"line":162},[106,2771,271],{"class":270},[106,2773,1775],{"class":132},[106,2775,2776],{"class":224}," createImageValidator ",[106,2778,136],{"class":112},[106,2780,2257],{"class":277},[106,2782,281],{"class":224},[106,2784,488],{"class":112},[106,2786,2787,2790,2792,2794,2796,2798,2800,2802,2804,2806,2809,2812,2814,2816,2818,2820,2822,2824,2826,2828,2830,2832,2834,2836,2838,2840,2842,2844,2846,2849,2851,2853,2855,2857,2859,2861,2863,2865,2867,2869,2871,2873,2875,2877,2879,2881,2883,2885,2887,2889,2891,2893,2895,2897,2899,2901,2903,2905,2907,2909,2911,2913,2915,2917,2919,2921,2923,2925,2927,2929,2931,2933,2935,2937,2939,2941,2943,2945,2947,2949,2951,2953,2955,2958,2960,2963,2965,2967,2969,2971,2973,2975,2977,2979],{"class":108,"line":200},[106,2788,2789],{"class":116},"  image",[106,2791,496],{"class":112},[106,2793,2252],{"class":277},[106,2795,506],{"class":224},[106,2797,52],{"class":112},[106,2799,2374],{"class":277},[106,2801,281],{"class":224},[106,2803,2234],{"class":290},[106,2805,297],{"class":132},[106,2807,2808],{"class":224}," image ",[106,2810,2811],{"class":112},"instanceof",[106,2813,2387],{"class":1041},[106,2815,355],{"class":112},[106,2817,413],{"class":112},[106,2819,1288],{"class":116},[106,2821,496],{"class":112},[106,2823,425],{"class":112},[106,2825,2400],{"class":142},[106,2827,754],{"class":112},[106,2829,419],{"class":112},[106,2831,294],{"class":224},[106,2833,52],{"class":112},[106,2835,2374],{"class":277},[106,2837,281],{"class":224},[106,2839,2234],{"class":290},[106,2841,297],{"class":132},[106,2843,2324],{"class":224},[106,2845,52],{"class":112},[106,2847,2848],{"class":224},"size ",[106,2850,113],{"class":112},[106,2852,2431],{"class":358},[106,2854,2434],{"class":112},[106,2856,2431],{"class":358},[106,2858,355],{"class":112},[106,2860,413],{"class":112},[106,2862,1288],{"class":116},[106,2864,496],{"class":112},[106,2866,425],{"class":112},[106,2868,2449],{"class":142},[106,2870,754],{"class":112},[106,2872,419],{"class":112},[106,2874,294],{"class":224},[106,2876,52],{"class":112},[106,2878,2374],{"class":277},[106,2880,281],{"class":224},[106,2882,2234],{"class":290},[106,2884,297],{"class":132},[106,2886,2324],{"class":224},[106,2888,52],{"class":112},[106,2890,2474],{"class":224},[106,2892,52],{"class":112},[106,2894,2479],{"class":277},[106,2896,281],{"class":224},[106,2898,754],{"class":112},[106,2900,2486],{"class":142},[106,2902,754],{"class":112},[106,2904,294],{"class":224},[106,2906,355],{"class":112},[106,2908,413],{"class":112},[106,2910,1288],{"class":116},[106,2912,496],{"class":112},[106,2914,425],{"class":112},[106,2916,2503],{"class":142},[106,2918,754],{"class":112},[106,2920,419],{"class":112},[106,2922,294],{"class":224},[106,2924,52],{"class":112},[106,2926,2374],{"class":277},[106,2928,281],{"class":224},[106,2930,2234],{"class":290},[106,2932,297],{"class":132},[106,2934,2524],{"class":224},[106,2936,754],{"class":112},[106,2938,2538],{"class":142},[106,2940,754],{"class":112},[106,2942,355],{"class":112},[106,2944,425],{"class":112},[106,2946,2529],{"class":142},[106,2948,754],{"class":112},[106,2950,785],{"class":224},[106,2952,52],{"class":112},[106,2954,2556],{"class":277},[106,2956,2957],{"class":224},"(image",[106,2959,52],{"class":112},[106,2961,2962],{"class":224},"type)",[106,2964,355],{"class":112},[106,2966,413],{"class":112},[106,2968,1288],{"class":116},[106,2970,496],{"class":112},[106,2972,425],{"class":112},[106,2974,2579],{"class":142},[106,2976,754],{"class":112},[106,2978,419],{"class":112},[106,2980,324],{"class":224},[106,2982,2983,2985],{"class":108,"line":221},[106,2984,366],{"class":112},[106,2986,324],{"class":224},[10,2988,2989],{},"Then, I can use it when the form is submitted to avoid sending invalid data to the server:",[96,2991,2993],{"className":98,"code":2992,"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>Validate Forms with 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        Submit\n      \u003C\u002Fbutton>\n    \u003C\u002Fform>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[103,2994,2995,3015,3037,3060,3080,3084,3108,3114,3135,3139,3157,3172,3182,3188,3192,3204,3219,3223,3227,3235,3239,3247,3256,3275,3279,3287,3292,3300,3329,3369,3387,3392,3401,3409,3417],{"__ignoreMap":101},[106,2996,2997,2999,3001,3003,3005,3007,3009,3011,3013],{"class":108,"line":109},[106,2998,113],{"class":112},[106,3000,1189],{"class":116},[106,3002,1192],{"class":132},[106,3004,136],{"class":112},[106,3006,139],{"class":112},[106,3008,263],{"class":142},[106,3010,139],{"class":112},[106,3012,1203],{"class":132},[106,3014,120],{"class":112},[106,3016,3017,3019,3021,3024,3026,3028,3030,3033,3035],{"class":108,"line":123},[106,3018,284],{"class":132},[106,3020,1360],{"class":132},[106,3022,3023],{"class":277}," onImageSubmit",[106,3025,281],{"class":112},[106,3027,291],{"class":290},[106,3029,496],{"class":112},[106,3031,3032],{"class":1041}," Event",[106,3034,294],{"class":112},[106,3036,300],{"class":112},[106,3038,3039,3041,3044,3046,3049,3051,3054,3057],{"class":108,"line":162},[106,3040,305],{"class":132},[106,3042,3043],{"class":224}," target",[106,3045,311],{"class":112},[106,3047,3048],{"class":224}," event",[106,3050,52],{"class":112},[106,3052,3053],{"class":224},"target",[106,3055,3056],{"class":270}," as",[106,3058,3059],{"class":1041}," HTMLFormElement\n",[106,3061,3062,3064,3066,3068,3071,3074,3076,3078],{"class":108,"line":200},[106,3063,305],{"class":132},[106,3065,2300],{"class":224},[106,3067,311],{"class":112},[106,3069,3070],{"class":112}," new",[106,3072,3073],{"class":277}," FormData",[106,3075,281],{"class":116},[106,3077,3053],{"class":224},[106,3079,324],{"class":116},[106,3081,3082],{"class":108,"line":221},[106,3083,330],{"emptyLinePlaceholder":329},[106,3085,3086,3088,3090,3092,3094,3096,3098,3100,3102,3104,3106],{"class":108,"line":228},[106,3087,305],{"class":132},[106,3089,2324],{"class":224},[106,3091,311],{"class":112},[106,3093,2300],{"class":224},[106,3095,52],{"class":112},[106,3097,2333],{"class":277},[106,3099,281],{"class":116},[106,3101,754],{"class":112},[106,3103,2234],{"class":142},[106,3105,754],{"class":112},[106,3107,324],{"class":116},[106,3109,3110,3112],{"class":108,"line":238},[106,3111,1372],{"class":270},[106,3113,300],{"class":112},[106,3115,3116,3118,3121,3123,3125,3127,3129,3131,3133],{"class":108,"line":248},[106,3117,1402],{"class":270},[106,3119,3120],{"class":224}," createImageValidator",[106,3122,52],{"class":112},[106,3124,872],{"class":277},[106,3126,281],{"class":116},[106,3128,1285],{"class":112},[106,3130,2324],{"class":224},[106,3132,419],{"class":112},[106,3134,324],{"class":116},[106,3136,3137],{"class":108,"line":534},[106,3138,330],{"emptyLinePlaceholder":329},[106,3140,3141,3143,3145,3147,3149,3151,3153,3155],{"class":108,"line":539},[106,3142,1402],{"class":270},[106,3144,1405],{"class":277},[106,3146,281],{"class":116},[106,3148,754],{"class":112},[106,3150,2105],{"class":142},[106,3152,754],{"class":112},[106,3154,355],{"class":112},[106,3156,300],{"class":112},[106,3158,3159,3161,3163,3165,3168,3170],{"class":108,"line":556},[106,3160,1422],{"class":116},[106,3162,496],{"class":112},[106,3164,425],{"class":112},[106,3166,3167],{"class":142},"POST",[106,3169,754],{"class":112},[106,3171,509],{"class":112},[106,3173,3174,3176,3178,3180],{"class":108,"line":759},[106,3175,1437],{"class":116},[106,3177,496],{"class":112},[106,3179,2300],{"class":224},[106,3181,509],{"class":112},[106,3183,3184,3186],{"class":108,"line":795},[106,3185,798],{"class":112},[106,3187,324],{"class":116},[106,3189,3190],{"class":108,"line":803},[106,3191,806],{"class":112},[106,3193,3194,3196,3198,3200,3202],{"class":108,"line":809},[106,3195,1458],{"class":270},[106,3197,287],{"class":116},[106,3199,771],{"class":224},[106,3201,713],{"class":116},[106,3203,488],{"class":112},[106,3205,3206,3209,3211,3213,3215,3217],{"class":108,"line":814},[106,3207,3208],{"class":224},"    console",[106,3210,52],{"class":112},[106,3212,771],{"class":277},[106,3214,281],{"class":116},[106,3216,771],{"class":224},[106,3218,324],{"class":116},[106,3220,3221],{"class":108,"line":830},[106,3222,806],{"class":112},[106,3224,3225],{"class":108,"line":835},[106,3226,1558],{"class":112},[106,3228,3229,3231,3233],{"class":108,"line":840},[106,3230,251],{"class":112},[106,3232,1189],{"class":116},[106,3234,120],{"class":112},[106,3236,3237],{"class":108,"line":845},[106,3238,330],{"emptyLinePlaceholder":329},[106,3240,3241,3243,3245],{"class":108,"line":862},[106,3242,113],{"class":112},[106,3244,117],{"class":116},[106,3246,120],{"class":112},[106,3248,3249,3251,3254],{"class":108,"line":1469},[106,3250,126],{"class":112},[106,3252,3253],{"class":116},"div",[106,3255,120],{"class":112},[106,3257,3258,3260,3263,3266,3269,3271,3273],{"class":108,"line":1486},[106,3259,165],{"class":112},[106,3261,3262],{"class":116},"h1",[106,3264,3265],{"class":112},">",[106,3267,3268],{"class":224},"Validate Forms with Nuxt",[106,3270,251],{"class":112},[106,3272,3262],{"class":116},[106,3274,120],{"class":112},[106,3276,3277],{"class":108,"line":1497},[106,3278,330],{"emptyLinePlaceholder":329},[106,3280,3281,3283,3285],{"class":108,"line":1508},[106,3282,165],{"class":112},[106,3284,54],{"class":116},[106,3286,120],{"class":112},[106,3288,3289],{"class":108,"line":1523},[106,3290,3291],{"class":224},"      Image\n",[106,3293,3294,3296,3298],{"class":108,"line":1538},[106,3295,231],{"class":112},[106,3297,54],{"class":116},[106,3299,120],{"class":112},[106,3301,3302,3304,3306,3308,3310,3312,3314,3316,3318,3320,3322,3325,3327],{"class":108,"line":1545},[106,3303,165],{"class":112},[106,3305,129],{"class":116},[106,3307,2110],{"class":132},[106,3309,136],{"class":112},[106,3311,139],{"class":112},[106,3313,2117],{"class":142},[106,3315,139],{"class":112},[106,3317,1591],{"class":132},[106,3319,136],{"class":112},[106,3321,139],{"class":112},[106,3323,3324],{"class":142},"onImageSubmit($event)",[106,3326,139],{"class":112},[106,3328,120],{"class":112},[106,3330,3331,3334,3336,3338,3340,3342,3344,3346,3349,3351,3353,3355,3357,3359,3361,3363,3365,3367],{"class":108,"line":1550},[106,3332,3333],{"class":112},"      \u003C",[106,3335,168],{"class":116},[106,3337,171],{"class":132},[106,3339,136],{"class":112},[106,3341,139],{"class":112},[106,3343,2146],{"class":142},[106,3345,139],{"class":112},[106,3347,3348],{"class":132}," name",[106,3350,136],{"class":112},[106,3352,139],{"class":112},[106,3354,2234],{"class":142},[106,3356,139],{"class":112},[106,3358,2151],{"class":132},[106,3360,136],{"class":112},[106,3362,139],{"class":112},[106,3364,2158],{"class":142},[106,3366,139],{"class":112},[106,3368,120],{"class":112},[106,3370,3371,3373,3375,3377,3379,3381,3383,3385],{"class":108,"line":1555},[106,3372,3333],{"class":112},[106,3374,205],{"class":116},[106,3376,171],{"class":132},[106,3378,136],{"class":112},[106,3380,139],{"class":112},[106,3382,214],{"class":142},[106,3384,139],{"class":112},[106,3386,120],{"class":112},[106,3388,3389],{"class":108,"line":1561},[106,3390,3391],{"class":224},"        Submit\n",[106,3393,3394,3397,3399],{"class":108,"line":1570},[106,3395,3396],{"class":112},"      \u003C\u002F",[106,3398,205],{"class":116},[106,3400,120],{"class":112},[106,3402,3403,3405,3407],{"class":108,"line":1575},[106,3404,231],{"class":112},[106,3406,129],{"class":116},[106,3408,120],{"class":112},[106,3410,3411,3413,3415],{"class":108,"line":1584},[106,3412,241],{"class":112},[106,3414,3253],{"class":116},[106,3416,120],{"class":112},[106,3418,3419,3421,3423],{"class":108,"line":1605},[106,3420,251],{"class":112},[106,3422,117],{"class":116},[106,3424,120],{"class":112},[10,3426,3427,3428,3431],{},"If the validation fails, the request will not be sent to the server, and the error will be displayed in the console. Of course, you can handle the error as you want, like using a ",[103,3429,3430],{},"toast"," or showing an error message under the related input.",[10,3433,3434],{},"On the server, I can replace the validation logic with the validator:",[96,3436,3438],{"className":261,"code":3437,"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,3439,3440,3459,3463,3485,3490,3494,3514,3518,3522],{"__ignoreMap":101},[106,3441,3442,3444,3446,3449,3451,3453,3455,3457],{"class":108,"line":109},[106,3443,410],{"class":270},[106,3445,413],{"class":112},[106,3447,3448],{"class":224}," saveImageValidator",[106,3450,419],{"class":112},[106,3452,422],{"class":270},[106,3454,425],{"class":112},[106,3456,1963],{"class":142},[106,3458,431],{"class":112},[106,3460,3461],{"class":108,"line":123},[106,3462,330],{"emptyLinePlaceholder":329},[106,3464,3465,3467,3469,3471,3473,3475,3477,3479,3481,3483],{"class":108,"line":162},[106,3466,271],{"class":270},[106,3468,274],{"class":270},[106,3470,278],{"class":277},[106,3472,281],{"class":224},[106,3474,284],{"class":132},[106,3476,287],{"class":112},[106,3478,291],{"class":290},[106,3480,294],{"class":112},[106,3482,297],{"class":132},[106,3484,300],{"class":112},[106,3486,3487],{"class":108,"line":200},[106,3488,3489],{"class":335},"  \u002F\u002F ...\n",[106,3491,3492],{"class":108,"line":221},[106,3493,330],{"emptyLinePlaceholder":329},[106,3495,3496,3498,3500,3502,3504,3506,3508,3510,3512],{"class":108,"line":228},[106,3497,2348],{"class":270},[106,3499,3448],{"class":224},[106,3501,52],{"class":112},[106,3503,872],{"class":277},[106,3505,281],{"class":116},[106,3507,1285],{"class":112},[106,3509,2324],{"class":224},[106,3511,419],{"class":112},[106,3513,324],{"class":116},[106,3515,3516],{"class":108,"line":238},[106,3517,330],{"emptyLinePlaceholder":329},[106,3519,3520],{"class":108,"line":248},[106,3521,3489],{"class":335},[106,3523,3524,3526],{"class":108,"line":534},[106,3525,366],{"class":112},[106,3527,324],{"class":224},[381,3529,3531],{"id":3530},"with-nuxthub","With NuxtHub",[10,3533,3534,3535,3537,3538],{},"NuxtHub comes with a utility named ",[103,3536,2064],{}," that can be used to check the file size and type. This utility is very useful because it will automatically throw an error if the file is not valid, but can only be used on the server. This means that I will have a Zod schema on the client and a NuxtHub utility on the server. ",[29,3539,3540],{},"Use constants to share validation limits between the Zod Schema and the NuxtHub utility.",[96,3542,3544],{"className":261,"code":3543,"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: '1MB',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Save the image to a database or a file system\n\n  return sendNoContent(event, 201)\n})\n",[103,3545,3546,3568,3586,3590,3620,3633,3649,3683,3689,3693,3697,3701,3717],{"__ignoreMap":101},[106,3547,3548,3550,3552,3554,3556,3558,3560,3562,3564,3566],{"class":108,"line":109},[106,3549,271],{"class":270},[106,3551,274],{"class":270},[106,3553,278],{"class":277},[106,3555,281],{"class":224},[106,3557,284],{"class":132},[106,3559,287],{"class":112},[106,3561,291],{"class":290},[106,3563,294],{"class":112},[106,3565,297],{"class":132},[106,3567,300],{"class":112},[106,3569,3570,3572,3574,3576,3578,3580,3582,3584],{"class":108,"line":123},[106,3571,305],{"class":132},[106,3573,2300],{"class":224},[106,3575,311],{"class":112},[106,3577,314],{"class":270},[106,3579,2307],{"class":277},[106,3581,281],{"class":116},[106,3583,291],{"class":224},[106,3585,324],{"class":116},[106,3587,3588],{"class":108,"line":162},[106,3589,330],{"emptyLinePlaceholder":329},[106,3591,3592,3594,3596,3598,3600,3602,3604,3606,3608,3610,3612,3614,3617],{"class":108,"line":200},[106,3593,305],{"class":132},[106,3595,2324],{"class":224},[106,3597,311],{"class":112},[106,3599,2300],{"class":224},[106,3601,52],{"class":112},[106,3603,2333],{"class":277},[106,3605,281],{"class":116},[106,3607,754],{"class":112},[106,3609,2234],{"class":142},[106,3611,754],{"class":112},[106,3613,713],{"class":116},[106,3615,3616],{"class":270},"as",[106,3618,3619],{"class":1041}," Blob\n",[106,3621,3622,3625,3627,3629,3631],{"class":108,"line":221},[106,3623,3624],{"class":277},"  ensureBlob",[106,3626,281],{"class":116},[106,3628,2234],{"class":224},[106,3630,355],{"class":112},[106,3632,300],{"class":112},[106,3634,3635,3638,3640,3642,3645,3647],{"class":108,"line":228},[106,3636,3637],{"class":116},"    maxSize",[106,3639,496],{"class":112},[106,3641,425],{"class":112},[106,3643,3644],{"class":142},"1MB",[106,3646,754],{"class":112},[106,3648,509],{"class":112},[106,3650,3651,3654,3656,3658,3660,3662,3664,3666,3668,3670,3672,3674,3676,3678,3680],{"class":108,"line":238},[106,3652,3653],{"class":116},"    types",[106,3655,496],{"class":112},[106,3657,2524],{"class":116},[106,3659,754],{"class":112},[106,3661,2529],{"class":142},[106,3663,754],{"class":112},[106,3665,355],{"class":112},[106,3667,425],{"class":112},[106,3669,2538],{"class":142},[106,3671,754],{"class":112},[106,3673,355],{"class":112},[106,3675,425],{"class":112},[106,3677,2547],{"class":142},[106,3679,754],{"class":112},[106,3681,3682],{"class":116},"]\n",[106,3684,3685,3687],{"class":108,"line":248},[106,3686,514],{"class":112},[106,3688,324],{"class":116},[106,3690,3691],{"class":108,"line":534},[106,3692,330],{"emptyLinePlaceholder":329},[106,3694,3695],{"class":108,"line":539},[106,3696,2700],{"class":335},[106,3698,3699],{"class":108,"line":556},[106,3700,330],{"emptyLinePlaceholder":329},[106,3702,3703,3705,3707,3709,3711,3713,3715],{"class":108,"line":759},[106,3704,345],{"class":270},[106,3706,348],{"class":277},[106,3708,281],{"class":116},[106,3710,291],{"class":224},[106,3712,355],{"class":112},[106,3714,359],{"class":358},[106,3716,324],{"class":116},[106,3718,3719,3721],{"class":108,"line":795},[106,3720,366],{"class":112},[106,3722,324],{"class":224},[54,3724,3726],{"id":3725},"text-and-blob-within-the-same-form","Text and blob within the same form",[10,3728,3729],{},"Until now, I've only one type of input in my form, text or file. But what if I want to have both in the same form?",[10,3731,3732,3733,3735],{},"You need to know that you absolutely need to use a ",[103,3734,2216],{}," to send the form to the server due to the file input.",[10,3737,3738],{},"The form is still very simple:",[96,3740,3742],{"className":98,"code":3741,"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=\"Name\" required>\n    \u003Cinput type=\"file\" name=\"image\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\" required>\n    \u003Cbutton type=\"submit\">\n      Create Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n",[103,3743,3744,3752,3791,3832,3872,3890,3894,3902,3910],{"__ignoreMap":101},[106,3745,3746,3748,3750],{"class":108,"line":109},[106,3747,113],{"class":112},[106,3749,117],{"class":116},[106,3751,120],{"class":112},[106,3753,3754,3756,3758,3760,3762,3764,3767,3769,3771,3773,3775,3777,3779,3781,3783,3785,3787,3789],{"class":108,"line":123},[106,3755,126],{"class":112},[106,3757,129],{"class":116},[106,3759,148],{"class":132},[106,3761,136],{"class":112},[106,3763,139],{"class":112},[106,3765,3766],{"class":142},"\u002Fapi\u002Ftext-image",[106,3768,139],{"class":112},[106,3770,2110],{"class":132},[106,3772,136],{"class":112},[106,3774,139],{"class":112},[106,3776,2117],{"class":142},[106,3778,139],{"class":112},[106,3780,133],{"class":132},[106,3782,136],{"class":112},[106,3784,139],{"class":112},[106,3786,143],{"class":142},[106,3788,139],{"class":112},[106,3790,120],{"class":112},[106,3792,3793,3795,3797,3799,3801,3803,3805,3807,3809,3811,3813,3816,3818,3820,3822,3824,3826,3828,3830],{"class":108,"line":162},[106,3794,165],{"class":112},[106,3796,168],{"class":116},[106,3798,171],{"class":132},[106,3800,136],{"class":112},[106,3802,139],{"class":112},[106,3804,178],{"class":142},[106,3806,139],{"class":112},[106,3808,3348],{"class":132},[106,3810,136],{"class":112},[106,3812,139],{"class":112},[106,3814,3815],{"class":142},"name",[106,3817,139],{"class":112},[106,3819,183],{"class":132},[106,3821,136],{"class":112},[106,3823,139],{"class":112},[106,3825,190],{"class":142},[106,3827,139],{"class":112},[106,3829,195],{"class":132},[106,3831,120],{"class":112},[106,3833,3834,3836,3838,3840,3842,3844,3846,3848,3850,3852,3854,3856,3858,3860,3862,3864,3866,3868,3870],{"class":108,"line":200},[106,3835,165],{"class":112},[106,3837,168],{"class":116},[106,3839,171],{"class":132},[106,3841,136],{"class":112},[106,3843,139],{"class":112},[106,3845,2146],{"class":142},[106,3847,139],{"class":112},[106,3849,3348],{"class":132},[106,3851,136],{"class":112},[106,3853,139],{"class":112},[106,3855,2234],{"class":142},[106,3857,139],{"class":112},[106,3859,2151],{"class":132},[106,3861,136],{"class":112},[106,3863,139],{"class":112},[106,3865,2158],{"class":142},[106,3867,139],{"class":112},[106,3869,195],{"class":132},[106,3871,120],{"class":112},[106,3873,3874,3876,3878,3880,3882,3884,3886,3888],{"class":108,"line":221},[106,3875,165],{"class":112},[106,3877,205],{"class":116},[106,3879,171],{"class":132},[106,3881,136],{"class":112},[106,3883,139],{"class":112},[106,3885,214],{"class":142},[106,3887,139],{"class":112},[106,3889,120],{"class":112},[106,3891,3892],{"class":108,"line":228},[106,3893,225],{"class":224},[106,3895,3896,3898,3900],{"class":108,"line":238},[106,3897,231],{"class":112},[106,3899,205],{"class":116},[106,3901,120],{"class":112},[106,3903,3904,3906,3908],{"class":108,"line":248},[106,3905,241],{"class":112},[106,3907,129],{"class":116},[106,3909,120],{"class":112},[106,3911,3912,3914,3916],{"class":108,"line":534},[106,3913,251],{"class":112},[106,3915,117],{"class":116},[106,3917,120],{"class":112},[10,3919,3920],{},"Now, I can create a validator using Zod:",[96,3922,3924],{"className":261,"code":3923,"language":263,"meta":101,"style":101},"import { any, object, string } from 'zod'\n\nexport const createValidator = object({\n  name: string({ message: 'Required' }),\n  image: any()\n    .refine(image => image instanceof File, { message: 'Image must be a file' })\n    .refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' })\n    .refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' })\n    .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n})\n",[103,3925,3926,3953,3957,3974,4002,4012,4049,4093,4143,4209],{"__ignoreMap":101},[106,3927,3928,3930,3932,3934,3936,3938,3940,3943,3945,3947,3949,3951],{"class":108,"line":109},[106,3929,410],{"class":270},[106,3931,413],{"class":112},[106,3933,2252],{"class":224},[106,3935,355],{"class":112},[106,3937,2257],{"class":224},[106,3939,355],{"class":112},[106,3941,3942],{"class":224}," string",[106,3944,419],{"class":112},[106,3946,422],{"class":270},[106,3948,425],{"class":112},[106,3950,428],{"class":142},[106,3952,431],{"class":112},[106,3954,3955],{"class":108,"line":123},[106,3956,330],{"emptyLinePlaceholder":329},[106,3958,3959,3961,3963,3966,3968,3970,3972],{"class":108,"line":162},[106,3960,271],{"class":270},[106,3962,1775],{"class":132},[106,3964,3965],{"class":224}," createValidator ",[106,3967,136],{"class":112},[106,3969,2257],{"class":277},[106,3971,281],{"class":224},[106,3973,488],{"class":112},[106,3975,3976,3978,3980,3982,3984,3986,3988,3990,3992,3994,3996,3998,4000],{"class":108,"line":200},[106,3977,1272],{"class":116},[106,3979,496],{"class":112},[106,3981,3942],{"class":277},[106,3983,281],{"class":224},[106,3985,1285],{"class":112},[106,3987,1288],{"class":116},[106,3989,496],{"class":112},[106,3991,425],{"class":112},[106,3993,1295],{"class":142},[106,3995,754],{"class":112},[106,3997,419],{"class":112},[106,3999,294],{"class":224},[106,4001,509],{"class":112},[106,4003,4004,4006,4008,4010],{"class":108,"line":221},[106,4005,2789],{"class":116},[106,4007,496],{"class":112},[106,4009,2252],{"class":277},[106,4011,2366],{"class":224},[106,4013,4014,4017,4019,4021,4023,4025,4027,4029,4031,4033,4035,4037,4039,4041,4043,4045,4047],{"class":108,"line":228},[106,4015,4016],{"class":112},"    .",[106,4018,2374],{"class":277},[106,4020,281],{"class":224},[106,4022,2234],{"class":290},[106,4024,297],{"class":132},[106,4026,2808],{"class":224},[106,4028,2811],{"class":112},[106,4030,2387],{"class":1041},[106,4032,355],{"class":112},[106,4034,413],{"class":112},[106,4036,1288],{"class":116},[106,4038,496],{"class":112},[106,4040,425],{"class":112},[106,4042,2400],{"class":142},[106,4044,754],{"class":112},[106,4046,419],{"class":112},[106,4048,324],{"class":224},[106,4050,4051,4053,4055,4057,4059,4061,4063,4065,4067,4069,4071,4073,4075,4077,4079,4081,4083,4085,4087,4089,4091],{"class":108,"line":238},[106,4052,4016],{"class":112},[106,4054,2374],{"class":277},[106,4056,281],{"class":224},[106,4058,2234],{"class":290},[106,4060,297],{"class":132},[106,4062,2324],{"class":224},[106,4064,52],{"class":112},[106,4066,2848],{"class":224},[106,4068,113],{"class":112},[106,4070,2431],{"class":358},[106,4072,2434],{"class":112},[106,4074,2431],{"class":358},[106,4076,355],{"class":112},[106,4078,413],{"class":112},[106,4080,1288],{"class":116},[106,4082,496],{"class":112},[106,4084,425],{"class":112},[106,4086,2449],{"class":142},[106,4088,754],{"class":112},[106,4090,419],{"class":112},[106,4092,324],{"class":224},[106,4094,4095,4097,4099,4101,4103,4105,4107,4109,4111,4113,4115,4117,4119,4121,4123,4125,4127,4129,4131,4133,4135,4137,4139,4141],{"class":108,"line":248},[106,4096,4016],{"class":112},[106,4098,2374],{"class":277},[106,4100,281],{"class":224},[106,4102,2234],{"class":290},[106,4104,297],{"class":132},[106,4106,2324],{"class":224},[106,4108,52],{"class":112},[106,4110,2474],{"class":224},[106,4112,52],{"class":112},[106,4114,2479],{"class":277},[106,4116,281],{"class":224},[106,4118,754],{"class":112},[106,4120,2486],{"class":142},[106,4122,754],{"class":112},[106,4124,294],{"class":224},[106,4126,355],{"class":112},[106,4128,413],{"class":112},[106,4130,1288],{"class":116},[106,4132,496],{"class":112},[106,4134,425],{"class":112},[106,4136,2503],{"class":142},[106,4138,754],{"class":112},[106,4140,419],{"class":112},[106,4142,324],{"class":224},[106,4144,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],{"class":108,"line":534},[106,4146,4016],{"class":112},[106,4148,2374],{"class":277},[106,4150,281],{"class":224},[106,4152,2234],{"class":290},[106,4154,297],{"class":132},[106,4156,2524],{"class":224},[106,4158,754],{"class":112},[106,4160,2529],{"class":142},[106,4162,754],{"class":112},[106,4164,355],{"class":112},[106,4166,425],{"class":112},[106,4168,2538],{"class":142},[106,4170,754],{"class":112},[106,4172,355],{"class":112},[106,4174,425],{"class":112},[106,4176,2547],{"class":142},[106,4178,754],{"class":112},[106,4180,785],{"class":224},[106,4182,52],{"class":112},[106,4184,2556],{"class":277},[106,4186,2957],{"class":224},[106,4188,52],{"class":112},[106,4190,2962],{"class":224},[106,4192,355],{"class":112},[106,4194,413],{"class":112},[106,4196,1288],{"class":116},[106,4198,496],{"class":112},[106,4200,425],{"class":112},[106,4202,2579],{"class":142},[106,4204,754],{"class":112},[106,4206,419],{"class":112},[106,4208,324],{"class":224},[106,4210,4211,4213],{"class":108,"line":539},[106,4212,366],{"class":112},[106,4214,324],{"class":224},[10,4216,4217],{},"This validator can be used as before, on both the server and client.",[10,4219,4220,4221,4223],{},"If you prefer to use the ",[103,4222,2064],{}," utility from NuxtHub, I recommend you split the validator into two parts: one for the text and one for the image. Both the text and the image will be used on the client by merging them into a single object, and only the text will be used on the server.",[96,4225,4227],{"className":261,"code":4226,"language":263,"meta":101,"style":101},"import { any, object, string } from 'zod'\n\nexport const createTextValidator = object({\n  name: string({ message: 'Required' }),\n})\n\nexport const createImageValidator = object({\n  image: any()\n    .refine(image => image instanceof File, { message: 'Image must be a file' })\n    .refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' })\n    .refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' })\n    .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n})\n\nexport const createValidator = createTextValidator.merge(createImageValidator)\n",[103,4228,4229,4255,4259,4276,4304,4310,4314,4330,4340,4376,4420,4470,4536,4542,4546],{"__ignoreMap":101},[106,4230,4231,4233,4235,4237,4239,4241,4243,4245,4247,4249,4251,4253],{"class":108,"line":109},[106,4232,410],{"class":270},[106,4234,413],{"class":112},[106,4236,2252],{"class":224},[106,4238,355],{"class":112},[106,4240,2257],{"class":224},[106,4242,355],{"class":112},[106,4244,3942],{"class":224},[106,4246,419],{"class":112},[106,4248,422],{"class":270},[106,4250,425],{"class":112},[106,4252,428],{"class":142},[106,4254,431],{"class":112},[106,4256,4257],{"class":108,"line":123},[106,4258,330],{"emptyLinePlaceholder":329},[106,4260,4261,4263,4265,4268,4270,4272,4274],{"class":108,"line":162},[106,4262,271],{"class":270},[106,4264,1775],{"class":132},[106,4266,4267],{"class":224}," createTextValidator ",[106,4269,136],{"class":112},[106,4271,2257],{"class":277},[106,4273,281],{"class":224},[106,4275,488],{"class":112},[106,4277,4278,4280,4282,4284,4286,4288,4290,4292,4294,4296,4298,4300,4302],{"class":108,"line":200},[106,4279,1272],{"class":116},[106,4281,496],{"class":112},[106,4283,3942],{"class":277},[106,4285,281],{"class":224},[106,4287,1285],{"class":112},[106,4289,1288],{"class":116},[106,4291,496],{"class":112},[106,4293,425],{"class":112},[106,4295,1295],{"class":142},[106,4297,754],{"class":112},[106,4299,419],{"class":112},[106,4301,294],{"class":224},[106,4303,509],{"class":112},[106,4305,4306,4308],{"class":108,"line":221},[106,4307,366],{"class":112},[106,4309,324],{"class":224},[106,4311,4312],{"class":108,"line":228},[106,4313,330],{"emptyLinePlaceholder":329},[106,4315,4316,4318,4320,4322,4324,4326,4328],{"class":108,"line":238},[106,4317,271],{"class":270},[106,4319,1775],{"class":132},[106,4321,2776],{"class":224},[106,4323,136],{"class":112},[106,4325,2257],{"class":277},[106,4327,281],{"class":224},[106,4329,488],{"class":112},[106,4331,4332,4334,4336,4338],{"class":108,"line":248},[106,4333,2789],{"class":116},[106,4335,496],{"class":112},[106,4337,2252],{"class":277},[106,4339,2366],{"class":224},[106,4341,4342,4344,4346,4348,4350,4352,4354,4356,4358,4360,4362,4364,4366,4368,4370,4372,4374],{"class":108,"line":534},[106,4343,4016],{"class":112},[106,4345,2374],{"class":277},[106,4347,281],{"class":224},[106,4349,2234],{"class":290},[106,4351,297],{"class":132},[106,4353,2808],{"class":224},[106,4355,2811],{"class":112},[106,4357,2387],{"class":1041},[106,4359,355],{"class":112},[106,4361,413],{"class":112},[106,4363,1288],{"class":116},[106,4365,496],{"class":112},[106,4367,425],{"class":112},[106,4369,2400],{"class":142},[106,4371,754],{"class":112},[106,4373,419],{"class":112},[106,4375,324],{"class":224},[106,4377,4378,4380,4382,4384,4386,4388,4390,4392,4394,4396,4398,4400,4402,4404,4406,4408,4410,4412,4414,4416,4418],{"class":108,"line":539},[106,4379,4016],{"class":112},[106,4381,2374],{"class":277},[106,4383,281],{"class":224},[106,4385,2234],{"class":290},[106,4387,297],{"class":132},[106,4389,2324],{"class":224},[106,4391,52],{"class":112},[106,4393,2848],{"class":224},[106,4395,113],{"class":112},[106,4397,2431],{"class":358},[106,4399,2434],{"class":112},[106,4401,2431],{"class":358},[106,4403,355],{"class":112},[106,4405,413],{"class":112},[106,4407,1288],{"class":116},[106,4409,496],{"class":112},[106,4411,425],{"class":112},[106,4413,2449],{"class":142},[106,4415,754],{"class":112},[106,4417,419],{"class":112},[106,4419,324],{"class":224},[106,4421,4422,4424,4426,4428,4430,4432,4434,4436,4438,4440,4442,4444,4446,4448,4450,4452,4454,4456,4458,4460,4462,4464,4466,4468],{"class":108,"line":556},[106,4423,4016],{"class":112},[106,4425,2374],{"class":277},[106,4427,281],{"class":224},[106,4429,2234],{"class":290},[106,4431,297],{"class":132},[106,4433,2324],{"class":224},[106,4435,52],{"class":112},[106,4437,2474],{"class":224},[106,4439,52],{"class":112},[106,4441,2479],{"class":277},[106,4443,281],{"class":224},[106,4445,754],{"class":112},[106,4447,2486],{"class":142},[106,4449,754],{"class":112},[106,4451,294],{"class":224},[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,2503],{"class":142},[106,4465,754],{"class":112},[106,4467,419],{"class":112},[106,4469,324],{"class":224},[106,4471,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],{"class":108,"line":759},[106,4473,4016],{"class":112},[106,4475,2374],{"class":277},[106,4477,281],{"class":224},[106,4479,2234],{"class":290},[106,4481,297],{"class":132},[106,4483,2524],{"class":224},[106,4485,754],{"class":112},[106,4487,2529],{"class":142},[106,4489,754],{"class":112},[106,4491,355],{"class":112},[106,4493,425],{"class":112},[106,4495,2538],{"class":142},[106,4497,754],{"class":112},[106,4499,355],{"class":112},[106,4501,425],{"class":112},[106,4503,2547],{"class":142},[106,4505,754],{"class":112},[106,4507,785],{"class":224},[106,4509,52],{"class":112},[106,4511,2556],{"class":277},[106,4513,2957],{"class":224},[106,4515,52],{"class":112},[106,4517,2962],{"class":224},[106,4519,355],{"class":112},[106,4521,413],{"class":112},[106,4523,1288],{"class":116},[106,4525,496],{"class":112},[106,4527,425],{"class":112},[106,4529,2579],{"class":142},[106,4531,754],{"class":112},[106,4533,419],{"class":112},[106,4535,324],{"class":224},[106,4537,4538,4540],{"class":108,"line":795},[106,4539,366],{"class":112},[106,4541,324],{"class":224},[106,4543,4544],{"class":108,"line":803},[106,4545,330],{"emptyLinePlaceholder":329},[106,4547,4548,4550,4552,4554,4556,4559,4561,4564],{"class":108,"line":809},[106,4549,271],{"class":270},[106,4551,1775],{"class":132},[106,4553,3965],{"class":224},[106,4555,136],{"class":112},[106,4557,4558],{"class":224}," createTextValidator",[106,4560,52],{"class":112},[106,4562,4563],{"class":277},"merge",[106,4565,4566],{"class":224},"(createImageValidator)\n",[10,4568,4569,4570,4573],{},"On the client, I will use the ",[103,4571,4572],{},"createValidator"," to validate the form before sending it to the server:",[96,4575,4577],{"className":98,"code":4576,"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,4578,4579,4599,4619,4637,4655,4659,4683,4707,4713,4738,4742,4760,4774,4784,4790,4794,4806,4820,4824,4828],{"__ignoreMap":101},[106,4580,4581,4583,4585,4587,4589,4591,4593,4595,4597],{"class":108,"line":109},[106,4582,113],{"class":112},[106,4584,1189],{"class":116},[106,4586,1192],{"class":132},[106,4588,136],{"class":112},[106,4590,139],{"class":112},[106,4592,263],{"class":142},[106,4594,139],{"class":112},[106,4596,1203],{"class":132},[106,4598,120],{"class":112},[106,4600,4601,4603,4605,4607,4609,4611,4613,4615,4617],{"class":108,"line":123},[106,4602,284],{"class":132},[106,4604,1360],{"class":132},[106,4606,1363],{"class":277},[106,4608,281],{"class":112},[106,4610,291],{"class":290},[106,4612,496],{"class":112},[106,4614,3032],{"class":1041},[106,4616,294],{"class":112},[106,4618,300],{"class":112},[106,4620,4621,4623,4625,4627,4629,4631,4633,4635],{"class":108,"line":162},[106,4622,305],{"class":132},[106,4624,3043],{"class":224},[106,4626,311],{"class":112},[106,4628,3048],{"class":224},[106,4630,52],{"class":112},[106,4632,3053],{"class":224},[106,4634,3056],{"class":270},[106,4636,3059],{"class":1041},[106,4638,4639,4641,4643,4645,4647,4649,4651,4653],{"class":108,"line":200},[106,4640,305],{"class":132},[106,4642,2300],{"class":224},[106,4644,311],{"class":112},[106,4646,3070],{"class":112},[106,4648,3073],{"class":277},[106,4650,281],{"class":116},[106,4652,3053],{"class":224},[106,4654,324],{"class":116},[106,4656,4657],{"class":108,"line":221},[106,4658,330],{"emptyLinePlaceholder":329},[106,4660,4661,4663,4665,4667,4669,4671,4673,4675,4677,4679,4681],{"class":108,"line":228},[106,4662,305],{"class":132},[106,4664,3348],{"class":224},[106,4666,311],{"class":112},[106,4668,2300],{"class":224},[106,4670,52],{"class":112},[106,4672,2333],{"class":277},[106,4674,281],{"class":116},[106,4676,754],{"class":112},[106,4678,3815],{"class":142},[106,4680,754],{"class":112},[106,4682,324],{"class":116},[106,4684,4685,4687,4689,4691,4693,4695,4697,4699,4701,4703,4705],{"class":108,"line":238},[106,4686,305],{"class":132},[106,4688,2324],{"class":224},[106,4690,311],{"class":112},[106,4692,2300],{"class":224},[106,4694,52],{"class":112},[106,4696,2333],{"class":277},[106,4698,281],{"class":116},[106,4700,754],{"class":112},[106,4702,2234],{"class":142},[106,4704,754],{"class":112},[106,4706,324],{"class":116},[106,4708,4709,4711],{"class":108,"line":248},[106,4710,1372],{"class":270},[106,4712,300],{"class":112},[106,4714,4715,4717,4720,4722,4724,4726,4728,4730,4732,4734,4736],{"class":108,"line":534},[106,4716,1402],{"class":270},[106,4718,4719],{"class":224}," createValidator",[106,4721,52],{"class":112},[106,4723,872],{"class":277},[106,4725,281],{"class":116},[106,4727,1285],{"class":112},[106,4729,3348],{"class":224},[106,4731,355],{"class":112},[106,4733,2324],{"class":224},[106,4735,419],{"class":112},[106,4737,324],{"class":116},[106,4739,4740],{"class":108,"line":539},[106,4741,330],{"emptyLinePlaceholder":329},[106,4743,4744,4746,4748,4750,4752,4754,4756,4758],{"class":108,"line":556},[106,4745,1402],{"class":270},[106,4747,1405],{"class":277},[106,4749,281],{"class":116},[106,4751,754],{"class":112},[106,4753,3766],{"class":142},[106,4755,754],{"class":112},[106,4757,355],{"class":112},[106,4759,300],{"class":112},[106,4761,4762,4764,4766,4768,4770,4772],{"class":108,"line":759},[106,4763,1422],{"class":116},[106,4765,496],{"class":112},[106,4767,425],{"class":112},[106,4769,3167],{"class":142},[106,4771,754],{"class":112},[106,4773,509],{"class":112},[106,4775,4776,4778,4780,4782],{"class":108,"line":795},[106,4777,1437],{"class":116},[106,4779,496],{"class":112},[106,4781,2300],{"class":224},[106,4783,509],{"class":112},[106,4785,4786,4788],{"class":108,"line":803},[106,4787,798],{"class":112},[106,4789,324],{"class":116},[106,4791,4792],{"class":108,"line":809},[106,4793,806],{"class":112},[106,4795,4796,4798,4800,4802,4804],{"class":108,"line":814},[106,4797,1458],{"class":270},[106,4799,287],{"class":116},[106,4801,771],{"class":224},[106,4803,713],{"class":116},[106,4805,488],{"class":112},[106,4807,4808,4810,4812,4814,4816,4818],{"class":108,"line":830},[106,4809,3208],{"class":224},[106,4811,52],{"class":112},[106,4813,771],{"class":277},[106,4815,281],{"class":116},[106,4817,771],{"class":224},[106,4819,324],{"class":116},[106,4821,4822],{"class":108,"line":835},[106,4823,806],{"class":112},[106,4825,4826],{"class":108,"line":840},[106,4827,1558],{"class":112},[106,4829,4830,4832,4834],{"class":108,"line":845},[106,4831,251],{"class":112},[106,4833,1189],{"class":116},[106,4835,120],{"class":112},[10,4837,4838,4839,4842,4843,4845],{},"On the server, I will use the ",[103,4840,4841],{},"createTextValidator"," to validate the text and the ",[103,4844,2064],{}," utility to validate the image:",[96,4847,4849],{"className":261,"code":4848,"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: 'Invalid data',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Save the text in the database...\n\n  const image = formData.get('image') as Blob\n  ensureBlob(image, {\n    maxSize: '1MB',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Save the image to a database or a file system\n\n  return sendNoContent(event, 201)\n})\n",[103,4850,4851,4869,4873,4895,4913,4917,4941,4983,4993,5003,5017,5039,5045,5051,5055,5060,5064,5092,5104,5118,5150,5156,5160,5164,5168,5184],{"__ignoreMap":101},[106,4852,4853,4855,4857,4859,4861,4863,4865,4867],{"class":108,"line":109},[106,4854,410],{"class":270},[106,4856,413],{"class":112},[106,4858,4558],{"class":224},[106,4860,419],{"class":112},[106,4862,422],{"class":270},[106,4864,425],{"class":112},[106,4866,1963],{"class":142},[106,4868,431],{"class":112},[106,4870,4871],{"class":108,"line":123},[106,4872,330],{"emptyLinePlaceholder":329},[106,4874,4875,4877,4879,4881,4883,4885,4887,4889,4891,4893],{"class":108,"line":162},[106,4876,271],{"class":270},[106,4878,274],{"class":270},[106,4880,278],{"class":277},[106,4882,281],{"class":224},[106,4884,284],{"class":132},[106,4886,287],{"class":112},[106,4888,291],{"class":290},[106,4890,294],{"class":112},[106,4892,297],{"class":132},[106,4894,300],{"class":112},[106,4896,4897,4899,4901,4903,4905,4907,4909,4911],{"class":108,"line":200},[106,4898,305],{"class":132},[106,4900,2300],{"class":224},[106,4902,311],{"class":112},[106,4904,314],{"class":270},[106,4906,2307],{"class":277},[106,4908,281],{"class":116},[106,4910,291],{"class":224},[106,4912,324],{"class":116},[106,4914,4915],{"class":108,"line":221},[106,4916,330],{"emptyLinePlaceholder":329},[106,4918,4919,4921,4923,4925,4927,4929,4931,4933,4935,4937,4939],{"class":108,"line":228},[106,4920,305],{"class":132},[106,4922,3348],{"class":224},[106,4924,311],{"class":112},[106,4926,2300],{"class":224},[106,4928,52],{"class":112},[106,4930,2333],{"class":277},[106,4932,281],{"class":116},[106,4934,754],{"class":112},[106,4936,3815],{"class":142},[106,4938,754],{"class":112},[106,4940,324],{"class":116},[106,4942,4943,4945,4947,4949,4951,4953,4955,4957,4959,4961,4963,4965,4967,4969,4971,4973,4975,4977,4979,4981],{"class":108,"line":238},[106,4944,305],{"class":132},[106,4946,964],{"class":224},[106,4948,311],{"class":112},[106,4950,314],{"class":270},[106,4952,4558],{"class":224},[106,4954,52],{"class":112},[106,4956,872],{"class":277},[106,4958,281],{"class":116},[106,4960,1285],{"class":112},[106,4962,3348],{"class":224},[106,4964,419],{"class":112},[106,4966,294],{"class":116},[106,4968,52],{"class":112},[106,4970,1014],{"class":277},[106,4972,281],{"class":116},[106,4974,281],{"class":112},[106,4976,771],{"class":290},[106,4978,294],{"class":112},[106,4980,297],{"class":132},[106,4982,300],{"class":112},[106,4984,4985,4987,4989,4991],{"class":108,"line":248},[106,4986,720],{"class":270},[106,4988,723],{"class":277},[106,4990,281],{"class":116},[106,4992,488],{"class":112},[106,4994,4995,4997,4999,5001],{"class":108,"line":534},[106,4996,732],{"class":116},[106,4998,496],{"class":112},[106,5000,737],{"class":358},[106,5002,509],{"class":112},[106,5004,5005,5007,5009,5011,5013,5015],{"class":108,"line":539},[106,5006,744],{"class":116},[106,5008,496],{"class":112},[106,5010,425],{"class":112},[106,5012,751],{"class":142},[106,5014,754],{"class":112},[106,5016,509],{"class":112},[106,5018,5019,5021,5023,5025,5027,5029,5031,5033,5035,5037],{"class":108,"line":556},[106,5020,762],{"class":116},[106,5022,496],{"class":112},[106,5024,1092],{"class":224},[106,5026,52],{"class":112},[106,5028,776],{"class":224},[106,5030,779],{"class":116},[106,5032,782],{"class":358},[106,5034,785],{"class":116},[106,5036,52],{"class":112},[106,5038,2679],{"class":224},[106,5040,5041,5043],{"class":108,"line":759},[106,5042,798],{"class":112},[106,5044,324],{"class":116},[106,5046,5047,5049],{"class":108,"line":795},[106,5048,514],{"class":112},[106,5050,324],{"class":116},[106,5052,5053],{"class":108,"line":803},[106,5054,330],{"emptyLinePlaceholder":329},[106,5056,5057],{"class":108,"line":809},[106,5058,5059],{"class":335},"  \u002F\u002F Save the text in the database...\n",[106,5061,5062],{"class":108,"line":814},[106,5063,330],{"emptyLinePlaceholder":329},[106,5065,5066,5068,5070,5072,5074,5076,5078,5080,5082,5084,5086,5088,5090],{"class":108,"line":830},[106,5067,305],{"class":132},[106,5069,2324],{"class":224},[106,5071,311],{"class":112},[106,5073,2300],{"class":224},[106,5075,52],{"class":112},[106,5077,2333],{"class":277},[106,5079,281],{"class":116},[106,5081,754],{"class":112},[106,5083,2234],{"class":142},[106,5085,754],{"class":112},[106,5087,713],{"class":116},[106,5089,3616],{"class":270},[106,5091,3619],{"class":1041},[106,5093,5094,5096,5098,5100,5102],{"class":108,"line":835},[106,5095,3624],{"class":277},[106,5097,281],{"class":116},[106,5099,2234],{"class":224},[106,5101,355],{"class":112},[106,5103,300],{"class":112},[106,5105,5106,5108,5110,5112,5114,5116],{"class":108,"line":840},[106,5107,3637],{"class":116},[106,5109,496],{"class":112},[106,5111,425],{"class":112},[106,5113,3644],{"class":142},[106,5115,754],{"class":112},[106,5117,509],{"class":112},[106,5119,5120,5122,5124,5126,5128,5130,5132,5134,5136,5138,5140,5142,5144,5146,5148],{"class":108,"line":845},[106,5121,3653],{"class":116},[106,5123,496],{"class":112},[106,5125,2524],{"class":116},[106,5127,754],{"class":112},[106,5129,2529],{"class":142},[106,5131,754],{"class":112},[106,5133,355],{"class":112},[106,5135,425],{"class":112},[106,5137,2538],{"class":142},[106,5139,754],{"class":112},[106,5141,355],{"class":112},[106,5143,425],{"class":112},[106,5145,2547],{"class":142},[106,5147,754],{"class":112},[106,5149,3682],{"class":116},[106,5151,5152,5154],{"class":108,"line":862},[106,5153,514],{"class":112},[106,5155,324],{"class":116},[106,5157,5158],{"class":108,"line":1469},[106,5159,330],{"emptyLinePlaceholder":329},[106,5161,5162],{"class":108,"line":1486},[106,5163,2700],{"class":335},[106,5165,5166],{"class":108,"line":1497},[106,5167,330],{"emptyLinePlaceholder":329},[106,5169,5170,5172,5174,5176,5178,5180,5182],{"class":108,"line":1508},[106,5171,345],{"class":270},[106,5173,348],{"class":277},[106,5175,281],{"class":116},[106,5177,291],{"class":224},[106,5179,355],{"class":112},[106,5181,359],{"class":358},[106,5183,324],{"class":116},[106,5185,5186,5188],{"class":108,"line":1523},[106,5187,366],{"class":112},[106,5189,324],{"class":224},[10,5191,5192,5193,5195],{},"For longer forms, I could use a loop to extract every field from the form and validate them. In this loop, I can handle specific cases like arrays of multi-select fields or files or skipping some fields (that could be validated using ",[103,5194,2064],{},").",[96,5197,5199],{"className":261,"code":5198,"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,5200,5201,5213,5249,5271,5298,5303,5307,5331,5335,5339,5345,5360,5364],{"__ignoreMap":101},[106,5202,5203,5205,5208,5210],{"class":108,"line":109},[106,5204,1252],{"class":132},[106,5206,5207],{"class":224}," data ",[106,5209,136],{"class":112},[106,5211,5212],{"class":112}," {}\n",[106,5214,5215,5218,5220,5222,5224,5227,5229,5232,5234,5237,5239,5241,5244,5247],{"class":108,"line":123},[106,5216,5217],{"class":270},"for",[106,5219,287],{"class":224},[106,5221,1252],{"class":132},[106,5223,2524],{"class":112},[106,5225,5226],{"class":224},"key",[106,5228,355],{"class":112},[106,5230,5231],{"class":224}," value",[106,5233,785],{"class":112},[106,5235,5236],{"class":112}," of",[106,5238,2300],{"class":224},[106,5240,52],{"class":112},[106,5242,5243],{"class":277},"entries",[106,5245,5246],{"class":224},"()) ",[106,5248,488],{"class":112},[106,5250,5251,5253,5255,5257,5260,5262,5265,5267,5269],{"class":108,"line":162},[106,5252,697],{"class":270},[106,5254,287],{"class":116},[106,5256,5226],{"class":224},[106,5258,5259],{"class":112}," ===",[106,5261,425],{"class":112},[106,5263,5264],{"class":142},"moduleId[]",[106,5266,754],{"class":112},[106,5268,713],{"class":116},[106,5270,488],{"class":112},[106,5272,5273,5276,5278,5280,5283,5285,5287,5289,5292,5294,5296],{"class":108,"line":200},[106,5274,5275],{"class":224},"    data",[106,5277,779],{"class":116},[106,5279,5226],{"class":224},[106,5281,5282],{"class":116},"] ",[106,5284,136],{"class":112},[106,5286,2300],{"class":224},[106,5288,52],{"class":112},[106,5290,5291],{"class":277},"getAll",[106,5293,281],{"class":116},[106,5295,5226],{"class":224},[106,5297,324],{"class":116},[106,5299,5300],{"class":108,"line":221},[106,5301,5302],{"class":270},"    continue\n",[106,5304,5305],{"class":108,"line":228},[106,5306,806],{"class":112},[106,5308,5309,5312,5315,5317,5319,5321,5323,5325,5327,5329],{"class":108,"line":238},[106,5310,5311],{"class":270},"  else",[106,5313,5314],{"class":270}," if",[106,5316,287],{"class":116},[106,5318,5226],{"class":224},[106,5320,5259],{"class":112},[106,5322,425],{"class":112},[106,5324,2234],{"class":142},[106,5326,754],{"class":112},[106,5328,713],{"class":116},[106,5330,488],{"class":112},[106,5332,5333],{"class":108,"line":248},[106,5334,5302],{"class":270},[106,5336,5337],{"class":108,"line":534},[106,5338,806],{"class":112},[106,5340,5341,5343],{"class":108,"line":539},[106,5342,5311],{"class":270},[106,5344,300],{"class":112},[106,5346,5347,5349,5351,5353,5355,5357],{"class":108,"line":556},[106,5348,5275],{"class":224},[106,5350,779],{"class":116},[106,5352,5226],{"class":224},[106,5354,5282],{"class":116},[106,5356,136],{"class":112},[106,5358,5359],{"class":224}," value\n",[106,5361,5362],{"class":108,"line":759},[106,5363,806],{"class":112},[106,5365,5366],{"class":108,"line":795},[106,5367,1558],{"class":112},[54,5369,5371],{"id":5370},"conclusion","Conclusion",[10,5373,5374,5375,5378,5379],{},"And ",[29,5376,5377],{},"voilà","! You are now able to validate forms on both the client and the server without duplicating the validation logic. This is a huge step forward in building a full-stack application with Nuxt. I hope this article will help you to build your next application with confidence. ",[29,5380,5381,5382,5387],{},"You should definitely check the ",[14,5383,5386],{"href":5384,"rel":5385},"https:\u002F\u002Fhub.nuxt.com\u002Fdocs",[18],"NuxtHub documentation"," to learn more about the platform and the power they provide to build full-stack applications with Nuxt.",[10,5389,5390],{},"Please, let me know if you have any questions or feedback. I could make some mistakes, so don't hesitate to correct me. I'm always looking for ways to improve my code and my articles.",[10,5392,5393],{},"Thanks for reading!",[5395,5396,5397],"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":5399},[5400,5401],{"id":56,"depth":123,"text":57},{"id":90,"depth":123,"text":91,"children":5402},[5403],{"id":383,"depth":162,"text":384},"6df6b96b-58fc-4294-8a38-d209f2b36d19","2024-06-07","Discover how to validate forms on both the client and server sides without duplicating the validation logic, from simple text fields to complex file uploads.","md",{},"\u002Fposts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation","---\ncontentId: 6df6b96b-58fc-4294-8a38-d209f2b36d19\ntitle: \"Nuxt Going Full-Stack: How to Streamline Form Validation?\"\ndescription: Discover how to validate forms on both the client and server sides without duplicating the validation logic, from simple text fields to complex file uploads.\ndate: 2024-06-07\n---\n\nSince weeks, I'm building a full-stack application called Orion with Nuxt and [NuxtHub](https:\u002F\u002Fhub.nuxt.com). Throughout this journey, I've been facing a lot of challenges, and one of them is forms validation. In this article, I'll show you how I've managed to **validate forms on both the client and server sides without duplicating** the validation logic, from simple text fields to complex file uploads with NuxtHub.\n\n_Orion is a community-driven collection of templates for your next project,\nfrom landing pages to complete web applications. NuxtHub is deployment and administration platform for Nuxt, powered by Cloudflare._\n\n\u003CCalloutReadMore id=\"c21a371e-abdd-4325-b296-dadcb8deec25\" \u002F>\n\n_During this article, I'll talk about [NuxtHub](https:\u002F\u002Fhub.nuxt.com) because it provides the primitives to build full-stack application, but even if you don't use it, you can easily follow this article. Forms will be handled without any libraries._\n\nYou can browse the example code in \u003CGitHubLink repo=\"barbapapazes\u002Fvalidate-forms-in-nuxt\" \u002F>.\n\n## The problem\n\nOn the web, forms are everywhere. They are the primary way to interact and ask for user input. But on the web, there is an essential rule: **never trust the client** (absolutely never and under no circumstances). This means that **every data** that comes from the client must be **verified** and **sanitized** on the server before being processed or stored in a database. A client can be a browser, a mobile app, an HTTP request, or anything else that can send data to your server, built by you or not.\n\nWhen you build a traditional MPA (Multi-Page Application), only the server is responsible for the validation. For a SPA (Single-Page Application), both the client and the server are responsible for the validation to provide a better user experience. _This is simplified because there is some way to provide a feeling of client-side validation with a traditional MPA, but it's not the subject of this article._ BUT, even if the data is validated on the client side, it is **mandatory** to validate them on the server side.\n\n## The simplest form\n\nLet's start our journey with the simplest form: a single text input.\n\n```vue\n\u003Ctemplate>\n  \u003Cform method=\"post\" action=\"\u002Ftag\">\n    \u003Cinput type=\"text\" placeholder=\"Name\" required>\n    \u003Cbutton type=\"submit\">\n      Create Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nThen, on the server side, I can create a route to handle the form submission:\n\n```ts\nexport default defineEventHandler(async (event) => {\n  const body = await readBody(event)\n\n  \u002F\u002F Save in the database...\n\n  return sendNoContent(event, 201)\n})\n```\n\nUntil now, I don't have any validation. I can send anything to the server, and it will accept it. _This is a huge security issue._\n\n_Remember that the way I'm handling the validation is specific to a SPA. A traditional MPA would have added errors to flash messages and redirect back the request._\n\n### Validating the body\n\nIn order to validate the body on the server side, I'm using [Zod](https:\u002F\u002Fzod.dev). _Zod is a TypeScript-first schema validation with static type inference._ The good part is that h3, the router and web framework that power Nitro, the server layer of Nuxt, provides a utility to validate the body: `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 Save in the database...\n\n  return sendNoContent(event, 201)\n})\n```\n\nThe utility `readValidatedBody` will automatically throw an error if the body doesn't match the schema. For example, if the name is missing, the request will fail.\n\n\u003CCallout icon=\"i-unjs-h3\" text=\"Validate Data using H3\" to=\"https:\u002F\u002Fh3.unjs.io\u002Fexamples\u002Fvalidate-data\" \u002F>\n\nBy default, the error will be handled by the server and throw a 500 error, but I can catch the error to return a more user-friendly error message.\n\nThe first solution is by using the method `safeParse` that will not throw an error but return a result with the error if any.\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: 'Invalid data',\n      data: result.error.errors[0].message,\n    })\n  }\n\n  const body = result.data\n\n  \u002F\u002F Save in the database...\n\n  return sendNoContent(event, 201)\n})\n```\n\nThe second solution is by using the `parseAsync` method to attach a catch to the promise and handle the error manually.\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: 'Invalid data',\n        data: error.errors[0].message,\n      })\n    }\n  }))\n\n  \u002F\u002F Save in the database...\n\n  return sendNoContent(event, 201)\n})\n```\n\n_You could wrap the validator with a `try\u002Fcatch`, but I found it less elegant and readable._\n\n### Validating the frontend\n\nOn the frontend, I can also use Zod to validate the form before sending it to the server.\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: 'Required' }), \u002F\u002F I can customize the error message shown to the user\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: 'Invalid data',\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=\"Name\" required>\n    \u003Cbutton type=\"submit\">\n      Create Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nThe implementation is very simple and straightforward. I could save the errors in a variable to display them to the user. If you want to go further, I recommend you try the `Form` component from [Nuxt UI](https:\u002F\u002Fui.nuxt.com\u002F) or a validation library like [VeeValidate](https:\u002F\u002Fvee-validate.logaretm.com\u002Fv4\u002F) or [FormKit](https:\u002F\u002Fformkit.com\u002F).\n\n## Let's refactor\n\nNow that I have the validation logic on both the client and server sides, let's see if there is some interesting code to refactor.\n\nThe first thing I notice is that the validation logic, on the client and server sides, is the same, and that's totally normal. But what happens if I decide to change the validation logic on the server side because my API is used by another application? My frontend could be broken because the validation logic will be different. To avoid this, it could be better to only write the validator once and use it on both sides. What do you think?\n\nI can create a file `validators.ts` in the `utils` folder of the client part (at the root) of a Nuxt application. This file will contain all the validators for my application and shared between the client and server.\n\n```ts\nimport { z } from 'zod'\n\nexport const createTagValidator = z.object({\n  name: z.string({ message: 'Required' }),\n})\n```\n\n> [!TIP]\n> This file can be used on the server but has been created on the client part so you need to explicitly import everything you need to avoid renderer issues.\n\nThis validator contains the message for the error to help the user understand what's wrong.\n\nThanks to the auto-import feature of Nuxt, I can easily use it on the 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\nThen, you can also use it on the server side by importing it:\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 Save in the database...\n\n  return sendNoContent(event, 201)\n})\n```\n\nNow, I can safely change the validation logic without breaking the frontend since they follow the same rules, and I don't have to duplicate the code.\n\n## Handling files\n\nFor this part, I'll show you two different ways to validate files on the server. The first one will use Zod and some refinements, and the second one will use the `ensureBlob` utility from NuxtHub.\n\n_This validation is not feature complete and should be improved to handle more cases._\n\n### With Zod\n\nFor this second part, I'll show you how to upload a file to the server. First, let's add our input to the form:\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      Create Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nThen, I can handle the file on the server side, but it's a bit more complex because I send a `formData` and h3 does not provide a utility like `readValidatedFormData`. So, I need to handle it manually.\n\nFirst, I will read the `formData` using the `readFormData` utility, then I will extract the `image` from the `formData` and validate it using 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: 'Image must be a file' })\n      .refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' })\n      .refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' })\n      .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n  }).parseAsync({ image }).catch((error) => {\n    throw createError({\n      status: 400,\n      message: 'Invalid image',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Save the image to a database or a file system\n\n  return sendNoContent(event, 201)\n})\n```\n\nOn the client side, I can also validate the file using the same technique as before and by sharing the validator between the client and server sides.\n\nFirst, I need to move the validator to the `utils\u002Fvalidators.ts` file:\n\n```ts\nimport { any, object } from 'zod'\n\nexport const createImageValidator = object({\n  image: any().refine(image => image instanceof File, { message: 'Image must be a file' }).refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' }).refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' }).refine(image => ['image\u002Fjpeg', 'image\u002Fpng'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n})\n```\n\nThen, I can use it when the form is submitted to avoid sending invalid data to the server:\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>Validate Forms with 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        Submit\n      \u003C\u002Fbutton>\n    \u003C\u002Fform>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n```\n\nIf the validation fails, the request will not be sent to the server, and the error will be displayed in the console. Of course, you can handle the error as you want, like using a `toast` or showing an error message under the related input.\n\nOn the server, I can replace the validation logic with the validator:\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### With NuxtHub\n\nNuxtHub comes with a utility named `ensureBlob` that can be used to check the file size and type. This utility is very useful because it will automatically throw an error if the file is not valid, but can only be used on the server. This means that I will have a Zod schema on the client and a NuxtHub utility on the server. _Use constants to share validation limits between the Zod Schema and the NuxtHub utility._\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: '1MB',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Save the image to a database or a file system\n\n  return sendNoContent(event, 201)\n})\n```\n\n## Text and blob within the same form\n\nUntil now, I've only one type of input in my form, text or file. But what if I want to have both in the same form?\n\nYou need to know that you absolutely need to use a `formData` to send the form to the server due to the file input.\n\nThe form is still very 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=\"Name\" required>\n    \u003Cinput type=\"file\" name=\"image\" accept=\"image\u002Fpng, image\u002Fjpeg, image\u002Fjpg\" required>\n    \u003Cbutton type=\"submit\">\n      Create Tag\n    \u003C\u002Fbutton>\n  \u003C\u002Fform>\n\u003C\u002Ftemplate>\n```\n\nNow, I can create a validator using Zod:\n\n```ts\nimport { any, object, string } from 'zod'\n\nexport const createValidator = object({\n  name: string({ message: 'Required' }),\n  image: any()\n    .refine(image => image instanceof File, { message: 'Image must be a file' })\n    .refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' })\n    .refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' })\n    .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n})\n```\n\nThis validator can be used as before, on both the server and client.\n\nIf you prefer to use the `ensureBlob` utility from NuxtHub, I recommend you split the validator into two parts: one for the text and one for the image. Both the text and the image will be used on the client by merging them into a single object, and only the text will be used on the server.\n\n```ts\nimport { any, object, string } from 'zod'\n\nexport const createTextValidator = object({\n  name: string({ message: 'Required' }),\n})\n\nexport const createImageValidator = object({\n  image: any()\n    .refine(image => image instanceof File, { message: 'Image must be a file' })\n    .refine(image => image.size \u003C 1024 * 1024, { message: 'Image must be less than 1MB' })\n    .refine(image => image.type.startsWith('image\u002F'), { message: 'Image must be an image' })\n    .refine(image => ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg'].includes(image.type), { message: 'Image must be a JPEG or PNG' })\n})\n\nexport const createValidator = createTextValidator.merge(createImageValidator)\n```\n\nOn the client, I will use the `createValidator` to validate the form before sending it to the server:\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\nOn the server, I will use the `createTextValidator` to validate the text and the `ensureBlob` utility to validate the 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: 'Invalid data',\n      data: error.errors[0].message\n    })\n  })\n\n  \u002F\u002F Save the text in the database...\n\n  const image = formData.get('image') as Blob\n  ensureBlob(image, {\n    maxSize: '1MB',\n    types: ['image\u002Fpng', 'image\u002Fjpeg', 'image\u002Fjpg']\n  })\n\n  \u002F\u002F Save the image to a database or a file system\n\n  return sendNoContent(event, 201)\n})\n```\n\nFor longer forms, I could use a loop to extract every field from the form and validate them. In this loop, I can handle specific cases like arrays of multi-select fields or files or skipping some fields (that could be validated using `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\nAnd _voilà_! You are now able to validate forms on both the client and the server without duplicating the validation logic. This is a huge step forward in building a full-stack application with Nuxt. I hope this article will help you to build your next application with confidence. _You should definitely check the [NuxtHub documentation](https:\u002F\u002Fhub.nuxt.com\u002Fdocs) to learn more about the platform and the power they provide to build full-stack applications with Nuxt._\n\nPlease, let me know if you have any questions or feedback. I could make some mistakes, so don't hesitate to correct me. I'm always looking for ways to improve my code and my articles.\n\nThanks for reading!\n",null,{"title":5,"description":5406},{"loc":5409},"posts\u002Fnuxt-going-full-stack-how-to-streamline-form-validation","sZN7dZMbuNKRMM9lErEft0QE-mw2moxSubiD2pPyYb8",{"id":5417,"title":5418,"body":5419,"contentId":35,"date":20032,"description":20033,"extension":5407,"meta":20034,"navigation":329,"path":20035,"rawbody":20036,"robots":5411,"schemaOrg":5411,"seo":20037,"sitemap":20038,"stem":20064,"__hash__":20065},"posts\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey.md","Create a Full-Stack Nuxt Application: A Twitch Journey",{"type":7,"value":5420,"toc":20028},[5421,5426,5429,5455,5458,5463,5466,5469,5473,5480,5504,5510,5516,5523,5536,5553,5720,5723,5733,5756,5759,5764,5771,5775,5781,5797,5800,5803,20025],[10,5422,5423],{},[22,5424,5425],{},"I finally did it!",[10,5427,5428],{},"After months of thinking about it, I finally started my first stream on Twitch, the 28th of April 2024. It's a very important step for me because I love sharing my knowledge and talking about what I'm doing.",[3253,5430,5436,5437],{"className":5431},[5432,5433,5434,5435],"grid","grid-cols-1","md:grid-cols-2","gap-4","\n  ",[5438,5439,5436,5440],"calloutfollowmeontwitch",{},[568,5441,5436,5445],{"icon":5442,"text":5443,"href":5444},"i-simple-icons-youtube","View Replays","https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes",[568,5446,5436,5450],{"icon":5447,"text":5448,"href":5449},"i-simple-icons-x","Don't Miss a Thing","https:\u002F\u002Fx.com\u002Fsoubiran_",[568,5451],{"icon":5452,"text":5453,"href":5454},"i-simple-icons-github","The Full-Stack Application","https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion",[10,5456,5457],{},"When I was at school, I organized some lessons for my classmates to explain programming. I also joined a community to explain things about computers, I gave some talks, and recently, I started to write articles on my blog.",[10,5459,5460],{},[22,5461,5462],{},"Twitch is just a new way to continue doing what I love!",[10,5464,5465],{},"For the coming weeks, I'll share with you my journey to build a full-stack Nuxt application, deployed on Cloudflare, called Orion. I'll explain how I built it, the choices I made, and the problems I encountered. My goal through this article is to give you an honest and realistic view of the pros and cons of building a full-stack application on the edge with Nuxt in May 2024 through the process of building a real-world application.",[10,5467,5468],{},"In this article, I'll share with you every step of this first series of streams. If you don't speak French (because streams are in French), if you missed a stream, or if you simply prefer to read, this article is for you!",[54,5470,5472],{"id":5471},"discovering-nuxthub-and-starting-the-full-stack-nuxt-application","Discovering NuxtHub and starting the full-stack Nuxt application",[10,5474,5475],{},[5476,5477],"img",{"alt":5478,"src":5479},"Thumbnail of my first live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-04-28.png",[10,5481,5482],{},[29,5483,5484,5488,5489,5494,5495,5499,5500,52],{},[14,5485,5487],{"href":5454,"rel":5486},[18],"Orion"," is a full-stack application built with Nuxt and developed live ",[14,5490,5493],{"href":5491,"rel":5492},"https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes",[18],"on Twitch",". You can see the replay on ",[14,5496,5498],{"href":5444,"rel":5497},[18],"YouTube"," and the source code is open-source on ",[14,5501,5503],{"href":5454,"rel":5502},[18],"GitHub",[10,5505,5506,5507],{},"This was my first stream, and to be honest, I was a bit stressed. The moment when you have to change the scene from the \"Starting Soon\" to the facecam is hard. But once I started to talk, everything went well. ",[29,5508,5509],{},"However, that doesn't mean it's the best stream you'll ever see. We're going to improve!",[10,5511,5512],{},[5476,5513],{"alt":5514,"src":5515},"Myself, not very comfortable in front of the camera.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-04-28-me.png",[10,5517,5518,5519,5522],{},"First, I introduce ",[14,5520,19],{"href":16,"rel":5521},[18],". It is a platform built by the Nuxt team to facilitate the deployment and management of Nuxt applications deployed on Cloudflare. In a few clicks, you can deploy your Nuxt application on Cloudflare. That's really magical! Thanks to a Nuxt module, you will be able to develop your application locally and then deploy it without tweaking your code. Everything, from the database to the blog storage, works seamlessly in both environments.",[10,5524,5525,5526,5535],{},"I tried to deploy a template from NuxtHub to show how easy it is to use the platform. I chose the Atinotes template. Unfortunately, this application produces a bundle bigger than 1MB, the limit for the free tier, so the deployment fails. Sad for a demonstration. ",[29,5527,5528,5529,5534],{},"After this fail, I was thinking about a way to prevent this and to tell users whether a template requires a paid plan or not. ",[14,5530,5533],{"href":5531,"rel":5532},"https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F13",[18],"I raised an issue"," that was quickly fixed!",". Demo will wait for later.",[10,5537,5538,5539,5546,5547,5552],{},"After this beautiful demo effect, we start coding the full-stack Nuxt application by adding authentication. To do so, we use the module ",[14,5540,5543],{"href":5541,"rel":5542},"https:\u002F\u002Fgithub.com\u002Fatinux\u002Fnuxt-auth-utils",[18],[103,5544,5545],{},"nuxt-auth-utils"," from ",[14,5548,5551],{"href":5549,"rel":5550},"https:\u002F\u002Fgithub.com\u002FAtinux",[18],"Sébastien Chopin",". With a few lines of code, we add authentication. Here is the code we used:",[96,5554,5556],{"className":261,"code":5555,"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,5557,5558,5576,5585,5598,5603,5632,5647,5656,5672,5677,5683,5687,5710,5714],{"__ignoreMap":101},[106,5559,5560,5562,5564,5567,5569,5572,5574],{"class":108,"line":109},[106,5561,271],{"class":270},[106,5563,274],{"class":270},[106,5565,5566],{"class":224}," oauth",[106,5568,52],{"class":112},[106,5570,5571],{"class":277},"githubEventHandler",[106,5573,281],{"class":224},[106,5575,488],{"class":112},[106,5577,5578,5581,5583],{"class":108,"line":123},[106,5579,5580],{"class":116},"  config",[106,5582,496],{"class":112},[106,5584,300],{"class":112},[106,5586,5587,5590,5592,5596],{"class":108,"line":162},[106,5588,5589],{"class":116},"    emailRequired",[106,5591,496],{"class":112},[106,5593,5595],{"class":5594},"sfNiH"," true",[106,5597,509],{"class":112},[106,5599,5600],{"class":108,"line":200},[106,5601,5602],{"class":112},"  },\n",[106,5604,5605,5608,5611,5613,5615,5617,5619,5622,5624,5627,5630],{"class":108,"line":221},[106,5606,5607],{"class":132},"  async",[106,5609,5610],{"class":116}," onSuccess",[106,5612,281],{"class":112},[106,5614,291],{"class":290},[106,5616,355],{"class":112},[106,5618,413],{"class":112},[106,5620,5621],{"class":290}," user",[106,5623,355],{"class":112},[106,5625,5626],{"class":290}," tokens",[106,5628,5629],{"class":112}," })",[106,5631,300],{"class":112},[106,5633,5634,5636,5639,5641,5643,5645],{"class":108,"line":228},[106,5635,1402],{"class":270},[106,5637,5638],{"class":277}," setUserSession",[106,5640,281],{"class":116},[106,5642,291],{"class":224},[106,5644,355],{"class":112},[106,5646,300],{"class":112},[106,5648,5649,5652,5654],{"class":108,"line":238},[106,5650,5651],{"class":116},"      user",[106,5653,496],{"class":112},[106,5655,300],{"class":112},[106,5657,5658,5661,5663,5665,5667,5670],{"class":108,"line":248},[106,5659,5660],{"class":116},"        githubId",[106,5662,496],{"class":112},[106,5664,5621],{"class":224},[106,5666,52],{"class":112},[106,5668,5669],{"class":224},"githubId",[106,5671,509],{"class":112},[106,5673,5674],{"class":108,"line":534},[106,5675,5676],{"class":112},"      },\n",[106,5678,5679,5681],{"class":108,"line":539},[106,5680,798],{"class":112},[106,5682,324],{"class":116},[106,5684,5685],{"class":108,"line":556},[106,5686,330],{"emptyLinePlaceholder":329},[106,5688,5689,5692,5695,5697,5699,5701,5703,5706,5708],{"class":108,"line":759},[106,5690,5691],{"class":270},"    return",[106,5693,5694],{"class":277}," sendRedirect",[106,5696,281],{"class":116},[106,5698,291],{"class":224},[106,5700,355],{"class":112},[106,5702,425],{"class":112},[106,5704,5705],{"class":142},"\u002F",[106,5707,754],{"class":112},[106,5709,324],{"class":116},[106,5711,5712],{"class":108,"line":795},[106,5713,5602],{"class":112},[106,5715,5716,5718],{"class":108,"line":803},[106,5717,366],{"class":112},[106,5719,324],{"class":224},[10,5721,5722],{},"That's really simple and it works like a charm, nicely integrated into a Nuxt application.",[10,5724,5725,5726,5732],{},"To finish the stream, I quickly set up NuxtHub into my application to show how easy it is to play with a database. I tried to use raw SQL, but the recipe to migrate the database was not working. ",[29,5727,5728],{},[14,5729,5533],{"href":5730,"rel":5731},"https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F15",[18]," about it and it's now fixed! After this second fail, I decided to start using Drizzle ORM, a simple but powerful ORM compatible with the workers of Cloudflare. In all cases, I should have to use Drizzle because managing everything with raw SQL is too much work.",[10,5734,5735,5736,5742,5743,5746,5747,5752,5753],{},"During the live, someone asked how to apply migration to the production database. I have to admit that I didn't know the answer and nothing in the documentation was clear about it. ",[29,5737,5738],{},[14,5739,5533],{"href":5740,"rel":5741},"https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F14",[18]," about it that received an answer. ",[29,5744,5745],{},"You have to deploy your app and then start the local dev with a remote connection to run migrations"," Clearly, it's far from being good for the moment, but with the upcoming ",[14,5748,5751],{"href":5749,"rel":5750},"https:\u002F\u002Fnitro.unjs.io\u002Fguide\u002Ftasks",[18],"Nitro tasks",", it should be easier in the near future. ",[29,5754,5755],{},"I really hope.",[10,5757,5758],{},"Globally, I'm so happy to have started this stream. Even if it's far from being good, but getting started is already a big step forward, and I know that future streams will only get easier to start.",[10,5760,5761],{},[29,5762,5763],{},"I was so stressed that I went very fast in my explanations, so I decided that the next stream would be more talkative to re-explain different concepts.",[10,5765,5766,5767],{},"The project is open-source and available on GitHub at ",[49,5768,5770],{"repo":5769},"barbapapazes\u002Forion","! Give it a star if you like it! ⭐",[54,5772,5774],{"id":5773},"discussion-on-cloudflare-nuxthub-and-github-oauth","Discussion on Cloudflare, NuxtHub, and GitHub OAuth",[10,5776,5777],{},[5476,5778],{"alt":5779,"src":5780},"Thumbnail of my second live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-02.png",[10,5782,5783],{},[29,5784,5785,5488,5788,5494,5791,5499,5794,52],{},[14,5786,5487],{"href":5454,"rel":5787},[18],[14,5789,5493],{"href":5491,"rel":5790},[18],[14,5792,5498],{"href":5444,"rel":5793},[18],[14,5795,5503],{"href":5454,"rel":5796},[18],[10,5798,5799],{},"I was frustrated by my first live stream. With the stress, I didn't explain things like what is Cloudflare, what is the edge and how NuxtHub is related to Cloudflare. But I know it's important to explain these concepts since it's new technologies and not everyone knows them.",[10,5801,5802],{},"At StrasbourgJS or Devoxx, I talked about UnJS and problems solved by the ecosystem. Many people, during or after, ask me about the edge. What is it? Why do we need it? Where does it come from?",[33,5804,5806],{"id":5805},"ba562d1f-534c-4cef-9004-2fefc09829a6",[33,5807,5809,5812,5816,5819,5822,5828,5831,5840,5850,5853,5856,5895,5898,5901,5907,5910,5917,5923,5933,5939,5942,5945,5967,5971,5977,5980,5986,6016,6019,6024,6030,6038,6741,6744,6746,6749,6753,6757,6763,6779,6782,6786,6789,6795,6798,6804,6813,6816,6849,6852,6856,6874,6881,6884,6888,6898,6946,6950,6953,6959,6963,6974,7120,7139,7146,7293,7299,7303,7306,7430,7436,7441,7733,7739,7742,7875,7878,8343,8353,8360,8365,8368],{"id":5808},"08852918-9829-4658-97b3-1aabede8e9fe",[10,5810,5811],{},"The goal of my streams is to try this new technology, and with these feedbacks, it was a necessity to explain it.",[381,5813,5815],{"id":5814},"cloudflare-at-a-glance","Cloudflare at a glance",[10,5817,5818],{},"Cloudflare is a company that provides a lot of services to make the web faster and more secure like a zero-trust solution, a WAF, a CDN, a DNS, a DDoS protection, and more for both companies and individuals.",[10,5820,5821],{},"Their most known product is the reverse proxy. Cloudflare will act as the main and single entry point for your traffic. Then, it will route, internally, the traffic to the right server using the DNS. This allows you to hide your IP server, to cache static assets, and to protect your server from DDoS attacks.",[10,5823,5824],{},[5476,5825],{"alt":5826,"src":5827},"Reverse proxy schema.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Freverse-proxy.png",[10,5829,5830],{},"In order to provide these services, they built a network of data centers around the world to be as close as possible to the users to serve cached content faster and to be able to mitigate attacks. It's called the edge network. It is at the frontier between users and the web, for which it is the gateway.",[10,5832,5833,5834,5839],{},"In September 2017, ",[14,5835,5838],{"href":5836,"rel":5837},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-workers",[18],"they announced Cloudflare Workers",". To understand the objective at the time, let's quote the article:",[1830,5841,5842,5845],{},[10,5843,5844],{},"What if you want to load balance with a custom affinity algorithm? What if standard HTTP caching rules aren't quite right, and you need some custom logic to boost your cache hit rate? What if you want to write custom WAF rules tailored for your application?",[10,5846,5847],{},[22,5848,5849],{},"You want to write code",[10,5851,5852],{},"Workers have been released to allow developers to customize the infrastructure to their needs. The Cloudflare Workers solution is built on top of the Service Worker API, a standard in the browser to intercept and modify requests and responses.",[10,5854,5855],{},"Since then, they have built a full capabilities platform for developers around the workers:",[5857,5858,5859,5867,5874,5881,5888],"ul",{},[5860,5861,5862],"li",{},[14,5863,5866],{"href":5864,"rel":5865},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-workers-kv",[18],"Workers KV, serverless key-value store",[5860,5868,5869],{},[14,5870,5873],{"href":5871,"rel":5872},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-d1",[18],"D1, serverless SQL database",[5860,5875,5876],{},[14,5877,5880],{"href":5878,"rel":5879},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-r2-object-storage",[18],"R2, serverless blob storage",[5860,5882,5883],{},[14,5884,5887],{"href":5885,"rel":5886},"https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-queues",[18],"Queues, serverless task queues",[5860,5889,5890],{},[14,5891,5894],{"href":5892,"rel":5893},"https:\u002F\u002Fblog.cloudflare.com\u002Fworkers-ai",[18],"Workers AI, serverless GPU-powered inference",[10,5896,5897],{},"With all these services, it's now (theoretically and that's the purpose of this series to answer the question) possible to build an application entirely on the Cloudflare's global network.",[381,5899,19],{"id":5900},"nuxthub",[10,5902,5903],{},[5476,5904],{"alt":5905,"src":5906},"NuxtHub home page.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-home-page.png",[10,5908,5909],{},"Since the version 3 of Nuxt, the team is pushing the edge. Nitro, the server part of Nuxt, can be deployed everywhere, current and future platform, and is optimized for the edge. H3, the underlying HTTP server wakes up in less than 2ms, and the build output of Nitro is less than 1 MB, node modules included.",[10,5911,5912,5913,5916],{},"NuxtHub is a continuation of this effort. First, it's a platform to deploy Nuxt applications on Cloudflare. It's a two-click deployment. You connect your Cloudflare account because it's built on top of Cloudflare, you choose a template or a repo, you click on deploy, and that's it. ",[29,5914,5915],{},"Your application is live on the edge."," It's feeling like magic.",[10,5918,5919],{},[5476,5920],{"alt":5921,"src":5922},"NuxtHub templates that can be deployed with two clicks.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-deploy.png",[10,5924,5925,5926,52],{},"NuxtHub is also a module for your Nuxt app to add data access capabilities. Locally, you will be able to play instantly with a database, a KV store, and even a blob storage. In production, you do not need to change your code, it will work the same way. This is clearly the true magic of NuxtHub, powered with ",[14,5927,5930],{"href":5928,"rel":5929},"https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworker",[18],[103,5931,5932],{},"workerd",[10,5934,5935],{},[5476,5936],{"alt":5937,"src":5938},"NuxtHub schema (from hub.nuxt.com).","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-schema.png",[10,5940,5941],{},"There is also a feature to allow a local dev environment to interact with the remote data. Not sure about the use case, but it's definitely interesting.",[10,5943,5944],{},"Even if the platform is still new, announced during Vue Amsterdam 2024, it's already very promising. The team is very reactive, and the platform is evolving quickly. I'm very excited to see what the future holds for NuxtHub but have some hints in documentation:",[1830,5946,5947,5950],{},[10,5948,5949],{},"We plan to provide a complete backend experience for Nuxt apps through various @nuxthub packages.",[5857,5951,5952,5955,5958,5961,5964],{},[5860,5953,5954],{},"@nuxthub\u002Fcore: Main package to provide storage features",[5860,5956,5957],{},"@nuxthub\u002Fauth: Add authentication for user management (soon)",[5860,5959,5960],{},"@nuxthub\u002Femail: Send transactional emails to your users (soon)",[5860,5962,5963],{},"@nuxthub\u002Fanalytics: Understand your traffic and track events within your application and API (soon)",[5860,5965,5966],{},"@nuxthub\u002F...: You name it!",[381,5968,5970],{"id":5969},"github-oauth","GitHub OAuth",[10,5972,5973,5974,5976],{},"During the previous live stream, I added authentication, using ",[103,5975,5545],{}," to Orion, the full-stack Nuxt application. I used GitHub OAuth, and I think it's important to see how it works under the hood.",[10,5978,5979],{},"During the live, we jumped into the code, and I explained how it works. Here is a schema to help you understand the process:",[10,5981,5982],{},[5476,5983],{"alt":5984,"src":5985},"GitHub OAuth schema.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Foauth-github.png",[5987,5988,5989,5995,5998,6001,6004,6007,6010,6013],"ol",{},[5860,5990,5991,5992,52],{},"The user clicks on the login button, which points to ",[103,5993,5994],{},"\u002Fauth\u002Fgithub",[5860,5996,5997],{},"The server sends a redirect to the GitHub OAuth page.",[5860,5999,6000],{},"The user accepts the authorization from GitHub.",[5860,6002,6003],{},"GitHub redirects the user back to the application callback URL with a code.",[5860,6005,6006],{},"The server exchanges the code for the tokens.",[5860,6008,6009],{},"The server uses the tokens to get the user.",[5860,6011,6012],{},"The server stores the user in a session.",[5860,6014,6015],{},"The server redirects the user to the home page.",[10,6017,6018],{},"In these steps, the server is Nitro, and we do not talk about the possible errors. There are many possible errors like the user refuses the authorization, the code is invalid, the tokens are invalid, the user is not found, etc. Each error must be handled to provide a good user experience.",[6020,6021,6023],"h4",{"id":6022},"code-of-the-github-oauth-handler","Code of the GitHub OAuth handler",[10,6025,6026,6027,6029],{},"Now, let's dive into the code of ",[103,6028,5545],{}," to see how it works.",[10,6031,6032,6033,52],{},"To simplify, I removed some parts of the code that are not necessary to understand the process, and I focused on the main steps. You can find the full code in the ",[14,6034,6037],{"href":6035,"rel":6036},"https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-auth-utils",[18],"GitHub repository",[96,6039,6041],{"className":261,"code":6040,"language":263,"meta":101,"style":101},"\u002F**\n * GitHub OAuth event handler.\n *\n * _The code is simplified for the article and doesn't work as is._\n *\u002F\nexport function githubEventHandler({ config, onSuccess }: OAuthConfig\u003COAuthGitHubConfig>) {\n  return eventHandler(async (event: H3Event) => {\n    config = { \u002F** ... *\u002F}\n\n    \u002F**\n     * Get the query from the event.\n     *\u002F\n    const query = getQuery(event)\n\n    if (query.error) {\n      \u002F\u002F Handle error\n      return\n    }\n\n    if (!query.code) {\n      \u002F**\n       * Redirect to GitHub OAuth page if there is no code in the query.\n       * If the user accepts the authorization, GitHub will redirect the user back to the application with the code.\n       * The code is the one-time code to exchange for the 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     * Use the code to get the 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     * Use the access token to get the user.\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     * Call the onSuccess callback with the user and the tokens.\n     *\n     * The OAuth is done, and the user can now be authenticated using our system.\n     * With Nitro, we store the user in a session within a cookie.\n     *\u002F\n    return onSuccess(event, {\n      user,\n      tokens,\n    })\n  })\n}\n",[103,6042,6043,6048,6053,6058,6063,6068,6103,6129,6143,6147,6152,6157,6162,6180,6184,6201,6206,6211,6215,6219,6237,6242,6247,6252,6257,6262,6286,6296,6303,6326,6342,6353,6382,6396,6405,6410,6414,6418,6422,6427,6431,6450,6467,6473,6489,6499,6515,6521,6526,6532,6537,6554,6559,6564,6570,6575,6606,6616,6641,6646,6653,6658,6663,6669,6675,6681,6687,6692,6707,6714,6722,6729,6736],{"__ignoreMap":101},[106,6044,6045],{"class":108,"line":109},[106,6046,6047],{"class":335},"\u002F**\n",[106,6049,6050],{"class":108,"line":123},[106,6051,6052],{"class":335}," * GitHub OAuth event handler.\n",[106,6054,6055],{"class":108,"line":162},[106,6056,6057],{"class":335}," *\n",[106,6059,6060],{"class":108,"line":200},[106,6061,6062],{"class":335}," * _The code is simplified for the article and doesn't work as is._\n",[106,6064,6065],{"class":108,"line":221},[106,6066,6067],{"class":335}," *\u002F\n",[106,6069,6070,6072,6074,6077,6080,6083,6085,6087,6090,6093,6095,6098,6101],{"class":108,"line":228},[106,6071,271],{"class":270},[106,6073,1360],{"class":132},[106,6075,6076],{"class":277}," githubEventHandler",[106,6078,6079],{"class":112},"({",[106,6081,6082],{"class":290}," config",[106,6084,355],{"class":112},[106,6086,5610],{"class":290},[106,6088,6089],{"class":112}," }:",[106,6091,6092],{"class":1041}," OAuthConfig",[106,6094,113],{"class":112},[106,6096,6097],{"class":1041},"OAuthGitHubConfig",[106,6099,6100],{"class":112},">)",[106,6102,300],{"class":112},[106,6104,6105,6107,6110,6112,6114,6116,6118,6120,6123,6125,6127],{"class":108,"line":238},[106,6106,345],{"class":270},[106,6108,6109],{"class":277}," eventHandler",[106,6111,281],{"class":116},[106,6113,284],{"class":132},[106,6115,287],{"class":112},[106,6117,291],{"class":290},[106,6119,496],{"class":112},[106,6121,6122],{"class":1041}," H3Event",[106,6124,294],{"class":112},[106,6126,297],{"class":132},[106,6128,300],{"class":112},[106,6130,6131,6134,6136,6138,6141],{"class":108,"line":248},[106,6132,6133],{"class":224},"    config",[106,6135,311],{"class":112},[106,6137,413],{"class":112},[106,6139,6140],{"class":335}," \u002F** ... *\u002F",[106,6142,1558],{"class":112},[106,6144,6145],{"class":108,"line":534},[106,6146,330],{"emptyLinePlaceholder":329},[106,6148,6149],{"class":108,"line":539},[106,6150,6151],{"class":335},"    \u002F**\n",[106,6153,6154],{"class":108,"line":556},[106,6155,6156],{"class":335},"     * Get the query from the event.\n",[106,6158,6159],{"class":108,"line":759},[106,6160,6161],{"class":335},"     *\u002F\n",[106,6163,6164,6166,6169,6171,6174,6176,6178],{"class":108,"line":795},[106,6165,1379],{"class":132},[106,6167,6168],{"class":224}," query",[106,6170,311],{"class":112},[106,6172,6173],{"class":277}," getQuery",[106,6175,281],{"class":116},[106,6177,291],{"class":224},[106,6179,324],{"class":116},[106,6181,6182],{"class":108,"line":803},[106,6183,330],{"emptyLinePlaceholder":329},[106,6185,6186,6188,6190,6193,6195,6197,6199],{"class":108,"line":809},[106,6187,1031],{"class":270},[106,6189,287],{"class":116},[106,6191,6192],{"class":224},"query",[106,6194,52],{"class":112},[106,6196,771],{"class":224},[106,6198,713],{"class":116},[106,6200,488],{"class":112},[106,6202,6203],{"class":108,"line":814},[106,6204,6205],{"class":335},"      \u002F\u002F Handle error\n",[106,6207,6208],{"class":108,"line":830},[106,6209,6210],{"class":270},"      return\n",[106,6212,6213],{"class":108,"line":835},[106,6214,1120],{"class":112},[106,6216,6217],{"class":108,"line":840},[106,6218,330],{"emptyLinePlaceholder":329},[106,6220,6221,6223,6225,6227,6229,6231,6233,6235],{"class":108,"line":845},[106,6222,1031],{"class":270},[106,6224,287],{"class":116},[106,6226,702],{"class":112},[106,6228,6192],{"class":224},[106,6230,52],{"class":112},[106,6232,103],{"class":224},[106,6234,713],{"class":116},[106,6236,488],{"class":112},[106,6238,6239],{"class":108,"line":862},[106,6240,6241],{"class":335},"      \u002F**\n",[106,6243,6244],{"class":108,"line":1469},[106,6245,6246],{"class":335},"       * Redirect to GitHub OAuth page if there is no code in the query.\n",[106,6248,6249],{"class":108,"line":1486},[106,6250,6251],{"class":335},"       * If the user accepts the authorization, GitHub will redirect the user back to the application with the code.\n",[106,6253,6254],{"class":108,"line":1497},[106,6255,6256],{"class":335},"       * The code is the one-time code to exchange for the tokens.\n",[106,6258,6259],{"class":108,"line":1508},[106,6260,6261],{"class":335},"       *\u002F\n",[106,6263,6264,6267,6270,6272,6275,6277,6279,6281,6283],{"class":108,"line":1523},[106,6265,6266],{"class":132},"      const",[106,6268,6269],{"class":224}," redirectUrl",[106,6271,311],{"class":112},[106,6273,6274],{"class":277}," getRequestURL",[106,6276,281],{"class":116},[106,6278,291],{"class":224},[106,6280,294],{"class":116},[106,6282,52],{"class":112},[106,6284,6285],{"class":224},"href\n",[106,6287,6288,6291,6293],{"class":108,"line":1538},[106,6289,6290],{"class":270},"      return",[106,6292,5694],{"class":277},[106,6294,6295],{"class":116},"(\n",[106,6297,6298,6301],{"class":108,"line":1545},[106,6299,6300],{"class":224},"        event",[106,6302,509],{"class":112},[106,6304,6305,6308,6310,6313,6315,6318,6320,6322,6324],{"class":108,"line":1550},[106,6306,6307],{"class":277},"        withQuery",[106,6309,281],{"class":116},[106,6311,6312],{"class":224},"config",[106,6314,52],{"class":112},[106,6316,6317],{"class":224},"authorizationURL",[106,6319,3056],{"class":270},[106,6321,3942],{"class":1041},[106,6323,355],{"class":112},[106,6325,300],{"class":112},[106,6327,6328,6331,6333,6335,6337,6340],{"class":108,"line":1555},[106,6329,6330],{"class":116},"          client_id",[106,6332,496],{"class":112},[106,6334,6082],{"class":224},[106,6336,52],{"class":112},[106,6338,6339],{"class":224},"clientId",[106,6341,509],{"class":112},[106,6343,6344,6347,6349,6351],{"class":108,"line":1561},[106,6345,6346],{"class":116},"          redirect_uri",[106,6348,496],{"class":112},[106,6350,6269],{"class":224},[106,6352,509],{"class":112},[106,6354,6355,6358,6360,6362,6364,6367,6369,6372,6374,6376,6378,6380],{"class":108,"line":1570},[106,6356,6357],{"class":116},"          scope",[106,6359,496],{"class":112},[106,6361,6082],{"class":224},[106,6363,52],{"class":112},[106,6365,6366],{"class":224},"scope",[106,6368,52],{"class":112},[106,6370,6371],{"class":277},"join",[106,6373,281],{"class":116},[106,6375,754],{"class":112},[106,6377,425],{"class":112},[106,6379,294],{"class":116},[106,6381,509],{"class":112},[106,6383,6384,6387,6389,6391,6394],{"class":108,"line":1575},[106,6385,6386],{"class":112},"          ...",[106,6388,6312],{"class":224},[106,6390,52],{"class":112},[106,6392,6393],{"class":224},"authorizationParams",[106,6395,509],{"class":112},[106,6397,6398,6401,6403],{"class":108,"line":1584},[106,6399,6400],{"class":112},"        }",[106,6402,294],{"class":116},[106,6404,509],{"class":112},[106,6406,6407],{"class":108,"line":1605},[106,6408,6409],{"class":116},"      )\n",[106,6411,6412],{"class":108,"line":1648},[106,6413,1120],{"class":112},[106,6415,6416],{"class":108,"line":1667},[106,6417,330],{"emptyLinePlaceholder":329},[106,6419,6420],{"class":108,"line":1672},[106,6421,6151],{"class":335},[106,6423,6424],{"class":108,"line":1681},[106,6425,6426],{"class":335},"     * Use the code to get the tokens.\n",[106,6428,6429],{"class":108,"line":1690},[106,6430,6161],{"class":335},[106,6432,6434,6436,6438,6440,6442,6444,6446,6448],{"class":108,"line":6433},41,[106,6435,1379],{"class":132},[106,6437,5626],{"class":224},[106,6439,496],{"class":112},[106,6441,2252],{"class":1041},[106,6443,311],{"class":112},[106,6445,314],{"class":270},[106,6447,1405],{"class":277},[106,6449,6295],{"class":116},[106,6451,6453,6456,6458,6461,6463,6465],{"class":108,"line":6452},42,[106,6454,6455],{"class":224},"      config",[106,6457,52],{"class":112},[106,6459,6460],{"class":224},"tokenURL",[106,6462,3056],{"class":270},[106,6464,3942],{"class":1041},[106,6466,509],{"class":112},[106,6468,6470],{"class":108,"line":6469},43,[106,6471,6472],{"class":112},"      {\n",[106,6474,6476,6479,6481,6483,6485,6487],{"class":108,"line":6475},44,[106,6477,6478],{"class":116},"        method",[106,6480,496],{"class":112},[106,6482,425],{"class":112},[106,6484,3167],{"class":142},[106,6486,754],{"class":112},[106,6488,509],{"class":112},[106,6490,6492,6495,6497],{"class":108,"line":6491},45,[106,6493,6494],{"class":116},"        body",[106,6496,496],{"class":112},[106,6498,300],{"class":112},[106,6500,6502,6505,6507,6509,6511,6513],{"class":108,"line":6501},46,[106,6503,6504],{"class":116},"          code",[106,6506,496],{"class":112},[106,6508,6168],{"class":224},[106,6510,52],{"class":112},[106,6512,103],{"class":224},[106,6514,509],{"class":112},[106,6516,6518],{"class":108,"line":6517},47,[106,6519,6520],{"class":112},"        },\n",[106,6522,6524],{"class":108,"line":6523},48,[106,6525,5676],{"class":112},[106,6527,6529],{"class":108,"line":6528},49,[106,6530,6531],{"class":116},"    )\n",[106,6533,6535],{"class":108,"line":6534},50,[106,6536,330],{"emptyLinePlaceholder":329},[106,6538,6540,6542,6545,6547,6549,6551],{"class":108,"line":6539},51,[106,6541,1379],{"class":132},[106,6543,6544],{"class":224}," accessToken",[106,6546,311],{"class":112},[106,6548,5626],{"class":224},[106,6550,52],{"class":112},[106,6552,6553],{"class":224},"access_token\n",[106,6555,6557],{"class":108,"line":6556},52,[106,6558,330],{"emptyLinePlaceholder":329},[106,6560,6562],{"class":108,"line":6561},53,[106,6563,6151],{"class":335},[106,6565,6567],{"class":108,"line":6566},54,[106,6568,6569],{"class":335},"     * Use the access token to get the user.\n",[106,6571,6573],{"class":108,"line":6572},55,[106,6574,6161],{"class":335},[106,6576,6578,6580,6582,6584,6586,6588,6590,6593,6595,6597,6600,6602,6604],{"class":108,"line":6577},56,[106,6579,1379],{"class":132},[106,6581,5621],{"class":224},[106,6583,496],{"class":112},[106,6585,2252],{"class":1041},[106,6587,311],{"class":112},[106,6589,314],{"class":270},[106,6591,6592],{"class":277}," ofetch",[106,6594,281],{"class":116},[106,6596,754],{"class":112},[106,6598,6599],{"class":142},"https:\u002F\u002Fapi.github.com\u002Fuser",[106,6601,754],{"class":112},[106,6603,355],{"class":112},[106,6605,300],{"class":112},[106,6607,6609,6612,6614],{"class":108,"line":6608},57,[106,6610,6611],{"class":116},"      headers",[106,6613,496],{"class":112},[106,6615,300],{"class":112},[106,6617,6619,6622,6624,6627,6630,6633,6636,6639],{"class":108,"line":6618},58,[106,6620,6621],{"class":116},"        Authorization",[106,6623,496],{"class":112},[106,6625,6626],{"class":112}," `",[106,6628,6629],{"class":142},"token ",[106,6631,6632],{"class":112},"${",[106,6634,6635],{"class":224},"accessToken",[106,6637,6638],{"class":112},"}`",[106,6640,509],{"class":112},[106,6642,6644],{"class":108,"line":6643},59,[106,6645,5676],{"class":112},[106,6647,6649,6651],{"class":108,"line":6648},60,[106,6650,798],{"class":112},[106,6652,324],{"class":116},[106,6654,6656],{"class":108,"line":6655},61,[106,6657,330],{"emptyLinePlaceholder":329},[106,6659,6661],{"class":108,"line":6660},62,[106,6662,6151],{"class":335},[106,6664,6666],{"class":108,"line":6665},63,[106,6667,6668],{"class":335},"     * Call the onSuccess callback with the user and the tokens.\n",[106,6670,6672],{"class":108,"line":6671},64,[106,6673,6674],{"class":335},"     *\n",[106,6676,6678],{"class":108,"line":6677},65,[106,6679,6680],{"class":335},"     * The OAuth is done, and the user can now be authenticated using our system.\n",[106,6682,6684],{"class":108,"line":6683},66,[106,6685,6686],{"class":335},"     * With Nitro, we store the user in a session within a cookie.\n",[106,6688,6690],{"class":108,"line":6689},67,[106,6691,6161],{"class":335},[106,6693,6695,6697,6699,6701,6703,6705],{"class":108,"line":6694},68,[106,6696,5691],{"class":270},[106,6698,5610],{"class":277},[106,6700,281],{"class":116},[106,6702,291],{"class":224},[106,6704,355],{"class":112},[106,6706,300],{"class":112},[106,6708,6710,6712],{"class":108,"line":6709},69,[106,6711,5651],{"class":224},[106,6713,509],{"class":112},[106,6715,6717,6720],{"class":108,"line":6716},70,[106,6718,6719],{"class":224},"      tokens",[106,6721,509],{"class":112},[106,6723,6725,6727],{"class":108,"line":6724},71,[106,6726,798],{"class":112},[106,6728,324],{"class":116},[106,6730,6732,6734],{"class":108,"line":6731},72,[106,6733,514],{"class":112},[106,6735,324],{"class":116},[106,6737,6739],{"class":108,"line":6738},73,[106,6740,1558],{"class":112},[10,6742,6743],{},"As we can expect, the code is quite simple, and we easily recognize the steps we saw in the schema.",[381,6745,5371],{"id":5370},[10,6747,6748],{},"I didn't code during this live. I wanted to explain many important concepts to help everyone understand the context of the project Orion. I hope it was clear, and that you learned something new.",[10,6750,5766,6751],{},[49,6752,5770],{"repo":5769},[54,6754,6756],{"id":6755},"sql-schema-and-first-blocks-of-orions-admin-interface","SQL schema and first blocks of Orion's admin interface",[10,6758,6759],{},[5476,6760],{"alt":6761,"src":6762},"Thumbnail of my third live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-03.png",[10,6764,6765],{},[29,6766,6767,5488,6770,5494,6773,5499,6776,52],{},[14,6768,5487],{"href":5454,"rel":6769},[18],[14,6771,5493],{"href":5491,"rel":6772},[18],[14,6774,5498],{"href":5444,"rel":6775},[18],[14,6777,5503],{"href":5454,"rel":6778},[18],[10,6780,6781],{},"In this third stream, I created the SQL schema for the database. I then set up Nuxt UI and Nuxt UI Pro to create the admin layout and the first form for categories. This article is a summary of the stream and the work done.",[381,6783,6785],{"id":6784},"sql-schema","SQL schema",[10,6787,6788],{},"What is a SQL schema and why did I create the SQL schema? These are two good questions.",[10,6790,6791,6792],{},"A SQL schema is a way to organize the database. It is a blueprint of how the database will be structured. So it's very useful to think about it before starting to code. It does not need to be perfect, but helps to iterate on the database structure, avoiding errors, and to have a clear vision of the project. ",[29,6793,6794],{},"I personally think it's a must-have when starting a new project.",[10,6796,6797],{},"So, before writing any code for the project Orion, I created the SQL schema.",[10,6799,6800],{},[5476,6801],{"alt":6802,"src":6803},"SQL Schema","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fsql-schema.png",[10,6805,6806,6807,6812],{},"To build this schema, I used ",[14,6808,6811],{"href":6809,"rel":6810},"https:\u002F\u002Fdrawsql.app\u002F",[18],"drawSQL",". It's a web app to create SQL schema with a nice UI. It's very easy to use and free for small projects. I recommend it.",[10,6814,6815],{},"The database for the project Orion, the full-stack Nuxt application, is simple and common for this type of project.",[5857,6817,6818,6825,6831,6837,6843],{},[5860,6819,6820,6821,6824],{},"A ",[103,6822,6823],{},"users"," table to store the users of the application. Storing users is useful for the admin interface, to ban a user, or to give admin rights.",[5860,6826,6820,6827,6830],{},[103,6828,6829],{},"categories"," table to store the categories of the theme like blog, e-commerce, documentation, etc. It's a simple table with a name and a slug. The slug is used for the URL of the category.",[5860,6832,6820,6833,6836],{},[103,6834,6835],{},"modules"," table to store the modules of the theme. The table will have a name, a slug, and a type (official, community). This will be useful to filter the themes by modules.",[5860,6838,6820,6839,6842],{},[103,6840,6841],{},"themes"," table to store the main content of the application. It will have a lot of columns but we will see that in another stream.",[5860,6844,6820,6845,6848],{},[103,6846,6847],{},"feedbacks"," table to store the feedbacks from admins about a theme. Indeed, a theme must be reviewed before being published and the feedback will be used to tell the creator what to improve.",[10,6850,6851],{},"This schema is a good start to clarify things and to know where to get started but I already know that it will evolve.",[381,6853,6855],{"id":6854},"nuxt-ui-and-nuxt-ui-pro","Nuxt UI and Nuxt UI Pro",[10,6857,6858,6859,6864,6865,6870,6871],{},"Nuxt UI is a component library made by the Nuxt team. Initially, it was an internal tool for their own projects like ",[14,6860,6863],{"href":6861,"rel":6862},"https:\u002F\u002Fvolta.net",[18],"Volta",". In May 2023, they decided to ",[14,6866,6869],{"href":6867,"rel":6868},"https:\u002F\u002Fgithub.com\u002Fnuxt\u002Fui\u002Freleases\u002Ftag\u002Fv2.0.0",[18],"open-source it"," and to make it available for everyone for free. ",[29,6872,6873],{},"Today, Nuxt UI is my go-to for building user interfaces with Nuxt because it's simple, beautiful but yet highly customizable.",[10,6875,6876,6877,6880],{},"Nuxt UI Pro is built on top of Nuxt UI and provides layouts related components like a page, a hero, a header and complete layouts like a dashboard, a blog, a documentation, etc. It's a paid product, but it's worth it. ",[29,6878,6879],{},"I'm using it for Orion, the full-stack application, because it's a time saver and it's beautiful."," The better part is that it's free during development (the build process will require a key), so you can try it before buying it.",[10,6882,6883],{},"During the stream, I start by installing Nuxt UI and Nuxt UI Pro. I then create the layout for the admin interface and the page for the categories. Let's see the code.",[381,6885,6887],{"id":6886},"admin-layout","Admin layout",[10,6889,6890,6891,6894,6895,52],{},"With Nuxt UI Pro, this part was very easy. There is no navbar, nor sidebar for the moment. ",[29,6892,6893],{},"The goal is to add features and components step by step, when needed, and to avoid to anticipate too much."," Here is the code for the layout ",[103,6896,6897],{},"layouts\u002Fadmin.vue",[96,6899,6901],{"className":98,"code":6900,"language":100,"meta":101,"style":101},"\u003Ctemplate>\n  \u003CUDashboardLayout>\n    \u003Cslot \u002F>\n  \u003C\u002FUDashboardLayout>\n\u003C\u002Ftemplate>\n",[103,6902,6903,6911,6920,6930,6938],{"__ignoreMap":101},[106,6904,6905,6907,6909],{"class":108,"line":109},[106,6906,113],{"class":112},[106,6908,117],{"class":116},[106,6910,120],{"class":112},[106,6912,6913,6915,6918],{"class":108,"line":123},[106,6914,126],{"class":112},[106,6916,6917],{"class":116},"UDashboardLayout",[106,6919,120],{"class":112},[106,6921,6922,6924,6927],{"class":108,"line":162},[106,6923,165],{"class":112},[106,6925,6926],{"class":116},"slot",[106,6928,6929],{"class":112}," \u002F>\n",[106,6931,6932,6934,6936],{"class":108,"line":200},[106,6933,241],{"class":112},[106,6935,6917],{"class":116},[106,6937,120],{"class":112},[106,6939,6940,6942,6944],{"class":108,"line":221},[106,6941,251],{"class":112},[106,6943,117],{"class":116},[106,6945,120],{"class":112},[381,6947,6949],{"id":6948},"categories-page","Categories page",[10,6951,6952],{},"Before developing the view, I create the API endpoint to get and update the categories.",[10,6954,6955],{},[5476,6956],{"alt":6957,"src":6958},"Result of the Layout and Page for Categories.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Flayout-and-page-for-categories.png",[6020,6960,6962],{"id":6961},"categories-api","Categories API",[10,6964,6965,6966,6969,6970,6973],{},"Thanks to Nitro and Drizzle (setup in the first stream), it is very easy. In the file ",[103,6967,6968],{},"server\u002Fapi\u002Fcategories\u002Findex.get.ts"," (the ",[103,6971,6972],{},".get."," is used to specify the HTTP verb), I wrote the following code:",[96,6975,6977],{"className":261,"code":6976,"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,6978,6979,6998,7023,7044,7064,7081,7103,7107,7114],{"__ignoreMap":101},[106,6980,6981,6983,6985,6987,6989,6991,6994,6996],{"class":108,"line":109},[106,6982,271],{"class":270},[106,6984,274],{"class":270},[106,6986,278],{"class":277},[106,6988,281],{"class":224},[106,6990,284],{"class":132},[106,6992,6993],{"class":112}," ()",[106,6995,297],{"class":132},[106,6997,300],{"class":112},[106,6999,7000,7002,7005,7007,7009,7012,7014,7016,7019,7021],{"class":108,"line":123},[106,7001,305],{"class":132},[106,7003,7004],{"class":224}," categories",[106,7006,311],{"class":112},[106,7008,314],{"class":270},[106,7010,7011],{"class":277}," useDrizzle",[106,7013,506],{"class":116},[106,7015,52],{"class":112},[106,7017,7018],{"class":277},"select",[106,7020,281],{"class":116},[106,7022,488],{"class":112},[106,7024,7025,7028,7030,7033,7035,7037,7039,7042],{"class":108,"line":162},[106,7026,7027],{"class":116},"    id",[106,7029,496],{"class":112},[106,7031,7032],{"class":224}," tables",[106,7034,52],{"class":112},[106,7036,6829],{"class":224},[106,7038,52],{"class":112},[106,7040,7041],{"class":224},"id",[106,7043,509],{"class":112},[106,7045,7046,7049,7051,7053,7055,7057,7059,7062],{"class":108,"line":200},[106,7047,7048],{"class":116},"    slug",[106,7050,496],{"class":112},[106,7052,7032],{"class":224},[106,7054,52],{"class":112},[106,7056,6829],{"class":224},[106,7058,52],{"class":112},[106,7060,7061],{"class":224},"slug",[106,7063,509],{"class":112},[106,7065,7066,7068,7070,7072,7074,7076,7078],{"class":108,"line":221},[106,7067,493],{"class":116},[106,7069,496],{"class":112},[106,7071,7032],{"class":224},[106,7073,52],{"class":112},[106,7075,6829],{"class":224},[106,7077,52],{"class":112},[106,7079,7080],{"class":224},"name\n",[106,7082,7083,7085,7087,7089,7092,7094,7097,7099,7101],{"class":108,"line":228},[106,7084,514],{"class":112},[106,7086,294],{"class":116},[106,7088,52],{"class":112},[106,7090,7091],{"class":277},"from",[106,7093,281],{"class":116},[106,7095,7096],{"class":224},"tables",[106,7098,52],{"class":112},[106,7100,6829],{"class":224},[106,7102,324],{"class":116},[106,7104,7105],{"class":108,"line":238},[106,7106,330],{"emptyLinePlaceholder":329},[106,7108,7109,7111],{"class":108,"line":248},[106,7110,345],{"class":270},[106,7112,7113],{"class":224}," categories\n",[106,7115,7116,7118],{"class":108,"line":534},[106,7117,366],{"class":112},[106,7119,324],{"class":224},[10,7121,7122,7123,7126,7127,7129,7130,7132,7133,7135,7136],{},"The ",[103,7124,7125],{},"useDrizzle"," utils is a wrapper around the Drizzle ORM. This code will return all the categories with only the ",[103,7128,7041],{},", ",[103,7131,7061],{},", and ",[103,7134,3815],{}," fields. ",[29,7137,7138],{},"It's a good practice to only return the fields you need to avoid sending too much data.",[10,7140,7141,7142,7145],{},"Then, I created the endpoint to create a new category, and it's the same process. I created the file ",[103,7143,7144],{},"server\u002Fapi\u002Fcategories\u002Findex.post.ts"," with the following code:",[96,7147,7149],{"className":261,"code":7148,"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,7150,7151,7173,7191,7195,7227,7249,7263,7276,7280,7287],{"__ignoreMap":101},[106,7152,7153,7155,7157,7159,7161,7163,7165,7167,7169,7171],{"class":108,"line":109},[106,7154,271],{"class":270},[106,7156,274],{"class":270},[106,7158,278],{"class":277},[106,7160,281],{"class":224},[106,7162,284],{"class":132},[106,7164,287],{"class":112},[106,7166,291],{"class":290},[106,7168,294],{"class":112},[106,7170,297],{"class":132},[106,7172,300],{"class":112},[106,7174,7175,7177,7179,7181,7183,7185,7187,7189],{"class":108,"line":123},[106,7176,305],{"class":132},[106,7178,308],{"class":224},[106,7180,311],{"class":112},[106,7182,314],{"class":270},[106,7184,317],{"class":277},[106,7186,281],{"class":116},[106,7188,291],{"class":224},[106,7190,324],{"class":116},[106,7192,7193],{"class":108,"line":162},[106,7194,330],{"emptyLinePlaceholder":329},[106,7196,7197,7199,7201,7203,7205,7208,7210,7212,7214,7216,7218,7220,7223,7225],{"class":108,"line":200},[106,7198,2348],{"class":270},[106,7200,7011],{"class":277},[106,7202,506],{"class":116},[106,7204,52],{"class":112},[106,7206,7207],{"class":277},"insert",[106,7209,281],{"class":116},[106,7211,7096],{"class":224},[106,7213,52],{"class":112},[106,7215,6829],{"class":224},[106,7217,294],{"class":116},[106,7219,52],{"class":112},[106,7221,7222],{"class":277},"values",[106,7224,281],{"class":116},[106,7226,488],{"class":112},[106,7228,7229,7231,7233,7236,7238,7241,7243,7245,7247],{"class":108,"line":221},[106,7230,7048],{"class":116},[106,7232,496],{"class":112},[106,7234,7235],{"class":277}," useSlugify",[106,7237,281],{"class":116},[106,7239,7240],{"class":224},"body",[106,7242,52],{"class":112},[106,7244,3815],{"class":224},[106,7246,294],{"class":116},[106,7248,509],{"class":112},[106,7250,7251,7253,7255,7257,7259,7261],{"class":108,"line":228},[106,7252,493],{"class":116},[106,7254,496],{"class":112},[106,7256,308],{"class":224},[106,7258,52],{"class":112},[106,7260,3815],{"class":224},[106,7262,509],{"class":112},[106,7264,7265,7267,7269,7271,7274],{"class":108,"line":238},[106,7266,514],{"class":112},[106,7268,294],{"class":116},[106,7270,52],{"class":112},[106,7272,7273],{"class":277},"execute",[106,7275,2366],{"class":116},[106,7277,7278],{"class":108,"line":248},[106,7279,330],{"emptyLinePlaceholder":329},[106,7281,7282,7284],{"class":108,"line":534},[106,7283,345],{"class":270},[106,7285,7286],{"class":224}," body\n",[106,7288,7289,7291],{"class":108,"line":539},[106,7290,366],{"class":112},[106,7292,324],{"class":224},[10,7294,7295,7296,52],{},"In this code, there is no security check, and every user can create a category. It's clearly not a use case for our application, but the bouncer will be added later. In the same time, I did not verify the body. It's important to validate data received from a client to avoid security issues. ",[22,7297,7298],{},"It will be the subject for the next stream",[6020,7300,7302],{"id":7301},"categories-content","Categories content",[10,7304,7305],{},"The page for the categories is built with Nuxt UI Pro, which makes the code very simple.",[96,7307,7309],{"className":98,"code":7308,"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!-- Content -->\n    \u003C\u002FUDashboardPanel>\n  \u003C\u002FUDashboardPage>\n\u003C\u002Ftemplate>\n",[103,7310,7311,7331,7340,7354,7360,7368,7372,7380,7389,7401,7406,7414,7422],{"__ignoreMap":101},[106,7312,7313,7315,7317,7319,7321,7323,7325,7327,7329],{"class":108,"line":109},[106,7314,113],{"class":112},[106,7316,1189],{"class":116},[106,7318,1203],{"class":132},[106,7320,1192],{"class":132},[106,7322,136],{"class":112},[106,7324,139],{"class":112},[106,7326,263],{"class":142},[106,7328,139],{"class":112},[106,7330,120],{"class":112},[106,7332,7333,7336,7338],{"class":108,"line":123},[106,7334,7335],{"class":277},"definePageMeta",[106,7337,281],{"class":224},[106,7339,488],{"class":112},[106,7341,7342,7345,7347,7349,7352],{"class":108,"line":162},[106,7343,7344],{"class":116},"  layout",[106,7346,496],{"class":112},[106,7348,425],{"class":112},[106,7350,7351],{"class":142},"admin",[106,7353,431],{"class":112},[106,7355,7356,7358],{"class":108,"line":200},[106,7357,366],{"class":112},[106,7359,324],{"class":224},[106,7361,7362,7364,7366],{"class":108,"line":221},[106,7363,251],{"class":112},[106,7365,1189],{"class":116},[106,7367,120],{"class":112},[106,7369,7370],{"class":108,"line":228},[106,7371,330],{"emptyLinePlaceholder":329},[106,7373,7374,7376,7378],{"class":108,"line":238},[106,7375,113],{"class":112},[106,7377,117],{"class":116},[106,7379,120],{"class":112},[106,7381,7382,7384,7387],{"class":108,"line":248},[106,7383,126],{"class":112},[106,7385,7386],{"class":116},"UDashboardPage",[106,7388,120],{"class":112},[106,7390,7391,7393,7396,7399],{"class":108,"line":534},[106,7392,165],{"class":112},[106,7394,7395],{"class":116},"UDashboardPanel",[106,7397,7398],{"class":132}," grow",[106,7400,120],{"class":112},[106,7402,7403],{"class":108,"line":539},[106,7404,7405],{"class":335},"      \u003C!-- Content -->\n",[106,7407,7408,7410,7412],{"class":108,"line":556},[106,7409,231],{"class":112},[106,7411,7395],{"class":116},[106,7413,120],{"class":112},[106,7415,7416,7418,7420],{"class":108,"line":759},[106,7417,241],{"class":112},[106,7419,7386],{"class":116},[106,7421,120],{"class":112},[106,7423,7424,7426,7428],{"class":108,"line":795},[106,7425,251],{"class":112},[106,7427,117],{"class":116},[106,7429,120],{"class":112},[10,7431,7432,7433,7435],{},"Within the ",[103,7434,7395],{},", we can add a header with the page title and a button to create a new category and a table to display the categories.",[10,7437,7438,7439,496],{},"First, let's add the table in the ",[103,7440,7395],{},[96,7442,7444],{"className":98,"code":7443,"language":100,"meta":101,"style":101},"\u003Cscript setup lang=\"ts\">\nconst columns = [{\n  key: 'id',\n  label: '#'\n}, {\n  key: 'name',\n  label: 'Name',\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,7445,7446,7466,7479,7494,7508,7515,7529,7543,7553,7559,7573,7588,7594,7598,7637,7649,7655,7663,7667,7675,7725],{"__ignoreMap":101},[106,7447,7448,7450,7452,7454,7456,7458,7460,7462,7464],{"class":108,"line":109},[106,7449,113],{"class":112},[106,7451,1189],{"class":116},[106,7453,1203],{"class":132},[106,7455,1192],{"class":132},[106,7457,136],{"class":112},[106,7459,139],{"class":112},[106,7461,263],{"class":142},[106,7463,139],{"class":112},[106,7465,120],{"class":112},[106,7467,7468,7470,7473,7475,7477],{"class":108,"line":123},[106,7469,1252],{"class":132},[106,7471,7472],{"class":224}," columns ",[106,7474,136],{"class":112},[106,7476,2524],{"class":224},[106,7478,488],{"class":112},[106,7480,7481,7484,7486,7488,7490,7492],{"class":108,"line":162},[106,7482,7483],{"class":116},"  key",[106,7485,496],{"class":112},[106,7487,425],{"class":112},[106,7489,7041],{"class":142},[106,7491,754],{"class":112},[106,7493,509],{"class":112},[106,7495,7496,7499,7501,7503,7506],{"class":108,"line":200},[106,7497,7498],{"class":116},"  label",[106,7500,496],{"class":112},[106,7502,425],{"class":112},[106,7504,7505],{"class":142},"#",[106,7507,431],{"class":112},[106,7509,7510,7513],{"class":108,"line":221},[106,7511,7512],{"class":112},"},",[106,7514,300],{"class":112},[106,7516,7517,7519,7521,7523,7525,7527],{"class":108,"line":228},[106,7518,7483],{"class":116},[106,7520,496],{"class":112},[106,7522,425],{"class":112},[106,7524,3815],{"class":142},[106,7526,754],{"class":112},[106,7528,509],{"class":112},[106,7530,7531,7533,7535,7537,7539,7541],{"class":108,"line":238},[106,7532,7498],{"class":116},[106,7534,496],{"class":112},[106,7536,425],{"class":112},[106,7538,190],{"class":142},[106,7540,754],{"class":112},[106,7542,509],{"class":112},[106,7544,7545,7548,7550],{"class":108,"line":248},[106,7546,7547],{"class":116},"  sortable",[106,7549,496],{"class":112},[106,7551,7552],{"class":5594}," true\n",[106,7554,7555,7557],{"class":108,"line":534},[106,7556,7512],{"class":112},[106,7558,300],{"class":112},[106,7560,7561,7563,7565,7567,7569,7571],{"class":108,"line":539},[106,7562,7483],{"class":116},[106,7564,496],{"class":112},[106,7566,425],{"class":112},[106,7568,7061],{"class":142},[106,7570,754],{"class":112},[106,7572,509],{"class":112},[106,7574,7575,7577,7579,7581,7584,7586],{"class":108,"line":556},[106,7576,7498],{"class":116},[106,7578,496],{"class":112},[106,7580,425],{"class":112},[106,7582,7583],{"class":142},"Slug",[106,7585,754],{"class":112},[106,7587,509],{"class":112},[106,7589,7590,7592],{"class":108,"line":759},[106,7591,366],{"class":112},[106,7593,3682],{"class":224},[106,7595,7596],{"class":108,"line":795},[106,7597,330],{"emptyLinePlaceholder":329},[106,7599,7600,7602,7604,7606,7608,7610,7612,7615,7617,7619,7621,7624,7626,7628,7631,7633,7635],{"class":108,"line":803},[106,7601,1252],{"class":132},[106,7603,413],{"class":112},[106,7605,964],{"class":116},[106,7607,496],{"class":112},[106,7609,7004],{"class":224},[106,7611,355],{"class":112},[106,7613,7614],{"class":224}," pending ",[106,7616,366],{"class":112},[106,7618,311],{"class":112},[106,7620,314],{"class":270},[106,7622,7623],{"class":277}," useFetch",[106,7625,281],{"class":224},[106,7627,754],{"class":112},[106,7629,7630],{"class":142},"\u002Fapi\u002Fcategories",[106,7632,754],{"class":112},[106,7634,355],{"class":112},[106,7636,300],{"class":112},[106,7638,7639,7642,7644,7647],{"class":108,"line":809},[106,7640,7641],{"class":116},"  deep",[106,7643,496],{"class":112},[106,7645,7646],{"class":5594}," false",[106,7648,509],{"class":112},[106,7650,7651,7653],{"class":108,"line":814},[106,7652,366],{"class":112},[106,7654,324],{"class":224},[106,7656,7657,7659,7661],{"class":108,"line":830},[106,7658,251],{"class":112},[106,7660,1189],{"class":116},[106,7662,120],{"class":112},[106,7664,7665],{"class":108,"line":835},[106,7666,330],{"emptyLinePlaceholder":329},[106,7668,7669,7671,7673],{"class":108,"line":840},[106,7670,113],{"class":112},[106,7672,117],{"class":116},[106,7674,120],{"class":112},[106,7676,7677,7679,7682,7685,7688,7690,7692,7694,7696,7698,7701,7703,7705,7707,7709,7711,7714,7716,7718,7721,7723],{"class":108,"line":845},[106,7678,126],{"class":112},[106,7680,7681],{"class":116},"UTable",[106,7683,7684],{"class":112}," :",[106,7686,7687],{"class":132},"columns",[106,7689,136],{"class":112},[106,7691,139],{"class":112},[106,7693,7687],{"class":224},[106,7695,139],{"class":112},[106,7697,7684],{"class":112},[106,7699,7700],{"class":132},"rows",[106,7702,136],{"class":112},[106,7704,139],{"class":112},[106,7706,6829],{"class":224},[106,7708,139],{"class":112},[106,7710,7684],{"class":112},[106,7712,7713],{"class":132},"loading",[106,7715,136],{"class":112},[106,7717,139],{"class":112},[106,7719,7720],{"class":224},"pending",[106,7722,139],{"class":112},[106,7724,6929],{"class":112},[106,7726,7727,7729,7731],{"class":108,"line":862},[106,7728,251],{"class":112},[106,7730,117],{"class":116},[106,7732,120],{"class":112},[10,7734,7735,7736],{},"It's hard to be simpler! ",[29,7737,7738],{},"Thanks Nuxt UI for the work!",[10,7740,7741],{},"Then, I add the header with the title and the button to create a new category:",[96,7743,7745],{"className":98,"code":7744,"language":100,"meta":101,"style":101},"\u003Ctemplate>\n  \u003CUDashboardNavbar\n    title=\"Categories\"\n  >\n    \u003Ctemplate #right>\n      \u003CUButton\n        label=\"New category\"\n        trailing-icon=\"i-heroicons-plus\"\n        color=\"gray\"\n      \u002F>\n    \u003C\u002Ftemplate>\n  \u003C\u002FUDashboardNavbar>\n\u003C\u002Ftemplate>\n",[103,7746,7747,7755,7762,7777,7782,7796,7803,7817,7831,7845,7850,7858,7867],{"__ignoreMap":101},[106,7748,7749,7751,7753],{"class":108,"line":109},[106,7750,113],{"class":112},[106,7752,117],{"class":116},[106,7754,120],{"class":112},[106,7756,7757,7759],{"class":108,"line":123},[106,7758,126],{"class":112},[106,7760,7761],{"class":116},"UDashboardNavbar\n",[106,7763,7764,7767,7769,7771,7774],{"class":108,"line":162},[106,7765,7766],{"class":132},"    title",[106,7768,136],{"class":112},[106,7770,139],{"class":112},[106,7772,7773],{"class":142},"Categories",[106,7775,7776],{"class":112},"\"\n",[106,7778,7779],{"class":108,"line":200},[106,7780,7781],{"class":112},"  >\n",[106,7783,7784,7786,7788,7791,7794],{"class":108,"line":221},[106,7785,165],{"class":112},[106,7787,117],{"class":116},[106,7789,7790],{"class":112}," #",[106,7792,7793],{"class":132},"right",[106,7795,120],{"class":112},[106,7797,7798,7800],{"class":108,"line":228},[106,7799,3333],{"class":112},[106,7801,7802],{"class":116},"UButton\n",[106,7804,7805,7808,7810,7812,7815],{"class":108,"line":238},[106,7806,7807],{"class":132},"        label",[106,7809,136],{"class":112},[106,7811,139],{"class":112},[106,7813,7814],{"class":142},"New category",[106,7816,7776],{"class":112},[106,7818,7819,7822,7824,7826,7829],{"class":108,"line":248},[106,7820,7821],{"class":132},"        trailing-icon",[106,7823,136],{"class":112},[106,7825,139],{"class":112},[106,7827,7828],{"class":142},"i-heroicons-plus",[106,7830,7776],{"class":112},[106,7832,7833,7836,7838,7840,7843],{"class":108,"line":534},[106,7834,7835],{"class":132},"        color",[106,7837,136],{"class":112},[106,7839,139],{"class":112},[106,7841,7842],{"class":142},"gray",[106,7844,7776],{"class":112},[106,7846,7847],{"class":108,"line":539},[106,7848,7849],{"class":112},"      \u002F>\n",[106,7851,7852,7854,7856],{"class":108,"line":556},[106,7853,231],{"class":112},[106,7855,117],{"class":116},[106,7857,120],{"class":112},[106,7859,7860,7862,7865],{"class":108,"line":759},[106,7861,241],{"class":112},[106,7863,7864],{"class":116},"UDashboardNavbar",[106,7866,120],{"class":112},[106,7868,7869,7871,7873],{"class":108,"line":795},[106,7870,251],{"class":112},[106,7872,117],{"class":116},[106,7874,120],{"class":112},[10,7876,7877],{},"Still very easy. The button will be used to open a modal with a form to create a new category. I started to create the form but in a very simple way:",[96,7879,7881],{"className":98,"code":7880,"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: 'Required' }),\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=\"Name\" name=\"name\">\n      \u003CUInput v-model=\"state.name\" \u002F>\n    \u003C\u002FUFormGroup>\n\n    \u003CUButton type=\"submit\">\n      Submit\n    \u003C\u002FUButton>\n  \u003C\u002FUForm>\n\u003C\u002Ftemplate>\n",[103,7882,7883,7903,7925,7946,7968,7972,7986,8014,8020,8024,8042,8046,8060,8069,8075,8079,8104,8122,8137,8152,8158,8162,8170,8174,8182,8230,8260,8283,8291,8295,8314,8319,8327,8335],{"__ignoreMap":101},[106,7884,7885,7887,7889,7891,7893,7895,7897,7899,7901],{"class":108,"line":109},[106,7886,113],{"class":112},[106,7888,1189],{"class":116},[106,7890,1192],{"class":132},[106,7892,136],{"class":112},[106,7894,139],{"class":112},[106,7896,263],{"class":142},[106,7898,139],{"class":112},[106,7900,1203],{"class":132},[106,7902,120],{"class":112},[106,7904,7905,7907,7909,7911,7914,7916,7918,7920,7923],{"class":108,"line":123},[106,7906,410],{"class":270},[106,7908,171],{"class":270},[106,7910,413],{"class":112},[106,7912,7913],{"class":224}," FormSubmitEvent",[106,7915,419],{"class":112},[106,7917,422],{"class":270},[106,7919,425],{"class":112},[106,7921,7922],{"class":142},"#ui\u002Ftypes",[106,7924,431],{"class":112},[106,7926,7927,7929,7931,7933,7936,7938,7940,7942,7944],{"class":108,"line":162},[106,7928,410],{"class":270},[106,7930,171],{"class":270},[106,7932,413],{"class":112},[106,7934,7935],{"class":224}," output",[106,7937,419],{"class":112},[106,7939,422],{"class":270},[106,7941,425],{"class":112},[106,7943,428],{"class":142},[106,7945,431],{"class":112},[106,7947,7948,7950,7952,7954,7956,7958,7960,7962,7964,7966],{"class":108,"line":200},[106,7949,410],{"class":270},[106,7951,413],{"class":112},[106,7953,2257],{"class":224},[106,7955,355],{"class":112},[106,7957,3942],{"class":224},[106,7959,419],{"class":112},[106,7961,422],{"class":270},[106,7963,425],{"class":112},[106,7965,428],{"class":142},[106,7967,431],{"class":112},[106,7969,7970],{"class":108,"line":221},[106,7971,330],{"emptyLinePlaceholder":329},[106,7973,7974,7976,7978,7980,7982,7984],{"class":108,"line":228},[106,7975,1252],{"class":132},[106,7977,1255],{"class":224},[106,7979,136],{"class":112},[106,7981,2257],{"class":277},[106,7983,281],{"class":224},[106,7985,488],{"class":112},[106,7987,7988,7990,7992,7994,7996,7998,8000,8002,8004,8006,8008,8010,8012],{"class":108,"line":238},[106,7989,1272],{"class":116},[106,7991,496],{"class":112},[106,7993,3942],{"class":277},[106,7995,281],{"class":224},[106,7997,1285],{"class":112},[106,7999,1288],{"class":116},[106,8001,496],{"class":112},[106,8003,425],{"class":112},[106,8005,1295],{"class":142},[106,8007,754],{"class":112},[106,8009,419],{"class":112},[106,8011,294],{"class":224},[106,8013,509],{"class":112},[106,8015,8016,8018],{"class":108,"line":248},[106,8017,366],{"class":112},[106,8019,324],{"class":224},[106,8021,8022],{"class":108,"line":534},[106,8023,330],{"emptyLinePlaceholder":329},[106,8025,8026,8028,8031,8033,8035,8038,8040],{"class":108,"line":539},[106,8027,2474],{"class":132},[106,8029,8030],{"class":1041}," Schema",[106,8032,311],{"class":112},[106,8034,7935],{"class":1041},[106,8036,8037],{"class":112},"\u003Ctypeof",[106,8039,1386],{"class":224},[106,8041,120],{"class":112},[106,8043,8044],{"class":108,"line":556},[106,8045,330],{"emptyLinePlaceholder":329},[106,8047,8048,8050,8052,8054,8056,8058],{"class":108,"line":759},[106,8049,1252],{"class":132},[106,8051,1323],{"class":224},[106,8053,136],{"class":112},[106,8055,1328],{"class":277},[106,8057,281],{"class":224},[106,8059,488],{"class":112},[106,8061,8062,8064,8066],{"class":108,"line":795},[106,8063,1272],{"class":116},[106,8065,496],{"class":112},[106,8067,8068],{"class":112}," undefined,\n",[106,8070,8071,8073],{"class":108,"line":803},[106,8072,366],{"class":112},[106,8074,324],{"class":224},[106,8076,8077],{"class":108,"line":809},[106,8078,330],{"emptyLinePlaceholder":329},[106,8080,8081,8083,8085,8087,8089,8091,8093,8095,8097,8100,8102],{"class":108,"line":814},[106,8082,284],{"class":132},[106,8084,1360],{"class":132},[106,8086,1363],{"class":277},[106,8088,281],{"class":112},[106,8090,291],{"class":290},[106,8092,496],{"class":112},[106,8094,7913],{"class":1041},[106,8096,113],{"class":112},[106,8098,8099],{"class":1041},"Schema",[106,8101,6100],{"class":112},[106,8103,300],{"class":112},[106,8105,8106,8108,8110,8112,8114,8116,8118,8120],{"class":108,"line":830},[106,8107,2348],{"class":270},[106,8109,1405],{"class":277},[106,8111,281],{"class":116},[106,8113,754],{"class":112},[106,8115,7630],{"class":142},[106,8117,754],{"class":112},[106,8119,355],{"class":112},[106,8121,300],{"class":112},[106,8123,8124,8127,8129,8131,8133,8135],{"class":108,"line":835},[106,8125,8126],{"class":116},"    method",[106,8128,496],{"class":112},[106,8130,425],{"class":112},[106,8132,3167],{"class":142},[106,8134,754],{"class":112},[106,8136,509],{"class":112},[106,8138,8139,8142,8144,8146,8148,8150],{"class":108,"line":840},[106,8140,8141],{"class":116},"    body",[106,8143,496],{"class":112},[106,8145,3048],{"class":224},[106,8147,52],{"class":112},[106,8149,1007],{"class":224},[106,8151,509],{"class":112},[106,8153,8154,8156],{"class":108,"line":845},[106,8155,514],{"class":112},[106,8157,324],{"class":116},[106,8159,8160],{"class":108,"line":862},[106,8161,1558],{"class":112},[106,8163,8164,8166,8168],{"class":108,"line":1469},[106,8165,251],{"class":112},[106,8167,1189],{"class":116},[106,8169,120],{"class":112},[106,8171,8172],{"class":108,"line":1486},[106,8173,330],{"emptyLinePlaceholder":329},[106,8175,8176,8178,8180],{"class":108,"line":1497},[106,8177,113],{"class":112},[106,8179,117],{"class":116},[106,8181,120],{"class":112},[106,8183,8184,8186,8189,8191,8194,8196,8198,8200,8202,8204,8206,8208,8210,8212,8214,8217,8219,8221,8223,8226,8228],{"class":108,"line":1508},[106,8185,126],{"class":112},[106,8187,8188],{"class":116},"UForm",[106,8190,7684],{"class":112},[106,8192,8193],{"class":132},"schema",[106,8195,136],{"class":112},[106,8197,139],{"class":112},[106,8199,8193],{"class":224},[106,8201,139],{"class":112},[106,8203,7684],{"class":112},[106,8205,1395],{"class":132},[106,8207,136],{"class":112},[106,8209,139],{"class":112},[106,8211,1395],{"class":224},[106,8213,139],{"class":112},[106,8215,8216],{"class":112}," @",[106,8218,214],{"class":132},[106,8220,136],{"class":112},[106,8222,139],{"class":112},[106,8224,8225],{"class":224},"onSubmit",[106,8227,139],{"class":112},[106,8229,120],{"class":112},[106,8231,8232,8234,8237,8240,8242,8244,8246,8248,8250,8252,8254,8256,8258],{"class":108,"line":1523},[106,8233,165],{"class":112},[106,8235,8236],{"class":116},"UFormGroup",[106,8238,8239],{"class":132}," label",[106,8241,136],{"class":112},[106,8243,139],{"class":112},[106,8245,190],{"class":142},[106,8247,139],{"class":112},[106,8249,3348],{"class":132},[106,8251,136],{"class":112},[106,8253,139],{"class":112},[106,8255,3815],{"class":142},[106,8257,139],{"class":112},[106,8259,120],{"class":112},[106,8261,8262,8264,8267,8269,8271,8273,8275,8277,8279,8281],{"class":108,"line":1538},[106,8263,3333],{"class":112},[106,8265,8266],{"class":116},"UInput",[106,8268,1612],{"class":132},[106,8270,136],{"class":112},[106,8272,139],{"class":112},[106,8274,1395],{"class":224},[106,8276,52],{"class":112},[106,8278,3815],{"class":224},[106,8280,139],{"class":112},[106,8282,6929],{"class":112},[106,8284,8285,8287,8289],{"class":108,"line":1545},[106,8286,231],{"class":112},[106,8288,8236],{"class":116},[106,8290,120],{"class":112},[106,8292,8293],{"class":108,"line":1550},[106,8294,330],{"emptyLinePlaceholder":329},[106,8296,8297,8299,8302,8304,8306,8308,8310,8312],{"class":108,"line":1555},[106,8298,165],{"class":112},[106,8300,8301],{"class":116},"UButton",[106,8303,171],{"class":132},[106,8305,136],{"class":112},[106,8307,139],{"class":112},[106,8309,214],{"class":142},[106,8311,139],{"class":112},[106,8313,120],{"class":112},[106,8315,8316],{"class":108,"line":1561},[106,8317,8318],{"class":224},"      Submit\n",[106,8320,8321,8323,8325],{"class":108,"line":1570},[106,8322,231],{"class":112},[106,8324,8301],{"class":116},[106,8326,120],{"class":112},[106,8328,8329,8331,8333],{"class":108,"line":1575},[106,8330,241],{"class":112},[106,8332,8188],{"class":116},[106,8334,120],{"class":112},[106,8336,8337,8339,8341],{"class":108,"line":1584},[106,8338,251],{"class":112},[106,8340,117],{"class":116},[106,8342,120],{"class":112},[10,8344,8345,8346,8349,8350,8352],{},"I use the form as explained in the Nuxt UI documentation. I do not really choose Yup for the validation but it was the first proposed. ",[29,8347,8348],{},"We will see if it's a good choice in the future."," In the ",[103,8351,8225],{}," method, I just do a fetch to the API to create a new category. In the next stream, we will improve it by handling the success and the error state.",[10,8354,8355,8356,8359],{},"This component is put into a modal using the component ",[103,8357,8358],{},"UModal"," and listening to the click event on the button to open a modal.",[10,8361,8362],{},[29,8363,8364],{},"A point I'm not sure about is how to handle the response of the API. Should I return the new category and put it in the table, or should I refresh the table? For the moment, I opted for the second one.",[10,8366,8367],{},"Using Nuxt UI helps me to build the admin interface quickly, without any custom components.",[568,8369,8372,8376,8379,8383,8387,8393,8409,8412,8416,8419,8437,8625,8634,8646,8650,8657,8663,8825,8831,9282,9285,9292,9626,9630,9633,9640,9752,9759,9766,9776,9994,9997,10051,10054,10058,10061,10065,10069,10075,10091,10108,10189,10203,10228],{"icon":5452,"text":8370,"to":5454,"target":8371},"View Source Code","_blank",[381,8373,8375],{"id":8374},"end-of-the-stream","End of the stream",[10,8377,8378],{},"It was definitely a good stream. The project is moving forward without a lot of difficulties and it is a pleasure to work on it. I am looking forward to the next stream to continue working on the admin interface and the forms. Nuxt UI and Nuxt UI Pro are game changers, simple code, easy to use, and I can focus on the features of the application.",[10,8380,5766,8381],{},[49,8382,5770],{"repo":5769},[54,8384,8386],{"id":8385},"lets-continue-the-admin-interface","Let's continue the admin interface",[10,8388,8389],{},[5476,8390],{"alt":8391,"src":8392},"Thumbnail of my fourth live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-04.png",[10,8394,8395],{},[29,8396,8397,5488,8400,5494,8403,5499,8406,52],{},[14,8398,5487],{"href":5454,"rel":8399},[18],[14,8401,5493],{"href":5491,"rel":8402},[18],[14,8404,5498],{"href":5444,"rel":8405},[18],[14,8407,5503],{"href":5454,"rel":8408},[18],[10,8410,8411],{},"During the previous stream, I started creating the categories page. I implemented the API endpoints to get and create categories, and I built the UI to display the categories and the form to create a new category. In this stream, I've continued working on the admin interface by adding the edit category form and creating a new page to manage users. I've also added the bouncer to secure the access to the admin part of the application, both for the frontend and the backend. Let's see the work done in more detail.",[381,8413,8415],{"id":8414},"edit-category-form","Edit category form",[10,8417,8418],{},"This part was very similar to the create category form. Instead of submitting the form data to the API to create a new category, I changed the endpoint to update an existing category.",[10,8420,8421,8422,8425,8426,8429,8430,8433,8434,8436],{},"I created the form in a new component ",[103,8423,8424],{},"components\u002Fcategories\u002FCategoryEditForm.vue"," (and renamed the previous form to ",[103,8427,8428],{},"CategoryCreateForm.vue","). I also created a new endpoint ",[103,8431,8432],{},"server\u002Fapi\u002Fcategories\u002F[id].put.ts"," to update a category. This endpoint updates a category using the ",[103,8435,7041],{}," in the URL and the body of the request:",[96,8438,8440],{"className":261,"code":8439,"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,8441,8442,8464,8482,8486,8518,8538,8552,8598,8602,8619],{"__ignoreMap":101},[106,8443,8444,8446,8448,8450,8452,8454,8456,8458,8460,8462],{"class":108,"line":109},[106,8445,271],{"class":270},[106,8447,274],{"class":270},[106,8449,278],{"class":277},[106,8451,281],{"class":224},[106,8453,284],{"class":132},[106,8455,287],{"class":112},[106,8457,291],{"class":290},[106,8459,294],{"class":112},[106,8461,297],{"class":132},[106,8463,300],{"class":112},[106,8465,8466,8468,8470,8472,8474,8476,8478,8480],{"class":108,"line":123},[106,8467,305],{"class":132},[106,8469,308],{"class":224},[106,8471,311],{"class":112},[106,8473,314],{"class":270},[106,8475,317],{"class":277},[106,8477,281],{"class":116},[106,8479,291],{"class":224},[106,8481,324],{"class":116},[106,8483,8484],{"class":108,"line":162},[106,8485,330],{"emptyLinePlaceholder":329},[106,8487,8488,8490,8492,8494,8496,8499,8501,8503,8505,8507,8509,8511,8514,8516],{"class":108,"line":200},[106,8489,2348],{"class":270},[106,8491,7011],{"class":277},[106,8493,506],{"class":116},[106,8495,52],{"class":112},[106,8497,8498],{"class":277},"update",[106,8500,281],{"class":116},[106,8502,7096],{"class":224},[106,8504,52],{"class":112},[106,8506,6829],{"class":224},[106,8508,294],{"class":116},[106,8510,52],{"class":112},[106,8512,8513],{"class":277},"set",[106,8515,281],{"class":116},[106,8517,488],{"class":112},[106,8519,8520,8522,8524,8526,8528,8530,8532,8534,8536],{"class":108,"line":221},[106,8521,7048],{"class":116},[106,8523,496],{"class":112},[106,8525,7235],{"class":277},[106,8527,281],{"class":116},[106,8529,7240],{"class":224},[106,8531,52],{"class":112},[106,8533,3815],{"class":224},[106,8535,294],{"class":116},[106,8537,509],{"class":112},[106,8539,8540,8542,8544,8546,8548,8550],{"class":108,"line":228},[106,8541,493],{"class":116},[106,8543,496],{"class":112},[106,8545,308],{"class":224},[106,8547,52],{"class":112},[106,8549,3815],{"class":224},[106,8551,509],{"class":112},[106,8553,8554,8556,8558,8560,8563,8565,8568,8570,8572,8574,8576,8578,8580,8582,8585,8587,8589,8592,8594,8596],{"class":108,"line":238},[106,8555,514],{"class":112},[106,8557,294],{"class":116},[106,8559,52],{"class":112},[106,8561,8562],{"class":277},"where",[106,8564,281],{"class":116},[106,8566,8567],{"class":277},"eq",[106,8569,281],{"class":116},[106,8571,7096],{"class":224},[106,8573,52],{"class":112},[106,8575,6829],{"class":224},[106,8577,52],{"class":112},[106,8579,7041],{"class":224},[106,8581,355],{"class":112},[106,8583,8584],{"class":224}," params",[106,8586,52],{"class":112},[106,8588,7041],{"class":224},[106,8590,8591],{"class":116},"))",[106,8593,52],{"class":112},[106,8595,7273],{"class":277},[106,8597,2366],{"class":116},[106,8599,8600],{"class":108,"line":248},[106,8601,330],{"emptyLinePlaceholder":329},[106,8603,8604,8606,8608,8610,8612,8614,8617],{"class":108,"line":534},[106,8605,345],{"class":270},[106,8607,348],{"class":277},[106,8609,281],{"class":116},[106,8611,291],{"class":224},[106,8613,355],{"class":112},[106,8615,8616],{"class":358}," 200",[106,8618,324],{"class":116},[106,8620,8621,8623],{"class":108,"line":539},[106,8622,366],{"class":112},[106,8624,324],{"class":224},[10,8626,8627],{},[29,8628,8629,8630,8633],{},"The 200 status code could not be the best choice (204 is better with a ",[103,8631,8632],{},"PUT","), but I'm still thinking if I should return the updated category or not.",[10,8635,8636,8637,8642,8643],{},"I also tried to validate the body of the request using Yup because it was the tool used in the frontend. However, I quickly realized that it was not possible to cast a string to a number. I know myzod can do it so I tried it. I encountered ",[14,8638,8641],{"href":8639,"rel":8640},"https:\u002F\u002Fgithub.com\u002Funjs\u002Fh3\u002Fissues\u002F745",[18],"an issue"," and instead used Zod. ",[29,8644,8645],{},"I moved the frontend to Zod off-stream.",[381,8647,8649],{"id":8648},"users-page","Users page",[10,8651,8652,8653,8656],{},"Actually, when a user logs in, related data from GitHub are stored in the database (",[29,8654,8655],{},"while writing this part, I'm wondering if this is necessary because it means that a log in is a write which is more expensive than a read",") and in the session. This behavior is useful to promote the user to an admin or to ban a user. But for the moment, there is no way to manage users. So, I created a new page to manage users. Like the categories page, the page will display the users in a table. However, it will not be possible to create or update a user for the moment.",[10,8658,8659,8660,496],{},"In order to retrieve the users from the database, I created the API endpoint ",[103,8661,8662],{},"server\u002Fapi\u002Fusers\u002Findex.get.ts",[96,8664,8666],{"className":261,"code":8665,"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,8667,8668,8690,8713,8731,8750,8770,8788,8808,8812,8819],{"__ignoreMap":101},[106,8669,8670,8672,8674,8676,8678,8680,8682,8684,8686,8688],{"class":108,"line":109},[106,8671,271],{"class":270},[106,8673,274],{"class":270},[106,8675,278],{"class":277},[106,8677,281],{"class":224},[106,8679,284],{"class":132},[106,8681,287],{"class":112},[106,8683,291],{"class":290},[106,8685,294],{"class":112},[106,8687,297],{"class":132},[106,8689,300],{"class":112},[106,8691,8692,8694,8697,8699,8701,8703,8705,8707,8709,8711],{"class":108,"line":123},[106,8693,305],{"class":132},[106,8695,8696],{"class":224}," users",[106,8698,311],{"class":112},[106,8700,314],{"class":270},[106,8702,7011],{"class":277},[106,8704,506],{"class":116},[106,8706,52],{"class":112},[106,8708,7018],{"class":277},[106,8710,281],{"class":116},[106,8712,488],{"class":112},[106,8714,8715,8717,8719,8721,8723,8725,8727,8729],{"class":108,"line":162},[106,8716,7027],{"class":116},[106,8718,496],{"class":112},[106,8720,7032],{"class":224},[106,8722,52],{"class":112},[106,8724,6823],{"class":224},[106,8726,52],{"class":112},[106,8728,7041],{"class":224},[106,8730,509],{"class":112},[106,8732,8733,8736,8738,8740,8742,8744,8746,8748],{"class":108,"line":200},[106,8734,8735],{"class":116},"    githubId",[106,8737,496],{"class":112},[106,8739,7032],{"class":224},[106,8741,52],{"class":112},[106,8743,6823],{"class":224},[106,8745,52],{"class":112},[106,8747,5669],{"class":224},[106,8749,509],{"class":112},[106,8751,8752,8755,8757,8759,8761,8763,8765,8768],{"class":108,"line":221},[106,8753,8754],{"class":116},"    username",[106,8756,496],{"class":112},[106,8758,7032],{"class":224},[106,8760,52],{"class":112},[106,8762,6823],{"class":224},[106,8764,52],{"class":112},[106,8766,8767],{"class":224},"username",[106,8769,509],{"class":112},[106,8771,8772,8775,8777,8779,8781,8783,8785],{"class":108,"line":228},[106,8773,8774],{"class":116},"    roleType",[106,8776,496],{"class":112},[106,8778,7032],{"class":224},[106,8780,52],{"class":112},[106,8782,6823],{"class":224},[106,8784,52],{"class":112},[106,8786,8787],{"class":224},"roleType\n",[106,8789,8790,8792,8794,8796,8798,8800,8802,8804,8806],{"class":108,"line":238},[106,8791,514],{"class":112},[106,8793,294],{"class":116},[106,8795,52],{"class":112},[106,8797,7091],{"class":277},[106,8799,281],{"class":116},[106,8801,7096],{"class":224},[106,8803,52],{"class":112},[106,8805,6823],{"class":224},[106,8807,324],{"class":116},[106,8809,8810],{"class":108,"line":248},[106,8811,330],{"emptyLinePlaceholder":329},[106,8813,8814,8816],{"class":108,"line":534},[106,8815,345],{"class":270},[106,8817,8818],{"class":224}," users\n",[106,8820,8821,8823],{"class":108,"line":539},[106,8822,366],{"class":112},[106,8824,324],{"class":224},[10,8826,8827,8828,8830],{},"To show them, I use the ",[103,8829,7681],{}," component from Nuxt UI Pro. The code is very similar to the categories page:",[96,8832,8834],{"className":98,"code":8833,"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: 'Username',\n  sortable: true\n}, {\n  key: 'roleType',\n  label: 'Role Type'\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,8835,8836,8856,8868,8882,8894,8900,8914,8929,8935,8949,8964,8972,8978,8993,9006,9012,9046,9056,9062,9070,9074,9082,9090,9100,9132,9159,9227,9232,9241,9250,9258,9266,9274],{"__ignoreMap":101},[106,8837,8838,8840,8842,8844,8846,8848,8850,8852,8854],{"class":108,"line":109},[106,8839,113],{"class":112},[106,8841,1189],{"class":116},[106,8843,1192],{"class":132},[106,8845,136],{"class":112},[106,8847,139],{"class":112},[106,8849,263],{"class":142},[106,8851,139],{"class":112},[106,8853,1203],{"class":132},[106,8855,120],{"class":112},[106,8857,8858,8860,8862,8864,8866],{"class":108,"line":123},[106,8859,1252],{"class":132},[106,8861,7472],{"class":224},[106,8863,136],{"class":112},[106,8865,2524],{"class":224},[106,8867,488],{"class":112},[106,8869,8870,8872,8874,8876,8878,8880],{"class":108,"line":162},[106,8871,7483],{"class":116},[106,8873,496],{"class":112},[106,8875,425],{"class":112},[106,8877,7041],{"class":142},[106,8879,754],{"class":112},[106,8881,509],{"class":112},[106,8883,8884,8886,8888,8890,8892],{"class":108,"line":200},[106,8885,7498],{"class":116},[106,8887,496],{"class":112},[106,8889,425],{"class":112},[106,8891,7505],{"class":142},[106,8893,431],{"class":112},[106,8895,8896,8898],{"class":108,"line":221},[106,8897,7512],{"class":112},[106,8899,300],{"class":112},[106,8901,8902,8904,8906,8908,8910,8912],{"class":108,"line":228},[106,8903,7483],{"class":116},[106,8905,496],{"class":112},[106,8907,425],{"class":112},[106,8909,5669],{"class":142},[106,8911,754],{"class":112},[106,8913,509],{"class":112},[106,8915,8916,8918,8920,8922,8925,8927],{"class":108,"line":238},[106,8917,7498],{"class":116},[106,8919,496],{"class":112},[106,8921,425],{"class":112},[106,8923,8924],{"class":142},"GitHub ID",[106,8926,754],{"class":112},[106,8928,509],{"class":112},[106,8930,8931,8933],{"class":108,"line":248},[106,8932,7512],{"class":112},[106,8934,300],{"class":112},[106,8936,8937,8939,8941,8943,8945,8947],{"class":108,"line":534},[106,8938,7483],{"class":116},[106,8940,496],{"class":112},[106,8942,425],{"class":112},[106,8944,8767],{"class":142},[106,8946,754],{"class":112},[106,8948,509],{"class":112},[106,8950,8951,8953,8955,8957,8960,8962],{"class":108,"line":539},[106,8952,7498],{"class":116},[106,8954,496],{"class":112},[106,8956,425],{"class":112},[106,8958,8959],{"class":142},"Username",[106,8961,754],{"class":112},[106,8963,509],{"class":112},[106,8965,8966,8968,8970],{"class":108,"line":556},[106,8967,7547],{"class":116},[106,8969,496],{"class":112},[106,8971,7552],{"class":5594},[106,8973,8974,8976],{"class":108,"line":759},[106,8975,7512],{"class":112},[106,8977,300],{"class":112},[106,8979,8980,8982,8984,8986,8989,8991],{"class":108,"line":795},[106,8981,7483],{"class":116},[106,8983,496],{"class":112},[106,8985,425],{"class":112},[106,8987,8988],{"class":142},"roleType",[106,8990,754],{"class":112},[106,8992,509],{"class":112},[106,8994,8995,8997,8999,9001,9004],{"class":108,"line":803},[106,8996,7498],{"class":116},[106,8998,496],{"class":112},[106,9000,425],{"class":112},[106,9002,9003],{"class":142},"Role Type",[106,9005,431],{"class":112},[106,9007,9008,9010],{"class":108,"line":809},[106,9009,366],{"class":112},[106,9011,3682],{"class":224},[106,9013,9014,9016,9018,9020,9022,9025,9027,9029,9031,9033,9035,9037,9040,9042,9044],{"class":108,"line":814},[106,9015,1252],{"class":132},[106,9017,413],{"class":112},[106,9019,964],{"class":116},[106,9021,496],{"class":112},[106,9023,9024],{"class":224}," users ",[106,9026,366],{"class":112},[106,9028,311],{"class":112},[106,9030,314],{"class":270},[106,9032,7623],{"class":277},[106,9034,281],{"class":224},[106,9036,754],{"class":112},[106,9038,9039],{"class":142},"\u002Fapi\u002Fusers",[106,9041,754],{"class":112},[106,9043,355],{"class":112},[106,9045,300],{"class":112},[106,9047,9048,9050,9052,9054],{"class":108,"line":830},[106,9049,7641],{"class":116},[106,9051,496],{"class":112},[106,9053,7646],{"class":5594},[106,9055,509],{"class":112},[106,9057,9058,9060],{"class":108,"line":835},[106,9059,366],{"class":112},[106,9061,324],{"class":224},[106,9063,9064,9066,9068],{"class":108,"line":840},[106,9065,251],{"class":112},[106,9067,1189],{"class":116},[106,9069,120],{"class":112},[106,9071,9072],{"class":108,"line":845},[106,9073,330],{"emptyLinePlaceholder":329},[106,9075,9076,9078,9080],{"class":108,"line":862},[106,9077,113],{"class":112},[106,9079,117],{"class":116},[106,9081,120],{"class":112},[106,9083,9084,9086,9088],{"class":108,"line":1469},[106,9085,126],{"class":112},[106,9087,7386],{"class":116},[106,9089,120],{"class":112},[106,9091,9092,9094,9096,9098],{"class":108,"line":1486},[106,9093,165],{"class":112},[106,9095,7395],{"class":116},[106,9097,7398],{"class":132},[106,9099,120],{"class":112},[106,9101,9102,9104,9106,9108,9110,9112,9114,9116,9118,9120,9122,9124,9126,9128,9130],{"class":108,"line":1497},[106,9103,3333],{"class":112},[106,9105,7681],{"class":116},[106,9107,7684],{"class":112},[106,9109,7687],{"class":132},[106,9111,136],{"class":112},[106,9113,139],{"class":112},[106,9115,7687],{"class":224},[106,9117,139],{"class":112},[106,9119,7684],{"class":112},[106,9121,7700],{"class":132},[106,9123,136],{"class":112},[106,9125,139],{"class":112},[106,9127,6823],{"class":224},[106,9129,139],{"class":112},[106,9131,120],{"class":112},[106,9133,9134,9137,9139,9141,9144,9146,9148,9150,9153,9155,9157],{"class":108,"line":1508},[106,9135,9136],{"class":112},"        \u003C",[106,9138,117],{"class":116},[106,9140,7790],{"class":112},[106,9142,9143],{"class":132},"roleType-data",[106,9145,136],{"class":112},[106,9147,139],{"class":112},[106,9149,1285],{"class":112},[106,9151,9152],{"class":224}," row ",[106,9154,366],{"class":112},[106,9156,139],{"class":112},[106,9158,120],{"class":112},[106,9160,9161,9164,9167,9170,9172,9174,9177,9179,9181,9184,9186,9188,9191,9193,9195,9198,9200,9202,9204,9207,9209,9212,9214,9217,9219,9222,9225],{"class":108,"line":1523},[106,9162,9163],{"class":112},"          \u003C",[106,9165,9166],{"class":116},"UBadge",[106,9168,9169],{"class":132}," variant",[106,9171,136],{"class":112},[106,9173,139],{"class":112},[106,9175,9176],{"class":142},"subtle",[106,9178,139],{"class":112},[106,9180,7684],{"class":112},[106,9182,9183],{"class":132},"color",[106,9185,136],{"class":112},[106,9187,139],{"class":112},[106,9189,9190],{"class":224},"row",[106,9192,52],{"class":112},[106,9194,8988],{"class":224},[106,9196,9197],{"class":112}," === ",[106,9199,754],{"class":112},[106,9201,7351],{"class":142},[106,9203,754],{"class":112},[106,9205,9206],{"class":112}," ? ",[106,9208,754],{"class":112},[106,9210,9211],{"class":142},"amber",[106,9213,754],{"class":112},[106,9215,9216],{"class":112}," : ",[106,9218,754],{"class":112},[106,9220,9221],{"class":142},"primary",[106,9223,9224],{"class":112},"'\"",[106,9226,120],{"class":112},[106,9228,9229],{"class":108,"line":1538},[106,9230,9231],{"class":224},"            {{ row.roleType }}\n",[106,9233,9234,9237,9239],{"class":108,"line":1545},[106,9235,9236],{"class":112},"          \u003C\u002F",[106,9238,9166],{"class":116},[106,9240,120],{"class":112},[106,9242,9243,9246,9248],{"class":108,"line":1550},[106,9244,9245],{"class":112},"        \u003C\u002F",[106,9247,117],{"class":116},[106,9249,120],{"class":112},[106,9251,9252,9254,9256],{"class":108,"line":1555},[106,9253,3396],{"class":112},[106,9255,7681],{"class":116},[106,9257,120],{"class":112},[106,9259,9260,9262,9264],{"class":108,"line":1561},[106,9261,231],{"class":112},[106,9263,7395],{"class":116},[106,9265,120],{"class":112},[106,9267,9268,9270,9272],{"class":108,"line":1570},[106,9269,241],{"class":112},[106,9271,7386],{"class":116},[106,9273,120],{"class":112},[106,9275,9276,9278,9280],{"class":108,"line":1575},[106,9277,251],{"class":112},[106,9279,117],{"class":116},[106,9281,120],{"class":112},[10,9283,9284],{},"As expected, the code is very similar to the categories page.",[10,9286,9287,9288,9291],{},"Now we have two pages, one for categories and one for users, so I added a sidebar to navigate between them. Nothing complicated here, I just used the ",[103,9289,9290],{},"UDashboardSidebar"," component from Nuxt UI Pro and added the links to the categories and users pages:",[96,9293,9295],{"className":98,"code":9294,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nconst links = [{\n  id: 'categories',\n  label: 'Categories',\n  icon: 'i-heroicons-tag',\n  to: '\u002Fadmin\u002Fcategories',\n  tooltip: {\n    text: 'Categories',\n  }\n}, {\n  id: 'users',\n  label: 'Users',\n  icon: 'i-heroicons-user-group',\n  to: '\u002Fadmin\u002Fusers',\n  tooltip: {\n    text: 'Users',\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,9296,9297,9317,9330,9345,9359,9375,9391,9400,9415,9419,9425,9439,9454,9469,9484,9492,9506,9510,9516,9524,9528,9536,9544,9552,9560,9582,9590,9598,9602,9610,9618],{"__ignoreMap":101},[106,9298,9299,9301,9303,9305,9307,9309,9311,9313,9315],{"class":108,"line":109},[106,9300,113],{"class":112},[106,9302,1189],{"class":116},[106,9304,1192],{"class":132},[106,9306,136],{"class":112},[106,9308,139],{"class":112},[106,9310,263],{"class":142},[106,9312,139],{"class":112},[106,9314,1203],{"class":132},[106,9316,120],{"class":112},[106,9318,9319,9321,9324,9326,9328],{"class":108,"line":123},[106,9320,1252],{"class":132},[106,9322,9323],{"class":224}," links ",[106,9325,136],{"class":112},[106,9327,2524],{"class":224},[106,9329,488],{"class":112},[106,9331,9332,9335,9337,9339,9341,9343],{"class":108,"line":162},[106,9333,9334],{"class":116},"  id",[106,9336,496],{"class":112},[106,9338,425],{"class":112},[106,9340,6829],{"class":142},[106,9342,754],{"class":112},[106,9344,509],{"class":112},[106,9346,9347,9349,9351,9353,9355,9357],{"class":108,"line":200},[106,9348,7498],{"class":116},[106,9350,496],{"class":112},[106,9352,425],{"class":112},[106,9354,7773],{"class":142},[106,9356,754],{"class":112},[106,9358,509],{"class":112},[106,9360,9361,9364,9366,9368,9371,9373],{"class":108,"line":221},[106,9362,9363],{"class":116},"  icon",[106,9365,496],{"class":112},[106,9367,425],{"class":112},[106,9369,9370],{"class":142},"i-heroicons-tag",[106,9372,754],{"class":112},[106,9374,509],{"class":112},[106,9376,9377,9380,9382,9384,9387,9389],{"class":108,"line":228},[106,9378,9379],{"class":116},"  to",[106,9381,496],{"class":112},[106,9383,425],{"class":112},[106,9385,9386],{"class":142},"\u002Fadmin\u002Fcategories",[106,9388,754],{"class":112},[106,9390,509],{"class":112},[106,9392,9393,9396,9398],{"class":108,"line":238},[106,9394,9395],{"class":116},"  tooltip",[106,9397,496],{"class":112},[106,9399,300],{"class":112},[106,9401,9402,9405,9407,9409,9411,9413],{"class":108,"line":248},[106,9403,9404],{"class":116},"    text",[106,9406,496],{"class":112},[106,9408,425],{"class":112},[106,9410,7773],{"class":142},[106,9412,754],{"class":112},[106,9414,509],{"class":112},[106,9416,9417],{"class":108,"line":534},[106,9418,806],{"class":112},[106,9420,9421,9423],{"class":108,"line":539},[106,9422,7512],{"class":112},[106,9424,300],{"class":112},[106,9426,9427,9429,9431,9433,9435,9437],{"class":108,"line":556},[106,9428,9334],{"class":116},[106,9430,496],{"class":112},[106,9432,425],{"class":112},[106,9434,6823],{"class":142},[106,9436,754],{"class":112},[106,9438,509],{"class":112},[106,9440,9441,9443,9445,9447,9450,9452],{"class":108,"line":759},[106,9442,7498],{"class":116},[106,9444,496],{"class":112},[106,9446,425],{"class":112},[106,9448,9449],{"class":142},"Users",[106,9451,754],{"class":112},[106,9453,509],{"class":112},[106,9455,9456,9458,9460,9462,9465,9467],{"class":108,"line":795},[106,9457,9363],{"class":116},[106,9459,496],{"class":112},[106,9461,425],{"class":112},[106,9463,9464],{"class":142},"i-heroicons-user-group",[106,9466,754],{"class":112},[106,9468,509],{"class":112},[106,9470,9471,9473,9475,9477,9480,9482],{"class":108,"line":803},[106,9472,9379],{"class":116},[106,9474,496],{"class":112},[106,9476,425],{"class":112},[106,9478,9479],{"class":142},"\u002Fadmin\u002Fusers",[106,9481,754],{"class":112},[106,9483,509],{"class":112},[106,9485,9486,9488,9490],{"class":108,"line":809},[106,9487,9395],{"class":116},[106,9489,496],{"class":112},[106,9491,300],{"class":112},[106,9493,9494,9496,9498,9500,9502,9504],{"class":108,"line":814},[106,9495,9404],{"class":116},[106,9497,496],{"class":112},[106,9499,425],{"class":112},[106,9501,9449],{"class":142},[106,9503,754],{"class":112},[106,9505,509],{"class":112},[106,9507,9508],{"class":108,"line":830},[106,9509,806],{"class":112},[106,9511,9512,9514],{"class":108,"line":835},[106,9513,366],{"class":112},[106,9515,3682],{"class":224},[106,9517,9518,9520,9522],{"class":108,"line":840},[106,9519,251],{"class":112},[106,9521,1189],{"class":116},[106,9523,120],{"class":112},[106,9525,9526],{"class":108,"line":845},[106,9527,330],{"emptyLinePlaceholder":329},[106,9529,9530,9532,9534],{"class":108,"line":862},[106,9531,113],{"class":112},[106,9533,117],{"class":116},[106,9535,120],{"class":112},[106,9537,9538,9540,9542],{"class":108,"line":1469},[106,9539,126],{"class":112},[106,9541,6917],{"class":116},[106,9543,120],{"class":112},[106,9545,9546,9548,9550],{"class":108,"line":1486},[106,9547,165],{"class":112},[106,9549,7395],{"class":116},[106,9551,120],{"class":112},[106,9553,9554,9556,9558],{"class":108,"line":1497},[106,9555,3333],{"class":112},[106,9557,9290],{"class":116},[106,9559,120],{"class":112},[106,9561,9562,9564,9567,9569,9572,9574,9576,9578,9580],{"class":108,"line":1508},[106,9563,9136],{"class":112},[106,9565,9566],{"class":116},"UDashboardSidebarLinks",[106,9568,7684],{"class":112},[106,9570,9571],{"class":132},"links",[106,9573,136],{"class":112},[106,9575,139],{"class":112},[106,9577,9571],{"class":224},[106,9579,139],{"class":112},[106,9581,6929],{"class":112},[106,9583,9584,9586,9588],{"class":108,"line":1523},[106,9585,3396],{"class":112},[106,9587,9290],{"class":116},[106,9589,120],{"class":112},[106,9591,9592,9594,9596],{"class":108,"line":1538},[106,9593,231],{"class":112},[106,9595,7395],{"class":116},[106,9597,120],{"class":112},[106,9599,9600],{"class":108,"line":1545},[106,9601,330],{"emptyLinePlaceholder":329},[106,9603,9604,9606,9608],{"class":108,"line":1550},[106,9605,165],{"class":112},[106,9607,6926],{"class":116},[106,9609,6929],{"class":112},[106,9611,9612,9614,9616],{"class":108,"line":1555},[106,9613,241],{"class":112},[106,9615,6917],{"class":116},[106,9617,120],{"class":112},[106,9619,9620,9622,9624],{"class":108,"line":1561},[106,9621,251],{"class":112},[106,9623,117],{"class":116},[106,9625,120],{"class":112},[381,9627,9629],{"id":9628},"protecting-the-admin-part","Protecting the admin part",[10,9631,9632],{},"When building the administration part of an application, it's important to secure and restrict access to it, for both the frontend and the backend. This means that only authorized users can view and manipulate the data.",[10,9634,9635,9636,9639],{},"To restrict access to the UI part of the admin, i.e., the frontend, I used a middleware. It's a function that runs before navigating to the page and can be used to check if the user is authenticated and has the right permissions. In this case, I created a middleware ",[103,9637,9638],{},"middleware\u002Fadmin.ts"," to check if the user is authenticated and has the role of an admin:",[96,9641,9643],{"className":261,"code":9642,"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,9644,9645,9662,9679,9683,9712,9716,9729,9746],{"__ignoreMap":101},[106,9646,9647,9649,9651,9654,9656,9658,9660],{"class":108,"line":109},[106,9648,271],{"class":270},[106,9650,274],{"class":270},[106,9652,9653],{"class":277}," defineNuxtRouteMiddleware",[106,9655,281],{"class":224},[106,9657,506],{"class":112},[106,9659,297],{"class":132},[106,9661,300],{"class":112},[106,9663,9664,9666,9668,9670,9672,9674,9677],{"class":108,"line":123},[106,9665,305],{"class":132},[106,9667,413],{"class":112},[106,9669,5621],{"class":224},[106,9671,419],{"class":112},[106,9673,311],{"class":112},[106,9675,9676],{"class":277}," useUserSession",[106,9678,2366],{"class":116},[106,9680,9681],{"class":108,"line":162},[106,9682,330],{"emptyLinePlaceholder":329},[106,9684,9685,9687,9690,9692,9694,9696,9699,9702,9704,9706,9708,9710],{"class":108,"line":200},[106,9686,305],{"class":132},[106,9688,9689],{"class":224}," isAdmin",[106,9691,311],{"class":112},[106,9693,5621],{"class":224},[106,9695,52],{"class":112},[106,9697,9698],{"class":224},"value",[106,9700,9701],{"class":112},"?.",[106,9703,8988],{"class":224},[106,9705,5259],{"class":112},[106,9707,425],{"class":112},[106,9709,7351],{"class":142},[106,9711,431],{"class":112},[106,9713,9714],{"class":108,"line":221},[106,9715,330],{"emptyLinePlaceholder":329},[106,9717,9718,9720,9722,9724,9727],{"class":108,"line":228},[106,9719,697],{"class":270},[106,9721,287],{"class":116},[106,9723,702],{"class":112},[106,9725,9726],{"class":224},"isAdmin",[106,9728,324],{"class":116},[106,9730,9731,9733,9736,9738,9740,9742,9744],{"class":108,"line":238},[106,9732,5691],{"class":270},[106,9734,9735],{"class":277}," redirectTo",[106,9737,281],{"class":116},[106,9739,754],{"class":112},[106,9741,5705],{"class":142},[106,9743,754],{"class":112},[106,9745,324],{"class":116},[106,9747,9748,9750],{"class":108,"line":248},[106,9749,366],{"class":112},[106,9751,324],{"class":224},[10,9753,9754],{},[29,9755,9756,9757,52],{},"The middleware must be used on pages that need to be protected using ",[103,9758,7335],{},[10,9760,9761,9762,9765],{},"I get the user from the session using ",[103,9763,9764],{},"useUserSession"," and check if the user has the role of an admin, if not, I redirect the user to the home page.",[10,9767,9768,9769,9772,9773,496],{},"The backend is way more critical than the frontend but it's not more difficult than the frontend to secure. In order to do so, I create a util named ",[103,9770,9771],{},"requireAdminUser"," in the file ",[103,9774,9775],{},"server\u002Futils\u002Fsession.ts",[96,9777,9779],{"className":261,"code":9778,"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,9780,9781,9803,9823,9827,9862,9882,9886,9928,9938,9950,9965,9971,9975,9979,9990],{"__ignoreMap":101},[106,9782,9783,9785,9787,9789,9792,9794,9796,9798,9801],{"class":108,"line":109},[106,9784,410],{"class":270},[106,9786,171],{"class":270},[106,9788,413],{"class":112},[106,9790,9791],{"class":224}," UserSessionRequired",[106,9793,419],{"class":112},[106,9795,422],{"class":270},[106,9797,425],{"class":112},[106,9799,9800],{"class":142},"#auth-utils",[106,9802,431],{"class":112},[106,9804,9805,9807,9809,9811,9813,9815,9817,9819,9821],{"class":108,"line":123},[106,9806,410],{"class":270},[106,9808,171],{"class":270},[106,9810,413],{"class":112},[106,9812,6122],{"class":224},[106,9814,419],{"class":112},[106,9816,422],{"class":270},[106,9818,425],{"class":112},[106,9820,381],{"class":142},[106,9822,431],{"class":112},[106,9824,9825],{"class":108,"line":162},[106,9826,330],{"emptyLinePlaceholder":329},[106,9828,9829,9831,9834,9836,9839,9841,9843,9845,9847,9850,9853,9855,9858,9860],{"class":108,"line":200},[106,9830,271],{"class":270},[106,9832,9833],{"class":132}," async",[106,9835,1360],{"class":132},[106,9837,9838],{"class":277}," requireAdminUser",[106,9840,281],{"class":112},[106,9842,291],{"class":290},[106,9844,496],{"class":112},[106,9846,6122],{"class":1041},[106,9848,9849],{"class":112},"):",[106,9851,9852],{"class":1041}," Promise",[106,9854,113],{"class":112},[106,9856,9857],{"class":1041},"UserSessionRequired",[106,9859,3265],{"class":112},[106,9861,300],{"class":112},[106,9863,9864,9866,9869,9871,9873,9876,9878,9880],{"class":108,"line":221},[106,9865,305],{"class":132},[106,9867,9868],{"class":224}," userSession",[106,9870,311],{"class":112},[106,9872,314],{"class":270},[106,9874,9875],{"class":277}," getUserSession",[106,9877,281],{"class":116},[106,9879,291],{"class":224},[106,9881,324],{"class":116},[106,9883,9884],{"class":108,"line":228},[106,9885,330],{"emptyLinePlaceholder":329},[106,9887,9888,9890,9892,9894,9897,9899,9902,9905,9907,9909,9911,9913,9915,9918,9920,9922,9924,9926],{"class":108,"line":238},[106,9889,697],{"class":270},[106,9891,287],{"class":116},[106,9893,702],{"class":112},[106,9895,9896],{"class":224},"userSession",[106,9898,52],{"class":112},[106,9900,9901],{"class":224},"user",[106,9903,9904],{"class":112}," ||",[106,9906,9868],{"class":224},[106,9908,52],{"class":112},[106,9910,9901],{"class":224},[106,9912,52],{"class":112},[106,9914,8988],{"class":224},[106,9916,9917],{"class":112}," !==",[106,9919,425],{"class":112},[106,9921,7351],{"class":142},[106,9923,754],{"class":112},[106,9925,713],{"class":116},[106,9927,488],{"class":112},[106,9929,9930,9932,9934,9936],{"class":108,"line":248},[106,9931,720],{"class":270},[106,9933,723],{"class":277},[106,9935,281],{"class":116},[106,9937,488],{"class":112},[106,9939,9940,9943,9945,9948],{"class":108,"line":534},[106,9941,9942],{"class":116},"      statusCode",[106,9944,496],{"class":112},[106,9946,9947],{"class":358}," 401",[106,9949,509],{"class":112},[106,9951,9952,9954,9956,9958,9961,9963],{"class":108,"line":539},[106,9953,744],{"class":116},[106,9955,496],{"class":112},[106,9957,425],{"class":112},[106,9959,9960],{"class":142},"Unauthorized",[106,9962,754],{"class":112},[106,9964,509],{"class":112},[106,9966,9967,9969],{"class":108,"line":556},[106,9968,798],{"class":112},[106,9970,324],{"class":116},[106,9972,9973],{"class":108,"line":759},[106,9974,806],{"class":112},[106,9976,9977],{"class":108,"line":795},[106,9978,330],{"emptyLinePlaceholder":329},[106,9980,9981,9983,9985,9987],{"class":108,"line":803},[106,9982,345],{"class":270},[106,9984,9868],{"class":224},[106,9986,3056],{"class":270},[106,9988,9989],{"class":1041}," UserSessionRequired\n",[106,9991,9992],{"class":108,"line":809},[106,9993,1558],{"class":112},[10,9995,9996],{},"This function checks if the user is authenticated and has the role of an admin. If not, it throws an error with a 401 status code. This function is used in the API endpoints to secure them:",[96,9998,10000],{"className":261,"code":9999,"language":263,"meta":101,"style":101},"export default defineEventHandler(async (event) => {\n  await requireAdminUser(event)\n\n  \u002F\u002F Code to handle the request\n})\n",[103,10001,10002,10024,10036,10040,10045],{"__ignoreMap":101},[106,10003,10004,10006,10008,10010,10012,10014,10016,10018,10020,10022],{"class":108,"line":109},[106,10005,271],{"class":270},[106,10007,274],{"class":270},[106,10009,278],{"class":277},[106,10011,281],{"class":224},[106,10013,284],{"class":132},[106,10015,287],{"class":112},[106,10017,291],{"class":290},[106,10019,294],{"class":112},[106,10021,297],{"class":132},[106,10023,300],{"class":112},[106,10025,10026,10028,10030,10032,10034],{"class":108,"line":123},[106,10027,2348],{"class":270},[106,10029,9838],{"class":277},[106,10031,281],{"class":116},[106,10033,291],{"class":224},[106,10035,324],{"class":116},[106,10037,10038],{"class":108,"line":162},[106,10039,330],{"emptyLinePlaceholder":329},[106,10041,10042],{"class":108,"line":200},[106,10043,10044],{"class":335},"  \u002F\u002F Code to handle the request\n",[106,10046,10047,10049],{"class":108,"line":221},[106,10048,366],{"class":112},[106,10050,324],{"class":224},[10,10052,10053],{},"With these two functions, the frontend and the backend of the admin part are secured. Only authenticated users with the role of admin can access the admin part of the application.",[381,10055,10057],{"id":10056},"coming","Coming",[10,10059,10060],{},"This stream was really good and set important things for the future. The next stream will be about the modules and the theme creator. I really hope to be able to push in production soon. I'm really excited about it and to see the feedback from the community.",[10,10062,5766,10063],{},[49,10064,5770],{"repo":5769},[54,10066,10068],{"id":10067},"fetch-an-external-api-for-the-modules","Fetch an external API for the modules",[10,10070,10071],{},[5476,10072],{"alt":10073,"src":10074},"Thumbnail of my fifth live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-18.png",[10,10076,10077],{},[29,10078,10079,5488,10082,5494,10085,5499,10088,52],{},[14,10080,5487],{"href":5454,"rel":10081},[18],[14,10083,5493],{"href":5491,"rel":10084},[18],[14,10086,5498],{"href":5444,"rel":10087},[18],[14,10089,5503],{"href":5454,"rel":10090},[18],[10,10092,10093,10094,10097,10098,10100,10101,10104,10105,10107],{},"Before going further, and implementing the modules page, I decided to change the ",[103,10095,10096],{},"useFetch"," behavior from current Categories and Users page. Actually, the ",[103,10099,10096],{}," composable block the client-side navigation until the data is fetched. This gives the sensation of a slow application. To avoid this, I can use the flag ",[103,10102,10103],{},"lazy",". On server-side, this does not change anything and the page will still be served with data. On the client-side, the navigation will be instant and I will show a skeleton while the data is fetching using the ",[103,10106,7720],{}," ref.",[96,10109,10111],{"className":261,"code":10110,"language":263,"meta":101,"style":101},"const { data, pending } = await useFetch('\u002Fapi\u002Fcategories', {\n  deep: false,\n  lazy: true, \u002F\u002F \u003C= Do not block the navigation\n  default: () => []\n})\n",[103,10112,10113,10145,10155,10169,10183],{"__ignoreMap":101},[106,10114,10115,10117,10119,10121,10123,10125,10127,10129,10131,10133,10135,10137,10139,10141,10143],{"class":108,"line":109},[106,10116,1252],{"class":132},[106,10118,413],{"class":112},[106,10120,964],{"class":224},[106,10122,355],{"class":112},[106,10124,7614],{"class":224},[106,10126,366],{"class":112},[106,10128,311],{"class":112},[106,10130,314],{"class":270},[106,10132,7623],{"class":277},[106,10134,281],{"class":224},[106,10136,754],{"class":112},[106,10138,7630],{"class":142},[106,10140,754],{"class":112},[106,10142,355],{"class":112},[106,10144,300],{"class":112},[106,10146,10147,10149,10151,10153],{"class":108,"line":123},[106,10148,7641],{"class":116},[106,10150,496],{"class":112},[106,10152,7646],{"class":5594},[106,10154,509],{"class":112},[106,10156,10157,10160,10162,10164,10166],{"class":108,"line":162},[106,10158,10159],{"class":116},"  lazy",[106,10161,496],{"class":112},[106,10163,5595],{"class":5594},[106,10165,355],{"class":112},[106,10167,10168],{"class":335}," \u002F\u002F \u003C= Do not block the navigation\n",[106,10170,10171,10174,10176,10178,10180],{"class":108,"line":200},[106,10172,10173],{"class":277},"  default",[106,10175,496],{"class":112},[106,10177,6993],{"class":112},[106,10179,297],{"class":132},[106,10181,10182],{"class":224}," []\n",[106,10184,10185,10187],{"class":108,"line":221},[106,10186,366],{"class":112},[106,10188,324],{"class":224},[10,10190,7122,10191,10193,10194,10196,10197,10199,10200,10202],{},[103,10192,7720],{}," ref is a boolean that tells if the data is fetching or not. I simply pass it to the ",[103,10195,7713],{}," props of the ",[103,10198,7681],{}," component from Nuxt UI Pro and ",[29,10201,5377],{},"! The user will see a loader while the data is fetching and the navigation will be instant.",[3253,10204,5436,10207,5436,10219],{"flex":10205,"md":10206,"gap-x-2":101},"~ col","flex-row",[10208,10209,10210,10211,10210,10215,5436],"figure",{},"\n    ",[5476,10212],{"src":10213,"alt":10214,"loading":10103},"https:\u002F\u002Fimages.soubiran.dev\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fblocking-fetch.gif","Without flag",[10216,10217,10218],"figcaption",{},"Without lazy flag (blocking navigation)",[10208,10220,10210,10221,10210,10225,5436],{},[5476,10222],{"src":10223,"alt":10224,"loading":10103},"https:\u002F\u002Fimages.soubiran.dev\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Flazy-fetch.gif","With lazy flag",[10216,10226,10227],{},"With lazy flag (instant navigation)",[568,10229,10232,10236,10245,10252,10256,10267,10270,10274,10288,10298,10369,10383,10504,10514,10523,10744,10752,10755,10794,10807,10826,10829,11081,11087,11092,11095,11099,11106,11262,11265,11268,11271,11277,11280,11283,11287,11292,11295,11307,11310,11321,11324,11330,11346,11349,11380,11383,11790,11806,11813,12464,12467,12471,12477,12493,12496,12500,12505,12508,12991,13000,13019,13030,13033,13143,13147,13150,13157,13608,13625,13634,13638,13643,13653,13659,13828,13831,13854,13860,13866,15168,15174,15376,15379,15506,15512,15626,15632,15649,16021,16038,16041,16044,16047,16051,16057,16073,16076,16087,16093,16097,16122,16125,16169,16188,16211,16214,16436,16442,16657,16660,16664,16667,16670,16727,16734,16745,16758,16948,16957,16961,16966,16971,16978,17004,17007,17013,17026,17112,17115,17121,17124,17168,17174,17177,17180,17369,17372,17376,17379,17382,17386,17391,17394,17400,17408,17412,17415,17418,17470,17480,17487,17508,17511,17578,17587,17591,17600,17680,17684,17687,17971,17974,17977,18275,18278,18284,18287,18290,18294,18300,18316,18319,18322,18325,18327,18333,18339,18348,18675,18679,18682,18686,18693,18867,18871,18878,19022,19025,19029,19032,19353,19358,19362,19367,19383,19395,19398,19531,19538,19553,19562,19852,19861,19864,20010,20016,20019,20022],{"icon":5442,"text":10230,"to":10231,"target":8371},"Learn More about useFetch","https:\u002F\u002Fyoutu.be\u002Fb1S5os65Urs",[381,10233,10235],{"id":10234},"modules-page","Modules page",[10,10237,10238,10239,10244],{},"Modules are ",[14,10240,10243],{"href":10241,"rel":10242},"https:\u002F\u002Fnuxt.com\u002Fmodules",[18],"Nuxt modules",". They will be linked to the themes and will be used to filter the themes. Imagine you're looking for a theme with good SEO and precisely a sitemap, you'll be able to filter the themes by the modules they use. This is the goal of the modules feature. It will also help the user to understand how the theme is built and what are the features.",[10,10246,10247,10248,10251],{},"The first thing I do is adding a new page ",[103,10249,10250],{},"pages\u002Fadmin\u002Fmodules.vue",". Then, I added the table to display the modules and page meta to use the admin layout and admin middleware. It's similar to the categories and users pages so I will not detail it here.",[6020,10253,10255],{"id":10254},"filling-the-database","Filling the database",[10,10257,10258,10259,10266],{},"The main question is: how to fill the database with modules? For the categories, I create them manually. This is okay because there are not a lot of categories and no way to get a predefined list, but for the modules, it's different. Modules are made by the core team or the community and there are a lot of them. Also, the list is not static and can change over time. So I need to find an API to fetch modules from. Fortunately, Nuxt has an API to get the modules list: ",[14,10260,10263],{"href":10261,"rel":10262},"https:\u002F\u002Fapi.nuxt.com\u002Fmodules",[18],[103,10264,10265],{},"api.nuxt.com\u002Fmodules",". This API is not documented but it's public and I can use it.",[10,10268,10269],{},"Thanks to this API, I will be able to fetch data, sanitize it to keep only the useful information, and store it in the database. I will create an endpoint, accessible only for the admin, to fetch the modules and store them in the database. A second endpoint will be available to retrieve the modules from the database. This will be useful for the frontend to display the modules.",[6020,10271,10273],{"id":10272},"fetching-the-external-api","Fetching the external API",[10,10275,10276,10277,287,10280,10287],{},"I create an endpoint ",[103,10278,10279],{},"server\u002Fapi\u002Fmodules\u002Ffetch.post.ts",[29,10281,10282,10283,10286],{},"off stream, I found that calling this endpoint ",[103,10284,10285],{},"sync"," was a better idea",") to fetch the modules from the external API and store them in the database.",[10,10289,10290,10291,10293,10294,10297],{},"First, I add the utility ",[103,10292,9771],{}," to secure the endpoint. Then, I fetch the modules from the external API using the global ",[103,10295,10296],{},"$fetch"," function:",[96,10299,10301],{"className":261,"code":10300,"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,10302,10303,10325,10337,10341,10363],{"__ignoreMap":101},[106,10304,10305,10307,10309,10311,10313,10315,10317,10319,10321,10323],{"class":108,"line":109},[106,10306,271],{"class":270},[106,10308,274],{"class":270},[106,10310,278],{"class":277},[106,10312,281],{"class":224},[106,10314,284],{"class":132},[106,10316,287],{"class":112},[106,10318,291],{"class":290},[106,10320,294],{"class":112},[106,10322,297],{"class":132},[106,10324,300],{"class":112},[106,10326,10327,10329,10331,10333,10335],{"class":108,"line":123},[106,10328,2348],{"class":270},[106,10330,9838],{"class":277},[106,10332,281],{"class":116},[106,10334,291],{"class":224},[106,10336,324],{"class":116},[106,10338,10339],{"class":108,"line":162},[106,10340,330],{"emptyLinePlaceholder":329},[106,10342,10343,10345,10347,10349,10351,10353,10355,10357,10359,10361],{"class":108,"line":200},[106,10344,305],{"class":132},[106,10346,964],{"class":224},[106,10348,311],{"class":112},[106,10350,314],{"class":270},[106,10352,1405],{"class":277},[106,10354,281],{"class":116},[106,10356,754],{"class":112},[106,10358,10261],{"class":142},[106,10360,754],{"class":112},[106,10362,324],{"class":116},[106,10364,10365,10367],{"class":108,"line":221},[106,10366,366],{"class":112},[106,10368,324],{"class":224},[10,10370,10371,10372,7129,10374,7129,10377,7132,10379,10382],{},"The data returned by the API contains a lot of data that I don't need. I only need the ",[103,10373,3815],{},[103,10375,10376],{},"repo",[103,10378,2474],{},[103,10380,10381],{},"icon"," of the modules. So I sanitize the data to keep only the useful information:",[96,10384,10386],{"className":261,"code":10385,"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,10387,10388,10404,10459],{"__ignoreMap":101},[106,10389,10390,10392,10395,10397,10399,10401],{"class":108,"line":109},[106,10391,1252],{"class":132},[106,10393,10394],{"class":224}," modules ",[106,10396,136],{"class":112},[106,10398,964],{"class":224},[106,10400,52],{"class":112},[106,10402,10403],{"class":224},"modules\n",[106,10405,10406,10409,10412,10414,10416,10418,10420,10422,10424,10427,10429,10432,10434,10436,10438,10440,10442,10444,10446,10448,10450,10452,10455,10457],{"class":108,"line":123},[106,10407,10408],{"class":112},"  .",[106,10410,10411],{"class":277},"map",[106,10413,281],{"class":224},[106,10415,6079],{"class":112},[106,10417,3348],{"class":290},[106,10419,355],{"class":112},[106,10421,171],{"class":290},[106,10423,355],{"class":112},[106,10425,10426],{"class":290}," icon",[106,10428,355],{"class":112},[106,10430,10431],{"class":290}," repo",[106,10433,5629],{"class":112},[106,10435,297],{"class":132},[106,10437,287],{"class":224},[106,10439,1285],{"class":112},[106,10441,3348],{"class":224},[106,10443,355],{"class":112},[106,10445,171],{"class":224},[106,10447,355],{"class":112},[106,10449,10426],{"class":224},[106,10451,355],{"class":112},[106,10453,10454],{"class":224}," repo ",[106,10456,366],{"class":112},[106,10458,1127],{"class":224},[106,10460,10461,10463,10466,10468,10470,10472,10474,10476,10479,10482,10484,10487,10489,10491,10493,10495,10497,10500,10502],{"class":108,"line":162},[106,10462,10408],{"class":112},[106,10464,10465],{"class":277},"filter",[106,10467,281],{"class":224},[106,10469,6079],{"class":112},[106,10471,171],{"class":290},[106,10473,5629],{"class":112},[106,10475,297],{"class":132},[106,10477,10478],{"class":224}," type ",[106,10480,10481],{"class":112},"===",[106,10483,425],{"class":112},[106,10485,10486],{"class":142},"official",[106,10488,754],{"class":112},[106,10490,9904],{"class":112},[106,10492,10478],{"class":224},[106,10494,10481],{"class":112},[106,10496,425],{"class":112},[106,10498,10499],{"class":142},"community",[106,10501,754],{"class":112},[106,10503,324],{"class":224},[10,10505,10506,10507,10510,10511,10513],{},"I choose to not keep the type ",[103,10508,10509],{},"third party"," because it can be a module from anybody and I don't know the quality of them. The ",[103,10512,10376],{}," key was not planned to have it but I think it's a good idea to have it. I could use it to add a link to the GitHub repository of the module on a theme page.",[10,10515,10516,10517,10520,10521,496],{},"Now I have my modules, I need to store them in the database, and I also need to create the table to store them. In the file ",[103,10518,10519],{},"server\u002Fdatabase\u002Fschema.ts",", I create the table ",[103,10522,6835],{},[96,10524,10526],{"className":261,"code":10525,"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,10527,10528,10553,10594,10629,10662,10718,10738],{"__ignoreMap":101},[106,10529,10530,10532,10534,10536,10538,10541,10543,10545,10547,10549,10551],{"class":108,"line":109},[106,10531,271],{"class":270},[106,10533,1775],{"class":132},[106,10535,10394],{"class":224},[106,10537,136],{"class":112},[106,10539,10540],{"class":277}," sqliteTable",[106,10542,281],{"class":224},[106,10544,754],{"class":112},[106,10546,6835],{"class":142},[106,10548,754],{"class":112},[106,10550,355],{"class":112},[106,10552,300],{"class":112},[106,10554,10555,10557,10559,10562,10564,10566,10568,10570,10572,10574,10577,10579,10581,10584,10586,10588,10590,10592],{"class":108,"line":123},[106,10556,9334],{"class":116},[106,10558,496],{"class":112},[106,10560,10561],{"class":277}," integer",[106,10563,281],{"class":224},[106,10565,754],{"class":112},[106,10567,7041],{"class":142},[106,10569,754],{"class":112},[106,10571,294],{"class":224},[106,10573,52],{"class":112},[106,10575,10576],{"class":277},"primaryKey",[106,10578,281],{"class":224},[106,10580,1285],{"class":112},[106,10582,10583],{"class":116}," autoIncrement",[106,10585,496],{"class":112},[106,10587,5595],{"class":5594},[106,10589,419],{"class":112},[106,10591,294],{"class":224},[106,10593,509],{"class":112},[106,10595,10596,10598,10600,10603,10605,10607,10609,10611,10613,10615,10618,10620,10622,10625,10627],{"class":108,"line":162},[106,10597,1272],{"class":116},[106,10599,496],{"class":112},[106,10601,10602],{"class":277}," text",[106,10604,281],{"class":224},[106,10606,754],{"class":112},[106,10608,3815],{"class":142},[106,10610,754],{"class":112},[106,10612,294],{"class":224},[106,10614,52],{"class":112},[106,10616,10617],{"class":277},"notNull",[106,10619,506],{"class":224},[106,10621,52],{"class":112},[106,10623,10624],{"class":277},"unique",[106,10626,506],{"class":224},[106,10628,509],{"class":112},[106,10630,10631,10634,10636,10638,10640,10642,10644,10646,10648,10650,10652,10654,10656,10658,10660],{"class":108,"line":200},[106,10632,10633],{"class":116},"  repo",[106,10635,496],{"class":112},[106,10637,10602],{"class":277},[106,10639,281],{"class":224},[106,10641,754],{"class":112},[106,10643,10376],{"class":142},[106,10645,754],{"class":112},[106,10647,294],{"class":224},[106,10649,52],{"class":112},[106,10651,10617],{"class":277},[106,10653,506],{"class":224},[106,10655,52],{"class":112},[106,10657,10624],{"class":277},[106,10659,506],{"class":224},[106,10661,509],{"class":112},[106,10663,10664,10667,10669,10671,10673,10675,10677,10679,10681,10683,10686,10688,10690,10692,10694,10696,10698,10700,10702,10704,10706,10708,10710,10712,10714,10716],{"class":108,"line":221},[106,10665,10666],{"class":116},"  type",[106,10668,496],{"class":112},[106,10670,10602],{"class":277},[106,10672,281],{"class":224},[106,10674,754],{"class":112},[106,10676,2474],{"class":142},[106,10678,754],{"class":112},[106,10680,355],{"class":112},[106,10682,413],{"class":112},[106,10684,10685],{"class":116}," enum",[106,10687,496],{"class":112},[106,10689,2524],{"class":224},[106,10691,754],{"class":112},[106,10693,10486],{"class":142},[106,10695,754],{"class":112},[106,10697,355],{"class":112},[106,10699,425],{"class":112},[106,10701,10499],{"class":142},[106,10703,754],{"class":112},[106,10705,5282],{"class":224},[106,10707,366],{"class":112},[106,10709,294],{"class":224},[106,10711,52],{"class":112},[106,10713,10617],{"class":277},[106,10715,506],{"class":224},[106,10717,509],{"class":112},[106,10719,10720,10722,10724,10726,10728,10730,10732,10734,10736],{"class":108,"line":228},[106,10721,9363],{"class":116},[106,10723,496],{"class":112},[106,10725,10602],{"class":277},[106,10727,281],{"class":224},[106,10729,754],{"class":112},[106,10731,10381],{"class":142},[106,10733,754],{"class":112},[106,10735,294],{"class":224},[106,10737,509],{"class":112},[106,10739,10740,10742],{"class":108,"line":238},[106,10741,366],{"class":112},[106,10743,324],{"class":224},[1830,10745,10746],{},[10,10747,10748,10751],{},[106,10749,10750],{},"!NOTE","\nDo not forget to run the drizzle command to generate the SQL migration.",[10,10753,10754],{},"In the endpoint, it's now possible to insert the modules into the database:",[96,10756,10758],{"className":261,"code":10757,"language":263,"meta":101,"style":101},"await useDrizzle().insert(tables.modules).values(modules).execute()\n",[103,10759,10760],{"__ignoreMap":101},[106,10761,10762,10765,10767,10769,10771,10773,10776,10778,10781,10783,10785,10788,10790,10792],{"class":108,"line":109},[106,10763,10764],{"class":270},"await",[106,10766,7011],{"class":277},[106,10768,506],{"class":224},[106,10770,52],{"class":112},[106,10772,7207],{"class":277},[106,10774,10775],{"class":224},"(tables",[106,10777,52],{"class":112},[106,10779,10780],{"class":224},"modules)",[106,10782,52],{"class":112},[106,10784,7222],{"class":277},[106,10786,10787],{"class":224},"(modules)",[106,10789,52],{"class":112},[106,10791,7273],{"class":277},[106,10793,2366],{"class":224},[10,10795,10796,10797,10800,10801,10806],{},"BUT, surprise, it ",[22,10798,10799],{},"does not work",". I did not know, but the ",[14,10802,10805],{"href":10803,"rel":10804},"https:\u002F\u002Fdevelopers.cloudflare.com\u002Fd1\u002Fplatform\u002Flimits\u002F",[18],"Cloudflare D1 has a (several) limit"," on the bound parameters at 100. In reality, this limit exists because they use a SQLite under the hood which has this limit for security reasons.",[10,10808,10809,10810,10813,10814,10817,10818,10821,10822,10825],{},"What is a bound parameter? It's a parameter in a SQL query that is replaced by a value when the query is executed. For example, in the query ",[103,10811,10812],{},"SELECT * FROM users WHERE id = ?",", the ",[103,10815,10816],{},"?"," is a bound parameter. In the case of the insert query ",[103,10819,10820],{},"INSERT INTO users (name, email) VALUES (?, ?)",", the bound parameters are the values to insert in the table. The limit is 100 bound parameters. Warning, ",[22,10823,10824],{},"a row is not a bound parameter",", a bound parameter is a single value, meaning that if you insert a row with 10 columns, it will count as 10 bound parameters. There are 42 modules with 4 columns, so 168 bound parameters.",[10,10827,10828],{},"This is a real problem because I'll have to split the insert into multiple queries and a worker is time limited, so if it runs too long, the worker could fail.",[96,10830,10832],{"className":261,"code":10831,"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,10833,10834,10864,10892,10921,10925,10964,11012,11077],{"__ignoreMap":101},[106,10835,10836,10838,10841,10843,10846,10848,10851,10854,10856,10859,10861],{"class":108,"line":109},[106,10837,1252],{"class":132},[106,10839,10840],{"class":224}," columnsPerModule ",[106,10842,136],{"class":112},[106,10844,10845],{"class":224}," Object",[106,10847,52],{"class":112},[106,10849,10850],{"class":277},"keys",[106,10852,10853],{"class":224},"(modules[",[106,10855,782],{"class":358},[106,10857,10858],{"class":224},"])",[106,10860,52],{"class":112},[106,10862,10863],{"class":224},"length\n",[106,10865,10866,10868,10871,10873,10876,10878,10881,10883,10886,10889],{"class":108,"line":123},[106,10867,1252],{"class":132},[106,10869,10870],{"class":224}," insertPerLoop ",[106,10872,136],{"class":112},[106,10874,10875],{"class":224}," Math",[106,10877,52],{"class":112},[106,10879,10880],{"class":277},"floor",[106,10882,281],{"class":224},[106,10884,10885],{"class":358},"100",[106,10887,10888],{"class":112}," \u002F",[106,10890,10891],{"class":224}," columnsPerModule)\n",[106,10893,10894,10896,10899,10901,10903,10905,10908,10911,10913,10916,10918],{"class":108,"line":162},[106,10895,1252],{"class":132},[106,10897,10898],{"class":224}," loops ",[106,10900,136],{"class":112},[106,10902,10875],{"class":224},[106,10904,52],{"class":112},[106,10906,10907],{"class":277},"ceil",[106,10909,10910],{"class":224},"(modules",[106,10912,52],{"class":112},[106,10914,10915],{"class":224},"length ",[106,10917,5705],{"class":112},[106,10919,10920],{"class":224}," insertPerLoop)\n",[106,10922,10923],{"class":108,"line":200},[106,10924,330],{"emptyLinePlaceholder":329},[106,10926,10927,10929,10931,10934,10937,10939,10942,10945,10947,10949,10952,10954,10957,10960,10962],{"class":108,"line":221},[106,10928,5217],{"class":270},[106,10930,287],{"class":224},[106,10932,10933],{"class":132},"let",[106,10935,10936],{"class":224}," loop ",[106,10938,136],{"class":112},[106,10940,10941],{"class":358}," 0",[106,10943,10944],{"class":112},";",[106,10946,10936],{"class":224},[106,10948,113],{"class":112},[106,10950,10951],{"class":224}," loops",[106,10953,10944],{"class":112},[106,10955,10956],{"class":224}," loop",[106,10958,10959],{"class":112},"++",[106,10961,713],{"class":224},[106,10963,488],{"class":112},[106,10965,10966,10968,10971,10973,10976,10978,10981,10983,10986,10988,10991,10993,10995,10997,11000,11003,11005,11008,11010],{"class":108,"line":228},[106,10967,305],{"class":132},[106,10969,10970],{"class":224}," values",[106,10972,311],{"class":112},[106,10974,10975],{"class":224}," modules",[106,10977,52],{"class":112},[106,10979,10980],{"class":277},"slice",[106,10982,281],{"class":116},[106,10984,10985],{"class":224},"loop",[106,10987,2434],{"class":112},[106,10989,10990],{"class":224}," insertPerLoop",[106,10992,355],{"class":112},[106,10994,287],{"class":116},[106,10996,10985],{"class":224},[106,10998,10999],{"class":112}," +",[106,11001,11002],{"class":358}," 1",[106,11004,713],{"class":116},[106,11006,11007],{"class":112},"*",[106,11009,10990],{"class":224},[106,11011,324],{"class":116},[106,11013,11014,11016,11018,11020,11022,11024,11026,11028,11030,11032,11034,11036,11038,11040,11042,11044,11046,11049,11051,11053,11055,11057,11059,11061,11063,11065,11067,11069,11071,11073,11075],{"class":108,"line":238},[106,11015,2348],{"class":270},[106,11017,7011],{"class":277},[106,11019,506],{"class":116},[106,11021,52],{"class":112},[106,11023,7207],{"class":277},[106,11025,281],{"class":116},[106,11027,7096],{"class":224},[106,11029,52],{"class":112},[106,11031,6835],{"class":224},[106,11033,294],{"class":116},[106,11035,52],{"class":112},[106,11037,7222],{"class":277},[106,11039,281],{"class":116},[106,11041,7222],{"class":224},[106,11043,294],{"class":116},[106,11045,52],{"class":112},[106,11047,11048],{"class":277},"onConflictDoNothing",[106,11050,281],{"class":116},[106,11052,1285],{"class":112},[106,11054,3043],{"class":116},[106,11056,496],{"class":112},[106,11058,7032],{"class":224},[106,11060,52],{"class":112},[106,11062,6835],{"class":224},[106,11064,52],{"class":112},[106,11066,10376],{"class":224},[106,11068,419],{"class":112},[106,11070,294],{"class":116},[106,11072,52],{"class":112},[106,11074,7273],{"class":277},[106,11076,2366],{"class":116},[106,11078,11079],{"class":108,"line":248},[106,11080,1558],{"class":112},[10,11082,11083,11084,11086],{},"In order to have a dynamic insert in case I change the number of columns, I calculate the maximum number of rows I can insert in a single query. Then, I loop over the modules and insert the maximum number of rows in each query. I also use the ",[103,11085,11048],{}," method to avoid inserting the same module twice if I run the endpoint multiple times.",[10,11088,11089],{},[29,11090,11091],{},"I tried this in a production environment, and everything is working fine.",[10,11093,11094],{},"I choose to return a 204 status code when the modules are fetched. If everything is ok, I can refresh the modules list in the UI to view the new modules.",[6020,11096,11098],{"id":11097},"serving-the-modules","Serving the modules",[10,11100,11101,11102,11105],{},"This part is exactly the same as the categories and users index endpoint. I create an endpoint ",[103,11103,11104],{},"server\u002Fapi\u002Fmodules\u002Findex.get.ts"," to get the modules from the database:",[96,11107,11109],{"className":261,"code":11108,"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,11110,11111,11129,11151,11169,11187,11206,11225,11245,11249,11256],{"__ignoreMap":101},[106,11112,11113,11115,11117,11119,11121,11123,11125,11127],{"class":108,"line":109},[106,11114,271],{"class":270},[106,11116,274],{"class":270},[106,11118,278],{"class":277},[106,11120,281],{"class":224},[106,11122,284],{"class":132},[106,11124,6993],{"class":112},[106,11126,297],{"class":132},[106,11128,300],{"class":112},[106,11130,11131,11133,11135,11137,11139,11141,11143,11145,11147,11149],{"class":108,"line":123},[106,11132,305],{"class":132},[106,11134,10975],{"class":224},[106,11136,311],{"class":112},[106,11138,314],{"class":270},[106,11140,7011],{"class":277},[106,11142,506],{"class":116},[106,11144,52],{"class":112},[106,11146,7018],{"class":277},[106,11148,281],{"class":116},[106,11150,488],{"class":112},[106,11152,11153,11155,11157,11159,11161,11163,11165,11167],{"class":108,"line":162},[106,11154,7027],{"class":116},[106,11156,496],{"class":112},[106,11158,7032],{"class":224},[106,11160,52],{"class":112},[106,11162,6835],{"class":224},[106,11164,52],{"class":112},[106,11166,7041],{"class":224},[106,11168,509],{"class":112},[106,11170,11171,11173,11175,11177,11179,11181,11183,11185],{"class":108,"line":200},[106,11172,493],{"class":116},[106,11174,496],{"class":112},[106,11176,7032],{"class":224},[106,11178,52],{"class":112},[106,11180,6835],{"class":224},[106,11182,52],{"class":112},[106,11184,3815],{"class":224},[106,11186,509],{"class":112},[106,11188,11189,11192,11194,11196,11198,11200,11202,11204],{"class":108,"line":221},[106,11190,11191],{"class":116},"    type",[106,11193,496],{"class":112},[106,11195,7032],{"class":224},[106,11197,52],{"class":112},[106,11199,6835],{"class":224},[106,11201,52],{"class":112},[106,11203,2474],{"class":224},[106,11205,509],{"class":112},[106,11207,11208,11211,11213,11215,11217,11219,11221,11223],{"class":108,"line":228},[106,11209,11210],{"class":116},"    icon",[106,11212,496],{"class":112},[106,11214,7032],{"class":224},[106,11216,52],{"class":112},[106,11218,6835],{"class":224},[106,11220,52],{"class":112},[106,11222,10381],{"class":224},[106,11224,509],{"class":112},[106,11226,11227,11229,11231,11233,11235,11237,11239,11241,11243],{"class":108,"line":238},[106,11228,514],{"class":112},[106,11230,294],{"class":116},[106,11232,52],{"class":112},[106,11234,7091],{"class":277},[106,11236,281],{"class":116},[106,11238,7096],{"class":224},[106,11240,52],{"class":112},[106,11242,6835],{"class":224},[106,11244,324],{"class":116},[106,11246,11247],{"class":108,"line":248},[106,11248,330],{"emptyLinePlaceholder":329},[106,11250,11251,11253],{"class":108,"line":534},[106,11252,345],{"class":270},[106,11254,11255],{"class":224}," modules\n",[106,11257,11258,11260],{"class":108,"line":539},[106,11259,366],{"class":112},[106,11261,324],{"class":224},[10,11263,11264],{},"Nothing new here, I just select the modules from the database and return them.",[381,11266,5371],{"id":11267},"conclusion-1",[10,11269,11270],{},"All of this stuff gives us this result:",[10,11272,11273],{},[5476,11274],{"alt":11275,"src":11276},"Modules Page","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmodules-page.png",[10,11278,11279],{},"This stream was different from the others. First, I now have a new mic and the sound is way better than before!",[10,11281,11282],{},"Second, I encountered a problem with the D1 limit (that could result in a need to rethink the project, hopefully not) on the bound parameters. This is a good lesson for me, and I hope it will be useful for you.",[54,11284,11286],{"id":11285},"refactoring-the-user-management","Refactoring the user management",[10,11288,11289],{},[29,11290,11291],{},"This part was done off-stream because it was not interesting to see and I was not sure of the direction I wanted to take.",[10,11293,11294],{},"While writing a part of this article, I ask myself if storing the user's data was a good idea. Couldn't it be better? This question comes from the way I handle the authentication endpoint.",[10,11296,11297,11298,11303,11304],{},"Actually, when a user logged in, I always write to the database the user's data to be sure to be up-to-date. The main concern is that a write is way more expensive than a read. To find ideas, I read the ",[14,11299,11302],{"href":11300,"rel":11301},"https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-todos-edge",[18],"Atinux's Nuxt todo list project",". In this project, it does not store the user's data. When the user logs in, it only stores the user's ID in the cookie. When the user creates a todo, it simply has to read the user's ID from the cookie to know who is the user. It's very smart and simple. I will do the same. ",[29,11305,11306],{},"First through.",[10,11308,11309],{},"Let's analyze if this way is possible for the Orion project. To know if it's possible, I need to answer some questions:",[5987,11311,11312,11315,11318],{},[5860,11313,11314],{},"Can I ban or promote a user without storing the user's data?",[5860,11316,11317],{},"Can a user create data without storing the user's data?",[5860,11319,11320],{},"Can I show user's data without storing the user's data?",[10,11322,11323],{},"For the first question, it's yes. I can imagine a system where you enter the login of a user, and when the user logs in, I will search in the database if the entry exists and get relevant data. It's not the best way but it's possible.",[10,11325,11326,11327],{},"For the second question, it's yes. Since data are in the cookie, I can easily know who is the user and create data using this information. ",[29,11328,11329],{},"And that's how it works actually. I do not fetch the user for every request.",[10,11331,11332,11333,1737,11336,11339,11340,11342,11343],{},"For the third question, it's no. Imagine, I want to show the template created by the user 'X'. In our SQL schema, we have a column ",[103,11334,11335],{},"user_id",[103,11337,11338],{},"templates"," table. If I do not store the user's data, I will not be able to know who is the user 'X', associated with the ",[103,11341,11335],{},". I will have to store the user's data in the database. ",[29,11344,11345],{},"It could be possible with a non-relational database but it's not the case here. We could also imagine a system where the user is saved when he creates a template. It's possible but it will add a lot of complexity. At the end, this answer and the one from the question one just over complicate the system for no real gain.",[10,11347,11348],{},"Now we know that we have to store the users in the database, what can we improve?",[5857,11350,11351,11354,11357,11366,11377],{},[5860,11352,11353],{},"Saving the user's data only if it's necessary. For example, I could first search for the user before saving its data when the user logs in.",[5860,11355,11356],{},"Saving the user's mail. This will be useful for the feedback system.",[5860,11358,11359,11360,11362,11363,52],{},"Saving the user's name and renaming the ",[103,11361,8767],{}," column to ",[103,11364,11365],{},"login",[5860,11367,11368,11369,11372,11373,11376],{},"Adding a ",[103,11370,11371],{},"created_at"," and an ",[103,11374,11375],{},"updated_at"," column.",[5860,11378,11379],{},"Updating data stored in the cookie.",[10,11381,11382],{},"With all of these points, I also update the UI accordingly. There is nothing difficult here; it's mainly renaming and adding fields in the schema.",[96,11384,11386],{"className":261,"code":11385,"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,11387,11388,11412,11450,11484,11518,11551,11571,11599,11665,11716,11784],{"__ignoreMap":101},[106,11389,11390,11392,11394,11396,11398,11400,11402,11404,11406,11408,11410],{"class":108,"line":109},[106,11391,271],{"class":270},[106,11393,1775],{"class":132},[106,11395,9024],{"class":224},[106,11397,136],{"class":112},[106,11399,10540],{"class":277},[106,11401,281],{"class":224},[106,11403,754],{"class":112},[106,11405,6823],{"class":142},[106,11407,754],{"class":112},[106,11409,355],{"class":112},[106,11411,300],{"class":112},[106,11413,11414,11416,11418,11420,11422,11424,11426,11428,11430,11432,11434,11436,11438,11440,11442,11444,11446,11448],{"class":108,"line":123},[106,11415,9334],{"class":116},[106,11417,496],{"class":112},[106,11419,10561],{"class":277},[106,11421,281],{"class":224},[106,11423,754],{"class":112},[106,11425,7041],{"class":142},[106,11427,754],{"class":112},[106,11429,294],{"class":224},[106,11431,52],{"class":112},[106,11433,10576],{"class":277},[106,11435,281],{"class":224},[106,11437,1285],{"class":112},[106,11439,10583],{"class":116},[106,11441,496],{"class":112},[106,11443,5595],{"class":5594},[106,11445,419],{"class":112},[106,11447,294],{"class":224},[106,11449,509],{"class":112},[106,11451,11452,11455,11457,11459,11461,11463,11466,11468,11470,11472,11474,11476,11478,11480,11482],{"class":108,"line":162},[106,11453,11454],{"class":116},"  githubId",[106,11456,496],{"class":112},[106,11458,10561],{"class":277},[106,11460,281],{"class":224},[106,11462,754],{"class":112},[106,11464,11465],{"class":142},"github_id",[106,11467,754],{"class":112},[106,11469,294],{"class":224},[106,11471,52],{"class":112},[106,11473,10617],{"class":277},[106,11475,506],{"class":224},[106,11477,52],{"class":112},[106,11479,10624],{"class":277},[106,11481,506],{"class":224},[106,11483,509],{"class":112},[106,11485,11486,11489,11491,11493,11495,11497,11500,11502,11504,11506,11508,11510,11512,11514,11516],{"class":108,"line":200},[106,11487,11488],{"class":116},"  email",[106,11490,496],{"class":112},[106,11492,10602],{"class":277},[106,11494,281],{"class":224},[106,11496,754],{"class":112},[106,11498,11499],{"class":142},"email",[106,11501,754],{"class":112},[106,11503,294],{"class":224},[106,11505,52],{"class":112},[106,11507,10617],{"class":277},[106,11509,506],{"class":224},[106,11511,52],{"class":112},[106,11513,10624],{"class":277},[106,11515,506],{"class":224},[106,11517,509],{"class":112},[106,11519,11520,11523,11525,11527,11529,11531,11533,11535,11537,11539,11541,11543,11545,11547,11549],{"class":108,"line":221},[106,11521,11522],{"class":116},"  login",[106,11524,496],{"class":112},[106,11526,10602],{"class":277},[106,11528,281],{"class":224},[106,11530,754],{"class":112},[106,11532,11365],{"class":142},[106,11534,754],{"class":112},[106,11536,294],{"class":224},[106,11538,52],{"class":112},[106,11540,10617],{"class":277},[106,11542,506],{"class":224},[106,11544,52],{"class":112},[106,11546,10624],{"class":277},[106,11548,506],{"class":224},[106,11550,509],{"class":112},[106,11552,11553,11555,11557,11559,11561,11563,11565,11567,11569],{"class":108,"line":228},[106,11554,1272],{"class":116},[106,11556,496],{"class":112},[106,11558,10602],{"class":277},[106,11560,281],{"class":224},[106,11562,754],{"class":112},[106,11564,3815],{"class":142},[106,11566,754],{"class":112},[106,11568,294],{"class":224},[106,11570,509],{"class":112},[106,11572,11573,11576,11578,11580,11582,11584,11587,11589,11591,11593,11595,11597],{"class":108,"line":238},[106,11574,11575],{"class":116},"  avatarUrl",[106,11577,496],{"class":112},[106,11579,10602],{"class":277},[106,11581,281],{"class":224},[106,11583,754],{"class":112},[106,11585,11586],{"class":142},"avatar_url",[106,11588,754],{"class":112},[106,11590,294],{"class":224},[106,11592,52],{"class":112},[106,11594,10617],{"class":277},[106,11596,506],{"class":224},[106,11598,509],{"class":112},[106,11600,11601,11604,11606,11608,11610,11612,11615,11617,11619,11621,11623,11625,11627,11629,11631,11633,11635,11637,11640,11642,11644,11646,11648,11650,11653,11655,11657,11659,11661,11663],{"class":108,"line":248},[106,11602,11603],{"class":116},"  roleType",[106,11605,496],{"class":112},[106,11607,10602],{"class":277},[106,11609,281],{"class":224},[106,11611,754],{"class":112},[106,11613,11614],{"class":142},"role_type",[106,11616,754],{"class":112},[106,11618,355],{"class":112},[106,11620,413],{"class":112},[106,11622,10685],{"class":116},[106,11624,496],{"class":112},[106,11626,2524],{"class":224},[106,11628,754],{"class":112},[106,11630,7351],{"class":142},[106,11632,754],{"class":112},[106,11634,355],{"class":112},[106,11636,425],{"class":112},[106,11638,11639],{"class":142},"creator",[106,11641,754],{"class":112},[106,11643,5282],{"class":224},[106,11645,366],{"class":112},[106,11647,294],{"class":224},[106,11649,52],{"class":112},[106,11651,11652],{"class":277},"default",[106,11654,281],{"class":224},[106,11656,754],{"class":112},[106,11658,11639],{"class":142},[106,11660,754],{"class":112},[106,11662,294],{"class":224},[106,11664,509],{"class":112},[106,11666,11667,11670,11672,11674,11676,11678,11680,11682,11684,11686,11688,11690,11692,11695,11697,11699,11701,11704,11707,11710,11712,11714],{"class":108,"line":534},[106,11668,11669],{"class":116},"  createdAt",[106,11671,496],{"class":112},[106,11673,10602],{"class":277},[106,11675,281],{"class":224},[106,11677,754],{"class":112},[106,11679,11371],{"class":142},[106,11681,754],{"class":112},[106,11683,294],{"class":224},[106,11685,52],{"class":112},[106,11687,10617],{"class":277},[106,11689,506],{"class":224},[106,11691,52],{"class":112},[106,11693,11694],{"class":277},"$defaultFn",[106,11696,281],{"class":224},[106,11698,506],{"class":112},[106,11700,297],{"class":132},[106,11702,11703],{"class":277}," sql",[106,11705,11706],{"class":112},"`",[106,11708,11709],{"class":142},"(current_timestamp)",[106,11711,11706],{"class":112},[106,11713,294],{"class":224},[106,11715,509],{"class":112},[106,11717,11718,11721,11723,11725,11727,11729,11731,11733,11735,11737,11739,11741,11743,11745,11747,11749,11751,11753,11755,11757,11759,11761,11763,11766,11768,11770,11772,11774,11776,11778,11780,11782],{"class":108,"line":539},[106,11719,11720],{"class":116},"  updatedAt",[106,11722,496],{"class":112},[106,11724,10602],{"class":277},[106,11726,281],{"class":224},[106,11728,754],{"class":112},[106,11730,11375],{"class":142},[106,11732,754],{"class":112},[106,11734,294],{"class":224},[106,11736,52],{"class":112},[106,11738,10617],{"class":277},[106,11740,506],{"class":224},[106,11742,52],{"class":112},[106,11744,11694],{"class":277},[106,11746,281],{"class":224},[106,11748,506],{"class":112},[106,11750,297],{"class":132},[106,11752,11703],{"class":277},[106,11754,11706],{"class":112},[106,11756,11709],{"class":142},[106,11758,11706],{"class":112},[106,11760,294],{"class":224},[106,11762,52],{"class":112},[106,11764,11765],{"class":277},"$onUpdateFn",[106,11767,281],{"class":224},[106,11769,506],{"class":112},[106,11771,297],{"class":132},[106,11773,11703],{"class":277},[106,11775,11706],{"class":112},[106,11777,11709],{"class":142},[106,11779,11706],{"class":112},[106,11781,294],{"class":224},[106,11783,509],{"class":112},[106,11785,11786,11788],{"class":108,"line":556},[106,11787,366],{"class":112},[106,11789,324],{"class":224},[10,11791,11792,11793,72,11795,11797,11798,11801,11802,11805],{},"Functions ",[103,11794,11694],{},[103,11796,11765],{}," are used to set the default value of the column. In the case of ",[103,11799,11800],{},"createdAt",", the default value is the current timestamp. In the case of ",[103,11803,11804],{},"updatedAt",", the default value is the current timestamp, and the value is updated when the row is updated. It's all automatic and handled by Drizzle.",[10,11807,11808,11809,11812],{},"I also update the ",[103,11810,11811],{},"server\u002Froutes\u002Fauth\u002Fgithub.ts"," to store the user's data only if it's necessary:",[96,11814,11816],{"className":261,"code":11815,"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     * If the user is not in the database or their data has changed, update the user data.\n     * A write is more expensive than a read, so we only write if necessary.\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     * Set only the necessary data in the 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,11817,11818,11834,11842,11852,11856,11874,11894,11898,11914,11918,11936,11940,12005,12009,12013,12018,12023,12027,12055,12089,12095,12110,12125,12140,12155,12170,12189,12198,12213,12228,12243,12258,12262,12281,12285,12289,12293,12298,12302,12316,12324,12340,12354,12368,12382,12396,12419,12423,12429,12433,12454,12458],{"__ignoreMap":101},[106,11819,11820,11822,11824,11826,11828,11830,11832],{"class":108,"line":109},[106,11821,271],{"class":270},[106,11823,274],{"class":270},[106,11825,5566],{"class":224},[106,11827,52],{"class":112},[106,11829,5571],{"class":277},[106,11831,281],{"class":224},[106,11833,488],{"class":112},[106,11835,11836,11838,11840],{"class":108,"line":123},[106,11837,5580],{"class":116},[106,11839,496],{"class":112},[106,11841,300],{"class":112},[106,11843,11844,11846,11848,11850],{"class":108,"line":162},[106,11845,5589],{"class":116},[106,11847,496],{"class":112},[106,11849,5595],{"class":5594},[106,11851,509],{"class":112},[106,11853,11854],{"class":108,"line":200},[106,11855,5602],{"class":112},[106,11857,11858,11860,11862,11864,11866,11868,11870,11872],{"class":108,"line":221},[106,11859,5607],{"class":132},[106,11861,5610],{"class":116},[106,11863,281],{"class":112},[106,11865,291],{"class":290},[106,11867,355],{"class":112},[106,11869,638],{"class":290},[106,11871,294],{"class":112},[106,11873,300],{"class":112},[106,11875,11876,11878,11880,11882,11884,11887,11889,11891],{"class":108,"line":228},[106,11877,1379],{"class":132},[106,11879,413],{"class":112},[106,11881,5621],{"class":116},[106,11883,496],{"class":112},[106,11885,11886],{"class":224}," ghUser",[106,11888,419],{"class":112},[106,11890,311],{"class":112},[106,11892,11893],{"class":224}," result\n",[106,11895,11896],{"class":108,"line":238},[106,11897,330],{"emptyLinePlaceholder":329},[106,11899,11900,11902,11905,11907,11909,11911],{"class":108,"line":248},[106,11901,1379],{"class":132},[106,11903,11904],{"class":224}," githubId",[106,11906,311],{"class":112},[106,11908,11886],{"class":224},[106,11910,52],{"class":112},[106,11912,11913],{"class":224},"id\n",[106,11915,11916],{"class":108,"line":534},[106,11917,330],{"emptyLinePlaceholder":329},[106,11919,11920,11923,11925,11927,11930,11933],{"class":108,"line":539},[106,11921,11922],{"class":132},"    let",[106,11924,5621],{"class":224},[106,11926,496],{"class":112},[106,11928,11929],{"class":1041}," User",[106,11931,11932],{"class":112}," |",[106,11934,11935],{"class":1041}," undefined\n",[106,11937,11938],{"class":108,"line":556},[106,11939,330],{"emptyLinePlaceholder":329},[106,11941,11942,11945,11947,11949,11951,11953,11955,11957,11959,11961,11963,11965,11967,11969,11971,11973,11975,11977,11979,11981,11983,11985,11987,11989,11991,11993,11995,11997,11999,12001,12003],{"class":108,"line":759},[106,11943,11944],{"class":224},"    user",[106,11946,311],{"class":112},[106,11948,314],{"class":270},[106,11950,7011],{"class":277},[106,11952,506],{"class":116},[106,11954,52],{"class":112},[106,11956,7018],{"class":277},[106,11958,506],{"class":116},[106,11960,52],{"class":112},[106,11962,7091],{"class":277},[106,11964,281],{"class":116},[106,11966,7096],{"class":224},[106,11968,52],{"class":112},[106,11970,6823],{"class":224},[106,11972,294],{"class":116},[106,11974,52],{"class":112},[106,11976,8562],{"class":277},[106,11978,281],{"class":116},[106,11980,8567],{"class":277},[106,11982,281],{"class":116},[106,11984,7096],{"class":224},[106,11986,52],{"class":112},[106,11988,6823],{"class":224},[106,11990,52],{"class":112},[106,11992,5669],{"class":224},[106,11994,355],{"class":112},[106,11996,11904],{"class":224},[106,11998,8591],{"class":116},[106,12000,52],{"class":112},[106,12002,2333],{"class":277},[106,12004,2366],{"class":116},[106,12006,12007],{"class":108,"line":795},[106,12008,330],{"emptyLinePlaceholder":329},[106,12010,12011],{"class":108,"line":803},[106,12012,6151],{"class":335},[106,12014,12015],{"class":108,"line":809},[106,12016,12017],{"class":335},"     * If the user is not in the database or their data has changed, update the user data.\n",[106,12019,12020],{"class":108,"line":814},[106,12021,12022],{"class":335},"     * A write is more expensive than a read, so we only write if necessary.\n",[106,12024,12025],{"class":108,"line":830},[106,12026,6161],{"class":335},[106,12028,12029,12031,12033,12035,12037,12039,12042,12044,12046,12048,12050,12053],{"class":108,"line":835},[106,12030,1031],{"class":270},[106,12032,287],{"class":116},[106,12034,702],{"class":112},[106,12036,9901],{"class":224},[106,12038,9904],{"class":112},[106,12040,12041],{"class":277}," userDataChanged",[106,12043,281],{"class":116},[106,12045,9901],{"class":224},[106,12047,355],{"class":112},[106,12049,11886],{"class":224},[106,12051,12052],{"class":116},")) ",[106,12054,488],{"class":112},[106,12056,12057,12059,12061,12063,12065,12067,12069,12071,12073,12075,12077,12079,12081,12083,12085,12087],{"class":108,"line":840},[106,12058,5651],{"class":224},[106,12060,311],{"class":112},[106,12062,314],{"class":270},[106,12064,7011],{"class":277},[106,12066,506],{"class":116},[106,12068,52],{"class":112},[106,12070,7207],{"class":277},[106,12072,281],{"class":116},[106,12074,7096],{"class":224},[106,12076,52],{"class":112},[106,12078,6823],{"class":224},[106,12080,294],{"class":116},[106,12082,52],{"class":112},[106,12084,7222],{"class":277},[106,12086,281],{"class":116},[106,12088,488],{"class":112},[106,12090,12091,12093],{"class":108,"line":845},[106,12092,5660],{"class":224},[106,12094,509],{"class":112},[106,12096,12097,12100,12102,12104,12106,12108],{"class":108,"line":862},[106,12098,12099],{"class":116},"        login",[106,12101,496],{"class":112},[106,12103,11886],{"class":224},[106,12105,52],{"class":112},[106,12107,11365],{"class":224},[106,12109,509],{"class":112},[106,12111,12112,12115,12117,12119,12121,12123],{"class":108,"line":1469},[106,12113,12114],{"class":116},"        email",[106,12116,496],{"class":112},[106,12118,11886],{"class":224},[106,12120,52],{"class":112},[106,12122,11499],{"class":224},[106,12124,509],{"class":112},[106,12126,12127,12130,12132,12134,12136,12138],{"class":108,"line":1486},[106,12128,12129],{"class":116},"        name",[106,12131,496],{"class":112},[106,12133,11886],{"class":224},[106,12135,52],{"class":112},[106,12137,3815],{"class":224},[106,12139,509],{"class":112},[106,12141,12142,12145,12147,12149,12151,12153],{"class":108,"line":1497},[106,12143,12144],{"class":116},"        avatarUrl",[106,12146,496],{"class":112},[106,12148,11886],{"class":224},[106,12150,52],{"class":112},[106,12152,11586],{"class":224},[106,12154,509],{"class":112},[106,12156,12157,12159,12161,12163,12166,12168],{"class":108,"line":1508},[106,12158,1113],{"class":112},[106,12160,294],{"class":116},[106,12162,52],{"class":112},[106,12164,12165],{"class":277},"onConflictDoUpdate",[106,12167,281],{"class":116},[106,12169,488],{"class":112},[106,12171,12172,12175,12177,12179,12181,12183,12185,12187],{"class":108,"line":1523},[106,12173,12174],{"class":116},"        target",[106,12176,496],{"class":112},[106,12178,7032],{"class":224},[106,12180,52],{"class":112},[106,12182,6823],{"class":224},[106,12184,52],{"class":112},[106,12186,5669],{"class":224},[106,12188,509],{"class":112},[106,12190,12191,12194,12196],{"class":108,"line":1538},[106,12192,12193],{"class":116},"        set",[106,12195,496],{"class":112},[106,12197,300],{"class":112},[106,12199,12200,12203,12205,12207,12209,12211],{"class":108,"line":1545},[106,12201,12202],{"class":116},"          login",[106,12204,496],{"class":112},[106,12206,11886],{"class":224},[106,12208,52],{"class":112},[106,12210,11365],{"class":224},[106,12212,509],{"class":112},[106,12214,12215,12218,12220,12222,12224,12226],{"class":108,"line":1550},[106,12216,12217],{"class":116},"          email",[106,12219,496],{"class":112},[106,12221,11886],{"class":224},[106,12223,52],{"class":112},[106,12225,11499],{"class":224},[106,12227,509],{"class":112},[106,12229,12230,12233,12235,12237,12239,12241],{"class":108,"line":1555},[106,12231,12232],{"class":116},"          name",[106,12234,496],{"class":112},[106,12236,11886],{"class":224},[106,12238,52],{"class":112},[106,12240,3815],{"class":224},[106,12242,509],{"class":112},[106,12244,12245,12248,12250,12252,12254,12256],{"class":108,"line":1561},[106,12246,12247],{"class":116},"          avatarUrl",[106,12249,496],{"class":112},[106,12251,11886],{"class":224},[106,12253,52],{"class":112},[106,12255,11586],{"class":224},[106,12257,509],{"class":112},[106,12259,12260],{"class":108,"line":1570},[106,12261,6520],{"class":112},[106,12263,12264,12266,12268,12270,12273,12275,12277,12279],{"class":108,"line":1575},[106,12265,1113],{"class":112},[106,12267,294],{"class":116},[106,12269,52],{"class":112},[106,12271,12272],{"class":277},"returning",[106,12274,506],{"class":116},[106,12276,52],{"class":112},[106,12278,2333],{"class":277},[106,12280,2366],{"class":116},[106,12282,12283],{"class":108,"line":1584},[106,12284,1120],{"class":112},[106,12286,12287],{"class":108,"line":1605},[106,12288,330],{"emptyLinePlaceholder":329},[106,12290,12291],{"class":108,"line":1648},[106,12292,6151],{"class":335},[106,12294,12295],{"class":108,"line":1667},[106,12296,12297],{"class":335},"     * Set only the necessary data in the session.\n",[106,12299,12300],{"class":108,"line":1672},[106,12301,6161],{"class":335},[106,12303,12304,12306,12308,12310,12312,12314],{"class":108,"line":1681},[106,12305,1402],{"class":270},[106,12307,5638],{"class":277},[106,12309,281],{"class":116},[106,12311,291],{"class":224},[106,12313,355],{"class":112},[106,12315,300],{"class":112},[106,12317,12318,12320,12322],{"class":108,"line":1690},[106,12319,5651],{"class":116},[106,12321,496],{"class":112},[106,12323,300],{"class":112},[106,12325,12326,12329,12331,12333,12335,12337],{"class":108,"line":6433},[106,12327,12328],{"class":116},"        id",[106,12330,496],{"class":112},[106,12332,5621],{"class":224},[106,12334,52],{"class":112},[106,12336,7041],{"class":224},[106,12338,12339],{"class":112},"!,\n",[106,12341,12342,12344,12346,12348,12350,12352],{"class":108,"line":6452},[106,12343,12099],{"class":116},[106,12345,496],{"class":112},[106,12347,11886],{"class":224},[106,12349,52],{"class":112},[106,12351,11365],{"class":224},[106,12353,509],{"class":112},[106,12355,12356,12358,12360,12362,12364,12366],{"class":108,"line":6469},[106,12357,12114],{"class":116},[106,12359,496],{"class":112},[106,12361,11886],{"class":224},[106,12363,52],{"class":112},[106,12365,11499],{"class":224},[106,12367,509],{"class":112},[106,12369,12370,12372,12374,12376,12378,12380],{"class":108,"line":6475},[106,12371,12129],{"class":116},[106,12373,496],{"class":112},[106,12375,11886],{"class":224},[106,12377,52],{"class":112},[106,12379,3815],{"class":224},[106,12381,509],{"class":112},[106,12383,12384,12386,12388,12390,12392,12394],{"class":108,"line":6491},[106,12385,12144],{"class":116},[106,12387,496],{"class":112},[106,12389,11886],{"class":224},[106,12391,52],{"class":112},[106,12393,11586],{"class":224},[106,12395,509],{"class":112},[106,12397,12398,12401,12403,12405,12407,12409,12411,12413,12415,12417],{"class":108,"line":6501},[106,12399,12400],{"class":116},"        roleType",[106,12402,496],{"class":112},[106,12404,5621],{"class":224},[106,12406,52],{"class":112},[106,12408,8988],{"class":224},[106,12410,9904],{"class":112},[106,12412,425],{"class":112},[106,12414,11639],{"class":142},[106,12416,754],{"class":112},[106,12418,509],{"class":112},[106,12420,12421],{"class":108,"line":6517},[106,12422,5676],{"class":112},[106,12424,12425,12427],{"class":108,"line":6523},[106,12426,798],{"class":112},[106,12428,324],{"class":116},[106,12430,12431],{"class":108,"line":6528},[106,12432,330],{"emptyLinePlaceholder":329},[106,12434,12435,12437,12439,12441,12443,12445,12447,12450,12452],{"class":108,"line":6534},[106,12436,5691],{"class":270},[106,12438,5694],{"class":277},[106,12440,281],{"class":116},[106,12442,291],{"class":224},[106,12444,355],{"class":112},[106,12446,425],{"class":112},[106,12448,12449],{"class":142},"\u002Fportal",[106,12451,754],{"class":112},[106,12453,324],{"class":116},[106,12455,12456],{"class":108,"line":6539},[106,12457,5602],{"class":112},[106,12459,12460,12462],{"class":108,"line":6556},[106,12461,366],{"class":112},[106,12463,324],{"class":224},[10,12465,12466],{},"With this new way to handle the user's data, I reduce the number of writes to the database while still updating the user's data when necessary.",[54,12468,12470],{"id":12469},"creating-a-form-for-templates","Creating a form for templates",[10,12472,12473],{},[5476,12474],{"alt":12475,"src":12476},"Thumbnail of my sixth live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-19.png",[10,12478,12479],{},[29,12480,12481,5488,12484,5494,12487,5499,12490,52],{},[14,12482,5487],{"href":5454,"rel":12483},[18],[14,12485,5493],{"href":5491,"rel":12486},[18],[14,12488,5498],{"href":5444,"rel":12489},[18],[14,12491,5503],{"href":5454,"rel":12492},[18],[10,12494,12495],{},"Since the beginning of the series, I've been working on the admin interface of the web application. Today, it's time to create the form to add a new template. This form will be used by the creators to add their templates or themes to the platform. As a reminder, Orion is a community-driven collection of templates for your next project, from landing pages to complete web applications.",[381,12497,12499],{"id":12498},"template-schema","Template schema",[10,12501,12502],{},[29,12503,12504],{},"Remember that things evolve and the schema is not definitive.",[10,12506,12507],{},"Since the template feature is a big one, I split it into multiple live streams. The first step is to create the simplest but working form possible and adding features over time. This explains why the schema above is not exactly the same as the one we drew in the SQL schema (no modules, no images, and no markdown, for the moment).",[96,12509,12511],{"className":261,"code":12510,"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,12512,12513,12538,12576,12610,12637,12665,12746,12818,12840,12868,12896,12941,12985],{"__ignoreMap":101},[106,12514,12515,12517,12519,12522,12524,12526,12528,12530,12532,12534,12536],{"class":108,"line":109},[106,12516,271],{"class":270},[106,12518,1775],{"class":132},[106,12520,12521],{"class":224}," templates ",[106,12523,136],{"class":112},[106,12525,10540],{"class":277},[106,12527,281],{"class":224},[106,12529,754],{"class":112},[106,12531,11338],{"class":142},[106,12533,754],{"class":112},[106,12535,355],{"class":112},[106,12537,300],{"class":112},[106,12539,12540,12542,12544,12546,12548,12550,12552,12554,12556,12558,12560,12562,12564,12566,12568,12570,12572,12574],{"class":108,"line":123},[106,12541,9334],{"class":116},[106,12543,496],{"class":112},[106,12545,10561],{"class":277},[106,12547,281],{"class":224},[106,12549,754],{"class":112},[106,12551,7041],{"class":142},[106,12553,754],{"class":112},[106,12555,294],{"class":224},[106,12557,52],{"class":112},[106,12559,10576],{"class":277},[106,12561,281],{"class":224},[106,12563,1285],{"class":112},[106,12565,10583],{"class":116},[106,12567,496],{"class":112},[106,12569,5595],{"class":5594},[106,12571,419],{"class":112},[106,12573,294],{"class":224},[106,12575,509],{"class":112},[106,12577,12578,12581,12583,12585,12587,12589,12592,12594,12596,12598,12600,12602,12604,12606,12608],{"class":108,"line":162},[106,12579,12580],{"class":116},"  hash",[106,12582,496],{"class":112},[106,12584,10602],{"class":277},[106,12586,281],{"class":224},[106,12588,754],{"class":112},[106,12590,12591],{"class":142},"hash",[106,12593,754],{"class":112},[106,12595,294],{"class":224},[106,12597,52],{"class":112},[106,12599,10617],{"class":277},[106,12601,506],{"class":224},[106,12603,52],{"class":112},[106,12605,10624],{"class":277},[106,12607,506],{"class":224},[106,12609,509],{"class":112},[106,12611,12612,12615,12617,12619,12621,12623,12625,12627,12629,12631,12633,12635],{"class":108,"line":200},[106,12613,12614],{"class":116},"  slug",[106,12616,496],{"class":112},[106,12618,10602],{"class":277},[106,12620,281],{"class":224},[106,12622,754],{"class":112},[106,12624,7061],{"class":142},[106,12626,754],{"class":112},[106,12628,294],{"class":224},[106,12630,52],{"class":112},[106,12632,10617],{"class":277},[106,12634,506],{"class":224},[106,12636,509],{"class":112},[106,12638,12639,12642,12644,12646,12648,12650,12653,12655,12657,12659,12661,12663],{"class":108,"line":221},[106,12640,12641],{"class":116},"  title",[106,12643,496],{"class":112},[106,12645,10602],{"class":277},[106,12647,281],{"class":224},[106,12649,754],{"class":112},[106,12651,12652],{"class":142},"title",[106,12654,754],{"class":112},[106,12656,294],{"class":224},[106,12658,52],{"class":112},[106,12660,10617],{"class":277},[106,12662,506],{"class":224},[106,12664,509],{"class":112},[106,12666,12667,12670,12672,12674,12676,12678,12681,12683,12685,12687,12689,12691,12693,12695,12698,12700,12702,12704,12707,12709,12711,12713,12716,12718,12720,12722,12724,12726,12728,12730,12732,12734,12736,12738,12740,12742,12744],{"class":108,"line":228},[106,12668,12669],{"class":116},"  status",[106,12671,496],{"class":112},[106,12673,10602],{"class":277},[106,12675,281],{"class":224},[106,12677,754],{"class":112},[106,12679,12680],{"class":142},"status",[106,12682,754],{"class":112},[106,12684,355],{"class":112},[106,12686,413],{"class":112},[106,12688,10685],{"class":116},[106,12690,496],{"class":112},[106,12692,2524],{"class":224},[106,12694,754],{"class":112},[106,12696,12697],{"class":142},"submitted",[106,12699,754],{"class":112},[106,12701,355],{"class":112},[106,12703,425],{"class":112},[106,12705,12706],{"class":142},"refused",[106,12708,754],{"class":112},[106,12710,355],{"class":112},[106,12712,425],{"class":112},[106,12714,12715],{"class":142},"validated",[106,12717,754],{"class":112},[106,12719,5282],{"class":224},[106,12721,366],{"class":112},[106,12723,294],{"class":224},[106,12725,52],{"class":112},[106,12727,10617],{"class":277},[106,12729,506],{"class":224},[106,12731,52],{"class":112},[106,12733,11652],{"class":277},[106,12735,281],{"class":224},[106,12737,754],{"class":112},[106,12739,12697],{"class":142},[106,12741,754],{"class":112},[106,12743,294],{"class":224},[106,12745,509],{"class":112},[106,12747,12748,12751,12753,12755,12757,12759,12762,12764,12766,12768,12770,12772,12774,12776,12779,12781,12783,12785,12788,12790,12792,12794,12796,12798,12800,12802,12804,12806,12808,12810,12812,12814,12816],{"class":108,"line":238},[106,12749,12750],{"class":116},"  paidStatus",[106,12752,496],{"class":112},[106,12754,10602],{"class":277},[106,12756,281],{"class":224},[106,12758,754],{"class":112},[106,12760,12761],{"class":142},"paid_status",[106,12763,754],{"class":112},[106,12765,355],{"class":112},[106,12767,413],{"class":112},[106,12769,10685],{"class":116},[106,12771,496],{"class":112},[106,12773,2524],{"class":224},[106,12775,754],{"class":112},[106,12777,12778],{"class":142},"free",[106,12780,754],{"class":112},[106,12782,355],{"class":112},[106,12784,425],{"class":112},[106,12786,12787],{"class":142},"paid",[106,12789,754],{"class":112},[106,12791,5282],{"class":224},[106,12793,366],{"class":112},[106,12795,294],{"class":224},[106,12797,52],{"class":112},[106,12799,10617],{"class":277},[106,12801,506],{"class":224},[106,12803,52],{"class":112},[106,12805,11652],{"class":277},[106,12807,281],{"class":224},[106,12809,754],{"class":112},[106,12811,12778],{"class":142},[106,12813,754],{"class":112},[106,12815,294],{"class":224},[106,12817,509],{"class":112},[106,12819,12820,12823,12825,12827,12829,12831,12834,12836,12838],{"class":108,"line":248},[106,12821,12822],{"class":116},"  liveUrl",[106,12824,496],{"class":112},[106,12826,10602],{"class":277},[106,12828,281],{"class":224},[106,12830,754],{"class":112},[106,12832,12833],{"class":142},"live_url",[106,12835,754],{"class":112},[106,12837,294],{"class":224},[106,12839,509],{"class":112},[106,12841,12842,12845,12847,12849,12851,12853,12856,12858,12860,12862,12864,12866],{"class":108,"line":534},[106,12843,12844],{"class":116},"  accessUrl",[106,12846,496],{"class":112},[106,12848,10602],{"class":277},[106,12850,281],{"class":224},[106,12852,754],{"class":112},[106,12854,12855],{"class":142},"access_url",[106,12857,754],{"class":112},[106,12859,294],{"class":224},[106,12861,52],{"class":112},[106,12863,10617],{"class":277},[106,12865,506],{"class":224},[106,12867,509],{"class":112},[106,12869,12870,12873,12875,12877,12879,12881,12884,12886,12888,12890,12892,12894],{"class":108,"line":539},[106,12871,12872],{"class":116},"  description",[106,12874,496],{"class":112},[106,12876,10602],{"class":277},[106,12878,281],{"class":224},[106,12880,754],{"class":112},[106,12882,12883],{"class":142},"description",[106,12885,754],{"class":112},[106,12887,294],{"class":224},[106,12889,52],{"class":112},[106,12891,10617],{"class":277},[106,12893,506],{"class":224},[106,12895,509],{"class":112},[106,12897,12898,12901,12903,12905,12907,12909,12911,12913,12915,12917,12919,12921,12923,12926,12928,12930,12932,12934,12936,12939],{"class":108,"line":556},[106,12899,12900],{"class":116},"  userId",[106,12902,496],{"class":112},[106,12904,10561],{"class":277},[106,12906,281],{"class":224},[106,12908,754],{"class":112},[106,12910,11335],{"class":142},[106,12912,754],{"class":112},[106,12914,294],{"class":224},[106,12916,52],{"class":112},[106,12918,10617],{"class":277},[106,12920,506],{"class":224},[106,12922,52],{"class":112},[106,12924,12925],{"class":277},"references",[106,12927,281],{"class":224},[106,12929,506],{"class":112},[106,12931,297],{"class":132},[106,12933,8696],{"class":224},[106,12935,52],{"class":112},[106,12937,12938],{"class":224},"id)",[106,12940,509],{"class":112},[106,12942,12943,12946,12948,12950,12952,12954,12957,12959,12961,12963,12965,12967,12969,12971,12973,12975,12977,12979,12981,12983],{"class":108,"line":759},[106,12944,12945],{"class":116},"  categoryId",[106,12947,496],{"class":112},[106,12949,10561],{"class":277},[106,12951,281],{"class":224},[106,12953,754],{"class":112},[106,12955,12956],{"class":142},"category_id",[106,12958,754],{"class":112},[106,12960,294],{"class":224},[106,12962,52],{"class":112},[106,12964,10617],{"class":277},[106,12966,506],{"class":224},[106,12968,52],{"class":112},[106,12970,12925],{"class":277},[106,12972,281],{"class":224},[106,12974,506],{"class":112},[106,12976,297],{"class":132},[106,12978,7004],{"class":224},[106,12980,52],{"class":112},[106,12982,12938],{"class":224},[106,12984,509],{"class":112},[106,12986,12987,12989],{"class":108,"line":795},[106,12988,366],{"class":112},[106,12990,324],{"class":224},[10,12992,7122,12993,12995,12996,12999],{},[103,12994,12591],{}," column is a small ID I'll store in the database to create URL like Notion or Dev.to: ",[103,12997,12998],{},"\u003Cslug>-\u003Chash>",". This is helpful to have a readable URL, for both humans and SEO, but because two templates can have the same name, or the name of a template can change, I need to have a unique ID.",[10,13001,13002,13003,13005,13006,13008,13009,13012,13013,13015,13016,13018],{},"For the first time, we create a relation between two tables: a one-to-many between the ",[103,13004,6823],{}," table and the ",[103,13007,11338],{}," table. This relation is used to know who is the creator of the template. The ",[103,13010,13011],{},"userId"," column is a foreign key that references the ",[103,13014,7041],{}," column of the ",[103,13017,6823],{}," table.",[10,13020,7122,13021,13024,13025,13015,13027,13029],{},[103,13022,13023],{},"categoryId"," column is also a foreign key that references the ",[103,13026,7041],{},[103,13028,6829],{}," table. This relation is used to know in which category the template is.",[10,13031,13032],{},"In order to be able to retrieve templates from a user or a category, I also need to define the relation the other way around. Thanks to Drizzle, it's pretty easy.",[96,13034,13036],{"className":261,"code":13035,"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,13037,13038,13071,13085,13091,13095,13125,13137],{"__ignoreMap":101},[106,13039,13040,13042,13044,13047,13049,13052,13055,13057,13060,13063,13065,13067,13069],{"class":108,"line":109},[106,13041,271],{"class":270},[106,13043,1775],{"class":132},[106,13045,13046],{"class":224}," usersRelations ",[106,13048,136],{"class":112},[106,13050,13051],{"class":277}," relations",[106,13053,13054],{"class":224},"(users",[106,13056,355],{"class":112},[106,13058,13059],{"class":112}," ({",[106,13061,13062],{"class":290}," many",[106,13064,5629],{"class":112},[106,13066,297],{"class":132},[106,13068,287],{"class":224},[106,13070,488],{"class":112},[106,13072,13073,13076,13078,13080,13083],{"class":108,"line":123},[106,13074,13075],{"class":116},"  templates",[106,13077,496],{"class":112},[106,13079,13062],{"class":277},[106,13081,13082],{"class":224},"(templates)",[106,13084,509],{"class":112},[106,13086,13087,13089],{"class":108,"line":162},[106,13088,366],{"class":112},[106,13090,1127],{"class":224},[106,13092,13093],{"class":108,"line":200},[106,13094,330],{"emptyLinePlaceholder":329},[106,13096,13097,13099,13101,13104,13106,13108,13111,13113,13115,13117,13119,13121,13123],{"class":108,"line":221},[106,13098,271],{"class":270},[106,13100,1775],{"class":132},[106,13102,13103],{"class":224}," categoriesRelations ",[106,13105,136],{"class":112},[106,13107,13051],{"class":277},[106,13109,13110],{"class":224},"(categories",[106,13112,355],{"class":112},[106,13114,13059],{"class":112},[106,13116,13062],{"class":290},[106,13118,5629],{"class":112},[106,13120,297],{"class":132},[106,13122,287],{"class":224},[106,13124,488],{"class":112},[106,13126,13127,13129,13131,13133,13135],{"class":108,"line":228},[106,13128,13075],{"class":116},[106,13130,496],{"class":112},[106,13132,13062],{"class":277},[106,13134,13082],{"class":224},[106,13136,509],{"class":112},[106,13138,13139,13141],{"class":108,"line":238},[106,13140,366],{"class":112},[106,13142,1127],{"class":224},[381,13144,13146],{"id":13145},"template-form","Template form",[10,13148,13149],{},"During this phase, I create the form on the UI, exactly like I did for the categories. I define the state, the schema, and the action on submit!",[10,13151,13152,13153,13156],{},"Now that a user is able to fill a form, I need an endpoint to save the data in the database. I create the endpoint ",[103,13154,13155],{},"server\u002Fapi\u002Ftemplates\u002Findex.post.ts"," to save the template in the database:",[96,13158,13160],{"className":261,"code":13159,"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,13161,13162,13198,13202,13224,13247,13251,13275,13287,13317,13330,13350,13363,13388,13400,13404,13434,13448,13468,13482,13496,13511,13526,13541,13555,13570,13582,13586,13602],{"__ignoreMap":101},[106,13163,13164,13166,13168,13171,13173,13175,13177,13179,13181,13183,13185,13188,13190,13192,13194,13196],{"class":108,"line":109},[106,13165,410],{"class":270},[106,13167,413],{"class":112},[106,13169,13170],{"class":224}," number",[106,13172,355],{"class":112},[106,13174,2257],{"class":224},[106,13176,355],{"class":112},[106,13178,3942],{"class":224},[106,13180,355],{"class":112},[106,13182,10685],{"class":224},[106,13184,3056],{"class":270},[106,13186,13187],{"class":224}," zEnum",[106,13189,419],{"class":112},[106,13191,422],{"class":270},[106,13193,425],{"class":112},[106,13195,428],{"class":142},[106,13197,431],{"class":112},[106,13199,13200],{"class":108,"line":123},[106,13201,330],{"emptyLinePlaceholder":329},[106,13203,13204,13206,13208,13210,13212,13214,13216,13218,13220,13222],{"class":108,"line":162},[106,13205,271],{"class":270},[106,13207,274],{"class":270},[106,13209,278],{"class":277},[106,13211,281],{"class":224},[106,13213,284],{"class":132},[106,13215,287],{"class":112},[106,13217,291],{"class":290},[106,13219,294],{"class":112},[106,13221,297],{"class":132},[106,13223,300],{"class":112},[106,13225,13226,13228,13230,13232,13234,13236,13238,13241,13243,13245],{"class":108,"line":200},[106,13227,305],{"class":132},[106,13229,413],{"class":112},[106,13231,5621],{"class":224},[106,13233,419],{"class":112},[106,13235,311],{"class":112},[106,13237,314],{"class":270},[106,13239,13240],{"class":277}," requireUserSession",[106,13242,281],{"class":116},[106,13244,291],{"class":224},[106,13246,324],{"class":116},[106,13248,13249],{"class":108,"line":221},[106,13250,330],{"emptyLinePlaceholder":329},[106,13252,13253,13255,13257,13259,13261,13263,13265,13267,13269,13271,13273],{"class":108,"line":228},[106,13254,305],{"class":132},[106,13256,308],{"class":224},[106,13258,311],{"class":112},[106,13260,314],{"class":270},[106,13262,470],{"class":277},[106,13264,281],{"class":116},[106,13266,291],{"class":224},[106,13268,355],{"class":112},[106,13270,2257],{"class":277},[106,13272,281],{"class":116},[106,13274,488],{"class":112},[106,13276,13277,13279,13281,13283,13285],{"class":108,"line":238},[106,13278,7766],{"class":116},[106,13280,496],{"class":112},[106,13282,3942],{"class":277},[106,13284,506],{"class":116},[106,13286,509],{"class":112},[106,13288,13289,13292,13294,13296,13299,13301,13303,13305,13307,13309,13311,13313,13315],{"class":108,"line":248},[106,13290,13291],{"class":116},"    paidStatus",[106,13293,496],{"class":112},[106,13295,13187],{"class":277},[106,13297,13298],{"class":116},"([",[106,13300,754],{"class":112},[106,13302,12778],{"class":142},[106,13304,754],{"class":112},[106,13306,355],{"class":112},[106,13308,425],{"class":112},[106,13310,12787],{"class":142},[106,13312,754],{"class":112},[106,13314,10858],{"class":116},[106,13316,509],{"class":112},[106,13318,13319,13322,13324,13326,13328],{"class":108,"line":534},[106,13320,13321],{"class":116},"    categoryId",[106,13323,496],{"class":112},[106,13325,13170],{"class":277},[106,13327,506],{"class":116},[106,13329,509],{"class":112},[106,13331,13332,13335,13337,13339,13341,13343,13346,13348],{"class":108,"line":539},[106,13333,13334],{"class":116},"    liveUrl",[106,13336,496],{"class":112},[106,13338,3942],{"class":277},[106,13340,506],{"class":116},[106,13342,52],{"class":112},[106,13344,13345],{"class":277},"optional",[106,13347,506],{"class":116},[106,13349,509],{"class":112},[106,13351,13352,13355,13357,13359,13361],{"class":108,"line":556},[106,13353,13354],{"class":116},"    accessUrl",[106,13356,496],{"class":112},[106,13358,3942],{"class":277},[106,13360,506],{"class":116},[106,13362,509],{"class":112},[106,13364,13365,13368,13370,13372,13374,13376,13379,13381,13384,13386],{"class":108,"line":759},[106,13366,13367],{"class":116},"    description",[106,13369,496],{"class":112},[106,13371,3942],{"class":277},[106,13373,506],{"class":116},[106,13375,52],{"class":112},[106,13377,13378],{"class":277},"max",[106,13380,281],{"class":116},[106,13382,13383],{"class":358},"1000",[106,13385,294],{"class":116},[106,13387,509],{"class":112},[106,13389,13390,13392,13394,13396,13398],{"class":108,"line":795},[106,13391,514],{"class":112},[106,13393,294],{"class":116},[106,13395,52],{"class":112},[106,13397,521],{"class":224},[106,13399,324],{"class":116},[106,13401,13402],{"class":108,"line":803},[106,13403,330],{"emptyLinePlaceholder":329},[106,13405,13406,13408,13410,13412,13414,13416,13418,13420,13422,13424,13426,13428,13430,13432],{"class":108,"line":809},[106,13407,2348],{"class":270},[106,13409,7011],{"class":277},[106,13411,506],{"class":116},[106,13413,52],{"class":112},[106,13415,7207],{"class":277},[106,13417,281],{"class":116},[106,13419,7096],{"class":224},[106,13421,52],{"class":112},[106,13423,11338],{"class":224},[106,13425,294],{"class":116},[106,13427,52],{"class":112},[106,13429,7222],{"class":277},[106,13431,281],{"class":116},[106,13433,488],{"class":112},[106,13435,13436,13439,13441,13444,13446],{"class":108,"line":814},[106,13437,13438],{"class":116},"    hash",[106,13440,496],{"class":112},[106,13442,13443],{"class":277}," useHash",[106,13445,506],{"class":116},[106,13447,509],{"class":112},[106,13449,13450,13452,13454,13456,13458,13460,13462,13464,13466],{"class":108,"line":830},[106,13451,7048],{"class":116},[106,13453,496],{"class":112},[106,13455,7235],{"class":277},[106,13457,281],{"class":116},[106,13459,7240],{"class":224},[106,13461,52],{"class":112},[106,13463,12652],{"class":224},[106,13465,294],{"class":116},[106,13467,509],{"class":112},[106,13469,13470,13472,13474,13476,13478,13480],{"class":108,"line":835},[106,13471,7766],{"class":116},[106,13473,496],{"class":112},[106,13475,308],{"class":224},[106,13477,52],{"class":112},[106,13479,12652],{"class":224},[106,13481,509],{"class":112},[106,13483,13484,13486,13488,13490,13492,13494],{"class":108,"line":840},[106,13485,13321],{"class":116},[106,13487,496],{"class":112},[106,13489,308],{"class":224},[106,13491,52],{"class":112},[106,13493,13023],{"class":224},[106,13495,509],{"class":112},[106,13497,13498,13500,13502,13504,13506,13509],{"class":108,"line":845},[106,13499,13291],{"class":116},[106,13501,496],{"class":112},[106,13503,308],{"class":224},[106,13505,52],{"class":112},[106,13507,13508],{"class":224},"paidStatus",[106,13510,509],{"class":112},[106,13512,13513,13515,13517,13519,13521,13524],{"class":108,"line":862},[106,13514,13334],{"class":116},[106,13516,496],{"class":112},[106,13518,308],{"class":224},[106,13520,52],{"class":112},[106,13522,13523],{"class":224},"liveUrl",[106,13525,509],{"class":112},[106,13527,13528,13530,13532,13534,13536,13539],{"class":108,"line":1469},[106,13529,13354],{"class":116},[106,13531,496],{"class":112},[106,13533,308],{"class":224},[106,13535,52],{"class":112},[106,13537,13538],{"class":224},"accessUrl",[106,13540,509],{"class":112},[106,13542,13543,13545,13547,13549,13551,13553],{"class":108,"line":1486},[106,13544,13367],{"class":116},[106,13546,496],{"class":112},[106,13548,308],{"class":224},[106,13550,52],{"class":112},[106,13552,12883],{"class":224},[106,13554,509],{"class":112},[106,13556,13557,13560,13562,13564,13566,13568],{"class":108,"line":1497},[106,13558,13559],{"class":116},"    userId",[106,13561,496],{"class":112},[106,13563,5621],{"class":224},[106,13565,52],{"class":112},[106,13567,7041],{"class":224},[106,13569,509],{"class":112},[106,13571,13572,13574,13576,13578,13580],{"class":108,"line":1508},[106,13573,514],{"class":112},[106,13575,294],{"class":116},[106,13577,52],{"class":112},[106,13579,7273],{"class":277},[106,13581,2366],{"class":116},[106,13583,13584],{"class":108,"line":1523},[106,13585,330],{"emptyLinePlaceholder":329},[106,13587,13588,13590,13592,13594,13596,13598,13600],{"class":108,"line":1538},[106,13589,345],{"class":270},[106,13591,348],{"class":277},[106,13593,281],{"class":116},[106,13595,291],{"class":224},[106,13597,355],{"class":112},[106,13599,359],{"class":358},[106,13601,324],{"class":116},[106,13603,13604,13606],{"class":108,"line":1545},[106,13605,366],{"class":112},[106,13607,324],{"class":224},[10,13609,13610,13611,13614,13615,13618,13619,13621,13622,13624],{},"Pretty similar to the endpoint to create a new category. ",[22,13612,13613],{},"One difference"," is the usage of the util ",[103,13616,13617],{},"requireUserSession"," to ensure that the request is from an authenticated user and to get the user's data. I use the data to fill the ",[103,13620,13011],{}," column in the ",[103,13623,11338],{}," table. Of course, I validate the data received from the client using Zod.",[10,13626,7122,13627,13630,13631,52],{},[103,13628,13629],{},"useHash"," util is a little wrapper around the package ",[103,13632,13633],{},"nanoid",[381,13635,13637],{"id":13636},"templates-in-admin-interface","Templates in admin interface",[10,13639,13640],{},[29,13641,13642],{},"During this part, I encountered an important bug. Read carefully the next part, it could save you a lot of time if you also plan to use Drizzle on Cloudflare.",[10,13644,13645,13646,13649,13650],{},"Users are able to create templates, that's nice. In order to monitor and approve them, I need to create a page in the admin interface to manage the templates. This page will display the templates in a table and allow the admin to approve or refuse them. ",[29,13647,13648],{},"I only implement the view, not the action."," The task is simple, it's like any other page in the admin interface. ",[29,13651,13652],{},"If only I knew...",[10,13654,13655,13656,496],{},"First, let's create the endpoint to get the templates from the database. I create the endpoint ",[103,13657,13658],{},"server\u002Fapi\u002Ftemplates\u002Findex.get.ts",[96,13660,13662],{"className":261,"code":13661,"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,13663,13664,13682,13697,13705,13721,13767,13811,13815,13822],{"__ignoreMap":101},[106,13665,13666,13668,13670,13672,13674,13676,13678,13680],{"class":108,"line":109},[106,13667,271],{"class":270},[106,13669,274],{"class":270},[106,13671,278],{"class":277},[106,13673,281],{"class":224},[106,13675,284],{"class":132},[106,13677,6993],{"class":112},[106,13679,297],{"class":132},[106,13681,300],{"class":112},[106,13683,13684,13686,13689,13691,13693,13695],{"class":108,"line":123},[106,13685,305],{"class":132},[106,13687,13688],{"class":224}," templates",[106,13690,311],{"class":112},[106,13692,314],{"class":270},[106,13694,7011],{"class":277},[106,13696,2366],{"class":116},[106,13698,13699,13701,13703],{"class":108,"line":162},[106,13700,4016],{"class":112},[106,13702,7018],{"class":277},[106,13704,2366],{"class":116},[106,13706,13707,13709,13711,13713,13715,13717,13719],{"class":108,"line":200},[106,13708,4016],{"class":112},[106,13710,7091],{"class":277},[106,13712,281],{"class":116},[106,13714,7096],{"class":224},[106,13716,52],{"class":112},[106,13718,11338],{"class":224},[106,13720,324],{"class":116},[106,13722,13723,13725,13728,13730,13732,13734,13736,13738,13741,13743,13745,13747,13749,13751,13753,13755,13757,13759,13761,13763,13765],{"class":108,"line":221},[106,13724,4016],{"class":112},[106,13726,13727],{"class":277},"leftJoin",[106,13729,281],{"class":116},[106,13731,7096],{"class":224},[106,13733,52],{"class":112},[106,13735,6823],{"class":224},[106,13737,355],{"class":112},[106,13739,13740],{"class":277}," eq",[106,13742,281],{"class":116},[106,13744,7096],{"class":224},[106,13746,52],{"class":112},[106,13748,11338],{"class":224},[106,13750,52],{"class":112},[106,13752,13011],{"class":224},[106,13754,355],{"class":112},[106,13756,7032],{"class":224},[106,13758,52],{"class":112},[106,13760,6823],{"class":224},[106,13762,52],{"class":112},[106,13764,7041],{"class":224},[106,13766,1127],{"class":116},[106,13768,13769,13771,13773,13775,13777,13779,13781,13783,13785,13787,13789,13791,13793,13795,13797,13799,13801,13803,13805,13807,13809],{"class":108,"line":228},[106,13770,4016],{"class":112},[106,13772,13727],{"class":277},[106,13774,281],{"class":116},[106,13776,7096],{"class":224},[106,13778,52],{"class":112},[106,13780,6829],{"class":224},[106,13782,355],{"class":112},[106,13784,13740],{"class":277},[106,13786,281],{"class":116},[106,13788,7096],{"class":224},[106,13790,52],{"class":112},[106,13792,11338],{"class":224},[106,13794,52],{"class":112},[106,13796,13023],{"class":224},[106,13798,355],{"class":112},[106,13800,7032],{"class":224},[106,13802,52],{"class":112},[106,13804,6829],{"class":224},[106,13806,52],{"class":112},[106,13808,7041],{"class":224},[106,13810,1127],{"class":116},[106,13812,13813],{"class":108,"line":238},[106,13814,330],{"emptyLinePlaceholder":329},[106,13816,13817,13819],{"class":108,"line":248},[106,13818,345],{"class":270},[106,13820,13821],{"class":224}," templates\n",[106,13823,13824,13826],{"class":108,"line":534},[106,13825,366],{"class":112},[106,13827,324],{"class":224},[10,13829,13830],{},"This endpoint gets all the templates from the database and populates them using a join and the relation we define earlier in the schema.",[10,13832,6820,13833,13835,13836,13838,13839,13005,13841,13843,13844,13846,13847,13849,13850,1715,13852,13018],{},[103,13834,6371],{}," is a SQL operation that combines rows from two or more tables based on a related column between them. In this case, I join the ",[103,13837,11338],{}," table with the ",[103,13840,6823],{},[103,13842,6829],{}," table. The ",[103,13845,13727],{}," method is used to keep all the rows from the ",[103,13848,11338],{}," table, even if there is no match in the ",[103,13851,6823],{},[103,13853,6829],{},[10,13855,13856],{},[5476,13857],{"alt":13858,"src":13859},"A schematic explanation of the join operation.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fleft-join.png",[10,13861,13862,13863,13865],{},"Now that I have the data, I can display it on the UI. I use the ",[103,13864,7681],{}," component from Nuxt UI Pro to display the templates in a table:",[96,13867,13869],{"className":98,"code":13868,"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,13870,13871,13891,13903,13917,13931,13937,13951,13966,13976,13982,13996,14011,14021,14027,14041,14056,14066,14072,14086,14101,14107,14121,14136,14142,14156,14171,14177,14192,14207,14213,14228,14243,14249,14253,14295,14305,14315,14330,14336,14344,14348,14356,14363,14378,14392,14406,14410,14435,14441,14458,14472,14484,14504,14518,14523,14540,14544,14564,14572,14592,14600,14625,14631,14643,14655,14673,14685,14689,14706,14710,14728,14736,14744,14769,14800,14808,14823,14837,14843,14849,14858,14867,14897,14904,14918,14931,14936,14942,14951,14960,14969,14995,15015,15023,15038,15053,15068,15073,15091,15100,15109,15135,15141,15150,15159],{"__ignoreMap":101},[106,13872,13873,13875,13877,13879,13881,13883,13885,13887,13889],{"class":108,"line":109},[106,13874,113],{"class":112},[106,13876,1189],{"class":116},[106,13878,1192],{"class":132},[106,13880,136],{"class":112},[106,13882,139],{"class":112},[106,13884,263],{"class":142},[106,13886,139],{"class":112},[106,13888,1203],{"class":132},[106,13890,120],{"class":112},[106,13892,13893,13895,13897,13899,13901],{"class":108,"line":123},[106,13894,1252],{"class":132},[106,13896,7472],{"class":224},[106,13898,136],{"class":112},[106,13900,2524],{"class":224},[106,13902,488],{"class":112},[106,13904,13905,13907,13909,13911,13913,13915],{"class":108,"line":162},[106,13906,7483],{"class":116},[106,13908,496],{"class":112},[106,13910,425],{"class":112},[106,13912,7041],{"class":142},[106,13914,754],{"class":112},[106,13916,509],{"class":112},[106,13918,13919,13921,13923,13925,13927,13929],{"class":108,"line":200},[106,13920,7498],{"class":116},[106,13922,496],{"class":112},[106,13924,425],{"class":112},[106,13926,7505],{"class":142},[106,13928,754],{"class":112},[106,13930,509],{"class":112},[106,13932,13933,13935],{"class":108,"line":221},[106,13934,7512],{"class":112},[106,13936,300],{"class":112},[106,13938,13939,13941,13943,13945,13947,13949],{"class":108,"line":228},[106,13940,7483],{"class":116},[106,13942,496],{"class":112},[106,13944,425],{"class":112},[106,13946,12652],{"class":142},[106,13948,754],{"class":112},[106,13950,509],{"class":112},[106,13952,13953,13955,13957,13959,13962,13964],{"class":108,"line":238},[106,13954,7498],{"class":116},[106,13956,496],{"class":112},[106,13958,425],{"class":112},[106,13960,13961],{"class":142},"Title",[106,13963,754],{"class":112},[106,13965,509],{"class":112},[106,13967,13968,13970,13972,13974],{"class":108,"line":248},[106,13969,7547],{"class":116},[106,13971,496],{"class":112},[106,13973,5595],{"class":5594},[106,13975,509],{"class":112},[106,13977,13978,13980],{"class":108,"line":534},[106,13979,7512],{"class":112},[106,13981,300],{"class":112},[106,13983,13984,13986,13988,13990,13992,13994],{"class":108,"line":539},[106,13985,7483],{"class":116},[106,13987,496],{"class":112},[106,13989,425],{"class":112},[106,13991,12680],{"class":142},[106,13993,754],{"class":112},[106,13995,509],{"class":112},[106,13997,13998,14000,14002,14004,14007,14009],{"class":108,"line":556},[106,13999,7498],{"class":116},[106,14001,496],{"class":112},[106,14003,425],{"class":112},[106,14005,14006],{"class":142},"Status",[106,14008,754],{"class":112},[106,14010,509],{"class":112},[106,14012,14013,14015,14017,14019],{"class":108,"line":759},[106,14014,7547],{"class":116},[106,14016,496],{"class":112},[106,14018,5595],{"class":5594},[106,14020,509],{"class":112},[106,14022,14023,14025],{"class":108,"line":795},[106,14024,7512],{"class":112},[106,14026,300],{"class":112},[106,14028,14029,14031,14033,14035,14037,14039],{"class":108,"line":803},[106,14030,7483],{"class":116},[106,14032,496],{"class":112},[106,14034,425],{"class":112},[106,14036,13508],{"class":142},[106,14038,754],{"class":112},[106,14040,509],{"class":112},[106,14042,14043,14045,14047,14049,14052,14054],{"class":108,"line":809},[106,14044,7498],{"class":116},[106,14046,496],{"class":112},[106,14048,425],{"class":112},[106,14050,14051],{"class":142},"Paid Status",[106,14053,754],{"class":112},[106,14055,509],{"class":112},[106,14057,14058,14060,14062,14064],{"class":108,"line":814},[106,14059,7547],{"class":116},[106,14061,496],{"class":112},[106,14063,5595],{"class":5594},[106,14065,509],{"class":112},[106,14067,14068,14070],{"class":108,"line":830},[106,14069,7512],{"class":112},[106,14071,300],{"class":112},[106,14073,14074,14076,14078,14080,14082,14084],{"class":108,"line":835},[106,14075,7483],{"class":116},[106,14077,496],{"class":112},[106,14079,425],{"class":112},[106,14081,13523],{"class":142},[106,14083,754],{"class":112},[106,14085,509],{"class":112},[106,14087,14088,14090,14092,14094,14097,14099],{"class":108,"line":840},[106,14089,7498],{"class":116},[106,14091,496],{"class":112},[106,14093,425],{"class":112},[106,14095,14096],{"class":142},"Live URL",[106,14098,754],{"class":112},[106,14100,509],{"class":112},[106,14102,14103,14105],{"class":108,"line":845},[106,14104,7512],{"class":112},[106,14106,300],{"class":112},[106,14108,14109,14111,14113,14115,14117,14119],{"class":108,"line":862},[106,14110,7483],{"class":116},[106,14112,496],{"class":112},[106,14114,425],{"class":112},[106,14116,13538],{"class":142},[106,14118,754],{"class":112},[106,14120,509],{"class":112},[106,14122,14123,14125,14127,14129,14132,14134],{"class":108,"line":1469},[106,14124,7498],{"class":116},[106,14126,496],{"class":112},[106,14128,425],{"class":112},[106,14130,14131],{"class":142},"Access URL",[106,14133,754],{"class":112},[106,14135,509],{"class":112},[106,14137,14138,14140],{"class":108,"line":1486},[106,14139,7512],{"class":112},[106,14141,300],{"class":112},[106,14143,14144,14146,14148,14150,14152,14154],{"class":108,"line":1497},[106,14145,7483],{"class":116},[106,14147,496],{"class":112},[106,14149,425],{"class":112},[106,14151,12883],{"class":142},[106,14153,754],{"class":112},[106,14155,509],{"class":112},[106,14157,14158,14160,14162,14164,14167,14169],{"class":108,"line":1508},[106,14159,7498],{"class":116},[106,14161,496],{"class":112},[106,14163,425],{"class":112},[106,14165,14166],{"class":142},"Description",[106,14168,754],{"class":112},[106,14170,509],{"class":112},[106,14172,14173,14175],{"class":108,"line":1523},[106,14174,7512],{"class":112},[106,14176,300],{"class":112},[106,14178,14179,14181,14183,14185,14188,14190],{"class":108,"line":1538},[106,14180,7483],{"class":116},[106,14182,496],{"class":112},[106,14184,425],{"class":112},[106,14186,14187],{"class":142},"category",[106,14189,754],{"class":112},[106,14191,509],{"class":112},[106,14193,14194,14196,14198,14200,14203,14205],{"class":108,"line":1545},[106,14195,7498],{"class":116},[106,14197,496],{"class":112},[106,14199,425],{"class":112},[106,14201,14202],{"class":142},"Category",[106,14204,754],{"class":112},[106,14206,509],{"class":112},[106,14208,14209,14211],{"class":108,"line":1550},[106,14210,7512],{"class":112},[106,14212,300],{"class":112},[106,14214,14215,14217,14219,14221,14224,14226],{"class":108,"line":1555},[106,14216,7483],{"class":116},[106,14218,496],{"class":112},[106,14220,425],{"class":112},[106,14222,14223],{"class":142},"createdBy",[106,14225,754],{"class":112},[106,14227,509],{"class":112},[106,14229,14230,14232,14234,14236,14239,14241],{"class":108,"line":1561},[106,14231,7498],{"class":116},[106,14233,496],{"class":112},[106,14235,425],{"class":112},[106,14237,14238],{"class":142},"Created By",[106,14240,754],{"class":112},[106,14242,509],{"class":112},[106,14244,14245,14247],{"class":108,"line":1570},[106,14246,366],{"class":112},[106,14248,3682],{"class":224},[106,14250,14251],{"class":108,"line":1575},[106,14252,330],{"emptyLinePlaceholder":329},[106,14254,14255,14257,14259,14261,14263,14265,14267,14270,14272,14274,14276,14278,14280,14282,14284,14286,14289,14291,14293],{"class":108,"line":1584},[106,14256,1252],{"class":132},[106,14258,413],{"class":112},[106,14260,964],{"class":116},[106,14262,496],{"class":112},[106,14264,13688],{"class":224},[106,14266,355],{"class":112},[106,14268,14269],{"class":224}," refresh",[106,14271,355],{"class":112},[106,14273,7614],{"class":224},[106,14275,366],{"class":112},[106,14277,311],{"class":112},[106,14279,314],{"class":270},[106,14281,7623],{"class":277},[106,14283,281],{"class":224},[106,14285,754],{"class":112},[106,14287,14288],{"class":142},"\u002Fapi\u002Ftemplates",[106,14290,754],{"class":112},[106,14292,355],{"class":112},[106,14294,300],{"class":112},[106,14296,14297,14299,14301,14303],{"class":108,"line":1605},[106,14298,7641],{"class":116},[106,14300,496],{"class":112},[106,14302,7646],{"class":5594},[106,14304,509],{"class":112},[106,14306,14307,14309,14311,14313],{"class":108,"line":1648},[106,14308,10159],{"class":116},[106,14310,496],{"class":112},[106,14312,5595],{"class":5594},[106,14314,509],{"class":112},[106,14316,14317,14319,14321,14323,14325,14328],{"class":108,"line":1667},[106,14318,10173],{"class":277},[106,14320,496],{"class":112},[106,14322,6993],{"class":112},[106,14324,297],{"class":132},[106,14326,14327],{"class":224}," []",[106,14329,509],{"class":112},[106,14331,14332,14334],{"class":108,"line":1672},[106,14333,366],{"class":112},[106,14335,324],{"class":224},[106,14337,14338,14340,14342],{"class":108,"line":1681},[106,14339,251],{"class":112},[106,14341,1189],{"class":116},[106,14343,120],{"class":112},[106,14345,14346],{"class":108,"line":1690},[106,14347,330],{"emptyLinePlaceholder":329},[106,14349,14350,14352,14354],{"class":108,"line":6433},[106,14351,113],{"class":112},[106,14353,117],{"class":116},[106,14355,120],{"class":112},[106,14357,14358,14360],{"class":108,"line":6452},[106,14359,126],{"class":112},[106,14361,14362],{"class":116},"UTable\n",[106,14364,14365,14368,14370,14372,14374,14376],{"class":108,"line":6469},[106,14366,14367],{"class":112},"    :",[106,14369,7687],{"class":132},[106,14371,136],{"class":112},[106,14373,139],{"class":112},[106,14375,7687],{"class":224},[106,14377,7776],{"class":112},[106,14379,14380,14382,14384,14386,14388,14390],{"class":108,"line":6475},[106,14381,14367],{"class":112},[106,14383,7700],{"class":132},[106,14385,136],{"class":112},[106,14387,139],{"class":112},[106,14389,11338],{"class":224},[106,14391,7776],{"class":112},[106,14393,14394,14396,14398,14400,14402,14404],{"class":108,"line":6491},[106,14395,14367],{"class":112},[106,14397,7713],{"class":132},[106,14399,136],{"class":112},[106,14401,139],{"class":112},[106,14403,7720],{"class":224},[106,14405,7776],{"class":112},[106,14407,14408],{"class":108,"line":6501},[106,14409,7781],{"class":112},[106,14411,14412,14414,14416,14418,14421,14423,14425,14427,14429,14431,14433],{"class":108,"line":6517},[106,14413,165],{"class":112},[106,14415,117],{"class":116},[106,14417,7790],{"class":112},[106,14419,14420],{"class":132},"liveUrl-data",[106,14422,136],{"class":112},[106,14424,139],{"class":112},[106,14426,1285],{"class":112},[106,14428,9152],{"class":224},[106,14430,366],{"class":112},[106,14432,139],{"class":112},[106,14434,120],{"class":112},[106,14436,14437,14439],{"class":108,"line":6523},[106,14438,3333],{"class":112},[106,14440,7802],{"class":116},[106,14442,14443,14446,14448,14450,14452,14454,14456],{"class":108,"line":6528},[106,14444,14445],{"class":270},"        v-if",[106,14447,136],{"class":112},[106,14449,139],{"class":112},[106,14451,9190],{"class":224},[106,14453,52],{"class":112},[106,14455,13523],{"class":224},[106,14457,7776],{"class":112},[106,14459,14460,14463,14465,14467,14470],{"class":108,"line":6534},[106,14461,14462],{"class":132},"        variant",[106,14464,136],{"class":112},[106,14466,139],{"class":112},[106,14468,14469],{"class":142},"link",[106,14471,7776],{"class":112},[106,14473,14474,14476,14478,14480,14482],{"class":108,"line":6539},[106,14475,12174],{"class":132},[106,14477,136],{"class":112},[106,14479,139],{"class":112},[106,14481,8371],{"class":142},[106,14483,7776],{"class":112},[106,14485,14486,14489,14492,14494,14496,14498,14500,14502],{"class":108,"line":6556},[106,14487,14488],{"class":112},"        :",[106,14490,14491],{"class":132},"to",[106,14493,136],{"class":112},[106,14495,139],{"class":112},[106,14497,9190],{"class":224},[106,14499,52],{"class":112},[106,14501,13523],{"class":224},[106,14503,7776],{"class":112},[106,14505,14506,14509,14511,14513,14516],{"class":108,"line":6561},[106,14507,14508],{"class":132},"        class",[106,14510,136],{"class":112},[106,14512,139],{"class":112},[106,14514,14515],{"class":142},"flex flex-row items-center gap-1",[106,14517,7776],{"class":112},[106,14519,14520],{"class":108,"line":6566},[106,14521,14522],{"class":112},"      >\n",[106,14524,14525,14527,14529,14531,14534,14536,14538],{"class":108,"line":6572},[106,14526,9136],{"class":112},[106,14528,106],{"class":116},[106,14530,3265],{"class":112},[106,14532,14533],{"class":224},"{{ row.liveUrl }}",[106,14535,251],{"class":112},[106,14537,106],{"class":116},[106,14539,120],{"class":112},[106,14541,14542],{"class":108,"line":6577},[106,14543,330],{"emptyLinePlaceholder":329},[106,14545,14546,14548,14550,14553,14555,14557,14560,14562],{"class":108,"line":6608},[106,14547,9136],{"class":112},[106,14549,106],{"class":116},[106,14551,14552],{"class":132}," class",[106,14554,136],{"class":112},[106,14556,139],{"class":112},[106,14558,14559],{"class":142},"i-heroicons-arrow-top-right-on-square-16-solid inline-block h-4 w-4",[106,14561,139],{"class":112},[106,14563,6929],{"class":112},[106,14565,14566,14568,14570],{"class":108,"line":6618},[106,14567,3396],{"class":112},[106,14569,8301],{"class":116},[106,14571,120],{"class":112},[106,14573,14574,14576,14578,14581,14583,14586,14588,14590],{"class":108,"line":6643},[106,14575,3333],{"class":112},[106,14577,106],{"class":116},[106,14579,14580],{"class":132}," v-else",[106,14582,3265],{"class":112},[106,14584,14585],{"class":224}," - ",[106,14587,251],{"class":112},[106,14589,106],{"class":116},[106,14591,120],{"class":112},[106,14593,14594,14596,14598],{"class":108,"line":6648},[106,14595,231],{"class":112},[106,14597,117],{"class":116},[106,14599,120],{"class":112},[106,14601,14602,14604,14606,14608,14611,14613,14615,14617,14619,14621,14623],{"class":108,"line":6655},[106,14603,165],{"class":112},[106,14605,117],{"class":116},[106,14607,7790],{"class":112},[106,14609,14610],{"class":132},"accessUrl-data",[106,14612,136],{"class":112},[106,14614,139],{"class":112},[106,14616,1285],{"class":112},[106,14618,9152],{"class":224},[106,14620,366],{"class":112},[106,14622,139],{"class":112},[106,14624,120],{"class":112},[106,14626,14627,14629],{"class":108,"line":6660},[106,14628,3333],{"class":112},[106,14630,7802],{"class":116},[106,14632,14633,14635,14637,14639,14641],{"class":108,"line":6665},[106,14634,14462],{"class":132},[106,14636,136],{"class":112},[106,14638,139],{"class":112},[106,14640,14469],{"class":142},[106,14642,7776],{"class":112},[106,14644,14645,14647,14649,14651,14653],{"class":108,"line":6671},[106,14646,12174],{"class":132},[106,14648,136],{"class":112},[106,14650,139],{"class":112},[106,14652,8371],{"class":142},[106,14654,7776],{"class":112},[106,14656,14657,14659,14661,14663,14665,14667,14669,14671],{"class":108,"line":6677},[106,14658,14488],{"class":112},[106,14660,14491],{"class":132},[106,14662,136],{"class":112},[106,14664,139],{"class":112},[106,14666,9190],{"class":224},[106,14668,52],{"class":112},[106,14670,13538],{"class":224},[106,14672,7776],{"class":112},[106,14674,14675,14677,14679,14681,14683],{"class":108,"line":6683},[106,14676,14508],{"class":132},[106,14678,136],{"class":112},[106,14680,139],{"class":112},[106,14682,14515],{"class":142},[106,14684,7776],{"class":112},[106,14686,14687],{"class":108,"line":6689},[106,14688,14522],{"class":112},[106,14690,14691,14693,14695,14697,14700,14702,14704],{"class":108,"line":6694},[106,14692,9136],{"class":112},[106,14694,106],{"class":116},[106,14696,3265],{"class":112},[106,14698,14699],{"class":224},"{{ row.accessUrl }}",[106,14701,251],{"class":112},[106,14703,106],{"class":116},[106,14705,120],{"class":112},[106,14707,14708],{"class":108,"line":6709},[106,14709,330],{"emptyLinePlaceholder":329},[106,14711,14712,14714,14716,14718,14720,14722,14724,14726],{"class":108,"line":6716},[106,14713,9136],{"class":112},[106,14715,106],{"class":116},[106,14717,14552],{"class":132},[106,14719,136],{"class":112},[106,14721,139],{"class":112},[106,14723,14559],{"class":142},[106,14725,139],{"class":112},[106,14727,6929],{"class":112},[106,14729,14730,14732,14734],{"class":108,"line":6724},[106,14731,3396],{"class":112},[106,14733,8301],{"class":116},[106,14735,120],{"class":112},[106,14737,14738,14740,14742],{"class":108,"line":6731},[106,14739,231],{"class":112},[106,14741,117],{"class":116},[106,14743,120],{"class":112},[106,14745,14746,14748,14750,14752,14755,14757,14759,14761,14763,14765,14767],{"class":108,"line":6738},[106,14747,165],{"class":112},[106,14749,117],{"class":116},[106,14751,7790],{"class":112},[106,14753,14754],{"class":132},"paidStatus-data",[106,14756,136],{"class":112},[106,14758,139],{"class":112},[106,14760,1285],{"class":112},[106,14762,9152],{"class":224},[106,14764,366],{"class":112},[106,14766,139],{"class":112},[106,14768,120],{"class":112},[106,14770,14772,14774,14776,14779,14781,14783,14785,14787,14790,14792,14794,14796,14798],{"class":108,"line":14771},74,[106,14773,3333],{"class":112},[106,14775,117],{"class":116},[106,14777,14778],{"class":270}," v-if",[106,14780,136],{"class":112},[106,14782,139],{"class":112},[106,14784,9190],{"class":224},[106,14786,52],{"class":112},[106,14788,14789],{"class":224},"paidStatus ",[106,14791,10481],{"class":112},[106,14793,425],{"class":112},[106,14795,12778],{"class":142},[106,14797,9224],{"class":112},[106,14799,120],{"class":112},[106,14801,14803,14805],{"class":108,"line":14802},75,[106,14804,9136],{"class":112},[106,14806,14807],{"class":116},"UBadge\n",[106,14809,14811,14814,14816,14818,14821],{"class":108,"line":14810},76,[106,14812,14813],{"class":132},"          color",[106,14815,136],{"class":112},[106,14817,139],{"class":112},[106,14819,14820],{"class":142},"green",[106,14822,7776],{"class":112},[106,14824,14826,14829,14831,14833,14835],{"class":108,"line":14825},77,[106,14827,14828],{"class":132},"          variant",[106,14830,136],{"class":112},[106,14832,139],{"class":112},[106,14834,9176],{"class":142},[106,14836,7776],{"class":112},[106,14838,14840],{"class":108,"line":14839},78,[106,14841,14842],{"class":112},"        >\n",[106,14844,14846],{"class":108,"line":14845},79,[106,14847,14848],{"class":224},"          Free\n",[106,14850,14852,14854,14856],{"class":108,"line":14851},80,[106,14853,9245],{"class":112},[106,14855,9166],{"class":116},[106,14857,120],{"class":112},[106,14859,14861,14863,14865],{"class":108,"line":14860},81,[106,14862,3396],{"class":112},[106,14864,117],{"class":116},[106,14866,120],{"class":112},[106,14868,14870,14872,14874,14877,14879,14881,14883,14885,14887,14889,14891,14893,14895],{"class":108,"line":14869},82,[106,14871,3333],{"class":112},[106,14873,117],{"class":116},[106,14875,14876],{"class":270}," v-else-if",[106,14878,136],{"class":112},[106,14880,139],{"class":112},[106,14882,9190],{"class":224},[106,14884,52],{"class":112},[106,14886,14789],{"class":224},[106,14888,10481],{"class":112},[106,14890,425],{"class":112},[106,14892,12787],{"class":142},[106,14894,9224],{"class":112},[106,14896,120],{"class":112},[106,14898,14900,14902],{"class":108,"line":14899},83,[106,14901,9136],{"class":112},[106,14903,14807],{"class":116},[106,14905,14907,14909,14911,14913,14916],{"class":108,"line":14906},84,[106,14908,14813],{"class":132},[106,14910,136],{"class":112},[106,14912,139],{"class":112},[106,14914,14915],{"class":142},"yellow",[106,14917,7776],{"class":112},[106,14919,14921,14923,14925,14927,14929],{"class":108,"line":14920},85,[106,14922,14828],{"class":132},[106,14924,136],{"class":112},[106,14926,139],{"class":112},[106,14928,9176],{"class":142},[106,14930,7776],{"class":112},[106,14932,14934],{"class":108,"line":14933},86,[106,14935,14842],{"class":112},[106,14937,14939],{"class":108,"line":14938},87,[106,14940,14941],{"class":224},"          Paid\n",[106,14943,14945,14947,14949],{"class":108,"line":14944},88,[106,14946,9245],{"class":112},[106,14948,9166],{"class":116},[106,14950,120],{"class":112},[106,14952,14954,14956,14958],{"class":108,"line":14953},89,[106,14955,3396],{"class":112},[106,14957,117],{"class":116},[106,14959,120],{"class":112},[106,14961,14963,14965,14967],{"class":108,"line":14962},90,[106,14964,231],{"class":112},[106,14966,117],{"class":116},[106,14968,120],{"class":112},[106,14970,14972,14974,14976,14978,14981,14983,14985,14987,14989,14991,14993],{"class":108,"line":14971},91,[106,14973,165],{"class":112},[106,14975,117],{"class":116},[106,14977,7790],{"class":112},[106,14979,14980],{"class":132},"createdBy-data",[106,14982,136],{"class":112},[106,14984,139],{"class":112},[106,14986,1285],{"class":112},[106,14988,9152],{"class":224},[106,14990,366],{"class":112},[106,14992,139],{"class":112},[106,14994,120],{"class":112},[106,14996,14998,15000,15002,15004,15006,15008,15011,15013],{"class":108,"line":14997},92,[106,14999,3333],{"class":112},[106,15001,3253],{"class":116},[106,15003,14552],{"class":132},[106,15005,136],{"class":112},[106,15007,139],{"class":112},[106,15009,15010],{"class":142},"flex flex-row items-center gap-2",[106,15012,139],{"class":112},[106,15014,120],{"class":112},[106,15016,15018,15020],{"class":108,"line":15017},93,[106,15019,9136],{"class":112},[106,15021,15022],{"class":116},"img\n",[106,15024,15026,15029,15031,15033,15036],{"class":108,"line":15025},94,[106,15027,15028],{"class":132},"          :src",[106,15030,136],{"class":112},[106,15032,139],{"class":112},[106,15034,15035],{"class":142},"row.user.avatarUrl",[106,15037,7776],{"class":112},[106,15039,15041,15044,15046,15048,15051],{"class":108,"line":15040},95,[106,15042,15043],{"class":132},"          alt",[106,15045,136],{"class":112},[106,15047,139],{"class":112},[106,15049,15050],{"class":142},"avatar",[106,15052,7776],{"class":112},[106,15054,15056,15059,15061,15063,15066],{"class":108,"line":15055},96,[106,15057,15058],{"class":132},"          class",[106,15060,136],{"class":112},[106,15062,139],{"class":112},[106,15064,15065],{"class":142},"h-6 w-6 rounded-full",[106,15067,7776],{"class":112},[106,15069,15071],{"class":108,"line":15070},97,[106,15072,14842],{"class":112},[106,15074,15076,15078,15080,15082,15085,15087,15089],{"class":108,"line":15075},98,[106,15077,9136],{"class":112},[106,15079,106],{"class":116},[106,15081,3265],{"class":112},[106,15083,15084],{"class":224},"{{ row.user.name ?? row.user.login }}",[106,15086,251],{"class":112},[106,15088,106],{"class":116},[106,15090,120],{"class":112},[106,15092,15094,15096,15098],{"class":108,"line":15093},99,[106,15095,3396],{"class":112},[106,15097,3253],{"class":116},[106,15099,120],{"class":112},[106,15101,15103,15105,15107],{"class":108,"line":15102},100,[106,15104,231],{"class":112},[106,15106,117],{"class":116},[106,15108,120],{"class":112},[106,15110,15112,15114,15116,15118,15121,15123,15125,15127,15129,15131,15133],{"class":108,"line":15111},101,[106,15113,165],{"class":112},[106,15115,117],{"class":116},[106,15117,7790],{"class":112},[106,15119,15120],{"class":132},"category-data",[106,15122,136],{"class":112},[106,15124,139],{"class":112},[106,15126,1285],{"class":112},[106,15128,9152],{"class":224},[106,15130,366],{"class":112},[106,15132,139],{"class":112},[106,15134,120],{"class":112},[106,15136,15138],{"class":108,"line":15137},102,[106,15139,15140],{"class":224},"      {{ row.category.name }}\n",[106,15142,15144,15146,15148],{"class":108,"line":15143},103,[106,15145,231],{"class":112},[106,15147,117],{"class":116},[106,15149,120],{"class":112},[106,15151,15153,15155,15157],{"class":108,"line":15152},104,[106,15154,241],{"class":112},[106,15156,7681],{"class":116},[106,15158,120],{"class":112},[106,15160,15162,15164,15166],{"class":108,"line":15161},105,[106,15163,251],{"class":112},[106,15165,117],{"class":116},[106,15167,120],{"class":112},[10,15169,15170,15173],{},[22,15171,15172],{},"But it does not work."," Using a lot of Adonis, I was familiar to having an object like this:",[96,15175,15179],{"className":15176,"code":15177,"language":15178,"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,15180,15181,15185,15200,15220,15238,15257,15269,15284,15303,15321,15325,15337,15351,15368,15372],{"__ignoreMap":101},[106,15182,15183],{"class":108,"line":109},[106,15184,488],{"class":112},[106,15186,15187,15190,15192,15194,15196,15198],{"class":108,"line":123},[106,15188,15189],{"class":112},"  \"",[106,15191,7041],{"class":132},[106,15193,139],{"class":112},[106,15195,496],{"class":112},[106,15197,11002],{"class":358},[106,15199,509],{"class":112},[106,15201,15202,15204,15206,15208,15210,15213,15216,15218],{"class":108,"line":162},[106,15203,15189],{"class":112},[106,15205,12652],{"class":132},[106,15207,139],{"class":112},[106,15209,496],{"class":112},[106,15211,15212],{"class":112}," \"",[106,15214,15215],{"class":142},"My template",[106,15217,139],{"class":112},[106,15219,509],{"class":112},[106,15221,15222,15224,15226,15228,15230,15232,15234,15236],{"class":108,"line":200},[106,15223,15189],{"class":112},[106,15225,12680],{"class":132},[106,15227,139],{"class":112},[106,15229,496],{"class":112},[106,15231,15212],{"class":112},[106,15233,12697],{"class":142},[106,15235,139],{"class":112},[106,15237,509],{"class":112},[106,15239,15240,15242,15244,15246,15248,15250,15253,15255],{"class":108,"line":221},[106,15241,15189],{"class":112},[106,15243,13523],{"class":132},[106,15245,139],{"class":112},[106,15247,496],{"class":112},[106,15249,15212],{"class":112},[106,15251,15252],{"class":142},"https:\u002F\u002Fexample.com",[106,15254,139],{"class":112},[106,15256,509],{"class":112},[106,15258,15259,15261,15263,15265,15267],{"class":108,"line":228},[106,15260,15189],{"class":112},[106,15262,9901],{"class":132},[106,15264,139],{"class":112},[106,15266,496],{"class":112},[106,15268,300],{"class":112},[106,15270,15271,15274,15276,15278,15280,15282],{"class":108,"line":238},[106,15272,15273],{"class":112},"    \"",[106,15275,7041],{"class":1041},[106,15277,139],{"class":112},[106,15279,496],{"class":112},[106,15281,11002],{"class":358},[106,15283,509],{"class":112},[106,15285,15286,15288,15290,15292,15294,15296,15299,15301],{"class":108,"line":248},[106,15287,15273],{"class":112},[106,15289,3815],{"class":1041},[106,15291,139],{"class":112},[106,15293,496],{"class":112},[106,15295,15212],{"class":112},[106,15297,15298],{"class":142},"John Doe",[106,15300,139],{"class":112},[106,15302,509],{"class":112},[106,15304,15305,15307,15310,15312,15314,15316,15319],{"class":108,"line":534},[106,15306,15273],{"class":112},[106,15308,15309],{"class":1041},"avatarUrl",[106,15311,139],{"class":112},[106,15313,496],{"class":112},[106,15315,15212],{"class":112},[106,15317,15318],{"class":142},"https:\u002F\u002Fexample.com\u002Favatar.jpg",[106,15320,7776],{"class":112},[106,15322,15323],{"class":108,"line":539},[106,15324,5602],{"class":112},[106,15326,15327,15329,15331,15333,15335],{"class":108,"line":556},[106,15328,15189],{"class":112},[106,15330,14187],{"class":132},[106,15332,139],{"class":112},[106,15334,496],{"class":112},[106,15336,300],{"class":112},[106,15338,15339,15341,15343,15345,15347,15349],{"class":108,"line":759},[106,15340,15273],{"class":112},[106,15342,7041],{"class":1041},[106,15344,139],{"class":112},[106,15346,496],{"class":112},[106,15348,11002],{"class":358},[106,15350,509],{"class":112},[106,15352,15353,15355,15357,15359,15361,15363,15366],{"class":108,"line":795},[106,15354,15273],{"class":112},[106,15356,3815],{"class":1041},[106,15358,139],{"class":112},[106,15360,496],{"class":112},[106,15362,15212],{"class":112},[106,15364,15365],{"class":142},"Landing Page",[106,15367,7776],{"class":112},[106,15369,15370],{"class":108,"line":803},[106,15371,806],{"class":112},[106,15373,15374],{"class":108,"line":809},[106,15375,1558],{"class":112},[10,15377,15378],{},"But I received an object like this:",[96,15380,15382],{"className":15176,"code":15381,"language":15178,"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,15383,15384,15388,15400,15414,15432,15450,15466,15470,15482,15486,15498,15502],{"__ignoreMap":101},[106,15385,15386],{"class":108,"line":109},[106,15387,488],{"class":112},[106,15389,15390,15392,15394,15396,15398],{"class":108,"line":123},[106,15391,15189],{"class":112},[106,15393,117],{"class":132},[106,15395,139],{"class":112},[106,15397,496],{"class":112},[106,15399,300],{"class":112},[106,15401,15402,15404,15406,15408,15410,15412],{"class":108,"line":162},[106,15403,15273],{"class":112},[106,15405,7041],{"class":1041},[106,15407,139],{"class":112},[106,15409,496],{"class":112},[106,15411,11002],{"class":358},[106,15413,509],{"class":112},[106,15415,15416,15418,15420,15422,15424,15426,15428,15430],{"class":108,"line":200},[106,15417,15273],{"class":112},[106,15419,12652],{"class":1041},[106,15421,139],{"class":112},[106,15423,496],{"class":112},[106,15425,15212],{"class":112},[106,15427,15215],{"class":142},[106,15429,139],{"class":112},[106,15431,509],{"class":112},[106,15433,15434,15436,15438,15440,15442,15444,15446,15448],{"class":108,"line":221},[106,15435,15273],{"class":112},[106,15437,12680],{"class":1041},[106,15439,139],{"class":112},[106,15441,496],{"class":112},[106,15443,15212],{"class":112},[106,15445,12697],{"class":142},[106,15447,139],{"class":112},[106,15449,509],{"class":112},[106,15451,15452,15454,15456,15458,15460,15462,15464],{"class":108,"line":228},[106,15453,15273],{"class":112},[106,15455,13523],{"class":1041},[106,15457,139],{"class":112},[106,15459,496],{"class":112},[106,15461,15212],{"class":112},[106,15463,15252],{"class":142},[106,15465,7776],{"class":112},[106,15467,15468],{"class":108,"line":238},[106,15469,5602],{"class":112},[106,15471,15472,15474,15476,15478,15480],{"class":108,"line":248},[106,15473,15189],{"class":112},[106,15475,9901],{"class":132},[106,15477,139],{"class":112},[106,15479,496],{"class":112},[106,15481,300],{"class":112},[106,15483,15484],{"class":108,"line":534},[106,15485,5602],{"class":112},[106,15487,15488,15490,15492,15494,15496],{"class":108,"line":539},[106,15489,15189],{"class":112},[106,15491,14187],{"class":132},[106,15493,139],{"class":112},[106,15495,496],{"class":112},[106,15497,300],{"class":112},[106,15499,15500],{"class":108,"line":556},[106,15501,806],{"class":112},[106,15503,15504],{"class":108,"line":759},[106,15505,1558],{"class":112},[10,15507,15508,15509],{},"and what a surprise it was to see that the keys and the values were exchanged between them. ",[29,15510,15511],{},"Hard to explain but see the example.",[96,15513,15515],{"className":15176,"code":15514,"language":15178,"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,15516,15517,15521,15533,15551,15567,15571,15583,15601,15618,15622],{"__ignoreMap":101},[106,15518,15519],{"class":108,"line":109},[106,15520,488],{"class":112},[106,15522,15523,15525,15527,15529,15531],{"class":108,"line":123},[106,15524,15189],{"class":112},[106,15526,117],{"class":132},[106,15528,139],{"class":112},[106,15530,496],{"class":112},[106,15532,300],{"class":112},[106,15534,15535,15537,15539,15541,15543,15545,15547,15549],{"class":108,"line":162},[106,15536,15273],{"class":112},[106,15538,7041],{"class":1041},[106,15540,139],{"class":112},[106,15542,496],{"class":112},[106,15544,15212],{"class":112},[106,15546,15215],{"class":142},[106,15548,139],{"class":112},[106,15550,509],{"class":112},[106,15552,15553,15555,15557,15559,15561,15563,15565],{"class":108,"line":200},[106,15554,15273],{"class":112},[106,15556,12680],{"class":1041},[106,15558,139],{"class":112},[106,15560,496],{"class":112},[106,15562,15212],{"class":112},[106,15564,15252],{"class":142},[106,15566,7776],{"class":112},[106,15568,15569],{"class":108,"line":221},[106,15570,5602],{"class":112},[106,15572,15573,15575,15577,15579,15581],{"class":108,"line":228},[106,15574,15189],{"class":112},[106,15576,9901],{"class":132},[106,15578,139],{"class":112},[106,15580,496],{"class":112},[106,15582,300],{"class":112},[106,15584,15585,15587,15589,15591,15593,15595,15597,15599],{"class":108,"line":238},[106,15586,15273],{"class":112},[106,15588,7041],{"class":1041},[106,15590,139],{"class":112},[106,15592,496],{"class":112},[106,15594,15212],{"class":112},[106,15596,15298],{"class":142},[106,15598,139],{"class":112},[106,15600,509],{"class":112},[106,15602,15603,15605,15607,15609,15611,15613,15616],{"class":108,"line":248},[106,15604,15273],{"class":112},[106,15606,11365],{"class":1041},[106,15608,139],{"class":112},[106,15610,496],{"class":112},[106,15612,15212],{"class":112},[106,15614,15615],{"class":142},"https:\u002F\u002Favatars.github.com\u002Fu\u002F1",[106,15617,7776],{"class":112},[106,15619,15620],{"class":108,"line":534},[106,15621,806],{"class":112},[106,15623,15624],{"class":108,"line":539},[106,15625,1558],{"class":112},[10,15627,15628,15629],{},"Very hard to play with data now! ",[29,15630,15631],{},"The ID should be a number and the login should definitely not be a URL.",[10,15633,15634,15635,72,15639,15643,15644,15648],{},"After a long research, I found two issues: ",[14,15636,15637],{"href":15637,"rel":15638},"https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworkers-sdk\u002Fissues\u002F3160",[18],[14,15640,15641],{"href":15641,"rel":15642},"https:\u002F\u002Fgithub.com\u002Fdrizzle-team\u002Fdrizzle-orm\u002Fissues\u002F555",[18]," explaining why it does not work. When two tables have the same column name, ",[15645,15646,15647],"del",{},"Drizzle and"," Cloudflare are unable to resolve them correctly and mix the data. The solution is to explicitly select the columns and rename similar columns.",[96,15650,15652],{"className":261,"code":15651,"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,15653,15654,15676,15694,15712,15730,15748,15766,15784,15802,15811,15829,15848,15867,15871,15880,15926,15930],{"__ignoreMap":101},[106,15655,15656,15658,15660,15662,15664,15666,15668,15670,15672,15674],{"class":108,"line":109},[106,15657,1252],{"class":132},[106,15659,12521],{"class":224},[106,15661,136],{"class":112},[106,15663,314],{"class":270},[106,15665,7011],{"class":277},[106,15667,506],{"class":224},[106,15669,52],{"class":112},[106,15671,7018],{"class":277},[106,15673,281],{"class":224},[106,15675,488],{"class":112},[106,15677,15678,15680,15682,15684,15686,15688,15690,15692],{"class":108,"line":123},[106,15679,9334],{"class":116},[106,15681,496],{"class":112},[106,15683,7032],{"class":224},[106,15685,52],{"class":112},[106,15687,11338],{"class":224},[106,15689,52],{"class":112},[106,15691,7041],{"class":224},[106,15693,509],{"class":112},[106,15695,15696,15698,15700,15702,15704,15706,15708,15710],{"class":108,"line":162},[106,15697,12641],{"class":116},[106,15699,496],{"class":112},[106,15701,7032],{"class":224},[106,15703,52],{"class":112},[106,15705,11338],{"class":224},[106,15707,52],{"class":112},[106,15709,12652],{"class":224},[106,15711,509],{"class":112},[106,15713,15714,15716,15718,15720,15722,15724,15726,15728],{"class":108,"line":200},[106,15715,12872],{"class":116},[106,15717,496],{"class":112},[106,15719,7032],{"class":224},[106,15721,52],{"class":112},[106,15723,11338],{"class":224},[106,15725,52],{"class":112},[106,15727,12883],{"class":224},[106,15729,509],{"class":112},[106,15731,15732,15734,15736,15738,15740,15742,15744,15746],{"class":108,"line":221},[106,15733,12669],{"class":116},[106,15735,496],{"class":112},[106,15737,7032],{"class":224},[106,15739,52],{"class":112},[106,15741,11338],{"class":224},[106,15743,52],{"class":112},[106,15745,12680],{"class":224},[106,15747,509],{"class":112},[106,15749,15750,15752,15754,15756,15758,15760,15762,15764],{"class":108,"line":228},[106,15751,12750],{"class":116},[106,15753,496],{"class":112},[106,15755,7032],{"class":224},[106,15757,52],{"class":112},[106,15759,11338],{"class":224},[106,15761,52],{"class":112},[106,15763,13508],{"class":224},[106,15765,509],{"class":112},[106,15767,15768,15770,15772,15774,15776,15778,15780,15782],{"class":108,"line":238},[106,15769,12822],{"class":116},[106,15771,496],{"class":112},[106,15773,7032],{"class":224},[106,15775,52],{"class":112},[106,15777,11338],{"class":224},[106,15779,52],{"class":112},[106,15781,13523],{"class":224},[106,15783,509],{"class":112},[106,15785,15786,15788,15790,15792,15794,15796,15798,15800],{"class":108,"line":248},[106,15787,12844],{"class":116},[106,15789,496],{"class":112},[106,15791,7032],{"class":224},[106,15793,52],{"class":112},[106,15795,11338],{"class":224},[106,15797,52],{"class":112},[106,15799,13538],{"class":224},[106,15801,509],{"class":112},[106,15803,15804,15807,15809],{"class":108,"line":534},[106,15805,15806],{"class":116},"  user",[106,15808,496],{"class":112},[106,15810,300],{"class":112},[106,15812,15813,15815,15817,15819,15821,15823,15825,15827],{"class":108,"line":539},[106,15814,493],{"class":116},[106,15816,496],{"class":112},[106,15818,7032],{"class":224},[106,15820,52],{"class":112},[106,15822,6823],{"class":224},[106,15824,52],{"class":112},[106,15826,3815],{"class":224},[106,15828,509],{"class":112},[106,15830,15831,15834,15836,15838,15840,15842,15844,15846],{"class":108,"line":556},[106,15832,15833],{"class":116},"    login",[106,15835,496],{"class":112},[106,15837,7032],{"class":224},[106,15839,52],{"class":112},[106,15841,6823],{"class":224},[106,15843,52],{"class":112},[106,15845,11365],{"class":224},[106,15847,509],{"class":112},[106,15849,15850,15853,15855,15857,15859,15861,15863,15865],{"class":108,"line":759},[106,15851,15852],{"class":116},"    avatarUrl",[106,15854,496],{"class":112},[106,15856,7032],{"class":224},[106,15858,52],{"class":112},[106,15860,6823],{"class":224},[106,15862,52],{"class":112},[106,15864,15309],{"class":224},[106,15866,509],{"class":112},[106,15868,15869],{"class":108,"line":795},[106,15870,5602],{"class":112},[106,15872,15873,15876,15878],{"class":108,"line":803},[106,15874,15875],{"class":116},"  category",[106,15877,496],{"class":112},[106,15879,300],{"class":112},[106,15881,15882,15884,15886,15888,15890,15892,15894,15897,15899,15901,15903,15905,15907,15909,15911,15913,15915,15917,15920,15922,15924],{"class":108,"line":809},[106,15883,493],{"class":116},[106,15885,496],{"class":112},[106,15887,11703],{"class":277},[106,15889,113],{"class":112},[106,15891,503],{"class":1041},[106,15893,3265],{"class":112},[106,15895,15896],{"class":112},"`${",[106,15898,7096],{"class":224},[106,15900,52],{"class":112},[106,15902,6829],{"class":224},[106,15904,52],{"class":112},[106,15906,3815],{"class":224},[106,15908,6638],{"class":112},[106,15910,52],{"class":112},[106,15912,3616],{"class":277},[106,15914,281],{"class":224},[106,15916,754],{"class":112},[106,15918,15919],{"class":142},"c_name",[106,15921,754],{"class":112},[106,15923,294],{"class":224},[106,15925,509],{"class":112},[106,15927,15928],{"class":108,"line":814},[106,15929,5602],{"class":112},[106,15931,15932,15934,15936,15938,15940,15942,15944,15947,15949,15951,15953,15955,15957,15959,15961,15963,15965,15967,15969,15971,15973,15975,15977,15979,15981,15984,15986,15988,15990,15992,15994,15996,15998,16000,16002,16004,16006,16008,16010,16012,16014,16016,16018],{"class":108,"line":830},[106,15933,366],{"class":112},[106,15935,294],{"class":224},[106,15937,52],{"class":112},[106,15939,7091],{"class":277},[106,15941,10775],{"class":224},[106,15943,52],{"class":112},[106,15945,15946],{"class":224},"templates)",[106,15948,52],{"class":112},[106,15950,13727],{"class":277},[106,15952,10775],{"class":224},[106,15954,52],{"class":112},[106,15956,6823],{"class":224},[106,15958,355],{"class":112},[106,15960,13740],{"class":277},[106,15962,10775],{"class":224},[106,15964,52],{"class":112},[106,15966,11338],{"class":224},[106,15968,52],{"class":112},[106,15970,13011],{"class":224},[106,15972,355],{"class":112},[106,15974,7032],{"class":224},[106,15976,52],{"class":112},[106,15978,6823],{"class":224},[106,15980,52],{"class":112},[106,15982,15983],{"class":224},"id))",[106,15985,52],{"class":112},[106,15987,13727],{"class":277},[106,15989,10775],{"class":224},[106,15991,52],{"class":112},[106,15993,6829],{"class":224},[106,15995,355],{"class":112},[106,15997,13740],{"class":277},[106,15999,10775],{"class":224},[106,16001,52],{"class":112},[106,16003,11338],{"class":224},[106,16005,52],{"class":112},[106,16007,13023],{"class":224},[106,16009,355],{"class":112},[106,16011,7032],{"class":224},[106,16013,52],{"class":112},[106,16015,6829],{"class":224},[106,16017,52],{"class":112},[106,16019,16020],{"class":224},"id))\n",[10,16022,16023,16026,16027,16029,16030,16032,16033,72,16035,16037],{},[103,16024,16025],{},"sql\u003Cstring>'${tables.categories.name}'.as('c_name')"," is the important code. I rename the column ",[103,16028,3815],{}," to ",[103,16031,15919],{}," to avoid the conflict. I also use this syntax to format the object with the key ",[103,16034,9901],{},[103,16036,14187],{},". Now, everything works as expected.",[381,16039,5371],{"id":16040},"conclusion-2",[10,16042,16043],{},"Working on the client side, and not only on the admin part, makes the project even more concrete. There is still a lot of work on the template form like the markdown or the image, but it's a pretty good start.",[10,16045,16046],{},"Really enjoy this project and I hope you too.",[54,16048,16050],{"id":16049},"continuing-the-template-form","Continuing the template form",[10,16052,16053],{},[5476,16054],{"alt":16055,"src":16056},"Thumbnail of my eighth live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-20.png",[10,16058,16059],{},[29,16060,16061,5488,16064,5494,16067,5499,16070,52],{},[14,16062,5487],{"href":5454,"rel":16063},[18],[14,16065,5493],{"href":5491,"rel":16066},[18],[14,16068,5498],{"href":5444,"rel":16069},[18],[14,16071,5503],{"href":5454,"rel":16072},[18],[10,16074,16075],{},"This stream was short so I try to make interesting stuff. I mainly do three things:",[5857,16077,16078,16081,16084],{},[5860,16079,16080],{},"Create the show template page",[5860,16082,16083],{},"Handle markdown on the server-side for the template description",[5860,16085,16086],{},"Add a markdown editor for the template description",[10,16088,16089,16090],{},"Let's see how it goes. ",[29,16091,16092],{},"During the stream, it goes well but since, I completely rewrite the markdown editor part.",[381,16094,16096],{"id":16095},"show-template-page","Show template page",[10,16098,16099,16100,16103,16104,16106,16107,16109,16110,16112,16113,16115,16116,16118,16119,16121],{},"This item was more about the usage of the URL that the design of the page. Indeed, the URL will work like Notion or Dev.to: ",[103,16101,16102],{},"\u002F\u003Cslug>-\u003Chash>"," where the ",[103,16105,7061],{}," is the name of the template and the ",[103,16108,12591],{}," is a unique identifier. The ",[103,16111,7061],{}," is used for SEO and the ",[103,16114,12591],{}," is used to get the template. The ",[103,16117,7061],{}," is not unique and can change over time. The ",[103,16120,12591],{}," is unique and serves to get the template.",[10,16123,16124],{},"In the page, we first start by getting the params from the route:",[96,16126,16128],{"className":261,"code":16127,"language":263,"meta":101,"style":101},"const route = useRoute()\nconst slug = route.params.slug \u002F\u002F \u003Cslug>-\u003Chash>\n",[103,16129,16130,16144],{"__ignoreMap":101},[106,16131,16132,16134,16137,16139,16142],{"class":108,"line":109},[106,16133,1252],{"class":132},[106,16135,16136],{"class":224}," route ",[106,16138,136],{"class":112},[106,16140,16141],{"class":277}," useRoute",[106,16143,2366],{"class":224},[106,16145,16146,16148,16151,16153,16156,16158,16161,16163,16166],{"class":108,"line":123},[106,16147,1252],{"class":132},[106,16149,16150],{"class":224}," slug ",[106,16152,136],{"class":112},[106,16154,16155],{"class":224}," route",[106,16157,52],{"class":112},[106,16159,16160],{"class":224},"params",[106,16162,52],{"class":112},[106,16164,16165],{"class":224},"slug ",[106,16167,16168],{"class":335},"\u002F\u002F \u003Cslug>-\u003Chash>\n",[10,16170,16171,16172,16174,16175,72,16177,16179,16180,16182,16183,16185,16186,496],{},"The variable ",[103,16173,7061],{}," contains both the template ",[103,16176,7061],{},[103,16178,12591],{},". We know the hash is exactly 12 characters long so we can split the ",[103,16181,7061],{}," to get the ",[103,16184,7061],{}," and the ",[103,16187,12591],{},[96,16189,16191],{"className":261,"code":16190,"language":263,"meta":101,"style":101},"slug.slice(-12)\n",[103,16192,16193],{"__ignoreMap":101},[106,16194,16195,16197,16199,16201,16203,16206,16209],{"class":108,"line":109},[106,16196,7061],{"class":224},[106,16198,52],{"class":112},[106,16200,10980],{"class":277},[106,16202,281],{"class":224},[106,16204,16205],{"class":112},"-",[106,16207,16208],{"class":358},"12",[106,16210,324],{"class":224},[10,16212,16213],{},"Finally, we can make a request to the backend to retrieve the template. All of this gives us this code:",[96,16215,16217],{"className":98,"code":16216,"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,16218,16219,16239,16251,16271,16306,16312,16354,16364,16370,16378,16382,16390,16397,16410,16414,16419,16428],{"__ignoreMap":101},[106,16220,16221,16223,16225,16227,16229,16231,16233,16235,16237],{"class":108,"line":109},[106,16222,113],{"class":112},[106,16224,1189],{"class":116},[106,16226,1192],{"class":132},[106,16228,136],{"class":112},[106,16230,139],{"class":112},[106,16232,263],{"class":142},[106,16234,139],{"class":112},[106,16236,1203],{"class":132},[106,16238,120],{"class":112},[106,16240,16241,16243,16245,16247,16249],{"class":108,"line":123},[106,16242,1252],{"class":132},[106,16244,16136],{"class":224},[106,16246,136],{"class":112},[106,16248,16141],{"class":277},[106,16250,2366],{"class":224},[106,16252,16253,16255,16258,16260,16263,16265,16267,16269],{"class":108,"line":162},[106,16254,1252],{"class":132},[106,16256,16257],{"class":224}," hash ",[106,16259,136],{"class":112},[106,16261,16262],{"class":277}," computed",[106,16264,281],{"class":224},[106,16266,506],{"class":112},[106,16268,297],{"class":132},[106,16270,300],{"class":112},[106,16272,16273,16275,16277,16280,16282,16284,16286,16288,16290,16292,16294,16296,16298,16300,16302,16304],{"class":108,"line":200},[106,16274,345],{"class":270},[106,16276,287],{"class":116},[106,16278,16279],{"class":224},"route",[106,16281,52],{"class":112},[106,16283,16160],{"class":224},[106,16285,52],{"class":112},[106,16287,7061],{"class":224},[106,16289,3056],{"class":270},[106,16291,3942],{"class":1041},[106,16293,294],{"class":116},[106,16295,52],{"class":112},[106,16297,10980],{"class":277},[106,16299,281],{"class":116},[106,16301,16205],{"class":112},[106,16303,16208],{"class":358},[106,16305,324],{"class":116},[106,16307,16308,16310],{"class":108,"line":221},[106,16309,366],{"class":112},[106,16311,324],{"class":224},[106,16313,16314,16316,16318,16320,16322,16325,16327,16329,16331,16333,16335,16337,16340,16342,16344,16346,16348,16350,16352],{"class":108,"line":228},[106,16315,1252],{"class":132},[106,16317,413],{"class":112},[106,16319,964],{"class":116},[106,16321,496],{"class":112},[106,16323,16324],{"class":224}," template ",[106,16326,366],{"class":112},[106,16328,311],{"class":112},[106,16330,314],{"class":270},[106,16332,7623],{"class":277},[106,16334,281],{"class":224},[106,16336,11706],{"class":112},[106,16338,16339],{"class":142},"\u002Fapi\u002Ftemplates\u002F",[106,16341,6632],{"class":112},[106,16343,12591],{"class":224},[106,16345,52],{"class":112},[106,16347,9698],{"class":224},[106,16349,6638],{"class":112},[106,16351,355],{"class":112},[106,16353,300],{"class":112},[106,16355,16356,16358,16360,16362],{"class":108,"line":238},[106,16357,7641],{"class":116},[106,16359,496],{"class":112},[106,16361,7646],{"class":5594},[106,16363,509],{"class":112},[106,16365,16366,16368],{"class":108,"line":248},[106,16367,366],{"class":112},[106,16369,324],{"class":224},[106,16371,16372,16374,16376],{"class":108,"line":534},[106,16373,251],{"class":112},[106,16375,1189],{"class":116},[106,16377,120],{"class":112},[106,16379,16380],{"class":108,"line":539},[106,16381,330],{"emptyLinePlaceholder":329},[106,16383,16384,16386,16388],{"class":108,"line":556},[106,16385,113],{"class":112},[106,16387,117],{"class":116},[106,16389,120],{"class":112},[106,16391,16392,16394],{"class":108,"line":759},[106,16393,126],{"class":112},[106,16395,16396],{"class":116},"article\n",[106,16398,16399,16402,16404,16406,16408],{"class":108,"line":795},[106,16400,16401],{"class":132},"    v-if",[106,16403,136],{"class":112},[106,16405,139],{"class":112},[106,16407,117],{"class":142},[106,16409,7776],{"class":112},[106,16411,16412],{"class":108,"line":803},[106,16413,7781],{"class":112},[106,16415,16416],{"class":108,"line":809},[106,16417,16418],{"class":224},"    {{ template }}\n",[106,16420,16421,16423,16426],{"class":108,"line":814},[106,16422,241],{"class":112},[106,16424,16425],{"class":116},"article",[106,16427,120],{"class":112},[106,16429,16430,16432,16434],{"class":108,"line":830},[106,16431,251],{"class":112},[106,16433,117],{"class":116},[106,16435,120],{"class":112},[10,16437,16438,16439,16441],{},"The server part is not that complicated since we only get the hash from the router param and search for the template using a ",[103,16440,8562],{}," clause:",[96,16443,16445],{"className":261,"code":16444,"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,16446,16447,16469,16473,16495,16520,16543,16555,16559,16575,16579,16640,16644,16651],{"__ignoreMap":101},[106,16448,16449,16451,16453,16455,16457,16459,16461,16463,16465,16467],{"class":108,"line":109},[106,16450,410],{"class":270},[106,16452,413],{"class":112},[106,16454,2257],{"class":224},[106,16456,355],{"class":112},[106,16458,3942],{"class":224},[106,16460,419],{"class":112},[106,16462,422],{"class":270},[106,16464,425],{"class":112},[106,16466,428],{"class":142},[106,16468,431],{"class":112},[106,16470,16471],{"class":108,"line":123},[106,16472,330],{"emptyLinePlaceholder":329},[106,16474,16475,16477,16479,16481,16483,16485,16487,16489,16491,16493],{"class":108,"line":162},[106,16476,271],{"class":270},[106,16478,274],{"class":270},[106,16480,278],{"class":277},[106,16482,281],{"class":224},[106,16484,284],{"class":132},[106,16486,287],{"class":112},[106,16488,291],{"class":290},[106,16490,294],{"class":112},[106,16492,297],{"class":132},[106,16494,300],{"class":112},[106,16496,16497,16499,16501,16503,16505,16508,16510,16512,16514,16516,16518],{"class":108,"line":200},[106,16498,305],{"class":132},[106,16500,8584],{"class":224},[106,16502,311],{"class":112},[106,16504,314],{"class":270},[106,16506,16507],{"class":277}," getValidatedRouterParams",[106,16509,281],{"class":116},[106,16511,291],{"class":224},[106,16513,355],{"class":112},[106,16515,2257],{"class":277},[106,16517,281],{"class":116},[106,16519,488],{"class":112},[106,16521,16522,16524,16526,16528,16530,16532,16535,16537,16539,16541],{"class":108,"line":221},[106,16523,13438],{"class":116},[106,16525,496],{"class":112},[106,16527,3942],{"class":277},[106,16529,506],{"class":116},[106,16531,52],{"class":112},[106,16533,16534],{"class":277},"length",[106,16536,281],{"class":116},[106,16538,16208],{"class":358},[106,16540,294],{"class":116},[106,16542,509],{"class":112},[106,16544,16545,16547,16549,16551,16553],{"class":108,"line":228},[106,16546,514],{"class":112},[106,16548,294],{"class":116},[106,16550,52],{"class":112},[106,16552,521],{"class":224},[106,16554,324],{"class":116},[106,16556,16557],{"class":108,"line":238},[106,16558,330],{"emptyLinePlaceholder":329},[106,16560,16561,16563,16565,16568,16570,16572],{"class":108,"line":248},[106,16562,305],{"class":132},[106,16564,413],{"class":112},[106,16566,16567],{"class":224}," hash",[106,16569,419],{"class":112},[106,16571,311],{"class":112},[106,16573,16574],{"class":224}," params\n",[106,16576,16577],{"class":108,"line":534},[106,16578,330],{"emptyLinePlaceholder":329},[106,16580,16581,16583,16586,16588,16590,16592,16594,16596,16598,16600,16602,16604,16606,16608,16610,16612,16614,16616,16618,16620,16622,16624,16626,16628,16630,16632,16634,16636,16638],{"class":108,"line":539},[106,16582,305],{"class":132},[106,16584,16585],{"class":224}," template",[106,16587,311],{"class":112},[106,16589,314],{"class":270},[106,16591,7011],{"class":277},[106,16593,506],{"class":116},[106,16595,52],{"class":112},[106,16597,7018],{"class":277},[106,16599,506],{"class":116},[106,16601,52],{"class":112},[106,16603,7091],{"class":277},[106,16605,281],{"class":116},[106,16607,7096],{"class":224},[106,16609,52],{"class":112},[106,16611,11338],{"class":224},[106,16613,294],{"class":116},[106,16615,52],{"class":112},[106,16617,8562],{"class":277},[106,16619,281],{"class":116},[106,16621,8567],{"class":277},[106,16623,281],{"class":116},[106,16625,7096],{"class":224},[106,16627,52],{"class":112},[106,16629,11338],{"class":224},[106,16631,52],{"class":112},[106,16633,12591],{"class":224},[106,16635,355],{"class":112},[106,16637,16567],{"class":224},[106,16639,1127],{"class":116},[106,16641,16642],{"class":108,"line":556},[106,16643,330],{"emptyLinePlaceholder":329},[106,16645,16646,16648],{"class":108,"line":759},[106,16647,345],{"class":270},[106,16649,16650],{"class":224}," template\n",[106,16652,16653,16655],{"class":108,"line":795},[106,16654,366],{"class":112},[106,16656,324],{"class":224},[10,16658,16659],{},"Later we will need to join the user's data and the category's data to have a complete template.",[381,16661,16663],{"id":16662},"server-side-markdown","Server-side Markdown",[10,16665,16666],{},"The idea of receiving markdown from the client is to easily sanitize it to avoid HTML injection.",[10,16668,16669],{},"Imagine if the client send this HTML code:",[96,16671,16675],{"className":16672,"code":16673,"language":16674,"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,16676,16677,16694],{"__ignoreMap":101},[106,16678,16679,16681,16683,16685,16688,16690,16692],{"class":108,"line":109},[106,16680,113],{"class":112},[106,16682,10],{"class":116},[106,16684,3265],{"class":112},[106,16686,16687],{"class":224}," Hello World!",[106,16689,251],{"class":112},[106,16691,10],{"class":116},[106,16693,120],{"class":112},[106,16695,16696,16698,16700,16702,16705,16707,16710,16712,16714,16717,16719,16721,16723,16725],{"class":108,"line":123},[106,16697,113],{"class":112},[106,16699,1189],{"class":116},[106,16701,3265],{"class":112},[106,16703,16704],{"class":224}," console",[106,16706,52],{"class":112},[106,16708,16709],{"class":277},"log",[106,16711,281],{"class":224},[106,16713,754],{"class":112},[106,16715,16716],{"class":142},"hacked",[106,16718,754],{"class":112},[106,16720,713],{"class":224},[106,16722,251],{"class":112},[106,16724,1189],{"class":116},[106,16726,120],{"class":112},[10,16728,16729,16730,16733],{},"If the server stores this content in the database and then display it using the directive ",[103,16731,16732],{},"v-html",", every user that will see the page will execute the page. That's a real security issue.",[10,16735,16736,16737,16740,16741,16744],{},"By receiving markdown from the client, I can easily transform it using ",[103,16738,16739],{},"markdown-it"," and store the ",[22,16742,16743],{},"sanitized HTML"," in the database. The sanitized HTML is HTML that does not contain any script or dangerous tag.",[10,16746,16747,16748,16750,16751,16753,16754,16757],{},"So to do this, I need to install ",[103,16749,16739],{}," and update the endpoint to create a template ",[103,16752,13155],{},". I also add a new column ",[103,16755,16756],{},"descriptionHTML"," to the schema to store the sanitized HTML. Storing the HTML avoids to do it every time we render the page.",[96,16759,16761],{"className":261,"code":16760,"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,16762,16763,16789,16801,16819,16833,16847,16861,16875,16889,16903,16922,16936],{"__ignoreMap":101},[106,16764,16765,16767,16769,16771,16773,16775,16777,16779,16781,16783,16785,16787],{"class":108,"line":109},[106,16766,10764],{"class":270},[106,16768,7011],{"class":277},[106,16770,506],{"class":224},[106,16772,52],{"class":112},[106,16774,7207],{"class":277},[106,16776,10775],{"class":224},[106,16778,52],{"class":112},[106,16780,15946],{"class":224},[106,16782,52],{"class":112},[106,16784,7222],{"class":277},[106,16786,281],{"class":224},[106,16788,488],{"class":112},[106,16790,16791,16793,16795,16797,16799],{"class":108,"line":123},[106,16792,12580],{"class":116},[106,16794,496],{"class":112},[106,16796,13443],{"class":277},[106,16798,506],{"class":224},[106,16800,509],{"class":112},[106,16802,16803,16805,16807,16809,16812,16814,16817],{"class":108,"line":162},[106,16804,12614],{"class":116},[106,16806,496],{"class":112},[106,16808,7235],{"class":277},[106,16810,16811],{"class":224},"(body",[106,16813,52],{"class":112},[106,16815,16816],{"class":224},"title)",[106,16818,509],{"class":112},[106,16820,16821,16823,16825,16827,16829,16831],{"class":108,"line":200},[106,16822,12641],{"class":116},[106,16824,496],{"class":112},[106,16826,308],{"class":224},[106,16828,52],{"class":112},[106,16830,12652],{"class":224},[106,16832,509],{"class":112},[106,16834,16835,16837,16839,16841,16843,16845],{"class":108,"line":221},[106,16836,12945],{"class":116},[106,16838,496],{"class":112},[106,16840,308],{"class":224},[106,16842,52],{"class":112},[106,16844,13023],{"class":224},[106,16846,509],{"class":112},[106,16848,16849,16851,16853,16855,16857,16859],{"class":108,"line":228},[106,16850,12750],{"class":116},[106,16852,496],{"class":112},[106,16854,308],{"class":224},[106,16856,52],{"class":112},[106,16858,13508],{"class":224},[106,16860,509],{"class":112},[106,16862,16863,16865,16867,16869,16871,16873],{"class":108,"line":238},[106,16864,12822],{"class":116},[106,16866,496],{"class":112},[106,16868,308],{"class":224},[106,16870,52],{"class":112},[106,16872,13523],{"class":224},[106,16874,509],{"class":112},[106,16876,16877,16879,16881,16883,16885,16887],{"class":108,"line":248},[106,16878,12844],{"class":116},[106,16880,496],{"class":112},[106,16882,308],{"class":224},[106,16884,52],{"class":112},[106,16886,13538],{"class":224},[106,16888,509],{"class":112},[106,16890,16891,16893,16895,16897,16899,16901],{"class":108,"line":534},[106,16892,12872],{"class":116},[106,16894,496],{"class":112},[106,16896,308],{"class":224},[106,16898,52],{"class":112},[106,16900,12883],{"class":224},[106,16902,509],{"class":112},[106,16904,16905,16908,16910,16913,16915,16917,16920],{"class":108,"line":539},[106,16906,16907],{"class":116},"  descriptionHTML",[106,16909,496],{"class":112},[106,16911,16912],{"class":277}," useMarkdown",[106,16914,16811],{"class":224},[106,16916,52],{"class":112},[106,16918,16919],{"class":224},"description)",[106,16921,509],{"class":112},[106,16923,16924,16926,16928,16930,16932,16934],{"class":108,"line":556},[106,16925,12900],{"class":116},[106,16927,496],{"class":112},[106,16929,5621],{"class":224},[106,16931,52],{"class":112},[106,16933,7041],{"class":224},[106,16935,509],{"class":112},[106,16937,16938,16940,16942,16944,16946],{"class":108,"line":759},[106,16939,366],{"class":112},[106,16941,294],{"class":224},[106,16943,52],{"class":112},[106,16945,7273],{"class":277},[106,16947,2366],{"class":224},[10,16949,7122,16950,16953,16954,16956],{},[103,16951,16952],{},"useMarkdown"," function is a simple function that uses ",[103,16955,16739],{}," to transform the markdown to HTML.",[381,16958,16960],{"id":16959},"markdown-editor","Markdown editor",[10,16962,16963],{},[29,16964,16965],{},"This part is highly experimental and could change in the future. This is a first implementation.",[10,16967,16968],{},[29,16969,16970],{},"While writing this part, I made some more research and everything will change. I think it's still interesting to see the process.",[10,16972,16973,16974,16977],{},"Before starting, I need to make a choice. Which markdown editor should I use? ",[29,16975,16976],{},"Remember that we've just implemented a server endpoint to handle markdown."," I have many options:",[5857,16979,16980,16983,16986,16989,16992,16995,16998,17001],{},[5860,16981,16982],{},"SimpleMDE",[5860,16984,16985],{},"EasyMDE",[5860,16987,16988],{},"TinyMDE",[5860,16990,16991],{},"Editor.js",[5860,16993,16994],{},"Quill",[5860,16996,16997],{},"Trix",[5860,16999,17000],{},"CKEditor",[5860,17002,17003],{},"TinyMCE",[10,17005,17006],{},"That's a lot of options. But, what are my needs? I need a dead simple editor that can return markdown. Sounds easy, right?",[10,17008,17009,17010],{},"Editor.js, CKEditor and TinyMCE are advanced editors so I will not use them. SimpleMDE is a good option but way too old. It has not received any updates since 6 years. SimpleMDE and TinyMDE are forks of SimpleMDE. EasyMDE is powerful, simple and returns markdown. It's a good option. TinyMDE does not have enough customization options. Trix and Quill are not markdown editors. They are rich text editors so they do not output markdown. I will go with EasyMDE. ",[29,17011,17012],{},"For the moment.",[10,17014,17015,17016,17019,17020],{},"So, we can now install the package and use it. The usage is a little tricky. Since a client only package, we cannot execute, nor import, it on the server. To use it, we will dynamically import it in a ",[103,17017,17018],{},"onMounted"," hook. ",[29,17021,17022,17025],{},[103,17023,17024],{},"onBeforeMount"," is even better!",[96,17027,17029],{"className":261,"code":17028,"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,17030,17031,17045,17091,17106],{"__ignoreMap":101},[106,17032,17033,17035,17037,17039,17041,17043],{"class":108,"line":109},[106,17034,17018],{"class":277},[106,17036,281],{"class":224},[106,17038,284],{"class":132},[106,17040,6993],{"class":112},[106,17042,297],{"class":132},[106,17044,300],{"class":112},[106,17046,17047,17049,17052,17054,17056,17059,17061,17063,17066,17068,17070,17072,17075,17077,17080,17082,17085,17087,17089],{"class":108,"line":123},[106,17048,305],{"class":132},[106,17050,17051],{"class":224}," EasyMDE",[106,17053,311],{"class":112},[106,17055,314],{"class":270},[106,17057,17058],{"class":112}," import",[106,17060,281],{"class":116},[106,17062,754],{"class":112},[106,17064,17065],{"class":142},"easymde",[106,17067,754],{"class":112},[106,17069,294],{"class":116},[106,17071,52],{"class":112},[106,17073,17074],{"class":277},"then",[106,17076,281],{"class":116},[106,17078,17079],{"class":290},"m",[106,17081,297],{"class":132},[106,17083,17084],{"class":224}," m",[106,17086,52],{"class":112},[106,17088,11652],{"class":224},[106,17090,324],{"class":116},[106,17092,17093,17095,17098,17100,17102,17104],{"class":108,"line":162},[106,17094,305],{"class":132},[106,17096,17097],{"class":224}," easymde",[106,17099,311],{"class":112},[106,17101,3070],{"class":112},[106,17103,17051],{"class":277},[106,17105,2366],{"class":116},[106,17107,17108,17110],{"class":108,"line":200},[106,17109,366],{"class":112},[106,17111,324],{"class":224},[10,17113,17114],{},"And it works? Yes but I think we completely forgot to import the style!",[10,17116,17117],{},[5476,17118],{"alt":17119,"src":17120},"EasyMDE without style","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-without-style.png",[10,17122,17123],{},"I think we forgot to add the style for EasyMDE. Thanks to Vite and its CSS loader, we can import the CSS file directly in our script.",[96,17125,17127],{"className":98,"code":17126,"language":100,"meta":101,"style":101},"\u003Cscript lang=\"ts\" setup>\nimport 'easymde\u002Fdist\u002Feasymde.min.css'\n\u003C\u002Fscript>\n",[103,17128,17129,17149,17160],{"__ignoreMap":101},[106,17130,17131,17133,17135,17137,17139,17141,17143,17145,17147],{"class":108,"line":109},[106,17132,113],{"class":112},[106,17134,1189],{"class":116},[106,17136,1192],{"class":132},[106,17138,136],{"class":112},[106,17140,139],{"class":112},[106,17142,263],{"class":142},[106,17144,139],{"class":112},[106,17146,1203],{"class":132},[106,17148,120],{"class":112},[106,17150,17151,17153,17155,17158],{"class":108,"line":123},[106,17152,410],{"class":270},[106,17154,425],{"class":112},[106,17156,17157],{"class":142},"easymde\u002Fdist\u002Feasymde.min.css",[106,17159,431],{"class":112},[106,17161,17162,17164,17166],{"class":108,"line":162},[106,17163,251],{"class":112},[106,17165,1189],{"class":116},[106,17167,120],{"class":112},[10,17169,17170],{},[5476,17171],{"alt":17172,"src":17173},"EasyMDE with style","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-with-style.png",[10,17175,17176],{},"Ok, it's not perfect and I will have some work to do, on both styles and icons. But it's a good start. The only part that I do not like is the markdown symbol. I would prefer to not have them because it's not user-friendly (anyone could have to create a template).",[10,17178,17179],{},"But anyway, it's a good start and I can now easily listen to the editor changes and update the description.",[96,17181,17183],{"className":98,"code":17182,"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,17184,17185,17205,17230,17244,17284,17298,17330,17349,17355,17361],{"__ignoreMap":101},[106,17186,17187,17189,17191,17193,17195,17197,17199,17201,17203],{"class":108,"line":109},[106,17188,113],{"class":112},[106,17190,1189],{"class":116},[106,17192,1192],{"class":132},[106,17194,136],{"class":112},[106,17196,139],{"class":112},[106,17198,263],{"class":142},[106,17200,139],{"class":112},[106,17202,1203],{"class":132},[106,17204,120],{"class":112},[106,17206,17207,17209,17212,17214,17217,17219,17221,17223,17225,17228],{"class":108,"line":123},[106,17208,1252],{"class":132},[106,17210,17211],{"class":224}," description ",[106,17213,136],{"class":112},[106,17215,17216],{"class":277}," ref",[106,17218,113],{"class":112},[106,17220,503],{"class":1041},[106,17222,3265],{"class":112},[106,17224,281],{"class":224},[106,17226,17227],{"class":112},"''",[106,17229,324],{"class":224},[106,17231,17232,17234,17236,17238,17240,17242],{"class":108,"line":162},[106,17233,17018],{"class":277},[106,17235,281],{"class":224},[106,17237,284],{"class":132},[106,17239,6993],{"class":112},[106,17241,297],{"class":132},[106,17243,300],{"class":112},[106,17245,17246,17248,17250,17252,17254,17256,17258,17260,17262,17264,17266,17268,17270,17272,17274,17276,17278,17280,17282],{"class":108,"line":200},[106,17247,305],{"class":132},[106,17249,17051],{"class":224},[106,17251,311],{"class":112},[106,17253,314],{"class":270},[106,17255,17058],{"class":112},[106,17257,281],{"class":116},[106,17259,754],{"class":112},[106,17261,17065],{"class":142},[106,17263,754],{"class":112},[106,17265,294],{"class":116},[106,17267,52],{"class":112},[106,17269,17074],{"class":277},[106,17271,281],{"class":116},[106,17273,17079],{"class":290},[106,17275,297],{"class":132},[106,17277,17084],{"class":224},[106,17279,52],{"class":112},[106,17281,11652],{"class":224},[106,17283,324],{"class":116},[106,17285,17286,17288,17290,17292,17294,17296],{"class":108,"line":221},[106,17287,305],{"class":132},[106,17289,17097],{"class":224},[106,17291,311],{"class":112},[106,17293,3070],{"class":112},[106,17295,17051],{"class":277},[106,17297,2366],{"class":116},[106,17299,17300,17303,17305,17308,17310,17313,17315,17317,17320,17322,17324,17326,17328],{"class":108,"line":228},[106,17301,17302],{"class":224},"  easyMDE",[106,17304,52],{"class":112},[106,17306,17307],{"class":224},"codemirror",[106,17309,52],{"class":112},[106,17311,17312],{"class":277},"on",[106,17314,281],{"class":116},[106,17316,754],{"class":112},[106,17318,17319],{"class":142},"change",[106,17321,754],{"class":112},[106,17323,355],{"class":112},[106,17325,6993],{"class":112},[106,17327,297],{"class":132},[106,17329,300],{"class":112},[106,17331,17332,17334,17336,17338,17340,17343,17345,17347],{"class":108,"line":238},[106,17333,13367],{"class":224},[106,17335,52],{"class":112},[106,17337,9698],{"class":224},[106,17339,311],{"class":112},[106,17341,17342],{"class":224}," easyMDE",[106,17344,52],{"class":112},[106,17346,9698],{"class":277},[106,17348,2366],{"class":116},[106,17350,17351,17353],{"class":108,"line":248},[106,17352,514],{"class":112},[106,17354,324],{"class":116},[106,17356,17357,17359],{"class":108,"line":534},[106,17358,366],{"class":112},[106,17360,324],{"class":224},[106,17362,17363,17365,17367],{"class":108,"line":539},[106,17364,251],{"class":112},[106,17366,1189],{"class":116},[106,17368,120],{"class":112},[10,17370,17371],{},"Now, I can send the description to the server and it will be transformed to HTML!",[381,17373,17375],{"id":17374},"whats-next-for-the-form","What's next for the form?",[10,17377,17378],{},"The form is far from done but it's progressing well. I will add the support for the images and the modules in the next stream.",[10,17380,17381],{},"The Orion project is nearly ready for an alpha release and I'm excited to see how the community will use it and discover the content they will share.",[54,17383,17385],{"id":17384},"replacing-the-markdown-editor","Replacing the Markdown editor",[10,17387,17388],{},[29,17389,17390],{},"This part was done off-stream because I was not satisfied with the EasyMDE editor but I did not know what to do.",[10,17392,17393],{},"After thinking about it, I realized that using Markdown, for a simple description and that can be used by non-technical users, is not the best idea. Furthermore, the EasyMDE editor uses CodeMirror which is slow and way too heavy for a simple editor.",[10,17395,17396,17397],{},"Now we know that the EasyMDE editor is not the best solution for our needs, we need to find a new one. We need a simple Rich Text Editor and there are two main options: Quill and Trix. In that case, Quill seems to be simpler so I will go with it. ",[29,17398,17399],{},"I will not detail the installation of Quill, it's pretty simple.",[10,17401,17402,17403,17405,17406,11376],{},"Because Quill is a rich text editor, it does not output markdown but HTML. So I need to update the endpoint to store the HTML instead of the markdown. I also need to update the schema to remove the ",[103,17404,12883],{}," column and add a ",[103,17407,16756],{},[381,17409,17411],{"id":17410},"updating-the-endpoint","Updating the endpoint",[10,17413,17414],{},"Why do I need to update the endpoint? Quill returns HTML that is already sanitized so I could directly store it in the database. Yes but no. We are building an API and it's impossible to trust the client, even our own client. Anyone can change the HTML or use an HTTP client to send a request to the server with malicious HTML. So I need to sanitize the HTML on the server.",[10,17416,17417],{},"For example, here's some malicious HTML:",[96,17419,17421],{"className":16672,"code":17420,"language":16674,"meta":101,"style":101},"\u003Cp> Hello World! \u003C\u002Fp>\n\u003Cscript> console.log('hacked') \u003C\u002Fscript>\n",[103,17422,17423,17440],{"__ignoreMap":101},[106,17424,17425,17427,17429,17431,17434,17436,17438],{"class":108,"line":109},[106,17426,113],{"class":112},[106,17428,10],{"class":116},[106,17430,3265],{"class":112},[106,17432,17433],{"class":224}," Hello World! ",[106,17435,251],{"class":112},[106,17437,10],{"class":116},[106,17439,120],{"class":112},[106,17441,17442,17444,17446,17448,17450,17452,17454,17456,17458,17460,17462,17464,17466,17468],{"class":108,"line":123},[106,17443,113],{"class":112},[106,17445,1189],{"class":116},[106,17447,3265],{"class":112},[106,17449,16704],{"class":224},[106,17451,52],{"class":112},[106,17453,16709],{"class":277},[106,17455,281],{"class":224},[106,17457,754],{"class":112},[106,17459,16716],{"class":142},[106,17461,754],{"class":112},[106,17463,713],{"class":224},[106,17465,251],{"class":112},[106,17467,1189],{"class":116},[106,17469,120],{"class":112},[10,17471,17472,17473,17475,17476,17479],{},"If you store this HTML in the database and display it using the directive ",[103,17474,16732],{},", every user that will see the page will execute the script. That's a real security issue. We can't use ",[103,17477,17478],{},"{{ html }}"," because Vue will escape the HTML so sanitizing our content is the only solution.",[10,17481,17482,17483,17486],{},"I will use the ",[103,17484,17485],{},"sanitize-html"," package to sanitize the HTML. After sanitizing using this package, the HTML will be:",[96,17488,17490],{"className":16672,"code":17489,"language":16674,"meta":101,"style":101},"\u003Cp> Hello World! \u003C\u002Fp>\n",[103,17491,17492],{"__ignoreMap":101},[106,17493,17494,17496,17498,17500,17502,17504,17506],{"class":108,"line":109},[106,17495,113],{"class":112},[106,17497,10],{"class":116},[106,17499,3265],{"class":112},[106,17501,17433],{"class":224},[106,17503,251],{"class":112},[106,17505,10],{"class":116},[106,17507,120],{"class":112},[10,17509,17510],{},"That's perfect!",[96,17512,17514],{"className":261,"code":17513,"language":263,"meta":101,"style":101},"await useDrizzle().insert(tables.templates).values({\n  description: body.description ? sanitizeHtml(body.description) : null,\n})\n",[103,17515,17516,17542,17572],{"__ignoreMap":101},[106,17517,17518,17520,17522,17524,17526,17528,17530,17532,17534,17536,17538,17540],{"class":108,"line":109},[106,17519,10764],{"class":270},[106,17521,7011],{"class":277},[106,17523,506],{"class":224},[106,17525,52],{"class":112},[106,17527,7207],{"class":277},[106,17529,10775],{"class":224},[106,17531,52],{"class":112},[106,17533,15946],{"class":224},[106,17535,52],{"class":112},[106,17537,7222],{"class":277},[106,17539,281],{"class":224},[106,17541,488],{"class":112},[106,17543,17544,17546,17548,17550,17552,17555,17557,17560,17562,17564,17567,17569],{"class":108,"line":123},[106,17545,12872],{"class":116},[106,17547,496],{"class":112},[106,17549,308],{"class":224},[106,17551,52],{"class":112},[106,17553,17554],{"class":224},"description ",[106,17556,10816],{"class":112},[106,17558,17559],{"class":277}," sanitizeHtml",[106,17561,16811],{"class":224},[106,17563,52],{"class":112},[106,17565,17566],{"class":224},"description) ",[106,17568,496],{"class":112},[106,17570,17571],{"class":112}," null,\n",[106,17573,17574,17576],{"class":108,"line":162},[106,17575,366],{"class":112},[106,17577,324],{"class":224},[10,17579,7122,17580,17583,17584,17586],{},[103,17581,17582],{},"sanitizeHtml"," function is the default export of the ",[103,17585,17485],{}," package. Easy right?",[381,17588,17590],{"id":17589},"updating-the-schema","Updating the schema",[10,17592,17593,17594,17596,17597,17599],{},"Now, I need to update the schema to remove the ",[103,17595,16756],{}," column since the ",[103,17598,12883],{}," is now HTML by default. It's important to know that if the user needs to edit his template, the HTML will be used by the Quill editor.",[96,17601,17604],{"className":17602,"code":17603,"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,17605,17606,17630,17650,17674],{"__ignoreMap":101},[106,17607,17608,17610,17612,17614,17616,17618,17620,17622,17624,17626,17628],{"class":108,"line":109},[106,17609,271],{"class":270},[106,17611,1775],{"class":132},[106,17613,12521],{"class":224},[106,17615,136],{"class":112},[106,17617,10540],{"class":277},[106,17619,281],{"class":224},[106,17621,754],{"class":112},[106,17623,11338],{"class":142},[106,17625,754],{"class":112},[106,17627,355],{"class":112},[106,17629,300],{"class":112},[106,17631,17632,17634,17636,17638,17640,17642,17644,17646,17648],{"class":108,"line":123},[106,17633,12872],{"class":116},[106,17635,496],{"class":112},[106,17637,10602],{"class":277},[106,17639,281],{"class":224},[106,17641,754],{"class":112},[106,17643,12883],{"class":142},[106,17645,754],{"class":112},[106,17647,294],{"class":224},[106,17649,509],{"class":112},[106,17651,17655,17657,17659,17661,17663,17665,17668,17670,17672],{"class":17652,"line":162},[108,17653,17654],"diff","remove",[106,17656,16907],{"class":116},[106,17658,496],{"class":112},[106,17660,10602],{"class":277},[106,17662,281],{"class":224},[106,17664,754],{"class":112},[106,17666,17667],{"class":142},"description_html",[106,17669,754],{"class":112},[106,17671,294],{"class":224},[106,17673,355],{"class":112},[106,17675,17676,17678],{"class":108,"line":200},[106,17677,366],{"class":112},[106,17679,324],{"class":224},[381,17681,17683],{"id":17682},"updating-the-ui","Updating the UI",[10,17685,17686],{},"The usage of Quill is not that different from EasyMDE. I lazy load the Quill package, create a new instance of it, and listen for the changes. I do not forget to import the CSS file.",[96,17688,17690],{"className":98,"code":17689,"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,17691,17692,17712,17723,17727,17741,17783,17811,17827,17833,17877,17911,17917,17923,17931,17935,17943,17963],{"__ignoreMap":101},[106,17693,17694,17696,17698,17700,17702,17704,17706,17708,17710],{"class":108,"line":109},[106,17695,113],{"class":112},[106,17697,1189],{"class":116},[106,17699,1192],{"class":132},[106,17701,136],{"class":112},[106,17703,139],{"class":112},[106,17705,263],{"class":142},[106,17707,139],{"class":112},[106,17709,1203],{"class":132},[106,17711,120],{"class":112},[106,17713,17714,17716,17718,17721],{"class":108,"line":123},[106,17715,410],{"class":270},[106,17717,425],{"class":112},[106,17719,17720],{"class":142},"quill\u002Fdist\u002Fquill.snow.css",[106,17722,431],{"class":112},[106,17724,17725],{"class":108,"line":162},[106,17726,330],{"emptyLinePlaceholder":329},[106,17728,17729,17731,17733,17735,17737,17739],{"class":108,"line":200},[106,17730,17024],{"class":277},[106,17732,281],{"class":224},[106,17734,284],{"class":132},[106,17736,6993],{"class":112},[106,17738,297],{"class":132},[106,17740,300],{"class":112},[106,17742,17743,17745,17748,17750,17752,17754,17756,17758,17761,17763,17765,17767,17769,17771,17773,17775,17777,17779,17781],{"class":108,"line":221},[106,17744,305],{"class":132},[106,17746,17747],{"class":224}," Quill",[106,17749,311],{"class":112},[106,17751,314],{"class":270},[106,17753,17058],{"class":112},[106,17755,281],{"class":116},[106,17757,754],{"class":112},[106,17759,17760],{"class":142},"quill",[106,17762,754],{"class":112},[106,17764,294],{"class":116},[106,17766,52],{"class":112},[106,17768,17074],{"class":277},[106,17770,281],{"class":116},[106,17772,17079],{"class":290},[106,17774,297],{"class":132},[106,17776,17084],{"class":224},[106,17778,52],{"class":112},[106,17780,11652],{"class":224},[106,17782,324],{"class":116},[106,17784,17785,17788,17790,17792,17794,17796,17798,17800,17802,17805,17807,17809],{"class":108,"line":228},[106,17786,17787],{"class":224},"  quill",[106,17789,52],{"class":112},[106,17791,9698],{"class":224},[106,17793,311],{"class":112},[106,17795,3070],{"class":112},[106,17797,17747],{"class":277},[106,17799,281],{"class":116},[106,17801,754],{"class":112},[106,17803,17804],{"class":142},"#editor",[106,17806,754],{"class":112},[106,17808,355],{"class":112},[106,17810,300],{"class":112},[106,17812,17813,17816,17818,17820,17823,17825],{"class":108,"line":238},[106,17814,17815],{"class":116},"    theme",[106,17817,496],{"class":112},[106,17819,425],{"class":112},[106,17821,17822],{"class":142},"snow",[106,17824,754],{"class":112},[106,17826,509],{"class":112},[106,17828,17829,17831],{"class":108,"line":248},[106,17830,514],{"class":112},[106,17832,324],{"class":116},[106,17834,17835,17837,17839,17841,17843,17845,17847,17849,17852,17854,17856,17858,17861,17863,17866,17868,17871,17873,17875],{"class":108,"line":534},[106,17836,17787],{"class":224},[106,17838,52],{"class":112},[106,17840,9698],{"class":224},[106,17842,9701],{"class":112},[106,17844,17312],{"class":277},[106,17846,281],{"class":116},[106,17848,754],{"class":112},[106,17850,17851],{"class":142},"text-change",[106,17853,754],{"class":112},[106,17855,355],{"class":112},[106,17857,287],{"class":112},[106,17859,17860],{"class":290},"_",[106,17862,355],{"class":112},[106,17864,17865],{"class":290}," __",[106,17867,355],{"class":112},[106,17869,17870],{"class":290}," source",[106,17872,294],{"class":112},[106,17874,297],{"class":132},[106,17876,300],{"class":112},[106,17878,17879,17882,17884,17886,17888,17891,17893,17895,17897,17900,17902,17905,17908],{"class":108,"line":539},[106,17880,17881],{"class":224},"    state",[106,17883,52],{"class":112},[106,17885,12883],{"class":224},[106,17887,311],{"class":112},[106,17889,17890],{"class":224}," quill",[106,17892,52],{"class":112},[106,17894,9698],{"class":224},[106,17896,9701],{"class":112},[106,17898,17899],{"class":224},"root",[106,17901,52],{"class":112},[106,17903,17904],{"class":224},"innerHTML",[106,17906,17907],{"class":112}," ??",[106,17909,17910],{"class":112}," ''\n",[106,17912,17913,17915],{"class":108,"line":556},[106,17914,514],{"class":112},[106,17916,324],{"class":116},[106,17918,17919,17921],{"class":108,"line":759},[106,17920,366],{"class":112},[106,17922,324],{"class":224},[106,17924,17925,17927,17929],{"class":108,"line":795},[106,17926,251],{"class":112},[106,17928,1189],{"class":116},[106,17930,120],{"class":112},[106,17932,17933],{"class":108,"line":803},[106,17934,330],{"emptyLinePlaceholder":329},[106,17936,17937,17939,17941],{"class":108,"line":809},[106,17938,113],{"class":112},[106,17940,117],{"class":116},[106,17942,120],{"class":112},[106,17944,17945,17947,17949,17952,17954,17956,17959,17961],{"class":108,"line":814},[106,17946,126],{"class":112},[106,17948,3253],{"class":116},[106,17950,17951],{"class":132}," id",[106,17953,136],{"class":112},[106,17955,139],{"class":112},[106,17957,17958],{"class":142},"editor",[106,17960,139],{"class":112},[106,17962,6929],{"class":112},[106,17964,17965,17967,17969],{"class":108,"line":830},[106,17966,251],{"class":112},[106,17968,117],{"class":116},[106,17970,120],{"class":112},[10,17972,17973],{},"Of course, I need to add some custom styles to the editor to make it more native into the design.",[10,17975,17976],{},"A last thing. I use this in a Nuxt UI form. In this form, every item is validated against the schema. This allows me to display an error message if the description is empty or too long. The problem is that it's not integrated in the Quill editor, it does not trigger any validation. Thanks to the Nuxt UI form API, I can easily validate the Quill editor content on every change.",[96,17978,17980],{"className":98,"code":17979,"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,17981,17982,18002,18015,18019,18033,18073,18101,18138,18144,18150,18158,18162,18170,18177,18190,18202,18237,18241,18259,18267],{"__ignoreMap":101},[106,17983,17984,17986,17988,17990,17992,17994,17996,17998,18000],{"class":108,"line":109},[106,17985,113],{"class":112},[106,17987,1189],{"class":116},[106,17989,1192],{"class":132},[106,17991,136],{"class":112},[106,17993,139],{"class":112},[106,17995,263],{"class":142},[106,17997,139],{"class":112},[106,17999,1203],{"class":132},[106,18001,120],{"class":112},[106,18003,18004,18006,18009,18011,18013],{"class":108,"line":123},[106,18005,1252],{"class":132},[106,18007,18008],{"class":224}," form ",[106,18010,136],{"class":112},[106,18012,17216],{"class":277},[106,18014,2366],{"class":224},[106,18016,18017],{"class":108,"line":162},[106,18018,330],{"emptyLinePlaceholder":329},[106,18020,18021,18023,18025,18027,18029,18031],{"class":108,"line":200},[106,18022,17024],{"class":277},[106,18024,281],{"class":224},[106,18026,284],{"class":132},[106,18028,6993],{"class":112},[106,18030,297],{"class":132},[106,18032,300],{"class":112},[106,18034,18035,18037,18039,18041,18043,18045,18047,18049,18051,18053,18055,18057,18059,18061,18063,18065,18067,18069,18071],{"class":108,"line":221},[106,18036,17787],{"class":224},[106,18038,52],{"class":112},[106,18040,9698],{"class":224},[106,18042,9701],{"class":112},[106,18044,17312],{"class":277},[106,18046,281],{"class":116},[106,18048,754],{"class":112},[106,18050,17851],{"class":142},[106,18052,754],{"class":112},[106,18054,355],{"class":112},[106,18056,287],{"class":112},[106,18058,17860],{"class":290},[106,18060,355],{"class":112},[106,18062,17865],{"class":290},[106,18064,355],{"class":112},[106,18066,17870],{"class":290},[106,18068,294],{"class":112},[106,18070,297],{"class":132},[106,18072,300],{"class":112},[106,18074,18075,18077,18079,18081,18083,18085,18087,18089,18091,18093,18095,18097,18099],{"class":108,"line":228},[106,18076,17881],{"class":224},[106,18078,52],{"class":112},[106,18080,12883],{"class":224},[106,18082,311],{"class":112},[106,18084,17890],{"class":224},[106,18086,52],{"class":112},[106,18088,9698],{"class":224},[106,18090,9701],{"class":112},[106,18092,17899],{"class":224},[106,18094,52],{"class":112},[106,18096,17904],{"class":224},[106,18098,17907],{"class":112},[106,18100,17910],{"class":112},[106,18102,18103,18106,18108,18110,18112,18115,18117,18119,18121,18123,18125,18127,18130,18132,18134,18136],{"class":108,"line":238},[106,18104,18105],{"class":224},"    form",[106,18107,52],{"class":112},[106,18109,9698],{"class":224},[106,18111,52],{"class":112},[106,18113,18114],{"class":277},"validate",[106,18116,281],{"class":116},[106,18118,754],{"class":112},[106,18120,12883],{"class":142},[106,18122,754],{"class":112},[106,18124,355],{"class":112},[106,18126,413],{"class":112},[106,18128,18129],{"class":116}," silent",[106,18131,496],{"class":112},[106,18133,5595],{"class":5594},[106,18135,419],{"class":112},[106,18137,324],{"class":116},[106,18139,18140,18142],{"class":108,"line":248},[106,18141,514],{"class":112},[106,18143,324],{"class":116},[106,18145,18146,18148],{"class":108,"line":534},[106,18147,366],{"class":112},[106,18149,324],{"class":224},[106,18151,18152,18154,18156],{"class":108,"line":539},[106,18153,251],{"class":112},[106,18155,1189],{"class":116},[106,18157,120],{"class":112},[106,18159,18160],{"class":108,"line":556},[106,18161,330],{"emptyLinePlaceholder":329},[106,18163,18164,18166,18168],{"class":108,"line":759},[106,18165,113],{"class":112},[106,18167,117],{"class":116},[106,18169,120],{"class":112},[106,18171,18172,18174],{"class":108,"line":795},[106,18173,126],{"class":112},[106,18175,18176],{"class":116},"UFormGroup\n",[106,18178,18179,18182,18184,18186,18188],{"class":108,"line":803},[106,18180,18181],{"class":132},"    label",[106,18183,136],{"class":112},[106,18185,139],{"class":112},[106,18187,14166],{"class":142},[106,18189,7776],{"class":112},[106,18191,18192,18194,18196,18198,18200],{"class":108,"line":809},[106,18193,493],{"class":132},[106,18195,136],{"class":112},[106,18197,139],{"class":112},[106,18199,12883],{"class":142},[106,18201,7776],{"class":112},[106,18203,18204,18206,18209,18211,18214,18216,18218,18220,18222,18224,18227,18229,18231,18234],{"class":108,"line":814},[106,18205,14367],{"class":112},[106,18207,18208],{"class":132},"hint",[106,18210,136],{"class":112},[106,18212,18213],{"class":112},"\"`${",[106,18215,1395],{"class":224},[106,18217,52],{"class":112},[106,18219,12883],{"class":224},[106,18221,9701],{"class":112},[106,18223,10915],{"class":224},[106,18225,18226],{"class":112},"||",[106,18228,10941],{"class":358},[106,18230,366],{"class":112},[106,18232,18233],{"class":142},"\u002F2000 characters",[106,18235,18236],{"class":112},"`\"\n",[106,18238,18239],{"class":108,"line":830},[106,18240,7781],{"class":112},[106,18242,18243,18245,18247,18249,18251,18253,18255,18257],{"class":108,"line":835},[106,18244,165],{"class":112},[106,18246,3253],{"class":116},[106,18248,17951],{"class":132},[106,18250,136],{"class":112},[106,18252,139],{"class":112},[106,18254,17958],{"class":142},[106,18256,139],{"class":112},[106,18258,6929],{"class":112},[106,18260,18261,18263,18265],{"class":108,"line":840},[106,18262,241],{"class":112},[106,18264,8236],{"class":116},[106,18266,120],{"class":112},[106,18268,18269,18271,18273],{"class":108,"line":845},[106,18270,251],{"class":112},[106,18272,117],{"class":116},[106,18274,120],{"class":112},[10,18276,18277],{},"Now, the Quill editor is fully integrated into the form and an error message will be displayed if the description is empty or too long.",[10,18279,18280],{},[5476,18281],{"alt":18282,"src":18283},"The rich text editor customized to fully integrate into the form.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Frich-text-editor.png",[381,18285,5371],{"id":18286},"conclusion-3",[10,18288,18289],{},"I'm really happy with this change. Since years, I was wondering how to install and use a rich text editor and now I know. You can use markdown for technical users, parse it on the server, and store HTML to simplify a get, or use a rich text editor for non-technical users and sanitize the HTML on the server. It's a good lesson for me and I hope it will be useful for you.",[54,18291,18293],{"id":18292},"adding-modules-to-the-template-form","Adding modules to the template form",[10,18295,18296],{},[5476,18297],{"alt":18298,"src":18299},"Thumbnail of my ninth live stream.","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-28.png",[10,18301,18302],{},[29,18303,18304,5488,18307,5494,18310,5499,18313,52],{},[14,18305,5487],{"href":5454,"rel":18306},[18],[14,18308,5493],{"href":5491,"rel":18309},[18],[14,18311,5498],{"href":5444,"rel":18312},[18],[14,18314,5503],{"href":5454,"rel":18315},[18],[10,18317,18318],{},"During this live stream, I added the ability to add modules to a template in the form. This feature is important because it will allow users to filter templates by modules.",[10,18320,18321],{},"I chose to do it in a separate stream because it's a many-to-many relation and a little bit more complex than the other parts. I was expecting to do it in 1 hour, but it took me 2 hours with the help of the chat. Indeed, I was stuck on the SQL query to retrieve a template populated with its modules, and the Drizzle documentation was not clear about it.",[10,18323,18324],{},"Let's deep dive into the code and let's understand how the Drizzle ORM relations concept works.",[381,18326,8099],{"id":8193},[10,18328,18329,18330],{},"The creator can add multiple modules to a template. At the same time, a module can be used by multiple templates. The relation is a many-to-many relation. To handle this, I need to create a new table, called a pivot table, to store the relation between the templates and the modules. It acts as a link between the two tables to connect them. ",[29,18331,18332],{},"We use a pivot table instead of an array of modules in the template because it's more efficient and it's the SQL way to do it.",[10,18334,18335],{},[5476,18336],{"alt":18337,"src":18338},"Many-to-many relation between templates and modules","\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmany-to-many-relation.png",[10,18340,18341,18342,18344,18345,13018],{},"The schema is simplified to only show the relation between the templates and the modules. From the ",[103,18343,11338],{}," table, you can retrieve all the modules associated and inversely thanks to the ",[103,18346,18347],{},"templates_modules",[96,18349,18351],{"className":261,"code":18350,"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,18352,18353,18377,18415,18441,18447,18451,18475,18513,18545,18551,18555,18581,18625,18669],{"__ignoreMap":101},[106,18354,18355,18357,18359,18361,18363,18365,18367,18369,18371,18373,18375],{"class":108,"line":109},[106,18356,271],{"class":270},[106,18358,1775],{"class":132},[106,18360,12521],{"class":224},[106,18362,136],{"class":112},[106,18364,10540],{"class":277},[106,18366,281],{"class":224},[106,18368,754],{"class":112},[106,18370,11338],{"class":142},[106,18372,754],{"class":112},[106,18374,355],{"class":112},[106,18376,300],{"class":112},[106,18378,18379,18381,18383,18385,18387,18389,18391,18393,18395,18397,18399,18401,18403,18405,18407,18409,18411,18413],{"class":108,"line":123},[106,18380,9334],{"class":116},[106,18382,496],{"class":112},[106,18384,10561],{"class":277},[106,18386,281],{"class":224},[106,18388,754],{"class":112},[106,18390,7041],{"class":142},[106,18392,754],{"class":112},[106,18394,294],{"class":224},[106,18396,52],{"class":112},[106,18398,10576],{"class":277},[106,18400,281],{"class":224},[106,18402,1285],{"class":112},[106,18404,10583],{"class":116},[106,18406,496],{"class":112},[106,18408,5595],{"class":5594},[106,18410,419],{"class":112},[106,18412,294],{"class":224},[106,18414,509],{"class":112},[106,18416,18417,18419,18421,18423,18425,18427,18429,18431,18433,18435,18437,18439],{"class":108,"line":162},[106,18418,12641],{"class":116},[106,18420,496],{"class":112},[106,18422,10602],{"class":277},[106,18424,281],{"class":224},[106,18426,754],{"class":112},[106,18428,12652],{"class":142},[106,18430,754],{"class":112},[106,18432,294],{"class":224},[106,18434,52],{"class":112},[106,18436,10617],{"class":277},[106,18438,506],{"class":224},[106,18440,509],{"class":112},[106,18442,18443,18445],{"class":108,"line":200},[106,18444,366],{"class":112},[106,18446,324],{"class":224},[106,18448,18449],{"class":108,"line":221},[106,18450,330],{"emptyLinePlaceholder":329},[106,18452,18453,18455,18457,18459,18461,18463,18465,18467,18469,18471,18473],{"class":108,"line":228},[106,18454,271],{"class":270},[106,18456,1775],{"class":132},[106,18458,10394],{"class":224},[106,18460,136],{"class":112},[106,18462,10540],{"class":277},[106,18464,281],{"class":224},[106,18466,754],{"class":112},[106,18468,6835],{"class":142},[106,18470,754],{"class":112},[106,18472,355],{"class":112},[106,18474,300],{"class":112},[106,18476,18477,18479,18481,18483,18485,18487,18489,18491,18493,18495,18497,18499,18501,18503,18505,18507,18509,18511],{"class":108,"line":238},[106,18478,9334],{"class":116},[106,18480,496],{"class":112},[106,18482,10561],{"class":277},[106,18484,281],{"class":224},[106,18486,754],{"class":112},[106,18488,7041],{"class":142},[106,18490,754],{"class":112},[106,18492,294],{"class":224},[106,18494,52],{"class":112},[106,18496,10576],{"class":277},[106,18498,281],{"class":224},[106,18500,1285],{"class":112},[106,18502,10583],{"class":116},[106,18504,496],{"class":112},[106,18506,5595],{"class":5594},[106,18508,419],{"class":112},[106,18510,294],{"class":224},[106,18512,509],{"class":112},[106,18514,18515,18517,18519,18521,18523,18525,18527,18529,18531,18533,18535,18537,18539,18541,18543],{"class":108,"line":248},[106,18516,1272],{"class":116},[106,18518,496],{"class":112},[106,18520,10602],{"class":277},[106,18522,281],{"class":224},[106,18524,754],{"class":112},[106,18526,3815],{"class":142},[106,18528,754],{"class":112},[106,18530,294],{"class":224},[106,18532,52],{"class":112},[106,18534,10617],{"class":277},[106,18536,506],{"class":224},[106,18538,52],{"class":112},[106,18540,10624],{"class":277},[106,18542,506],{"class":224},[106,18544,509],{"class":112},[106,18546,18547,18549],{"class":108,"line":534},[106,18548,366],{"class":112},[106,18550,324],{"class":224},[106,18552,18553],{"class":108,"line":539},[106,18554,330],{"emptyLinePlaceholder":329},[106,18556,18557,18559,18561,18564,18566,18568,18570,18572,18575,18577,18579],{"class":108,"line":556},[106,18558,271],{"class":270},[106,18560,1775],{"class":132},[106,18562,18563],{"class":224}," modulesToTemplates ",[106,18565,136],{"class":112},[106,18567,10540],{"class":277},[106,18569,281],{"class":224},[106,18571,754],{"class":112},[106,18573,18574],{"class":142},"modulesToTemplates",[106,18576,754],{"class":112},[106,18578,355],{"class":112},[106,18580,300],{"class":112},[106,18582,18583,18586,18588,18590,18592,18594,18597,18599,18601,18603,18605,18607,18609,18611,18613,18615,18617,18619,18621,18623],{"class":108,"line":759},[106,18584,18585],{"class":116},"  moduleId",[106,18587,496],{"class":112},[106,18589,10561],{"class":277},[106,18591,281],{"class":224},[106,18593,754],{"class":112},[106,18595,18596],{"class":142},"module_id",[106,18598,754],{"class":112},[106,18600,294],{"class":224},[106,18602,52],{"class":112},[106,18604,10617],{"class":277},[106,18606,506],{"class":224},[106,18608,52],{"class":112},[106,18610,12925],{"class":277},[106,18612,281],{"class":224},[106,18614,506],{"class":112},[106,18616,297],{"class":132},[106,18618,10975],{"class":224},[106,18620,52],{"class":112},[106,18622,12938],{"class":224},[106,18624,509],{"class":112},[106,18626,18627,18630,18632,18634,18636,18638,18641,18643,18645,18647,18649,18651,18653,18655,18657,18659,18661,18663,18665,18667],{"class":108,"line":795},[106,18628,18629],{"class":116},"  templateId",[106,18631,496],{"class":112},[106,18633,10561],{"class":277},[106,18635,281],{"class":224},[106,18637,754],{"class":112},[106,18639,18640],{"class":142},"template_id",[106,18642,754],{"class":112},[106,18644,294],{"class":224},[106,18646,52],{"class":112},[106,18648,10617],{"class":277},[106,18650,506],{"class":224},[106,18652,52],{"class":112},[106,18654,12925],{"class":277},[106,18656,281],{"class":224},[106,18658,506],{"class":112},[106,18660,297],{"class":132},[106,18662,13688],{"class":224},[106,18664,52],{"class":112},[106,18666,12938],{"class":224},[106,18668,509],{"class":112},[106,18670,18671,18673],{"class":108,"line":803},[106,18672,366],{"class":112},[106,18674,324],{"class":224},[381,18676,18678],{"id":18677},"modules-form","Modules form",[10,18680,18681],{},"In this part, I will add the modules to the form, from the client side to the server side.",[6020,18683,18685],{"id":18684},"adding-modules-to-the-template","Adding modules to the template",[10,18687,18688,18689,18692],{},"In the UI, handling the modules is pretty simple. I fetch the modules from the server and display them in a ",[103,18690,18691],{},"USelectMenu"," with a multiple prop.",[96,18694,18696],{"className":98,"code":18695,"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,18697,18698,18718,18751,18761,18775,18781,18789,18793,18801,18808,18823,18828,18841,18854,18859],{"__ignoreMap":101},[106,18699,18700,18702,18704,18706,18708,18710,18712,18714,18716],{"class":108,"line":109},[106,18701,113],{"class":112},[106,18703,1189],{"class":116},[106,18705,1192],{"class":132},[106,18707,136],{"class":112},[106,18709,139],{"class":112},[106,18711,263],{"class":142},[106,18713,139],{"class":112},[106,18715,1203],{"class":132},[106,18717,120],{"class":112},[106,18719,18720,18722,18724,18726,18728,18730,18732,18734,18736,18738,18740,18742,18745,18747,18749],{"class":108,"line":123},[106,18721,1252],{"class":132},[106,18723,413],{"class":112},[106,18725,964],{"class":116},[106,18727,496],{"class":112},[106,18729,10394],{"class":224},[106,18731,366],{"class":112},[106,18733,311],{"class":112},[106,18735,314],{"class":270},[106,18737,7623],{"class":277},[106,18739,281],{"class":224},[106,18741,754],{"class":112},[106,18743,18744],{"class":142},"\u002Fapi\u002Fmodules",[106,18746,754],{"class":112},[106,18748,355],{"class":112},[106,18750,300],{"class":112},[106,18752,18753,18755,18757,18759],{"class":108,"line":162},[106,18754,7641],{"class":116},[106,18756,496],{"class":112},[106,18758,7646],{"class":5594},[106,18760,509],{"class":112},[106,18762,18763,18765,18767,18769,18771,18773],{"class":108,"line":200},[106,18764,10173],{"class":277},[106,18766,496],{"class":112},[106,18768,6993],{"class":112},[106,18770,297],{"class":132},[106,18772,14327],{"class":224},[106,18774,509],{"class":112},[106,18776,18777,18779],{"class":108,"line":221},[106,18778,366],{"class":112},[106,18780,324],{"class":224},[106,18782,18783,18785,18787],{"class":108,"line":228},[106,18784,251],{"class":112},[106,18786,1189],{"class":116},[106,18788,120],{"class":112},[106,18790,18791],{"class":108,"line":238},[106,18792,330],{"emptyLinePlaceholder":329},[106,18794,18795,18797,18799],{"class":108,"line":248},[106,18796,113],{"class":112},[106,18798,117],{"class":116},[106,18800,120],{"class":112},[106,18802,18803,18805],{"class":108,"line":534},[106,18804,126],{"class":112},[106,18806,18807],{"class":116},"USelectMenu\n",[106,18809,18810,18812,18815,18817,18819,18821],{"class":108,"line":539},[106,18811,14367],{"class":112},[106,18813,18814],{"class":132},"options",[106,18816,136],{"class":112},[106,18818,139],{"class":112},[106,18820,6835],{"class":224},[106,18822,7776],{"class":112},[106,18824,18825],{"class":108,"line":556},[106,18826,18827],{"class":132},"    multiple\n",[106,18829,18830,18833,18835,18837,18839],{"class":108,"line":759},[106,18831,18832],{"class":132},"    value-attribute",[106,18834,136],{"class":112},[106,18836,139],{"class":112},[106,18838,7041],{"class":142},[106,18840,7776],{"class":112},[106,18842,18843,18846,18848,18850,18852],{"class":108,"line":795},[106,18844,18845],{"class":132},"    option-attribute",[106,18847,136],{"class":112},[106,18849,139],{"class":112},[106,18851,3815],{"class":142},[106,18853,7776],{"class":112},[106,18855,18856],{"class":108,"line":803},[106,18857,18858],{"class":112},"  \u002F>\n",[106,18860,18861,18863,18865],{"class":108,"line":809},[106,18862,251],{"class":112},[106,18864,117],{"class":116},[106,18866,120],{"class":112},[6020,18868,18870],{"id":18869},"validating-the-modules","Validating the modules",[10,18872,18873,18874,18877],{},"In the validator, I add a new field ",[103,18875,18876],{},"moduleIds"," that verifies that the modules are an array of integers.",[96,18879,18881],{"className":261,"code":18880,"language":263,"meta":101,"style":101},"import { array, number, object, string } from 'zod'\n\nexport const createTemplateValidator = object({\n  title: string({ message: 'Required' }),\n  moduleIds: array(number()).max(6, { message: 'Max 6 modules' }).optional(),\n})\n",[103,18882,18883,18914,18918,18935,18963,19016],{"__ignoreMap":101},[106,18884,18885,18887,18889,18892,18894,18896,18898,18900,18902,18904,18906,18908,18910,18912],{"class":108,"line":109},[106,18886,410],{"class":270},[106,18888,413],{"class":112},[106,18890,18891],{"class":224}," array",[106,18893,355],{"class":112},[106,18895,13170],{"class":224},[106,18897,355],{"class":112},[106,18899,2257],{"class":224},[106,18901,355],{"class":112},[106,18903,3942],{"class":224},[106,18905,419],{"class":112},[106,18907,422],{"class":270},[106,18909,425],{"class":112},[106,18911,428],{"class":142},[106,18913,431],{"class":112},[106,18915,18916],{"class":108,"line":123},[106,18917,330],{"emptyLinePlaceholder":329},[106,18919,18920,18922,18924,18927,18929,18931,18933],{"class":108,"line":162},[106,18921,271],{"class":270},[106,18923,1775],{"class":132},[106,18925,18926],{"class":224}," createTemplateValidator ",[106,18928,136],{"class":112},[106,18930,2257],{"class":277},[106,18932,281],{"class":224},[106,18934,488],{"class":112},[106,18936,18937,18939,18941,18943,18945,18947,18949,18951,18953,18955,18957,18959,18961],{"class":108,"line":200},[106,18938,12641],{"class":116},[106,18940,496],{"class":112},[106,18942,3942],{"class":277},[106,18944,281],{"class":224},[106,18946,1285],{"class":112},[106,18948,1288],{"class":116},[106,18950,496],{"class":112},[106,18952,425],{"class":112},[106,18954,1295],{"class":142},[106,18956,754],{"class":112},[106,18958,419],{"class":112},[106,18960,294],{"class":224},[106,18962,509],{"class":112},[106,18964,18965,18968,18970,18972,18974,18977,18980,18982,18984,18986,18989,18991,18993,18995,18997,18999,19002,19004,19006,19008,19010,19012,19014],{"class":108,"line":221},[106,18966,18967],{"class":116},"  moduleIds",[106,18969,496],{"class":112},[106,18971,18891],{"class":277},[106,18973,281],{"class":224},[106,18975,18976],{"class":277},"number",[106,18978,18979],{"class":224},"())",[106,18981,52],{"class":112},[106,18983,13378],{"class":277},[106,18985,281],{"class":224},[106,18987,18988],{"class":358},"6",[106,18990,355],{"class":112},[106,18992,413],{"class":112},[106,18994,1288],{"class":116},[106,18996,496],{"class":112},[106,18998,425],{"class":112},[106,19000,19001],{"class":142},"Max 6 modules",[106,19003,754],{"class":112},[106,19005,419],{"class":112},[106,19007,294],{"class":224},[106,19009,52],{"class":112},[106,19011,13345],{"class":277},[106,19013,506],{"class":224},[106,19015,509],{"class":112},[106,19017,19018,19020],{"class":108,"line":228},[106,19019,366],{"class":112},[106,19021,324],{"class":224},[10,19023,19024],{},"I arbitrarily set the maximum number of modules to 6. It's a number that I think is enough for a template.",[6020,19026,19028],{"id":19027},"saving-the-modules","Saving the modules",[10,19030,19031],{},"Finally, I update the endpoint to save the template and related modules. I first save the template and then save the modules in the pivot table.",[96,19033,19035],{"className":261,"code":19034,"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,19036,19037,19056,19060,19082,19104,19108,19134,19138,19178,19192,19206,19224,19230,19234,19248,19327,19331,19347],{"__ignoreMap":101},[106,19038,19039,19041,19043,19046,19048,19050,19052,19054],{"class":108,"line":109},[106,19040,410],{"class":270},[106,19042,413],{"class":112},[106,19044,19045],{"class":224}," createTemplateValidator",[106,19047,419],{"class":112},[106,19049,422],{"class":270},[106,19051,425],{"class":112},[106,19053,1963],{"class":142},[106,19055,431],{"class":112},[106,19057,19058],{"class":108,"line":123},[106,19059,330],{"emptyLinePlaceholder":329},[106,19061,19062,19064,19066,19068,19070,19072,19074,19076,19078,19080],{"class":108,"line":162},[106,19063,271],{"class":270},[106,19065,274],{"class":270},[106,19067,278],{"class":277},[106,19069,281],{"class":224},[106,19071,284],{"class":132},[106,19073,287],{"class":112},[106,19075,291],{"class":290},[106,19077,294],{"class":112},[106,19079,297],{"class":132},[106,19081,300],{"class":112},[106,19083,19084,19086,19088,19090,19092,19094,19096,19098,19100,19102],{"class":108,"line":200},[106,19085,305],{"class":132},[106,19087,413],{"class":112},[106,19089,5621],{"class":224},[106,19091,419],{"class":112},[106,19093,311],{"class":112},[106,19095,314],{"class":270},[106,19097,13240],{"class":277},[106,19099,281],{"class":116},[106,19101,291],{"class":224},[106,19103,324],{"class":116},[106,19105,19106],{"class":108,"line":221},[106,19107,330],{"emptyLinePlaceholder":329},[106,19109,19110,19112,19114,19116,19118,19120,19122,19124,19126,19128,19130,19132],{"class":108,"line":228},[106,19111,305],{"class":132},[106,19113,308],{"class":224},[106,19115,311],{"class":112},[106,19117,314],{"class":270},[106,19119,470],{"class":277},[106,19121,281],{"class":116},[106,19123,291],{"class":224},[106,19125,355],{"class":112},[106,19127,19045],{"class":224},[106,19129,52],{"class":112},[106,19131,521],{"class":224},[106,19133,324],{"class":116},[106,19135,19136],{"class":108,"line":238},[106,19137,330],{"emptyLinePlaceholder":329},[106,19139,19140,19142,19144,19146,19148,19150,19152,19154,19156,19158,19160,19162,19164,19166,19168,19170,19172,19174,19176],{"class":108,"line":248},[106,19141,305],{"class":132},[106,19143,2524],{"class":112},[106,19145,117],{"class":224},[106,19147,785],{"class":112},[106,19149,311],{"class":112},[106,19151,314],{"class":270},[106,19153,7011],{"class":277},[106,19155,506],{"class":116},[106,19157,52],{"class":112},[106,19159,7207],{"class":277},[106,19161,281],{"class":116},[106,19163,7096],{"class":224},[106,19165,52],{"class":112},[106,19167,11338],{"class":224},[106,19169,294],{"class":116},[106,19171,52],{"class":112},[106,19173,7222],{"class":277},[106,19175,281],{"class":116},[106,19177,488],{"class":112},[106,19179,19180,19182,19184,19186,19188,19190],{"class":108,"line":534},[106,19181,7766],{"class":116},[106,19183,496],{"class":112},[106,19185,308],{"class":224},[106,19187,52],{"class":112},[106,19189,12652],{"class":224},[106,19191,509],{"class":112},[106,19193,19194,19196,19198,19200,19202,19204],{"class":108,"line":539},[106,19195,514],{"class":112},[106,19197,294],{"class":116},[106,19199,52],{"class":112},[106,19201,12272],{"class":277},[106,19203,281],{"class":116},[106,19205,488],{"class":112},[106,19207,19208,19210,19212,19214,19216,19218,19220,19222],{"class":108,"line":556},[106,19209,7027],{"class":116},[106,19211,496],{"class":112},[106,19213,7032],{"class":224},[106,19215,52],{"class":112},[106,19217,11338],{"class":224},[106,19219,52],{"class":112},[106,19221,7041],{"class":224},[106,19223,509],{"class":112},[106,19225,19226,19228],{"class":108,"line":759},[106,19227,514],{"class":112},[106,19229,324],{"class":116},[106,19231,19232],{"class":108,"line":795},[106,19233,330],{"emptyLinePlaceholder":329},[106,19235,19236,19238,19240,19242,19244,19246],{"class":108,"line":803},[106,19237,697],{"class":270},[106,19239,287],{"class":116},[106,19241,7240],{"class":224},[106,19243,52],{"class":112},[106,19245,18876],{"class":224},[106,19247,324],{"class":116},[106,19249,19250,19252,19254,19256,19258,19260,19262,19264,19266,19268,19270,19272,19274,19276,19278,19280,19282,19284,19286,19288,19290,19292,19294,19296,19299,19301,19303,19305,19308,19310,19312,19314,19316,19318,19321,19323,19325],{"class":108,"line":809},[106,19251,1402],{"class":270},[106,19253,7011],{"class":277},[106,19255,506],{"class":116},[106,19257,52],{"class":112},[106,19259,7207],{"class":277},[106,19261,281],{"class":116},[106,19263,7096],{"class":224},[106,19265,52],{"class":112},[106,19267,18574],{"class":224},[106,19269,294],{"class":116},[106,19271,52],{"class":112},[106,19273,7222],{"class":277},[106,19275,281],{"class":116},[106,19277,7240],{"class":224},[106,19279,52],{"class":112},[106,19281,18876],{"class":224},[106,19283,52],{"class":112},[106,19285,10411],{"class":277},[106,19287,281],{"class":116},[106,19289,7041],{"class":290},[106,19291,297],{"class":132},[106,19293,287],{"class":116},[106,19295,1285],{"class":112},[106,19297,19298],{"class":116}," moduleId",[106,19300,496],{"class":112},[106,19302,17951],{"class":224},[106,19304,355],{"class":112},[106,19306,19307],{"class":116}," templateId",[106,19309,496],{"class":112},[106,19311,16585],{"class":224},[106,19313,52],{"class":112},[106,19315,7041],{"class":224},[106,19317,419],{"class":112},[106,19319,19320],{"class":116},")))",[106,19322,52],{"class":112},[106,19324,7273],{"class":277},[106,19326,2366],{"class":116},[106,19328,19329],{"class":108,"line":814},[106,19330,330],{"emptyLinePlaceholder":329},[106,19332,19333,19335,19337,19339,19341,19343,19345],{"class":108,"line":830},[106,19334,345],{"class":270},[106,19336,348],{"class":277},[106,19338,281],{"class":116},[106,19340,291],{"class":224},[106,19342,355],{"class":112},[106,19344,359],{"class":358},[106,19346,324],{"class":116},[106,19348,19349,19351],{"class":108,"line":835},[106,19350,366],{"class":112},[106,19352,324],{"class":224},[10,19354,19355],{},[29,19356,19357],{},"I have to admit that I'm not very confident with this part. I will need to check if there is a better way to do it (using a transaction or a built-in Drizzle feature).",[381,19359,19361],{"id":19360},"populating-the-template-with-modules","Populating the template with modules",[10,19363,19364],{},[29,19365,19366],{},"This is where the hard times begin.",[10,19368,19369,19370,19372,19373,19375,19376,19378,19379,52],{},"Initially, I had a custom SQL query that joined the ",[103,19371,11338],{}," table, the ",[103,19374,6823],{}," table, and the ",[103,19377,6829],{}," table. You can learn more about this in the ",[14,19380,19382],{"href":19381},"#creating-a-form-for-templates","previous part",[10,19384,19385,19386,19388,19389,19391,19392,19394],{},"To link the ",[103,19387,6835],{}," table, I tried to add a join with the ",[103,19390,18574],{}," table and then the ",[103,19393,6835],{}," table. The issue I encounter is how to group the modules by template to produce an array of modules for each template.",[10,19396,19397],{},"Something like:",[96,19399,19401],{"className":15176,"code":19400,"language":15178,"meta":101,"style":101},"{\n  \"id\": 1,\n  \"title\": \"My template\",\n  \"modules\": [\n    { \"id\": 1, \"name\": \"Module 1\" },\n    { \"id\": 2, \"name\": \"Module 2\" }\n  ]\n}\n",[103,19402,19403,19407,19421,19439,19452,19487,19522,19527],{"__ignoreMap":101},[106,19404,19405],{"class":108,"line":109},[106,19406,488],{"class":112},[106,19408,19409,19411,19413,19415,19417,19419],{"class":108,"line":123},[106,19410,15189],{"class":112},[106,19412,7041],{"class":132},[106,19414,139],{"class":112},[106,19416,496],{"class":112},[106,19418,11002],{"class":358},[106,19420,509],{"class":112},[106,19422,19423,19425,19427,19429,19431,19433,19435,19437],{"class":108,"line":162},[106,19424,15189],{"class":112},[106,19426,12652],{"class":132},[106,19428,139],{"class":112},[106,19430,496],{"class":112},[106,19432,15212],{"class":112},[106,19434,15215],{"class":142},[106,19436,139],{"class":112},[106,19438,509],{"class":112},[106,19440,19441,19443,19445,19447,19449],{"class":108,"line":200},[106,19442,15189],{"class":112},[106,19444,6835],{"class":132},[106,19446,139],{"class":112},[106,19448,496],{"class":112},[106,19450,19451],{"class":112}," [\n",[106,19453,19454,19457,19459,19461,19463,19465,19467,19469,19471,19473,19475,19477,19479,19482,19484],{"class":108,"line":221},[106,19455,19456],{"class":112},"    {",[106,19458,15212],{"class":112},[106,19460,7041],{"class":1041},[106,19462,139],{"class":112},[106,19464,496],{"class":112},[106,19466,11002],{"class":358},[106,19468,355],{"class":112},[106,19470,15212],{"class":112},[106,19472,3815],{"class":1041},[106,19474,139],{"class":112},[106,19476,496],{"class":112},[106,19478,15212],{"class":112},[106,19480,19481],{"class":142},"Module 1",[106,19483,139],{"class":112},[106,19485,19486],{"class":112}," },\n",[106,19488,19489,19491,19493,19495,19497,19499,19502,19504,19506,19508,19510,19512,19514,19517,19519],{"class":108,"line":228},[106,19490,19456],{"class":112},[106,19492,15212],{"class":112},[106,19494,7041],{"class":1041},[106,19496,139],{"class":112},[106,19498,496],{"class":112},[106,19500,19501],{"class":358}," 2",[106,19503,355],{"class":112},[106,19505,15212],{"class":112},[106,19507,3815],{"class":1041},[106,19509,139],{"class":112},[106,19511,496],{"class":112},[106,19513,15212],{"class":112},[106,19515,19516],{"class":142},"Module 2",[106,19518,139],{"class":112},[106,19520,19521],{"class":112}," }\n",[106,19523,19524],{"class":108,"line":238},[106,19525,19526],{"class":112},"  ]\n",[106,19528,19529],{"class":108,"line":248},[106,19530,1558],{"class":112},[10,19532,19533,19534,19537],{},"I found the SQLite function ",[103,19535,19536],{},"group_concat"," that can be used to concatenate the modules. I was a little disappointed to have to do this manually because Drizzle presents itself as an ORM. I was expecting to have a way to do this easily.",[10,19539,19540,19541,19552],{},"BUT, the Twitch chat came to the rescue and suggested me to use the ",[14,19542,19545,19548,19549,133],{"href":19543,"rel":19544},"https:\u002F\u002Form.drizzle.team\u002Fdocs\u002Frqb#include-relations",[18],[103,19546,19547],{},"with"," key from the ",[103,19550,19551],{},"findMany",". I tried, and it did not really work. Cloudflare was complaining about a missing relation. I was a little bit lost.",[10,19554,19555,19556,19558,19559,19561],{},"I end up by carefully reading the documentation about the many-to-many relation page, and I understood that I need to explicitly define every relation between every table. In other words, I have to tell Drizzle how to join the tables. A template has many modules by using the ",[103,19557,18574],{}," table. A module has many templates by using the ",[103,19560,18574],{}," table. This is the way to define the many-to-many relation.",[96,19563,19565],{"className":261,"code":19564,"language":263,"meta":101,"style":101},"\u002F\u002F A template has a many-to-many relation\nexport const templatesRelations = relations(templates, ({ one, many }) => ({\n  \u002F\u002F Linked to the pivot table and not directly to the modules table\n  modules: many(modulesToTemplates),\n}))\n\n\u002F\u002F The pivot table has 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 A module has a many-to-many relation\nexport const modulesRelations = relations(modules, ({ one, many }) => ({\n  \u002F\u002F Linked to the pivot table and not directly to the templates table\n  templates: many(modulesToTemplates),\n}))\n",[103,19566,19567,19572,19607,19612,19626,19632,19636,19641,19671,19686,19703,19720,19728,19743,19758,19773,19781,19787,19791,19796,19829,19834,19846],{"__ignoreMap":101},[106,19568,19569],{"class":108,"line":109},[106,19570,19571],{"class":335},"\u002F\u002F A template has a many-to-many relation\n",[106,19573,19574,19576,19578,19581,19583,19585,19588,19590,19592,19595,19597,19599,19601,19603,19605],{"class":108,"line":123},[106,19575,271],{"class":270},[106,19577,1775],{"class":132},[106,19579,19580],{"class":224}," templatesRelations ",[106,19582,136],{"class":112},[106,19584,13051],{"class":277},[106,19586,19587],{"class":224},"(templates",[106,19589,355],{"class":112},[106,19591,13059],{"class":112},[106,19593,19594],{"class":290}," one",[106,19596,355],{"class":112},[106,19598,13062],{"class":290},[106,19600,5629],{"class":112},[106,19602,297],{"class":132},[106,19604,287],{"class":224},[106,19606,488],{"class":112},[106,19608,19609],{"class":108,"line":162},[106,19610,19611],{"class":335},"  \u002F\u002F Linked to the pivot table and not directly to the modules table\n",[106,19613,19614,19617,19619,19621,19624],{"class":108,"line":200},[106,19615,19616],{"class":116},"  modules",[106,19618,496],{"class":112},[106,19620,13062],{"class":277},[106,19622,19623],{"class":224},"(modulesToTemplates)",[106,19625,509],{"class":112},[106,19627,19628,19630],{"class":108,"line":221},[106,19629,366],{"class":112},[106,19631,1127],{"class":224},[106,19633,19634],{"class":108,"line":228},[106,19635,330],{"emptyLinePlaceholder":329},[106,19637,19638],{"class":108,"line":238},[106,19639,19640],{"class":335},"\u002F\u002F The pivot table has 2 relations\n",[106,19642,19643,19645,19647,19650,19652,19654,19657,19659,19661,19663,19665,19667,19669],{"class":108,"line":248},[106,19644,271],{"class":270},[106,19646,1775],{"class":132},[106,19648,19649],{"class":224}," modulesToTemplatesRelations ",[106,19651,136],{"class":112},[106,19653,13051],{"class":277},[106,19655,19656],{"class":224},"(modulesToTemplates",[106,19658,355],{"class":112},[106,19660,13059],{"class":112},[106,19662,19594],{"class":290},[106,19664,5629],{"class":112},[106,19666,297],{"class":132},[106,19668,287],{"class":224},[106,19670,488],{"class":112},[106,19672,19673,19676,19678,19680,19682,19684],{"class":108,"line":534},[106,19674,19675],{"class":116},"  module",[106,19677,496],{"class":112},[106,19679,19594],{"class":277},[106,19681,10910],{"class":224},[106,19683,355],{"class":112},[106,19685,300],{"class":112},[106,19687,19688,19691,19693,19696,19698,19701],{"class":108,"line":539},[106,19689,19690],{"class":116},"    fields",[106,19692,496],{"class":112},[106,19694,19695],{"class":224}," [modulesToTemplates",[106,19697,52],{"class":112},[106,19699,19700],{"class":224},"moduleId]",[106,19702,509],{"class":112},[106,19704,19705,19708,19710,19713,19715,19718],{"class":108,"line":556},[106,19706,19707],{"class":116},"    references",[106,19709,496],{"class":112},[106,19711,19712],{"class":224}," [modules",[106,19714,52],{"class":112},[106,19716,19717],{"class":224},"id]",[106,19719,509],{"class":112},[106,19721,19722,19724,19726],{"class":108,"line":759},[106,19723,514],{"class":112},[106,19725,294],{"class":224},[106,19727,509],{"class":112},[106,19729,19730,19733,19735,19737,19739,19741],{"class":108,"line":795},[106,19731,19732],{"class":116},"  template",[106,19734,496],{"class":112},[106,19736,19594],{"class":277},[106,19738,19587],{"class":224},[106,19740,355],{"class":112},[106,19742,300],{"class":112},[106,19744,19745,19747,19749,19751,19753,19756],{"class":108,"line":803},[106,19746,19690],{"class":116},[106,19748,496],{"class":112},[106,19750,19695],{"class":224},[106,19752,52],{"class":112},[106,19754,19755],{"class":224},"templateId]",[106,19757,509],{"class":112},[106,19759,19760,19762,19764,19767,19769,19771],{"class":108,"line":809},[106,19761,19707],{"class":116},[106,19763,496],{"class":112},[106,19765,19766],{"class":224}," [templates",[106,19768,52],{"class":112},[106,19770,19717],{"class":224},[106,19772,509],{"class":112},[106,19774,19775,19777,19779],{"class":108,"line":814},[106,19776,514],{"class":112},[106,19778,294],{"class":224},[106,19780,509],{"class":112},[106,19782,19783,19785],{"class":108,"line":830},[106,19784,366],{"class":112},[106,19786,1127],{"class":224},[106,19788,19789],{"class":108,"line":835},[106,19790,330],{"emptyLinePlaceholder":329},[106,19792,19793],{"class":108,"line":840},[106,19794,19795],{"class":335},"\u002F\u002F A module has a many-to-many relation\n",[106,19797,19798,19800,19802,19805,19807,19809,19811,19813,19815,19817,19819,19821,19823,19825,19827],{"class":108,"line":845},[106,19799,271],{"class":270},[106,19801,1775],{"class":132},[106,19803,19804],{"class":224}," modulesRelations ",[106,19806,136],{"class":112},[106,19808,13051],{"class":277},[106,19810,10910],{"class":224},[106,19812,355],{"class":112},[106,19814,13059],{"class":112},[106,19816,19594],{"class":290},[106,19818,355],{"class":112},[106,19820,13062],{"class":290},[106,19822,5629],{"class":112},[106,19824,297],{"class":132},[106,19826,287],{"class":224},[106,19828,488],{"class":112},[106,19830,19831],{"class":108,"line":862},[106,19832,19833],{"class":335},"  \u002F\u002F Linked to the pivot table and not directly to the templates table\n",[106,19835,19836,19838,19840,19842,19844],{"class":108,"line":1469},[106,19837,13075],{"class":116},[106,19839,496],{"class":112},[106,19841,13062],{"class":277},[106,19843,19623],{"class":224},[106,19845,509],{"class":112},[106,19847,19848,19850],{"class":108,"line":1486},[106,19849,366],{"class":112},[106,19851,1127],{"class":224},[10,19853,19854,19855,19860],{},"This part is super important. In an ORM like ",[14,19856,19859],{"href":19857,"rel":19858},"https:\u002F\u002Flucid.adonisjs.com",[18],"Lucid",", the usage of a pivot table is transparent. With Drizzle, you have to define relation between every table. It's a little bit more verbose, but it's also more explicit.",[10,19862,19863],{},"Finally, I can update the endpoint to retrieve the templates with their modules:",[96,19865,19867],{"className":261,"code":19866,"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,19868,19869,19887,19917,19926,19937,19948,19957,19966,19975,19979,19983,19988,19994,19998,20004],{"__ignoreMap":101},[106,19870,19871,19873,19875,19877,19879,19881,19883,19885],{"class":108,"line":109},[106,19872,271],{"class":270},[106,19874,274],{"class":270},[106,19876,278],{"class":277},[106,19878,281],{"class":224},[106,19880,284],{"class":132},[106,19882,6993],{"class":112},[106,19884,297],{"class":132},[106,19886,300],{"class":112},[106,19888,19889,19891,19893,19895,19897,19899,19901,19903,19905,19907,19909,19911,19913,19915],{"class":108,"line":123},[106,19890,305],{"class":132},[106,19892,13688],{"class":224},[106,19894,311],{"class":112},[106,19896,314],{"class":270},[106,19898,7011],{"class":277},[106,19900,506],{"class":116},[106,19902,52],{"class":112},[106,19904,6192],{"class":224},[106,19906,52],{"class":112},[106,19908,11338],{"class":224},[106,19910,52],{"class":112},[106,19912,19551],{"class":277},[106,19914,281],{"class":116},[106,19916,488],{"class":112},[106,19918,19919,19922,19924],{"class":108,"line":162},[106,19920,19921],{"class":116},"    with",[106,19923,496],{"class":112},[106,19925,300],{"class":112},[106,19927,19928,19931,19933,19935],{"class":108,"line":200},[106,19929,19930],{"class":116},"      category",[106,19932,496],{"class":112},[106,19934,5595],{"class":5594},[106,19936,509],{"class":112},[106,19938,19939,19942,19944,19946],{"class":108,"line":221},[106,19940,19941],{"class":116},"      creator",[106,19943,496],{"class":112},[106,19945,5595],{"class":5594},[106,19947,509],{"class":112},[106,19949,19950,19953,19955],{"class":108,"line":228},[106,19951,19952],{"class":116},"      modules",[106,19954,496],{"class":112},[106,19956,300],{"class":112},[106,19958,19959,19962,19964],{"class":108,"line":238},[106,19960,19961],{"class":116},"        with",[106,19963,496],{"class":112},[106,19965,300],{"class":112},[106,19967,19968,19971,19973],{"class":108,"line":248},[106,19969,19970],{"class":116},"          module",[106,19972,496],{"class":112},[106,19974,7552],{"class":5594},[106,19976,19977],{"class":108,"line":534},[106,19978,6520],{"class":112},[106,19980,19981],{"class":108,"line":539},[106,19982,5676],{"class":112},[106,19984,19985],{"class":108,"line":556},[106,19986,19987],{"class":112},"    },\n",[106,19989,19990,19992],{"class":108,"line":759},[106,19991,514],{"class":112},[106,19993,324],{"class":116},[106,19995,19996],{"class":108,"line":795},[106,19997,330],{"emptyLinePlaceholder":329},[106,19999,20000,20002],{"class":108,"line":803},[106,20001,345],{"class":270},[106,20003,13821],{"class":224},[106,20005,20006,20008],{"class":108,"line":809},[106,20007,366],{"class":112},[106,20009,324],{"class":224},[10,20011,20012,20013,20015],{},"The usage of the ",[103,20014,19547],{},", once everything is correctly defined, makes things so much easier; I really love it.",[381,20017,5371],{"id":20018},"conclusion-4",[10,20020,20021],{},"In my mind, this part was easy, and in 30 minutes, it was done. I already did a similar thing using Lucid from Adonis. In reality, it was not really hard, but finding the right part of the Drizzle documentation and then understanding it was way more difficult than expected. This is largely because Drizzle does not abstract SQL but acts as a little wrapper, and everything must be explicit.",[10,20023,20024],{},"I'm happy to have done it, I learned a lot, and a huge thanks to the chat for helping me.",[5395,20026,20027],{},"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":20029},[20030,20031],{"id":5471,"depth":123,"text":5472},{"id":5773,"depth":123,"text":5774},"2024-05-01","Since months I want to start streaming on Twitch for the pleasure of sharing things and live coding. I finally started my first stream.",{},"\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey","---\ncontentId: c21a371e-abdd-4325-b296-dadcb8deec25\ntitle: \"Create a Full-Stack Nuxt Application: A Twitch Journey\"\ndescription: Since months I want to start streaming on Twitch for the pleasure of sharing things and live coding. I finally started my first stream.\ndate: 2024-05-01\n---\n\n**I finally did it!**\n\nAfter months of thinking about it, I finally started my first stream on Twitch, the 28th of April 2024. It's a very important step for me because I love sharing my knowledge and talking about what I'm doing.\n\n\u003Cdiv\n  class=\"grid grid-cols-1 md:grid-cols-2 gap-4\"\n>\n  \u003CCalloutFollowMeOnTwitch \u002F>\n  \u003CCallout icon=\"i-simple-icons-youtube\" text=\"View Replays\" href=\"https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes\" \u002F>\n  \u003CCallout icon=\"i-simple-icons-x\" text=\"Don't Miss a Thing\" href=\"https:\u002F\u002Fx.com\u002Fsoubiran_\" \u002F>\n  \u003CCallout icon=\"i-simple-icons-github\" text=\"The Full-Stack Application\" href=\"https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion\" \u002F>\n\u003C\u002Fdiv>\n\nWhen I was at school, I organized some lessons for my classmates to explain programming. I also joined a community to explain things about computers, I gave some talks, and recently, I started to write articles on my blog.\n\n**Twitch is just a new way to continue doing what I love!**\n\nFor the coming weeks, I'll share with you my journey to build a full-stack Nuxt application, deployed on Cloudflare, called Orion. I'll explain how I built it, the choices I made, and the problems I encountered. My goal through this article is to give you an honest and realistic view of the pros and cons of building a full-stack application on the edge with Nuxt in May 2024 through the process of building a real-world application.\n\nIn this article, I'll share with you every step of this first series of streams. If you don't speak French (because streams are in French), if you missed a stream, or if you simply prefer to read, this article is for you!\n\n## Discovering NuxtHub and starting the full-stack Nuxt application\n\n![Thumbnail of my first 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) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nThis was my first stream, and to be honest, I was a bit stressed. The moment when you have to change the scene from the \"Starting Soon\" to the facecam is hard. But once I started to talk, everything went well. _However, that doesn't mean it's the best stream you'll ever see. We're going to improve!_\n\n![Myself, not very comfortable in front of the camera.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-04-28-me.png)\n\nFirst, I introduce [NuxtHub](https:\u002F\u002Fhub.nuxt.com). It is a platform built by the Nuxt team to facilitate the deployment and management of Nuxt applications deployed on Cloudflare. In a few clicks, you can deploy your Nuxt application on Cloudflare. That's really magical! Thanks to a Nuxt module, you will be able to develop your application locally and then deploy it without tweaking your code. Everything, from the database to the blog storage, works seamlessly in both environments.\n\nI tried to deploy a template from NuxtHub to show how easy it is to use the platform. I chose the Atinotes template. Unfortunately, this application produces a bundle bigger than 1MB, the limit for the free tier, so the deployment fails. Sad for a demonstration. _After this fail, I was thinking about a way to prevent this and to tell users whether a template requires a paid plan or not. [I raised an issue](https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F13) that was quickly fixed!_. Demo will wait for later.\n\nAfter this beautiful demo effect, we start coding the full-stack Nuxt application by adding authentication. To do so, we use the module [`nuxt-auth-utils`](https:\u002F\u002Fgithub.com\u002Fatinux\u002Fnuxt-auth-utils) from [Sébastien Chopin](https:\u002F\u002Fgithub.com\u002FAtinux). With a few lines of code, we add authentication. Here is the code we used:\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\nThat's really simple and it works like a charm, nicely integrated into a Nuxt application.\n\nTo finish the stream, I quickly set up NuxtHub into my application to show how easy it is to play with a database. I tried to use raw SQL, but the recipe to migrate the database was not working. _[I raised an issue](https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F15)_ about it and it's now fixed! After this second fail, I decided to start using Drizzle ORM, a simple but powerful ORM compatible with the workers of Cloudflare. In all cases, I should have to use Drizzle because managing everything with raw SQL is too much work.\n\nDuring the live, someone asked how to apply migration to the production database. I have to admit that I didn't know the answer and nothing in the documentation was clear about it. _[I raised an issue](https:\u002F\u002Fgithub.com\u002Fnuxt-hub\u002Ffeedback\u002Fissues\u002F14)_ about it that received an answer. _You have to deploy your app and then start the local dev with a remote connection to run migrations_ Clearly, it's far from being good for the moment, but with the upcoming [Nitro tasks](https:\u002F\u002Fnitro.unjs.io\u002Fguide\u002Ftasks), it should be easier in the near future. _I really hope._\n\nGlobally, I'm so happy to have started this stream. Even if it's far from being good, but getting started is already a big step forward, and I know that future streams will only get easier to start.\n\n_I was so stressed that I went very fast in my explanations, so I decided that the next stream would be more talkative to re-explain different concepts._\n\nThe project is open-source and available on GitHub at \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F>! Give it a star if you like it! ⭐\n\n## Discussion on Cloudflare, NuxtHub, and GitHub OAuth\n\n![Thumbnail of my second 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) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nI was frustrated by my first live stream. With the stress, I didn't explain things like what is Cloudflare, what is the edge and how NuxtHub is related to Cloudflare. But I know it's important to explain these concepts since it's new technologies and not everyone knows them.\n\nAt StrasbourgJS or Devoxx, I talked about UnJS and problems solved by the ecosystem. Many people, during or after, ask me about the edge. What is it? Why do we need it? Where does it come from?\n\n\u003CCalloutReadMore id=\"ba562d1f-534c-4cef-9004-2fefc09829a6\" \u002F>\n\n\u003CCalloutReadMore id=\"08852918-9829-4658-97b3-1aabede8e9fe\" \u002F>\n\nThe goal of my streams is to try this new technology, and with these feedbacks, it was a necessity to explain it.\n\n### Cloudflare at a glance\n\nCloudflare is a company that provides a lot of services to make the web faster and more secure like a zero-trust solution, a WAF, a CDN, a DNS, a DDoS protection, and more for both companies and individuals.\n\nTheir most known product is the reverse proxy. Cloudflare will act as the main and single entry point for your traffic. Then, it will route, internally, the traffic to the right server using the DNS. This allows you to hide your IP server, to cache static assets, and to protect your server from DDoS attacks.\n\n![Reverse proxy schema.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Freverse-proxy.png)\n\nIn order to provide these services, they built a network of data centers around the world to be as close as possible to the users to serve cached content faster and to be able to mitigate attacks. It's called the edge network. It is at the frontier between users and the web, for which it is the gateway.\n\nIn September 2017, [they announced Cloudflare Workers](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-workers). To understand the objective at the time, let's quote the article:\n\n> What if you want to load balance with a custom affinity algorithm? What if standard HTTP caching rules aren't quite right, and you need some custom logic to boost your cache hit rate? What if you want to write custom WAF rules tailored for your application?\n>\n> **You want to write code**\n\nWorkers have been released to allow developers to customize the infrastructure to their needs. The Cloudflare Workers solution is built on top of the Service Worker API, a standard in the browser to intercept and modify requests and responses.\n\nSince then, they have built a full capabilities platform for developers around the workers:\n\n\u003C!-- TODO: create a component grid (and document it: heavily related items, better presentation than a simple list) -->\n\n- [Workers KV, serverless key-value store](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-workers-kv)\n- [D1, serverless SQL database](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-d1)\n- [R2, serverless blob storage](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-r2-object-storage)\n- [Queues, serverless task queues](https:\u002F\u002Fblog.cloudflare.com\u002Fintroducing-cloudflare-queues)\n- [Workers AI, serverless GPU-powered inference](https:\u002F\u002Fblog.cloudflare.com\u002Fworkers-ai)\n\nWith all these services, it's now (theoretically and that's the purpose of this series to answer the question) possible to build an application entirely on the Cloudflare's global network.\n\n### NuxtHub\n\n![NuxtHub home page.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-home-page.png)\n\nSince the version 3 of Nuxt, the team is pushing the edge. Nitro, the server part of Nuxt, can be deployed everywhere, current and future platform, and is optimized for the edge. H3, the underlying HTTP server wakes up in less than 2ms, and the build output of Nitro is less than 1 MB, node modules included.\n\nNuxtHub is a continuation of this effort. First, it's a platform to deploy Nuxt applications on Cloudflare. It's a two-click deployment. You connect your Cloudflare account because it's built on top of Cloudflare, you choose a template or a repo, you click on deploy, and that's it. _Your application is live on the edge._ It's feeling like magic.\n\n![NuxtHub templates that can be deployed with two clicks.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-deploy.png)\n\nNuxtHub is also a module for your Nuxt app to add data access capabilities. Locally, you will be able to play instantly with a database, a KV store, and even a blob storage. In production, you do not need to change your code, it will work the same way. This is clearly the true magic of NuxtHub, powered with [`workerd`](https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworker).\n\n![NuxtHub schema (from hub.nuxt.com).](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fnuxt-hub-schema.png)\n\nThere is also a feature to allow a local dev environment to interact with the remote data. Not sure about the use case, but it's definitely interesting.\n\nEven if the platform is still new, announced during Vue Amsterdam 2024, it's already very promising. The team is very reactive, and the platform is evolving quickly. I'm very excited to see what the future holds for NuxtHub but have some hints in documentation:\n\n> We plan to provide a complete backend experience for Nuxt apps through various @nuxthub packages.\n>\n> - @nuxthub\u002Fcore: Main package to provide storage features\n> - @nuxthub\u002Fauth: Add authentication for user management (soon)\n> - @nuxthub\u002Femail: Send transactional emails to your users (soon)\n> - @nuxthub\u002Fanalytics: Understand your traffic and track events within your application and API (soon)\n> - @nuxthub\u002F...: You name it!\n\n### GitHub OAuth\n\nDuring the previous live stream, I added authentication, using `nuxt-auth-utils` to Orion, the full-stack Nuxt application. I used GitHub OAuth, and I think it's important to see how it works under the hood.\n\nDuring the live, we jumped into the code, and I explained how it works. Here is a schema to help you understand the process:\n\n![GitHub OAuth schema.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Foauth-github.png)\n\n1. The user clicks on the login button, which points to `\u002Fauth\u002Fgithub`.\n2. The server sends a redirect to the GitHub OAuth page.\n3. The user accepts the authorization from GitHub.\n4. GitHub redirects the user back to the application callback URL with a code.\n5. The server exchanges the code for the tokens.\n6. The server uses the tokens to get the user.\n7. The server stores the user in a session.\n8. The server redirects the user to the home page.\n\nIn these steps, the server is Nitro, and we do not talk about the possible errors. There are many possible errors like the user refuses the authorization, the code is invalid, the tokens are invalid, the user is not found, etc. Each error must be handled to provide a good user experience.\n\n#### Code of the GitHub OAuth handler\n\nNow, let's dive into the code of `nuxt-auth-utils` to see how it works.\n\nTo simplify, I removed some parts of the code that are not necessary to understand the process, and I focused on the main steps. You can find the full code in the [GitHub repository](https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-auth-utils).\n\n```ts\n\u002F**\n * GitHub OAuth event handler.\n *\n * _The code is simplified for the article and doesn't work as is._\n *\u002F\nexport function githubEventHandler({ config, onSuccess }: OAuthConfig\u003COAuthGitHubConfig>) {\n  return eventHandler(async (event: H3Event) => {\n    config = { \u002F** ... *\u002F}\n\n    \u002F**\n     * Get the query from the event.\n     *\u002F\n    const query = getQuery(event)\n\n    if (query.error) {\n      \u002F\u002F Handle error\n      return\n    }\n\n    if (!query.code) {\n      \u002F**\n       * Redirect to GitHub OAuth page if there is no code in the query.\n       * If the user accepts the authorization, GitHub will redirect the user back to the application with the code.\n       * The code is the one-time code to exchange for the 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     * Use the code to get the 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     * Use the access token to get the user.\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     * Call the onSuccess callback with the user and the tokens.\n     *\n     * The OAuth is done, and the user can now be authenticated using our system.\n     * With Nitro, we store the user in a session within a cookie.\n     *\u002F\n    return onSuccess(event, {\n      user,\n      tokens,\n    })\n  })\n}\n```\n\nAs we can expect, the code is quite simple, and we easily recognize the steps we saw in the schema.\n\n### Conclusion\n\nI didn't code during this live. I wanted to explain many important concepts to help everyone understand the context of the project Orion. I hope it was clear, and that you learned something new.\n\nThe project is open-source and available on GitHub at \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F>! Give it a star if you like it! ⭐\n\n## SQL schema and first blocks of Orion's admin interface\n\n![Thumbnail of my third 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) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nIn this third stream, I created the SQL schema for the database. I then set up Nuxt UI and Nuxt UI Pro to create the admin layout and the first form for categories. This article is a summary of the stream and the work done.\n\n### SQL schema\n\nWhat is a SQL schema and why did I create the SQL schema? These are two good questions.\n\nA SQL schema is a way to organize the database. It is a blueprint of how the database will be structured. So it's very useful to think about it before starting to code. It does not need to be perfect, but helps to iterate on the database structure, avoiding errors, and to have a clear vision of the project. _I personally think it's a must-have when starting a new project._\n\nSo, before writing any code for the project Orion, I created the SQL schema.\n\n![SQL Schema](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fsql-schema.png)\n\nTo build this schema, I used [drawSQL](https:\u002F\u002Fdrawsql.app\u002F). It's a web app to create SQL schema with a nice UI. It's very easy to use and free for small projects. I recommend it.\n\nThe database for the project Orion, the full-stack Nuxt application, is simple and common for this type of project.\n\n- A `users` table to store the users of the application. Storing users is useful for the admin interface, to ban a user, or to give admin rights.\n- A `categories` table to store the categories of the theme like blog, e-commerce, documentation, etc. It's a simple table with a name and a slug. The slug is used for the URL of the category.\n- A `modules` table to store the modules of the theme. The table will have a name, a slug, and a type (official, community). This will be useful to filter the themes by modules.\n- A `themes` table to store the main content of the application. It will have a lot of columns but we will see that in another stream.\n- A `feedbacks` table to store the feedbacks from admins about a theme. Indeed, a theme must be reviewed before being published and the feedback will be used to tell the creator what to improve.\n\nThis schema is a good start to clarify things and to know where to get started but I already know that it will evolve.\n\n### Nuxt UI and Nuxt UI Pro\n\nNuxt UI is a component library made by the Nuxt team. Initially, it was an internal tool for their own projects like [Volta](https:\u002F\u002Fvolta.net). In May 2023, they decided to [open-source it](https:\u002F\u002Fgithub.com\u002Fnuxt\u002Fui\u002Freleases\u002Ftag\u002Fv2.0.0) and to make it available for everyone for free. _Today, Nuxt UI is my go-to for building user interfaces with Nuxt because it's simple, beautiful but yet highly customizable._\n\nNuxt UI Pro is built on top of Nuxt UI and provides layouts related components like a page, a hero, a header and complete layouts like a dashboard, a blog, a documentation, etc. It's a paid product, but it's worth it. _I'm using it for Orion, the full-stack application, because it's a time saver and it's beautiful._ The better part is that it's free during development (the build process will require a key), so you can try it before buying it.\n\nDuring the stream, I start by installing Nuxt UI and Nuxt UI Pro. I then create the layout for the admin interface and the page for the categories. Let's see the code.\n\n### Admin layout\n\nWith Nuxt UI Pro, this part was very easy. There is no navbar, nor sidebar for the moment. _The goal is to add features and components step by step, when needed, and to avoid to anticipate too much._ Here is the code for the layout `layouts\u002Fadmin.vue`.\n\n```vue\n\u003Ctemplate>\n  \u003CUDashboardLayout>\n    \u003Cslot \u002F>\n  \u003C\u002FUDashboardLayout>\n\u003C\u002Ftemplate>\n```\n\n### Categories page\n\nBefore developing the view, I create the API endpoint to get and update the categories.\n\n![Result of the Layout and Page for Categories.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Flayout-and-page-for-categories.png)\n\n#### Categories API\n\nThanks to Nitro and Drizzle (setup in the first stream), it is very easy. In the file `server\u002Fapi\u002Fcategories\u002Findex.get.ts` (the `.get.` is used to specify the HTTP verb), I wrote the following code:\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\nThe `useDrizzle` utils is a wrapper around the Drizzle ORM. This code will return all the categories with only the `id`, `slug`, and `name` fields. _It's a good practice to only return the fields you need to avoid sending too much data._\n\nThen, I created the endpoint to create a new category, and it's the same process. I created the file `server\u002Fapi\u002Fcategories\u002Findex.post.ts` with the following code:\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\nIn this code, there is no security check, and every user can create a category. It's clearly not a use case for our application, but the bouncer will be added later. In the same time, I did not verify the body. It's important to validate data received from a client to avoid security issues. **It will be the subject for the next stream**.\n\n#### Categories content\n\nThe page for the categories is built with Nuxt UI Pro, which makes the code very 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!-- Content -->\n    \u003C\u002FUDashboardPanel>\n  \u003C\u002FUDashboardPage>\n\u003C\u002Ftemplate>\n```\n\nWithin the `UDashboardPanel`, we can add a header with the page title and a button to create a new category and a table to display the categories.\n\nFirst, let's add the table in the `UDashboardPanel`:\n\n```vue\n\u003Cscript setup lang=\"ts\">\nconst columns = [{\n  key: 'id',\n  label: '#'\n}, {\n  key: 'name',\n  label: 'Name',\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\nIt's hard to be simpler! _Thanks Nuxt UI for the work!_\n\nThen, I add the header with the title and the button to create a new category:\n\n```vue\n\u003Ctemplate>\n  \u003CUDashboardNavbar\n    title=\"Categories\"\n  >\n    \u003Ctemplate #right>\n      \u003CUButton\n        label=\"New category\"\n        trailing-icon=\"i-heroicons-plus\"\n        color=\"gray\"\n      \u002F>\n    \u003C\u002Ftemplate>\n  \u003C\u002FUDashboardNavbar>\n\u003C\u002Ftemplate>\n```\n\nStill very easy. The button will be used to open a modal with a form to create a new category. I started to create the form but in a very simple way:\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: 'Required' }),\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=\"Name\" name=\"name\">\n      \u003CUInput v-model=\"state.name\" \u002F>\n    \u003C\u002FUFormGroup>\n\n    \u003CUButton type=\"submit\">\n      Submit\n    \u003C\u002FUButton>\n  \u003C\u002FUForm>\n\u003C\u002Ftemplate>\n```\n\nI use the form as explained in the Nuxt UI documentation. I do not really choose Yup for the validation but it was the first proposed. _We will see if it's a good choice in the future._ In the `onSubmit` method, I just do a fetch to the API to create a new category. In the next stream, we will improve it by handling the success and the error state.\n\nThis component is put into a modal using the component `UModal` and listening to the click event on the button to open a modal.\n\n_A point I'm not sure about is how to handle the response of the API. Should I return the new category and put it in the table, or should I refresh the table? For the moment, I opted for the second one._\n\nUsing Nuxt UI helps me to build the admin interface quickly, without any custom components.\n\n\u003CCallout icon=\"i-simple-icons-github\" text=\"View Source Code\" to=\"https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion\" target=\"_blank\" \u002F>\n\n### End of the stream\n\nIt was definitely a good stream. The project is moving forward without a lot of difficulties and it is a pleasure to work on it. I am looking forward to the next stream to continue working on the admin interface and the forms. Nuxt UI and Nuxt UI Pro are game changers, simple code, easy to use, and I can focus on the features of the application.\n\nThe project is open-source and available on GitHub at \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F>! Give it a star if you like it! ⭐\n\n## Let's continue the admin interface\n\n![Thumbnail of my fourth 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) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nDuring the previous stream, I started creating the categories page. I implemented the API endpoints to get and create categories, and I built the UI to display the categories and the form to create a new category. In this stream, I've continued working on the admin interface by adding the edit category form and creating a new page to manage users. I've also added the bouncer to secure the access to the admin part of the application, both for the frontend and the backend. Let's see the work done in more detail.\n\n### Edit category form\n\nThis part was very similar to the create category form. Instead of submitting the form data to the API to create a new category, I changed the endpoint to update an existing category.\n\nI created the form in a new component `components\u002Fcategories\u002FCategoryEditForm.vue` (and renamed the previous form to `CategoryCreateForm.vue`). I also created a new endpoint `server\u002Fapi\u002Fcategories\u002F[id].put.ts` to update a category. This endpoint updates a category using the `id` in the URL and the body of the request:\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_The 200 status code could not be the best choice (204 is better with a `PUT`), but I'm still thinking if I should return the updated category or not._\n\nI also tried to validate the body of the request using Yup because it was the tool used in the frontend. However, I quickly realized that it was not possible to cast a string to a number. I know myzod can do it so I tried it. I encountered [an issue](https:\u002F\u002Fgithub.com\u002Funjs\u002Fh3\u002Fissues\u002F745) and instead used Zod. _I moved the frontend to Zod off-stream._\n\n### Users page\n\nActually, when a user logs in, related data from GitHub are stored in the database (_while writing this part, I'm wondering if this is necessary because it means that a log in is a write which is more expensive than a read_) and in the session. This behavior is useful to promote the user to an admin or to ban a user. But for the moment, there is no way to manage users. So, I created a new page to manage users. Like the categories page, the page will display the users in a table. However, it will not be possible to create or update a user for the moment.\n\nIn order to retrieve the users from the database, I created the API endpoint `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\nTo show them, I use the `UTable` component from Nuxt UI Pro. The code is very similar to the categories page:\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: 'Username',\n  sortable: true\n}, {\n  key: 'roleType',\n  label: 'Role Type'\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\nAs expected, the code is very similar to the categories page.\n\nNow we have two pages, one for categories and one for users, so I added a sidebar to navigate between them. Nothing complicated here, I just used the `UDashboardSidebar` component from Nuxt UI Pro and added the links to the categories and users pages:\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nconst links = [{\n  id: 'categories',\n  label: 'Categories',\n  icon: 'i-heroicons-tag',\n  to: '\u002Fadmin\u002Fcategories',\n  tooltip: {\n    text: 'Categories',\n  }\n}, {\n  id: 'users',\n  label: 'Users',\n  icon: 'i-heroicons-user-group',\n  to: '\u002Fadmin\u002Fusers',\n  tooltip: {\n    text: 'Users',\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### Protecting the admin part\n\nWhen building the administration part of an application, it's important to secure and restrict access to it, for both the frontend and the backend. This means that only authorized users can view and manipulate the data.\n\nTo restrict access to the UI part of the admin, i.e., the frontend, I used a middleware. It's a function that runs before navigating to the page and can be used to check if the user is authenticated and has the right permissions. In this case, I created a middleware `middleware\u002Fadmin.ts` to check if the user is authenticated and has the role of an 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_The middleware must be used on pages that need to be protected using `definePageMeta`._\n\nI get the user from the session using `useUserSession` and check if the user has the role of an admin, if not, I redirect the user to the home page.\n\nThe backend is way more critical than the frontend but it's not more difficult than the frontend to secure. In order to do so, I create a util named `requireAdminUser` in the file `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\nThis function checks if the user is authenticated and has the role of an admin. If not, it throws an error with a 401 status code. This function is used in the API endpoints to secure them:\n\n```ts\nexport default defineEventHandler(async (event) => {\n  await requireAdminUser(event)\n\n  \u002F\u002F Code to handle the request\n})\n```\n\nWith these two functions, the frontend and the backend of the admin part are secured. Only authenticated users with the role of admin can access the admin part of the application.\n\n### Coming\n\nThis stream was really good and set important things for the future. The next stream will be about the modules and the theme creator. I really hope to be able to push in production soon. I'm really excited about it and to see the feedback from the community.\n\nThe project is open-source and available on GitHub at \u003CGitHubLink repo=\"barbapapazes\u002Forion\" \u002F>! Give it a star if you like it! ⭐\n\n## Fetch an external API for the modules\n\n![Thumbnail of my fifth live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-18.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nBefore going further, and implementing the modules page, I decided to change the `useFetch` behavior from current Categories and Users page. Actually, the `useFetch` composable block the client-side navigation until the data is fetched. This gives the sensation of a slow application. To avoid this, I can use the flag `lazy`. On server-side, this does not change anything and the page will still be served with data. On the client-side, the navigation will be instant and I will show a skeleton while the data is fetching using the `pending` ref.\n\n```ts\nconst { data, pending } = await useFetch('\u002Fapi\u002Fcategories', {\n  deep: false,\n  lazy: true, \u002F\u002F \u003C= Do not block the navigation\n  default: () => []\n})\n```\n\nThe `pending` ref is a boolean that tells if the data is fetching or not. I simply pass it to the `loading` props of the `UTable` component from Nuxt UI Pro and _voilà_! The user will see a loader while the data is fetching and the navigation will be instant.\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=\"Without flag\" loading=\"lazy\"\u002F>\n    \u003Cfigcaption>Without lazy flag (blocking 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=\"With lazy flag\" loading=\"lazy\" \u002F>\n    \u003Cfigcaption>With lazy flag (instant navigation)\u003C\u002Ffigcaption>\n  \u003C\u002Ffigure>\n\u003C\u002Fdiv>\n\n\u003CCallout icon=\"i-simple-icons-youtube\" text=\"Learn More about useFetch\" to=\"https:\u002F\u002Fyoutu.be\u002Fb1S5os65Urs\" target=\"_blank\" \u002F>\n\n### Modules page\n\nModules are [Nuxt modules](https:\u002F\u002Fnuxt.com\u002Fmodules). They will be linked to the themes and will be used to filter the themes. Imagine you're looking for a theme with good SEO and precisely a sitemap, you'll be able to filter the themes by the modules they use. This is the goal of the modules feature. It will also help the user to understand how the theme is built and what are the features.\n\nThe first thing I do is adding a new page `pages\u002Fadmin\u002Fmodules.vue`. Then, I added the table to display the modules and page meta to use the admin layout and admin middleware. It's similar to the categories and users pages so I will not detail it here.\n\n#### Filling the database\n\nThe main question is: how to fill the database with modules? For the categories, I create them manually. This is okay because there are not a lot of categories and no way to get a predefined list, but for the modules, it's different. Modules are made by the core team or the community and there are a lot of them. Also, the list is not static and can change over time. So I need to find an API to fetch modules from. Fortunately, Nuxt has an API to get the modules list: [`api.nuxt.com\u002Fmodules`](https:\u002F\u002Fapi.nuxt.com\u002Fmodules). This API is not documented but it's public and I can use it.\n\nThanks to this API, I will be able to fetch data, sanitize it to keep only the useful information, and store it in the database. I will create an endpoint, accessible only for the admin, to fetch the modules and store them in the database. A second endpoint will be available to retrieve the modules from the database. This will be useful for the frontend to display the modules.\n\n#### Fetching the external API\n\nI create an endpoint `server\u002Fapi\u002Fmodules\u002Ffetch.post.ts` (_off stream, I found that calling this endpoint `sync` was a better idea_) to fetch the modules from the external API and store them in the database.\n\nFirst, I add the utility `requireAdminUser` to secure the endpoint. Then, I fetch the modules from the external API using the global `$fetch` function:\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\nThe data returned by the API contains a lot of data that I don't need. I only need the `name`, `repo`, `type`, and `icon` of the modules. So I sanitize the data to keep only the useful information:\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\nI choose to not keep the type `third party` because it can be a module from anybody and I don't know the quality of them. The `repo` key was not planned to have it but I think it's a good idea to have it. I could use it to add a link to the GitHub repository of the module on a theme page.\n\nNow I have my modules, I need to store them in the database, and I also need to create the table to store them. In the file `server\u002Fdatabase\u002Fschema.ts`, I create the 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> Do not forget to run the drizzle command to generate the SQL migration.\n\nIn the endpoint, it's now possible to insert the modules into the database:\n\n```ts\nawait useDrizzle().insert(tables.modules).values(modules).execute()\n```\n\nBUT, surprise, it **does not work**. I did not know, but the [Cloudflare D1 has a (several) limit](https:\u002F\u002Fdevelopers.cloudflare.com\u002Fd1\u002Fplatform\u002Flimits\u002F) on the bound parameters at 100. In reality, this limit exists because they use a SQLite under the hood which has this limit for security reasons.\n\nWhat is a bound parameter? It's a parameter in a SQL query that is replaced by a value when the query is executed. For example, in the query `SELECT * FROM users WHERE id = ?`, the `?` is a bound parameter. In the case of the insert query `INSERT INTO users (name, email) VALUES (?, ?)`, the bound parameters are the values to insert in the table. The limit is 100 bound parameters. Warning, **a row is not a bound parameter**, a bound parameter is a single value, meaning that if you insert a row with 10 columns, it will count as 10 bound parameters. There are 42 modules with 4 columns, so 168 bound parameters.\n\nThis is a real problem because I'll have to split the insert into multiple queries and a worker is time limited, so if it runs too long, the worker could fail.\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\nIn order to have a dynamic insert in case I change the number of columns, I calculate the maximum number of rows I can insert in a single query. Then, I loop over the modules and insert the maximum number of rows in each query. I also use the `onConflictDoNothing` method to avoid inserting the same module twice if I run the endpoint multiple times.\n\n_I tried this in a production environment, and everything is working fine._\n\nI choose to return a 204 status code when the modules are fetched. If everything is ok, I can refresh the modules list in the UI to view the new modules.\n\n#### Serving the modules\n\nThis part is exactly the same as the categories and users index endpoint. I create an endpoint `server\u002Fapi\u002Fmodules\u002Findex.get.ts` to get the modules from the database:\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\nNothing new here, I just select the modules from the database and return them.\n\n### Conclusion\n\nAll of this stuff gives us this result:\n\n![Modules Page](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmodules-page.png)\n\nThis stream was different from the others. First, I now have a new mic and the sound is way better than before!\n\nSecond, I encountered a problem with the D1 limit (that could result in a need to rethink the project, hopefully not) on the bound parameters. This is a good lesson for me, and I hope it will be useful for you.\n\n## Refactoring the user management\n\n_This part was done off-stream because it was not interesting to see and I was not sure of the direction I wanted to take._\n\nWhile writing a part of this article, I ask myself if storing the user's data was a good idea. Couldn't it be better? This question comes from the way I handle the authentication endpoint.\n\nActually, when a user logged in, I always write to the database the user's data to be sure to be up-to-date. The main concern is that a write is way more expensive than a read. To find ideas, I read the [Atinux's Nuxt todo list project](https:\u002F\u002Fgithub.com\u002FAtinux\u002Fnuxt-todos-edge). In this project, it does not store the user's data. When the user logs in, it only stores the user's ID in the cookie. When the user creates a todo, it simply has to read the user's ID from the cookie to know who is the user. It's very smart and simple. I will do the same. _First through._\n\nLet's analyze if this way is possible for the Orion project. To know if it's possible, I need to answer some questions:\n\n1. Can I ban or promote a user without storing the user's data?\n2. Can a user create data without storing the user's data?\n3. Can I show user's data without storing the user's data?\n\nFor the first question, it's yes. I can imagine a system where you enter the login of a user, and when the user logs in, I will search in the database if the entry exists and get relevant data. It's not the best way but it's possible.\n\nFor the second question, it's yes. Since data are in the cookie, I can easily know who is the user and create data using this information. _And that's how it works actually. I do not fetch the user for every request._\n\nFor the third question, it's no. Imagine, I want to show the template created by the user 'X'. In our SQL schema, we have a column `user_id` in the `templates` table. If I do not store the user's data, I will not be able to know who is the user 'X', associated with the `user_id`. I will have to store the user's data in the database. _It could be possible with a non-relational database but it's not the case here. We could also imagine a system where the user is saved when he creates a template. It's possible but it will add a lot of complexity. At the end, this answer and the one from the question one just over complicate the system for no real gain._\n\nNow we know that we have to store the users in the database, what can we improve?\n\n- Saving the user's data only if it's necessary. For example, I could first search for the user before saving its data when the user logs in.\n- Saving the user's mail. This will be useful for the feedback system.\n- Saving the user's name and renaming the `username` column to `login`.\n- Adding a `created_at` and an `updated_at` column.\n- Updating data stored in the cookie.\n\nWith all of these points, I also update the UI accordingly. There is nothing difficult here; it's mainly renaming and adding fields in the schema.\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\nFunctions `$defaultFn` and `$onUpdateFn` are used to set the default value of the column. In the case of `createdAt`, the default value is the current timestamp. In the case of `updatedAt`, the default value is the current timestamp, and the value is updated when the row is updated. It's all automatic and handled by Drizzle.\n\nI also update the `server\u002Froutes\u002Fauth\u002Fgithub.ts` to store the user's data only if it's necessary:\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     * If the user is not in the database or their data has changed, update the user data.\n     * A write is more expensive than a read, so we only write if necessary.\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     * Set only the necessary data in the 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\nWith this new way to handle the user's data, I reduce the number of writes to the database while still updating the user's data when necessary.\n\n## Creating a form for templates\n\n![Thumbnail of my sixth live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-19.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nSince the beginning of the series, I've been working on the admin interface of the web application. Today, it's time to create the form to add a new template. This form will be used by the creators to add their templates or themes to the platform. As a reminder, Orion is a community-driven collection of templates for your next project, from landing pages to complete web applications.\n\n### Template schema\n\n_Remember that things evolve and the schema is not definitive._\n\nSince the template feature is a big one, I split it into multiple live streams. The first step is to create the simplest but working form possible and adding features over time. This explains why the schema above is not exactly the same as the one we drew in the SQL schema (no modules, no images, and no markdown, for the moment).\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\nThe `hash` column is a small ID I'll store in the database to create URL like Notion or Dev.to: `\u003Cslug>-\u003Chash>`. This is helpful to have a readable URL, for both humans and SEO, but because two templates can have the same name, or the name of a template can change, I need to have a unique ID.\n\nFor the first time, we create a relation between two tables: a one-to-many between the `users` table and the `templates` table. This relation is used to know who is the creator of the template. The `userId` column is a foreign key that references the `id` column of the `users` table.\n\nThe `categoryId` column is also a foreign key that references the `id` column of the `categories` table. This relation is used to know in which category the template is.\n\nIn order to be able to retrieve templates from a user or a category, I also need to define the relation the other way around. Thanks to Drizzle, it's pretty easy.\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### Template form\n\nDuring this phase, I create the form on the UI, exactly like I did for the categories. I define the state, the schema, and the action on submit!\n\nNow that a user is able to fill a form, I need an endpoint to save the data in the database. I create the endpoint `server\u002Fapi\u002Ftemplates\u002Findex.post.ts` to save the template in the database:\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\nPretty similar to the endpoint to create a new category. **One difference** is the usage of the util `requireUserSession` to ensure that the request is from an authenticated user and to get the user's data. I use the data to fill the `userId` column in the `templates` table. Of course, I validate the data received from the client using Zod.\n\nThe `useHash` util is a little wrapper around the package `nanoid`.\n\n### Templates in admin interface\n\n_During this part, I encountered an important bug. Read carefully the next part, it could save you a lot of time if you also plan to use Drizzle on Cloudflare._\n\nUsers are able to create templates, that's nice. In order to monitor and approve them, I need to create a page in the admin interface to manage the templates. This page will display the templates in a table and allow the admin to approve or refuse them. _I only implement the view, not the action._ The task is simple, it's like any other page in the admin interface. _If only I knew..._\n\nFirst, let's create the endpoint to get the templates from the database. I create the endpoint `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\nThis endpoint gets all the templates from the database and populates them using a join and the relation we define earlier in the schema.\n\nA `join` is a SQL operation that combines rows from two or more tables based on a related column between them. In this case, I join the `templates` table with the `users` table and the `categories` table. The `leftJoin` method is used to keep all the rows from the `templates` table, even if there is no match in the `users` or `categories` table.\n\n![A schematic explanation of the join operation.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fleft-join.png)\n\nNow that I have the data, I can display it on the UI. I use the `UTable` component from Nuxt UI Pro to display the templates in a table:\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**But it does not work.** Using a lot of Adonis, I was familiar to having an object like this:\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\nBut I received an object like this:\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\nand what a surprise it was to see that the keys and the values were exchanged between them. _Hard to explain but see the example._\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\nVery hard to play with data now! _The ID should be a number and the login should definitely not be a URL._\n\nAfter a long research, I found two issues: https:\u002F\u002Fgithub.com\u002Fcloudflare\u002Fworkers-sdk\u002Fissues\u002F3160 and https:\u002F\u002Fgithub.com\u002Fdrizzle-team\u002Fdrizzle-orm\u002Fissues\u002F555 explaining why it does not work. When two tables have the same column name, ~~Drizzle and~~ Cloudflare are unable to resolve them correctly and mix the data. The solution is to explicitly select the columns and rename similar columns.\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')` is the important code. I rename the column `name` to `c_name` to avoid the conflict. I also use this syntax to format the object with the key `user` and `category`. Now, everything works as expected.\n\n### Conclusion\n\nWorking on the client side, and not only on the admin part, makes the project even more concrete. There is still a lot of work on the template form like the markdown or the image, but it's a pretty good start.\n\nReally enjoy this project and I hope you too.\n\n## Continuing the template form\n\n![Thumbnail of my eighth live stream.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002F2024-05-20.png)\n\n_[Orion](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nThis stream was short so I try to make interesting stuff. I mainly do three things:\n\n- Create the show template page\n- Handle markdown on the server-side for the template description\n- Add a markdown editor for the template description\n\nLet's see how it goes. _During the stream, it goes well but since, I completely rewrite the markdown editor part._\n\n### Show template page\n\nThis item was more about the usage of the URL that the design of the page. Indeed, the URL will work like Notion or Dev.to: `\u002F\u003Cslug>-\u003Chash>` where the `slug` is the name of the template and the `hash` is a unique identifier. The `slug` is used for SEO and the `hash` is used to get the template. The `slug` is not unique and can change over time. The `hash` is unique and serves to get the template.\n\nIn the page, we first start by getting the params from the route:\n\n```ts\nconst route = useRoute()\nconst slug = route.params.slug \u002F\u002F \u003Cslug>-\u003Chash>\n```\n\nThe variable `slug` contains both the template `slug` and `hash`. We know the hash is exactly 12 characters long so we can split the `slug` to get the `slug` and the `hash`:\n\n```ts\nslug.slice(-12)\n```\n\nFinally, we can make a request to the backend to retrieve the template. All of this gives us this 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\nThe server part is not that complicated since we only get the hash from the router param and search for the template using a `where` clause:\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\nLater we will need to join the user's data and the category's data to have a complete template.\n\n### Server-side Markdown\n\nThe idea of receiving markdown from the client is to easily sanitize it to avoid HTML injection.\n\nImagine if the client send this HTML code:\n\n```html\n\u003Cp> Hello World!\u003C\u002Fp>\n\u003Cscript> console.log('hacked') \u003C\u002Fscript>\n```\n\nIf the server stores this content in the database and then display it using the directive `v-html`, every user that will see the page will execute the page. That's a real security issue.\n\nBy receiving markdown from the client, I can easily transform it using `markdown-it` and store the **sanitized HTML** in the database. The sanitized HTML is HTML that does not contain any script or dangerous tag.\n\nSo to do this, I need to install `markdown-it` and update the endpoint to create a template `server\u002Fapi\u002Ftemplates\u002Findex.post.ts`. I also add a new column `descriptionHTML` to the schema to store the sanitized HTML. Storing the HTML avoids to do it every time we render the 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\nThe `useMarkdown` function is a simple function that uses `markdown-it` to transform the markdown to HTML.\n\n### Markdown editor\n\n_This part is highly experimental and could change in the future. This is a first implementation._\n\n_While writing this part, I made some more research and everything will change. I think it's still interesting to see the process._\n\nBefore starting, I need to make a choice. Which markdown editor should I use? _Remember that we've just implemented a server endpoint to handle markdown._ I have many options:\n\n- SimpleMDE\n- EasyMDE\n- TinyMDE\n- Editor.js\n- Quill\n- Trix\n- CKEditor\n- TinyMCE\n\nThat's a lot of options. But, what are my needs? I need a dead simple editor that can return markdown. Sounds easy, right?\n\nEditor.js, CKEditor and TinyMCE are advanced editors so I will not use them. SimpleMDE is a good option but way too old. It has not received any updates since 6 years. SimpleMDE and TinyMDE are forks of SimpleMDE. EasyMDE is powerful, simple and returns markdown. It's a good option. TinyMDE does not have enough customization options. Trix and Quill are not markdown editors. They are rich text editors so they do not output markdown. I will go with EasyMDE. _For the moment._\n\nSo, we can now install the package and use it. The usage is a little tricky. Since a client only package, we cannot execute, nor import, it on the server. To use it, we will dynamically import it in a `onMounted` hook. _`onBeforeMount` is even better!_\n\n```ts\nonMounted(async () => {\n  const EasyMDE = await import('easymde').then(m => m.default)\n  const easymde = new EasyMDE()\n})\n```\n\nAnd it works? Yes but I think we completely forgot to import the style!\n\n![EasyMDE without style](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-without-style.png)\n\nI think we forgot to add the style for EasyMDE. Thanks to Vite and its CSS loader, we can import the CSS file directly in our script.\n\n```vue\n\u003Cscript lang=\"ts\" setup>\nimport 'easymde\u002Fdist\u002Feasymde.min.css'\n\u003C\u002Fscript>\n```\n\n![EasyMDE with style](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Feasymde-with-style.png)\n\nOk, it's not perfect and I will have some work to do, on both styles and icons. But it's a good start. The only part that I do not like is the markdown symbol. I would prefer to not have them because it's not user-friendly (anyone could have to create a template).\n\nBut anyway, it's a good start and I can now easily listen to the editor changes and update the 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\nNow, I can send the description to the server and it will be transformed to HTML!\n\n### What's next for the form?\n\nThe form is far from done but it's progressing well. I will add the support for the images and the modules in the next stream.\n\nThe Orion project is nearly ready for an alpha release and I'm excited to see how the community will use it and discover the content they will share.\n\n## Replacing the Markdown editor\n\n_This part was done off-stream because I was not satisfied with the EasyMDE editor but I did not know what to do._\n\nAfter thinking about it, I realized that using Markdown, for a simple description and that can be used by non-technical users, is not the best idea. Furthermore, the EasyMDE editor uses CodeMirror which is slow and way too heavy for a simple editor.\n\nNow we know that the EasyMDE editor is not the best solution for our needs, we need to find a new one. We need a simple Rich Text Editor and there are two main options: Quill and Trix. In that case, Quill seems to be simpler so I will go with it. _I will not detail the installation of Quill, it's pretty simple._\n\nBecause Quill is a rich text editor, it does not output markdown but HTML. So I need to update the endpoint to store the HTML instead of the markdown. I also need to update the schema to remove the `description` column and add a `descriptionHTML` column.\n\n### Updating the endpoint\n\nWhy do I need to update the endpoint? Quill returns HTML that is already sanitized so I could directly store it in the database. Yes but no. We are building an API and it's impossible to trust the client, even our own client. Anyone can change the HTML or use an HTTP client to send a request to the server with malicious HTML. So I need to sanitize the HTML on the server.\n\nFor example, here's some malicious HTML:\n\n```html\n\u003Cp> Hello World! \u003C\u002Fp>\n\u003Cscript> console.log('hacked') \u003C\u002Fscript>\n```\n\nIf you store this HTML in the database and display it using the directive `v-html`, every user that will see the page will execute the script. That's a real security issue. We can't use `{{ html }}` because Vue will escape the HTML so sanitizing our content is the only solution.\n\nI will use the `sanitize-html` package to sanitize the HTML. After sanitizing using this package, the HTML will be:\n\n```html\n\u003Cp> Hello World! \u003C\u002Fp>\n```\n\nThat's perfect!\n\n```ts\nawait useDrizzle().insert(tables.templates).values({\n  description: body.description ? sanitizeHtml(body.description) : null,\n})\n```\n\nThe `sanitizeHtml` function is the default export of the `sanitize-html` package. Easy right?\n\n### Updating the schema\n\nNow, I need to update the schema to remove the `descriptionHTML` column since the `description` is now HTML by default. It's important to know that if the user needs to edit his template, the HTML will be used by the Quill editor.\n\n```ts\nexport const templates = sqliteTable('templates', {\n  description: text('description'),\n  descriptionHTML: text('description_html'), \u002F\u002F [!code --]\n})\n```\n\n### Updating the UI\n\nThe usage of Quill is not that different from EasyMDE. I lazy load the Quill package, create a new instance of it, and listen for the changes. I do not forget to import the CSS file.\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\nOf course, I need to add some custom styles to the editor to make it more native into the design.\n\nA last thing. I use this in a Nuxt UI form. In this form, every item is validated against the schema. This allows me to display an error message if the description is empty or too long. The problem is that it's not integrated in the Quill editor, it does not trigger any validation. Thanks to the Nuxt UI form API, I can easily validate the Quill editor content on every change.\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\nNow, the Quill editor is fully integrated into the form and an error message will be displayed if the description is empty or too long.\n\n![The rich text editor customized to fully integrate into the form.](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Frich-text-editor.png)\n\n### Conclusion\n\nI'm really happy with this change. Since years, I was wondering how to install and use a rich text editor and now I know. You can use markdown for technical users, parse it on the server, and store HTML to simplify a get, or use a rich text editor for non-technical users and sanitize the HTML on the server. It's a good lesson for me and I hope it will be useful for you.\n\n## Adding modules to the template form\n\n![Thumbnail of my ninth 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) is a full-stack application built with Nuxt and developed live [on Twitch](https:\u002F\u002Fwww.twitch.tv\u002Fbarbapapazes). You can see the replay on [YouTube](https:\u002F\u002Fwww.youtube.com\u002F@barbapapazes) and the source code is open-source on [GitHub](https:\u002F\u002Fgithub.com\u002Fbarbapapazes\u002Forion)._\n\nDuring this live stream, I added the ability to add modules to a template in the form. This feature is important because it will allow users to filter templates by modules.\n\nI chose to do it in a separate stream because it's a many-to-many relation and a little bit more complex than the other parts. I was expecting to do it in 1 hour, but it took me 2 hours with the help of the chat. Indeed, I was stuck on the SQL query to retrieve a template populated with its modules, and the Drizzle documentation was not clear about it.\n\nLet's deep dive into the code and let's understand how the Drizzle ORM relations concept works.\n\n### Schema\n\nThe creator can add multiple modules to a template. At the same time, a module can be used by multiple templates. The relation is a many-to-many relation. To handle this, I need to create a new table, called a pivot table, to store the relation between the templates and the modules. It acts as a link between the two tables to connect them. _We use a pivot table instead of an array of modules in the template because it's more efficient and it's the SQL way to do it._\n\n![Many-to-many relation between templates and modules](\u002Fposts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey\u002Fmany-to-many-relation.png)\n\nThe schema is simplified to only show the relation between the templates and the modules. From the `templates` table, you can retrieve all the modules associated and inversely thanks to the `templates_modules` table.\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### Modules form\n\nIn this part, I will add the modules to the form, from the client side to the server side.\n\n#### Adding modules to the template\n\nIn the UI, handling the modules is pretty simple. I fetch the modules from the server and display them in a `USelectMenu` with a multiple prop.\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#### Validating the modules\n\nIn the validator, I add a new field `moduleIds` that verifies that the modules are an array of integers.\n\n```ts\nimport { array, number, object, string } from 'zod'\n\nexport const createTemplateValidator = object({\n  title: string({ message: 'Required' }),\n  moduleIds: array(number()).max(6, { message: 'Max 6 modules' }).optional(),\n})\n```\n\nI arbitrarily set the maximum number of modules to 6. It's a number that I think is enough for a template.\n\n#### Saving the modules\n\nFinally, I update the endpoint to save the template and related modules. I first save the template and then save the modules in the pivot table.\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_I have to admit that I'm not very confident with this part. I will need to check if there is a better way to do it (using a transaction or a built-in Drizzle feature)._\n\n### Populating the template with modules\n\n_This is where the hard times begin._\n\nInitially, I had a custom SQL query that joined the `templates` table, the `users` table, and the `categories` table. You can learn more about this in the [previous part](#creating-a-form-for-templates).\n\nTo link the `modules` table, I tried to add a join with the `modulesToTemplates` table and then the `modules` table. The issue I encounter is how to group the modules by template to produce an array of modules for each template.\n\nSomething like:\n\n```json\n{\n  \"id\": 1,\n  \"title\": \"My template\",\n  \"modules\": [\n    { \"id\": 1, \"name\": \"Module 1\" },\n    { \"id\": 2, \"name\": \"Module 2\" }\n  ]\n}\n```\n\nI found the SQLite function `group_concat` that can be used to concatenate the modules. I was a little disappointed to have to do this manually because Drizzle presents itself as an ORM. I was expecting to have a way to do this easily.\n\nBUT, the Twitch chat came to the rescue and suggested me to use the [`with` key from the `findMany` method](https:\u002F\u002Form.drizzle.team\u002Fdocs\u002Frqb#include-relations). I tried, and it did not really work. Cloudflare was complaining about a missing relation. I was a little bit lost.\n\nI end up by carefully reading the documentation about the many-to-many relation page, and I understood that I need to explicitly define every relation between every table. In other words, I have to tell Drizzle how to join the tables. A template has many modules by using the `modulesToTemplates` table. A module has many templates by using the `modulesToTemplates` table. This is the way to define the many-to-many relation.\n\n```ts\n\u002F\u002F A template has a many-to-many relation\nexport const templatesRelations = relations(templates, ({ one, many }) => ({\n  \u002F\u002F Linked to the pivot table and not directly to the modules table\n  modules: many(modulesToTemplates),\n}))\n\n\u002F\u002F The pivot table has 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 A module has a many-to-many relation\nexport const modulesRelations = relations(modules, ({ one, many }) => ({\n  \u002F\u002F Linked to the pivot table and not directly to the templates table\n  templates: many(modulesToTemplates),\n}))\n```\n\nThis part is super important. In an ORM like [Lucid](https:\u002F\u002Flucid.adonisjs.com), the usage of a pivot table is transparent. With Drizzle, you have to define relation between every table. It's a little bit more verbose, but it's also more explicit.\n\nFinally, I can update the endpoint to retrieve the templates with their 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\nThe usage of the `with`, once everything is correctly defined, makes things so much easier; I really love it.\n\n### Conclusion\n\nIn my mind, this part was easy, and in 30 minutes, it was done. I already did a similar thing using Lucid from Adonis. In reality, it was not really hard, but finding the right part of the Drizzle documentation and then understanding it was way more difficult than expected. This is largely because Drizzle does not abstract SQL but acts as a little wrapper, and everything must be explicit.\n\nI'm happy to have done it, I learned a lot, and a huge thanks to the chat for helping me.\n",{"title":5418,"description":20033},{"loc":20035,"images":20039},[20040,20041,20042,20043,20044,20045,20046,20047,20048,20049,20050,20051,20052,20053,20054,20055,20056,20057,20058,20059,20060,20061,20062,20063],{"loc":5479},{"loc":5515},{"loc":5780},{"loc":5827},{"loc":5906},{"loc":5922},{"loc":5938},{"loc":5985},{"loc":6762},{"loc":6803},{"loc":6958},{"loc":8392},{"loc":10074},{"loc":10213},{"loc":10223},{"loc":11276},{"loc":12476},{"loc":13859},{"loc":16056},{"loc":17120},{"loc":17173},{"loc":18283},{"loc":18299},{"loc":18338},"posts\u002Fcreate-a-full-stack-nuxt-application-a-twitch-journey","nQU22X3QR1Z9e5n0gLRc_sPIph31lDoByOH1mreNcTo",1790086901922]