[{"data":1,"prerenderedAt":4882},["ShallowReactive",2],{"page-\u002Fvue\u002F24-exercises-and-projects":3},{"id":4,"title":5,"body":6,"description":4875,"extension":4876,"meta":4877,"navigation":64,"path":4878,"seo":4879,"stem":4880,"__hash__":4881},"content\u002Fvue\u002F24-exercises-and-projects.md","Vue 3 Engineering Reference — Exercises & Projects",{"type":7,"value":8,"toc":4866},"minimark",[9,14,19,1300,1304,2097,2101,4317,4321,4420,4424,4542,4546,4550,4673,4862],[10,11,13],"h1",{"id":12},"_24-exercises-projects","24 — Exercises & Projects",[15,16,18],"h2",{"id":17},"project-1-reactive-kanban-board","Project 1 — Reactive Kanban Board",[20,21,24],"code-wrapper",{"filename":22,"language":23},"kanban\u002FuseBoard.ts","typescript",[25,26,30],"pre",{"className":27,"code":28,"language":23,"meta":29,"style":29},"language-typescript shiki shiki-themes github-light github-dark","import { ref, computed, watch, type Ref } from 'vue'\n\n\u002F\u002F ── Capstone: build a Kanban board with drag-and-drop, persistence ──\n\u002F\u002F Concepts: reactive state, computed derived state, watch for persistence,\n\u002F\u002F composable composition, scoped slots for column rendering.\n\ninterface Column {\n  id: string\n  title: string\n  cardIds: string[]\n}\n\ninterface Card {\n  id: string\n  title: string\n  description: string\n  columnId: string\n  order: number\n  createdAt: number\n}\n\nexport function useKanbanBoard() {\n  const columns: Ref\u003CColumn[]> = ref([])\n  const cards: Ref\u003CCard[]> = ref([])\n\n  \u002F\u002F ── Computed: cards grouped by column for rendering ──\n  const cardsByColumn = computed(() => {\n    const map = new Map\u003Cstring, Card[]>()\n    for (const col of columns.value) {\n      map.set(col.id, cards.value\n        .filter(c => c.columnId === col.id)\n        .sort((a, b) => a.order - b.order))\n    }\n    return map\n  })\n\n  \u002F\u002F ── Move card: update column + order ────────────────────\n  function moveCard(cardId: string, toColumnId: string, toIndex: number) {\n    const card = cards.value.find(c => c.id === cardId)\n    if (!card) return\n\n    const oldColumn = columns.value.find(c => c.id === card.columnId)\n    const newColumn = columns.value.find(c => c.id === toColumnId)\n    if (!oldColumn || !newColumn) return\n\n    \u002F\u002F Remove from old column's card list\n    oldColumn.cardIds = oldColumn.cardIds.filter(id => id !== cardId)\n\n    \u002F\u002F Insert at new position in new column\n    newColumn.cardIds.splice(toIndex, 0, cardId)\n    card.columnId = toColumnId\n\n    \u002F\u002F Re-order all cards in the new column\n    newColumn.cardIds.forEach((id, i) => {\n      const c = cards.value.find(card => card.id === id)\n      if (c) c.order = i\n    })\n  }\n\n  function addCard(columnId: string, title: string) {\n    const card: Card = {\n      id: crypto.randomUUID(),\n      title,\n      description: '',\n      columnId,\n      order: cards.value.filter(c => c.columnId === columnId).length,\n      createdAt: Date.now(),\n    }\n    cards.value.push(card)\n    columns.value.find(c => c.id === columnId)?.cardIds.push(card.id)\n  }\n\n  \u002F\u002F ── Persistence: auto-save to localStorage on every change ──\n  watch([columns, cards], () => {\n    localStorage.setItem('kanban', JSON.stringify({\n      columns: columns.value,\n      cards: cards.value,\n    }))\n  }, { deep: true })\n\n  \u002F\u002F ── Restore from localStorage on init ──────────────────\n  function loadFromStorage() {\n    const saved = localStorage.getItem('kanban')\n    if (saved) {\n      const data = JSON.parse(saved)\n      columns.value = data.columns\n      cards.value = data.cards\n    } else {\n      \u002F\u002F Default columns\n      columns.value = [\n        { id: 'todo', title: 'To Do', cardIds: [] },\n        { id: 'progress', title: 'In Progress', cardIds: [] },\n        { id: 'done', title: 'Done', cardIds: [] },\n      ]\n    }\n  }\n\n  loadFromStorage()\n\n  return { columns, cards, cardsByColumn, moveCard, addCard }\n}\n","",[31,32,33,59,66,73,79,85,90,103,117,127,141,147,152,162,171,180,190,200,211,221,226,231,246,278,303,308,314,336,366,387,399,426,459,465,474,480,485,491,531,561,578,583,611,638,661,666,672,700,705,711,729,740,745,751,774,804,818,824,830,835,863,878,890,896,908,914,940,951,956,968,994,999,1004,1010,1023,1051,1057,1063,1069,1081,1086,1092,1102,1125,1133,1154,1165,1176,1187,1193,1203,1221,1236,1251,1257,1262,1267,1272,1281,1286,1295],"code",{"__ignoreMap":29},[34,35,38,42,46,49,52,55],"span",{"class":36,"line":37},"line",1,[34,39,41],{"class":40},"svdQ7","import",[34,43,45],{"class":44},"ssxIu"," { ref, computed, watch, ",[34,47,48],{"class":40},"type",[34,50,51],{"class":44}," Ref } ",[34,53,54],{"class":40},"from",[34,56,58],{"class":57},"sJ6F3"," 'vue'\n",[34,60,62],{"class":36,"line":61},2,[34,63,65],{"emptyLinePlaceholder":64},true,"\n",[34,67,69],{"class":36,"line":68},3,[34,70,72],{"class":71},"sdCPZ","\u002F\u002F ── Capstone: build a Kanban board with drag-and-drop, persistence ──\n",[34,74,76],{"class":36,"line":75},4,[34,77,78],{"class":71},"\u002F\u002F Concepts: reactive state, computed derived state, watch for persistence,\n",[34,80,82],{"class":36,"line":81},5,[34,83,84],{"class":71},"\u002F\u002F composable composition, scoped slots for column rendering.\n",[34,86,88],{"class":36,"line":87},6,[34,89,65],{"emptyLinePlaceholder":64},[34,91,93,96,100],{"class":36,"line":92},7,[34,94,95],{"class":40},"interface",[34,97,99],{"class":98},"sIsaT"," Column",[34,101,102],{"class":44}," {\n",[34,104,106,110,113],{"class":36,"line":105},8,[34,107,109],{"class":108},"sCrzJ","  id",[34,111,112],{"class":40},":",[34,114,116],{"class":115},"snvgF"," string\n",[34,118,120,123,125],{"class":36,"line":119},9,[34,121,122],{"class":108},"  title",[34,124,112],{"class":40},[34,126,116],{"class":115},[34,128,130,133,135,138],{"class":36,"line":129},10,[34,131,132],{"class":108},"  cardIds",[34,134,112],{"class":40},[34,136,137],{"class":115}," string",[34,139,140],{"class":44},"[]\n",[34,142,144],{"class":36,"line":143},11,[34,145,146],{"class":44},"}\n",[34,148,150],{"class":36,"line":149},12,[34,151,65],{"emptyLinePlaceholder":64},[34,153,155,157,160],{"class":36,"line":154},13,[34,156,95],{"class":40},[34,158,159],{"class":98}," Card",[34,161,102],{"class":44},[34,163,165,167,169],{"class":36,"line":164},14,[34,166,109],{"class":108},[34,168,112],{"class":40},[34,170,116],{"class":115},[34,172,174,176,178],{"class":36,"line":173},15,[34,175,122],{"class":108},[34,177,112],{"class":40},[34,179,116],{"class":115},[34,181,183,186,188],{"class":36,"line":182},16,[34,184,185],{"class":108},"  description",[34,187,112],{"class":40},[34,189,116],{"class":115},[34,191,193,196,198],{"class":36,"line":192},17,[34,194,195],{"class":108},"  columnId",[34,197,112],{"class":40},[34,199,116],{"class":115},[34,201,203,206,208],{"class":36,"line":202},18,[34,204,205],{"class":108},"  order",[34,207,112],{"class":40},[34,209,210],{"class":115}," number\n",[34,212,214,217,219],{"class":36,"line":213},19,[34,215,216],{"class":108},"  createdAt",[34,218,112],{"class":40},[34,220,210],{"class":115},[34,222,224],{"class":36,"line":223},20,[34,225,146],{"class":44},[34,227,229],{"class":36,"line":228},21,[34,230,65],{"emptyLinePlaceholder":64},[34,232,234,237,240,243],{"class":36,"line":233},22,[34,235,236],{"class":40},"export",[34,238,239],{"class":40}," function",[34,241,242],{"class":98}," useKanbanBoard",[34,244,245],{"class":44},"() {\n",[34,247,249,252,255,257,260,263,266,269,272,275],{"class":36,"line":248},23,[34,250,251],{"class":40},"  const",[34,253,254],{"class":115}," columns",[34,256,112],{"class":40},[34,258,259],{"class":98}," Ref",[34,261,262],{"class":44},"\u003C",[34,264,265],{"class":98},"Column",[34,267,268],{"class":44},"[]> ",[34,270,271],{"class":40},"=",[34,273,274],{"class":98}," ref",[34,276,277],{"class":44},"([])\n",[34,279,281,283,286,288,290,292,295,297,299,301],{"class":36,"line":280},24,[34,282,251],{"class":40},[34,284,285],{"class":115}," cards",[34,287,112],{"class":40},[34,289,259],{"class":98},[34,291,262],{"class":44},[34,293,294],{"class":98},"Card",[34,296,268],{"class":44},[34,298,271],{"class":40},[34,300,274],{"class":98},[34,302,277],{"class":44},[34,304,306],{"class":36,"line":305},25,[34,307,65],{"emptyLinePlaceholder":64},[34,309,311],{"class":36,"line":310},26,[34,312,313],{"class":71},"  \u002F\u002F ── Computed: cards grouped by column for rendering ──\n",[34,315,317,319,322,325,328,331,334],{"class":36,"line":316},27,[34,318,251],{"class":40},[34,320,321],{"class":115}," cardsByColumn",[34,323,324],{"class":40}," =",[34,326,327],{"class":98}," computed",[34,329,330],{"class":44},"(() ",[34,332,333],{"class":40},"=>",[34,335,102],{"class":44},[34,337,339,342,345,347,350,353,355,358,361,363],{"class":36,"line":338},28,[34,340,341],{"class":40},"    const",[34,343,344],{"class":115}," map",[34,346,324],{"class":40},[34,348,349],{"class":40}," new",[34,351,352],{"class":98}," Map",[34,354,262],{"class":44},[34,356,357],{"class":115},"string",[34,359,360],{"class":44},", ",[34,362,294],{"class":98},[34,364,365],{"class":44},"[]>()\n",[34,367,369,372,375,378,381,384],{"class":36,"line":368},29,[34,370,371],{"class":40},"    for",[34,373,374],{"class":44}," (",[34,376,377],{"class":40},"const",[34,379,380],{"class":115}," col",[34,382,383],{"class":40}," of",[34,385,386],{"class":44}," columns.value) {\n",[34,388,390,393,396],{"class":36,"line":389},30,[34,391,392],{"class":44},"      map.",[34,394,395],{"class":98},"set",[34,397,398],{"class":44},"(col.id, cards.value\n",[34,400,402,405,408,411,414,417,420,423],{"class":36,"line":401},31,[34,403,404],{"class":44},"        .",[34,406,407],{"class":98},"filter",[34,409,410],{"class":44},"(",[34,412,413],{"class":108},"c",[34,415,416],{"class":40}," =>",[34,418,419],{"class":44}," c.columnId ",[34,421,422],{"class":40},"===",[34,424,425],{"class":44}," col.id)\n",[34,427,429,431,434,437,440,442,445,448,450,453,456],{"class":36,"line":428},32,[34,430,404],{"class":44},[34,432,433],{"class":98},"sort",[34,435,436],{"class":44},"((",[34,438,439],{"class":108},"a",[34,441,360],{"class":44},[34,443,444],{"class":108},"b",[34,446,447],{"class":44},") ",[34,449,333],{"class":40},[34,451,452],{"class":44}," a.order ",[34,454,455],{"class":40},"-",[34,457,458],{"class":44}," b.order))\n",[34,460,462],{"class":36,"line":461},33,[34,463,464],{"class":44},"    }\n",[34,466,468,471],{"class":36,"line":467},34,[34,469,470],{"class":40},"    return",[34,472,473],{"class":44}," map\n",[34,475,477],{"class":36,"line":476},35,[34,478,479],{"class":44},"  })\n",[34,481,483],{"class":36,"line":482},36,[34,484,65],{"emptyLinePlaceholder":64},[34,486,488],{"class":36,"line":487},37,[34,489,490],{"class":71},"  \u002F\u002F ── Move card: update column + order ────────────────────\n",[34,492,494,497,500,502,505,507,509,511,514,516,518,520,523,525,528],{"class":36,"line":493},38,[34,495,496],{"class":40},"  function",[34,498,499],{"class":98}," moveCard",[34,501,410],{"class":44},[34,503,504],{"class":108},"cardId",[34,506,112],{"class":40},[34,508,137],{"class":115},[34,510,360],{"class":44},[34,512,513],{"class":108},"toColumnId",[34,515,112],{"class":40},[34,517,137],{"class":115},[34,519,360],{"class":44},[34,521,522],{"class":108},"toIndex",[34,524,112],{"class":40},[34,526,527],{"class":115}," number",[34,529,530],{"class":44},") {\n",[34,532,534,536,539,541,544,547,549,551,553,556,558],{"class":36,"line":533},39,[34,535,341],{"class":40},[34,537,538],{"class":115}," card",[34,540,324],{"class":40},[34,542,543],{"class":44}," cards.value.",[34,545,546],{"class":98},"find",[34,548,410],{"class":44},[34,550,413],{"class":108},[34,552,416],{"class":40},[34,554,555],{"class":44}," c.id ",[34,557,422],{"class":40},[34,559,560],{"class":44}," cardId)\n",[34,562,564,567,569,572,575],{"class":36,"line":563},40,[34,565,566],{"class":40},"    if",[34,568,374],{"class":44},[34,570,571],{"class":40},"!",[34,573,574],{"class":44},"card) ",[34,576,577],{"class":40},"return\n",[34,579,581],{"class":36,"line":580},41,[34,582,65],{"emptyLinePlaceholder":64},[34,584,586,588,591,593,596,598,600,602,604,606,608],{"class":36,"line":585},42,[34,587,341],{"class":40},[34,589,590],{"class":115}," oldColumn",[34,592,324],{"class":40},[34,594,595],{"class":44}," columns.value.",[34,597,546],{"class":98},[34,599,410],{"class":44},[34,601,413],{"class":108},[34,603,416],{"class":40},[34,605,555],{"class":44},[34,607,422],{"class":40},[34,609,610],{"class":44}," card.columnId)\n",[34,612,614,616,619,621,623,625,627,629,631,633,635],{"class":36,"line":613},43,[34,615,341],{"class":40},[34,617,618],{"class":115}," newColumn",[34,620,324],{"class":40},[34,622,595],{"class":44},[34,624,546],{"class":98},[34,626,410],{"class":44},[34,628,413],{"class":108},[34,630,416],{"class":40},[34,632,555],{"class":44},[34,634,422],{"class":40},[34,636,637],{"class":44}," toColumnId)\n",[34,639,641,643,645,647,650,653,656,659],{"class":36,"line":640},44,[34,642,566],{"class":40},[34,644,374],{"class":44},[34,646,571],{"class":40},[34,648,649],{"class":44},"oldColumn ",[34,651,652],{"class":40},"||",[34,654,655],{"class":40}," !",[34,657,658],{"class":44},"newColumn) ",[34,660,577],{"class":40},[34,662,664],{"class":36,"line":663},45,[34,665,65],{"emptyLinePlaceholder":64},[34,667,669],{"class":36,"line":668},46,[34,670,671],{"class":71},"    \u002F\u002F Remove from old column's card list\n",[34,673,675,678,680,683,685,687,690,692,695,698],{"class":36,"line":674},47,[34,676,677],{"class":44},"    oldColumn.cardIds ",[34,679,271],{"class":40},[34,681,682],{"class":44}," oldColumn.cardIds.",[34,684,407],{"class":98},[34,686,410],{"class":44},[34,688,689],{"class":108},"id",[34,691,416],{"class":40},[34,693,694],{"class":44}," id ",[34,696,697],{"class":40},"!==",[34,699,560],{"class":44},[34,701,703],{"class":36,"line":702},48,[34,704,65],{"emptyLinePlaceholder":64},[34,706,708],{"class":36,"line":707},49,[34,709,710],{"class":71},"    \u002F\u002F Insert at new position in new column\n",[34,712,714,717,720,723,726],{"class":36,"line":713},50,[34,715,716],{"class":44},"    newColumn.cardIds.",[34,718,719],{"class":98},"splice",[34,721,722],{"class":44},"(toIndex, ",[34,724,725],{"class":115},"0",[34,727,728],{"class":44},", cardId)\n",[34,730,732,735,737],{"class":36,"line":731},51,[34,733,734],{"class":44},"    card.columnId ",[34,736,271],{"class":40},[34,738,739],{"class":44}," toColumnId\n",[34,741,743],{"class":36,"line":742},52,[34,744,65],{"emptyLinePlaceholder":64},[34,746,748],{"class":36,"line":747},53,[34,749,750],{"class":71},"    \u002F\u002F Re-order all cards in the new column\n",[34,752,754,756,759,761,763,765,768,770,772],{"class":36,"line":753},54,[34,755,716],{"class":44},[34,757,758],{"class":98},"forEach",[34,760,436],{"class":44},[34,762,689],{"class":108},[34,764,360],{"class":44},[34,766,767],{"class":108},"i",[34,769,447],{"class":44},[34,771,333],{"class":40},[34,773,102],{"class":44},[34,775,777,780,783,785,787,789,791,794,796,799,801],{"class":36,"line":776},55,[34,778,779],{"class":40},"      const",[34,781,782],{"class":115}," c",[34,784,324],{"class":40},[34,786,543],{"class":44},[34,788,546],{"class":98},[34,790,410],{"class":44},[34,792,793],{"class":108},"card",[34,795,416],{"class":40},[34,797,798],{"class":44}," card.id ",[34,800,422],{"class":40},[34,802,803],{"class":44}," id)\n",[34,805,807,810,813,815],{"class":36,"line":806},56,[34,808,809],{"class":40},"      if",[34,811,812],{"class":44}," (c) c.order ",[34,814,271],{"class":40},[34,816,817],{"class":44}," i\n",[34,819,821],{"class":36,"line":820},57,[34,822,823],{"class":44},"    })\n",[34,825,827],{"class":36,"line":826},58,[34,828,829],{"class":44},"  }\n",[34,831,833],{"class":36,"line":832},59,[34,834,65],{"emptyLinePlaceholder":64},[34,836,838,840,843,845,848,850,852,854,857,859,861],{"class":36,"line":837},60,[34,839,496],{"class":40},[34,841,842],{"class":98}," addCard",[34,844,410],{"class":44},[34,846,847],{"class":108},"columnId",[34,849,112],{"class":40},[34,851,137],{"class":115},[34,853,360],{"class":44},[34,855,856],{"class":108},"title",[34,858,112],{"class":40},[34,860,137],{"class":115},[34,862,530],{"class":44},[34,864,866,868,870,872,874,876],{"class":36,"line":865},61,[34,867,341],{"class":40},[34,869,538],{"class":115},[34,871,112],{"class":40},[34,873,159],{"class":98},[34,875,324],{"class":40},[34,877,102],{"class":44},[34,879,881,884,887],{"class":36,"line":880},62,[34,882,883],{"class":44},"      id: crypto.",[34,885,886],{"class":98},"randomUUID",[34,888,889],{"class":44},"(),\n",[34,891,893],{"class":36,"line":892},63,[34,894,895],{"class":44},"      title,\n",[34,897,899,902,905],{"class":36,"line":898},64,[34,900,901],{"class":44},"      description: ",[34,903,904],{"class":57},"''",[34,906,907],{"class":44},",\n",[34,909,911],{"class":36,"line":910},65,[34,912,913],{"class":44},"      columnId,\n",[34,915,917,920,922,924,926,928,930,932,935,938],{"class":36,"line":916},66,[34,918,919],{"class":44},"      order: cards.value.",[34,921,407],{"class":98},[34,923,410],{"class":44},[34,925,413],{"class":108},[34,927,416],{"class":40},[34,929,419],{"class":44},[34,931,422],{"class":40},[34,933,934],{"class":44}," columnId).",[34,936,937],{"class":115},"length",[34,939,907],{"class":44},[34,941,943,946,949],{"class":36,"line":942},67,[34,944,945],{"class":44},"      createdAt: Date.",[34,947,948],{"class":98},"now",[34,950,889],{"class":44},[34,952,954],{"class":36,"line":953},68,[34,955,464],{"class":44},[34,957,959,962,965],{"class":36,"line":958},69,[34,960,961],{"class":44},"    cards.value.",[34,963,964],{"class":98},"push",[34,966,967],{"class":44},"(card)\n",[34,969,971,974,976,978,980,982,984,986,989,991],{"class":36,"line":970},70,[34,972,973],{"class":44},"    columns.value.",[34,975,546],{"class":98},[34,977,410],{"class":44},[34,979,413],{"class":108},[34,981,416],{"class":40},[34,983,555],{"class":44},[34,985,422],{"class":40},[34,987,988],{"class":44}," columnId)?.cardIds.",[34,990,964],{"class":98},[34,992,993],{"class":44},"(card.id)\n",[34,995,997],{"class":36,"line":996},71,[34,998,829],{"class":44},[34,1000,1002],{"class":36,"line":1001},72,[34,1003,65],{"emptyLinePlaceholder":64},[34,1005,1007],{"class":36,"line":1006},73,[34,1008,1009],{"class":71},"  \u002F\u002F ── Persistence: auto-save to localStorage on every change ──\n",[34,1011,1013,1016,1019,1021],{"class":36,"line":1012},74,[34,1014,1015],{"class":98},"  watch",[34,1017,1018],{"class":44},"([columns, cards], () ",[34,1020,333],{"class":40},[34,1022,102],{"class":44},[34,1024,1026,1029,1032,1034,1037,1039,1042,1045,1048],{"class":36,"line":1025},75,[34,1027,1028],{"class":44},"    localStorage.",[34,1030,1031],{"class":98},"setItem",[34,1033,410],{"class":44},[34,1035,1036],{"class":57},"'kanban'",[34,1038,360],{"class":44},[34,1040,1041],{"class":115},"JSON",[34,1043,1044],{"class":44},".",[34,1046,1047],{"class":98},"stringify",[34,1049,1050],{"class":44},"({\n",[34,1052,1054],{"class":36,"line":1053},76,[34,1055,1056],{"class":44},"      columns: columns.value,\n",[34,1058,1060],{"class":36,"line":1059},77,[34,1061,1062],{"class":44},"      cards: cards.value,\n",[34,1064,1066],{"class":36,"line":1065},78,[34,1067,1068],{"class":44},"    }))\n",[34,1070,1072,1075,1078],{"class":36,"line":1071},79,[34,1073,1074],{"class":44},"  }, { deep: ",[34,1076,1077],{"class":115},"true",[34,1079,1080],{"class":44}," })\n",[34,1082,1084],{"class":36,"line":1083},80,[34,1085,65],{"emptyLinePlaceholder":64},[34,1087,1089],{"class":36,"line":1088},81,[34,1090,1091],{"class":71},"  \u002F\u002F ── Restore from localStorage on init ──────────────────\n",[34,1093,1095,1097,1100],{"class":36,"line":1094},82,[34,1096,496],{"class":40},[34,1098,1099],{"class":98}," loadFromStorage",[34,1101,245],{"class":44},[34,1103,1105,1107,1110,1112,1115,1118,1120,1122],{"class":36,"line":1104},83,[34,1106,341],{"class":40},[34,1108,1109],{"class":115}," saved",[34,1111,324],{"class":40},[34,1113,1114],{"class":44}," localStorage.",[34,1116,1117],{"class":98},"getItem",[34,1119,410],{"class":44},[34,1121,1036],{"class":57},[34,1123,1124],{"class":44},")\n",[34,1126,1128,1130],{"class":36,"line":1127},84,[34,1129,566],{"class":40},[34,1131,1132],{"class":44}," (saved) {\n",[34,1134,1136,1138,1141,1143,1146,1148,1151],{"class":36,"line":1135},85,[34,1137,779],{"class":40},[34,1139,1140],{"class":115}," data",[34,1142,324],{"class":40},[34,1144,1145],{"class":115}," JSON",[34,1147,1044],{"class":44},[34,1149,1150],{"class":98},"parse",[34,1152,1153],{"class":44},"(saved)\n",[34,1155,1157,1160,1162],{"class":36,"line":1156},86,[34,1158,1159],{"class":44},"      columns.value ",[34,1161,271],{"class":40},[34,1163,1164],{"class":44}," data.columns\n",[34,1166,1168,1171,1173],{"class":36,"line":1167},87,[34,1169,1170],{"class":44},"      cards.value ",[34,1172,271],{"class":40},[34,1174,1175],{"class":44}," data.cards\n",[34,1177,1179,1182,1185],{"class":36,"line":1178},88,[34,1180,1181],{"class":44},"    } ",[34,1183,1184],{"class":40},"else",[34,1186,102],{"class":44},[34,1188,1190],{"class":36,"line":1189},89,[34,1191,1192],{"class":71},"      \u002F\u002F Default columns\n",[34,1194,1196,1198,1200],{"class":36,"line":1195},90,[34,1197,1159],{"class":44},[34,1199,271],{"class":40},[34,1201,1202],{"class":44}," [\n",[34,1204,1206,1209,1212,1215,1218],{"class":36,"line":1205},91,[34,1207,1208],{"class":44},"        { id: ",[34,1210,1211],{"class":57},"'todo'",[34,1213,1214],{"class":44},", title: ",[34,1216,1217],{"class":57},"'To Do'",[34,1219,1220],{"class":44},", cardIds: [] },\n",[34,1222,1224,1226,1229,1231,1234],{"class":36,"line":1223},92,[34,1225,1208],{"class":44},[34,1227,1228],{"class":57},"'progress'",[34,1230,1214],{"class":44},[34,1232,1233],{"class":57},"'In Progress'",[34,1235,1220],{"class":44},[34,1237,1239,1241,1244,1246,1249],{"class":36,"line":1238},93,[34,1240,1208],{"class":44},[34,1242,1243],{"class":57},"'done'",[34,1245,1214],{"class":44},[34,1247,1248],{"class":57},"'Done'",[34,1250,1220],{"class":44},[34,1252,1254],{"class":36,"line":1253},94,[34,1255,1256],{"class":44},"      ]\n",[34,1258,1260],{"class":36,"line":1259},95,[34,1261,464],{"class":44},[34,1263,1265],{"class":36,"line":1264},96,[34,1266,829],{"class":44},[34,1268,1270],{"class":36,"line":1269},97,[34,1271,65],{"emptyLinePlaceholder":64},[34,1273,1275,1278],{"class":36,"line":1274},98,[34,1276,1277],{"class":98},"  loadFromStorage",[34,1279,1280],{"class":44},"()\n",[34,1282,1284],{"class":36,"line":1283},99,[34,1285,65],{"emptyLinePlaceholder":64},[34,1287,1289,1292],{"class":36,"line":1288},100,[34,1290,1291],{"class":40},"  return",[34,1293,1294],{"class":44}," { columns, cards, cardsByColumn, moveCard, addCard }\n",[34,1296,1298],{"class":36,"line":1297},101,[34,1299,146],{"class":44},[15,1301,1303],{"id":1302},"project-2-real-time-chat-with-websocket","Project 2 — Real-Time Chat with WebSocket",[20,1305,1307],{"filename":1306,"language":23},"chat\u002FuseChat.ts",[25,1308,1310],{"className":27,"code":1309,"language":23,"meta":29,"style":29},"import { ref, onScopeDispose, type Ref } from 'vue'\n\n\u002F\u002F ── Capstone: real-time chat with WebSocket composable ──\n\u002F\u002F Concepts: WebSocket lifecycle, reconnection, message buffering,\n\u002F\u002F onScopeDispose for cleanup, reactive message list.\n\ninterface ChatMessage {\n  id: string\n  userId: string\n  text: string\n  timestamp: number\n}\n\nexport function useChat(roomId: string) {\n  const messages: Ref\u003CChatMessage[]> = ref([])\n  const connected = ref(false)\n  const error = ref\u003CError | null>(null)\n\n  let ws: WebSocket | null = null\n  let reconnectTimer: ReturnType\u003Ctypeof setTimeout> | null = null\n  let reconnectAttempts = 0\n\n  function connect() {\n    \u002F\u002F ── Clean up existing connection ──\n    ws?.close()\n\n    const url = `wss:\u002F\u002Fchat.example.com\u002Frooms\u002F${roomId}`\n    ws = new WebSocket(url)\n\n    ws.onopen = () => {\n      connected.value = true\n      error.value = null\n      reconnectAttempts = 0  \u002F\u002F reset on successful connect\n    }\n\n    ws.onmessage = (event) => {\n      const message: ChatMessage = JSON.parse(event.data)\n      messages.value.push(message)\n    }\n\n    ws.onerror = (e) => {\n      error.value = new Error('WebSocket error')\n    }\n\n    ws.onclose = () => {\n      connected.value = false\n      \u002F\u002F ── Exponential backoff reconnection ────────────\n      if (reconnectAttempts \u003C 5) {\n        const delay = Math.min(1000 * 2 ** reconnectAttempts, 30_000)\n        reconnectTimer = setTimeout(() => {\n          reconnectAttempts++\n          connect()\n        }, delay)\n      }\n    }\n  }\n\n  function send(text: string) {\n    if (!ws || ws.readyState !== WebSocket.OPEN) {\n      error.value = new Error('Not connected')\n      return\n    }\n    ws.send(JSON.stringify({ text, timestamp: Date.now() }))\n  }\n\n  \u002F\u002F ── Cleanup: close WebSocket + clear timer on scope dispose ──\n  onScopeDispose(() => {\n    ws?.close()\n    if (reconnectTimer) clearTimeout(reconnectTimer)\n  })\n\n  connect()  \u002F\u002F start immediately\n\n  return { messages, connected, error, send, reconnect: connect }\n}\n",[31,1311,1312,1327,1331,1336,1341,1346,1350,1359,1367,1376,1385,1394,1398,1402,1422,1446,1464,1494,1498,1520,1549,1561,1565,1574,1579,1589,1593,1610,1624,1628,1645,1655,1664,1677,1681,1685,1705,1727,1737,1741,1745,1765,1783,1787,1791,1806,1815,1820,1834,1872,1888,1896,1903,1908,1913,1917,1921,1925,1943,1969,1986,1991,1995,2018,2022,2026,2031,2042,2050,2063,2067,2071,2082,2086,2093],{"__ignoreMap":29},[34,1313,1314,1316,1319,1321,1323,1325],{"class":36,"line":37},[34,1315,41],{"class":40},[34,1317,1318],{"class":44}," { ref, onScopeDispose, ",[34,1320,48],{"class":40},[34,1322,51],{"class":44},[34,1324,54],{"class":40},[34,1326,58],{"class":57},[34,1328,1329],{"class":36,"line":61},[34,1330,65],{"emptyLinePlaceholder":64},[34,1332,1333],{"class":36,"line":68},[34,1334,1335],{"class":71},"\u002F\u002F ── Capstone: real-time chat with WebSocket composable ──\n",[34,1337,1338],{"class":36,"line":75},[34,1339,1340],{"class":71},"\u002F\u002F Concepts: WebSocket lifecycle, reconnection, message buffering,\n",[34,1342,1343],{"class":36,"line":81},[34,1344,1345],{"class":71},"\u002F\u002F onScopeDispose for cleanup, reactive message list.\n",[34,1347,1348],{"class":36,"line":87},[34,1349,65],{"emptyLinePlaceholder":64},[34,1351,1352,1354,1357],{"class":36,"line":92},[34,1353,95],{"class":40},[34,1355,1356],{"class":98}," ChatMessage",[34,1358,102],{"class":44},[34,1360,1361,1363,1365],{"class":36,"line":105},[34,1362,109],{"class":108},[34,1364,112],{"class":40},[34,1366,116],{"class":115},[34,1368,1369,1372,1374],{"class":36,"line":119},[34,1370,1371],{"class":108},"  userId",[34,1373,112],{"class":40},[34,1375,116],{"class":115},[34,1377,1378,1381,1383],{"class":36,"line":129},[34,1379,1380],{"class":108},"  text",[34,1382,112],{"class":40},[34,1384,116],{"class":115},[34,1386,1387,1390,1392],{"class":36,"line":143},[34,1388,1389],{"class":108},"  timestamp",[34,1391,112],{"class":40},[34,1393,210],{"class":115},[34,1395,1396],{"class":36,"line":149},[34,1397,146],{"class":44},[34,1399,1400],{"class":36,"line":154},[34,1401,65],{"emptyLinePlaceholder":64},[34,1403,1404,1406,1408,1411,1413,1416,1418,1420],{"class":36,"line":164},[34,1405,236],{"class":40},[34,1407,239],{"class":40},[34,1409,1410],{"class":98}," useChat",[34,1412,410],{"class":44},[34,1414,1415],{"class":108},"roomId",[34,1417,112],{"class":40},[34,1419,137],{"class":115},[34,1421,530],{"class":44},[34,1423,1424,1426,1429,1431,1433,1435,1438,1440,1442,1444],{"class":36,"line":173},[34,1425,251],{"class":40},[34,1427,1428],{"class":115}," messages",[34,1430,112],{"class":40},[34,1432,259],{"class":98},[34,1434,262],{"class":44},[34,1436,1437],{"class":98},"ChatMessage",[34,1439,268],{"class":44},[34,1441,271],{"class":40},[34,1443,274],{"class":98},[34,1445,277],{"class":44},[34,1447,1448,1450,1453,1455,1457,1459,1462],{"class":36,"line":182},[34,1449,251],{"class":40},[34,1451,1452],{"class":115}," connected",[34,1454,324],{"class":40},[34,1456,274],{"class":98},[34,1458,410],{"class":44},[34,1460,1461],{"class":115},"false",[34,1463,1124],{"class":44},[34,1465,1466,1468,1471,1473,1475,1477,1480,1483,1486,1489,1492],{"class":36,"line":192},[34,1467,251],{"class":40},[34,1469,1470],{"class":115}," error",[34,1472,324],{"class":40},[34,1474,274],{"class":98},[34,1476,262],{"class":44},[34,1478,1479],{"class":98},"Error",[34,1481,1482],{"class":40}," |",[34,1484,1485],{"class":115}," null",[34,1487,1488],{"class":44},">(",[34,1490,1491],{"class":115},"null",[34,1493,1124],{"class":44},[34,1495,1496],{"class":36,"line":202},[34,1497,65],{"emptyLinePlaceholder":64},[34,1499,1500,1503,1506,1508,1511,1513,1515,1517],{"class":36,"line":213},[34,1501,1502],{"class":40},"  let",[34,1504,1505],{"class":44}," ws",[34,1507,112],{"class":40},[34,1509,1510],{"class":98}," WebSocket",[34,1512,1482],{"class":40},[34,1514,1485],{"class":115},[34,1516,324],{"class":40},[34,1518,1519],{"class":115}," null\n",[34,1521,1522,1524,1527,1529,1532,1534,1537,1540,1543,1545,1547],{"class":36,"line":223},[34,1523,1502],{"class":40},[34,1525,1526],{"class":44}," reconnectTimer",[34,1528,112],{"class":40},[34,1530,1531],{"class":98}," ReturnType",[34,1533,262],{"class":44},[34,1535,1536],{"class":40},"typeof",[34,1538,1539],{"class":44}," setTimeout> ",[34,1541,1542],{"class":40},"|",[34,1544,1485],{"class":115},[34,1546,324],{"class":40},[34,1548,1519],{"class":115},[34,1550,1551,1553,1556,1558],{"class":36,"line":228},[34,1552,1502],{"class":40},[34,1554,1555],{"class":44}," reconnectAttempts ",[34,1557,271],{"class":40},[34,1559,1560],{"class":115}," 0\n",[34,1562,1563],{"class":36,"line":233},[34,1564,65],{"emptyLinePlaceholder":64},[34,1566,1567,1569,1572],{"class":36,"line":248},[34,1568,496],{"class":40},[34,1570,1571],{"class":98}," connect",[34,1573,245],{"class":44},[34,1575,1576],{"class":36,"line":280},[34,1577,1578],{"class":71},"    \u002F\u002F ── Clean up existing connection ──\n",[34,1580,1581,1584,1587],{"class":36,"line":305},[34,1582,1583],{"class":44},"    ws?.",[34,1585,1586],{"class":98},"close",[34,1588,1280],{"class":44},[34,1590,1591],{"class":36,"line":310},[34,1592,65],{"emptyLinePlaceholder":64},[34,1594,1595,1597,1600,1602,1605,1607],{"class":36,"line":316},[34,1596,341],{"class":40},[34,1598,1599],{"class":115}," url",[34,1601,324],{"class":40},[34,1603,1604],{"class":57}," `wss:\u002F\u002Fchat.example.com\u002Frooms\u002F${",[34,1606,1415],{"class":44},[34,1608,1609],{"class":57},"}`\n",[34,1611,1612,1615,1617,1619,1621],{"class":36,"line":338},[34,1613,1614],{"class":44},"    ws ",[34,1616,271],{"class":40},[34,1618,349],{"class":40},[34,1620,1510],{"class":98},[34,1622,1623],{"class":44},"(url)\n",[34,1625,1626],{"class":36,"line":368},[34,1627,65],{"emptyLinePlaceholder":64},[34,1629,1630,1633,1636,1638,1641,1643],{"class":36,"line":389},[34,1631,1632],{"class":44},"    ws.",[34,1634,1635],{"class":98},"onopen",[34,1637,324],{"class":40},[34,1639,1640],{"class":44}," () ",[34,1642,333],{"class":40},[34,1644,102],{"class":44},[34,1646,1647,1650,1652],{"class":36,"line":401},[34,1648,1649],{"class":44},"      connected.value ",[34,1651,271],{"class":40},[34,1653,1654],{"class":115}," true\n",[34,1656,1657,1660,1662],{"class":36,"line":428},[34,1658,1659],{"class":44},"      error.value ",[34,1661,271],{"class":40},[34,1663,1519],{"class":115},[34,1665,1666,1669,1671,1674],{"class":36,"line":461},[34,1667,1668],{"class":44},"      reconnectAttempts ",[34,1670,271],{"class":40},[34,1672,1673],{"class":115}," 0",[34,1675,1676],{"class":71},"  \u002F\u002F reset on successful connect\n",[34,1678,1679],{"class":36,"line":467},[34,1680,464],{"class":44},[34,1682,1683],{"class":36,"line":476},[34,1684,65],{"emptyLinePlaceholder":64},[34,1686,1687,1689,1692,1694,1696,1699,1701,1703],{"class":36,"line":482},[34,1688,1632],{"class":44},[34,1690,1691],{"class":98},"onmessage",[34,1693,324],{"class":40},[34,1695,374],{"class":44},[34,1697,1698],{"class":108},"event",[34,1700,447],{"class":44},[34,1702,333],{"class":40},[34,1704,102],{"class":44},[34,1706,1707,1709,1712,1714,1716,1718,1720,1722,1724],{"class":36,"line":487},[34,1708,779],{"class":40},[34,1710,1711],{"class":115}," message",[34,1713,112],{"class":40},[34,1715,1356],{"class":98},[34,1717,324],{"class":40},[34,1719,1145],{"class":115},[34,1721,1044],{"class":44},[34,1723,1150],{"class":98},[34,1725,1726],{"class":44},"(event.data)\n",[34,1728,1729,1732,1734],{"class":36,"line":493},[34,1730,1731],{"class":44},"      messages.value.",[34,1733,964],{"class":98},[34,1735,1736],{"class":44},"(message)\n",[34,1738,1739],{"class":36,"line":533},[34,1740,464],{"class":44},[34,1742,1743],{"class":36,"line":563},[34,1744,65],{"emptyLinePlaceholder":64},[34,1746,1747,1749,1752,1754,1756,1759,1761,1763],{"class":36,"line":580},[34,1748,1632],{"class":44},[34,1750,1751],{"class":98},"onerror",[34,1753,324],{"class":40},[34,1755,374],{"class":44},[34,1757,1758],{"class":108},"e",[34,1760,447],{"class":44},[34,1762,333],{"class":40},[34,1764,102],{"class":44},[34,1766,1767,1769,1771,1773,1776,1778,1781],{"class":36,"line":585},[34,1768,1659],{"class":44},[34,1770,271],{"class":40},[34,1772,349],{"class":40},[34,1774,1775],{"class":98}," Error",[34,1777,410],{"class":44},[34,1779,1780],{"class":57},"'WebSocket error'",[34,1782,1124],{"class":44},[34,1784,1785],{"class":36,"line":613},[34,1786,464],{"class":44},[34,1788,1789],{"class":36,"line":640},[34,1790,65],{"emptyLinePlaceholder":64},[34,1792,1793,1795,1798,1800,1802,1804],{"class":36,"line":663},[34,1794,1632],{"class":44},[34,1796,1797],{"class":98},"onclose",[34,1799,324],{"class":40},[34,1801,1640],{"class":44},[34,1803,333],{"class":40},[34,1805,102],{"class":44},[34,1807,1808,1810,1812],{"class":36,"line":668},[34,1809,1649],{"class":44},[34,1811,271],{"class":40},[34,1813,1814],{"class":115}," false\n",[34,1816,1817],{"class":36,"line":674},[34,1818,1819],{"class":71},"      \u002F\u002F ── Exponential backoff reconnection ────────────\n",[34,1821,1822,1824,1827,1829,1832],{"class":36,"line":702},[34,1823,809],{"class":40},[34,1825,1826],{"class":44}," (reconnectAttempts ",[34,1828,262],{"class":40},[34,1830,1831],{"class":115}," 5",[34,1833,530],{"class":44},[34,1835,1836,1839,1842,1844,1847,1850,1852,1855,1858,1861,1864,1867,1870],{"class":36,"line":707},[34,1837,1838],{"class":40},"        const",[34,1840,1841],{"class":115}," delay",[34,1843,324],{"class":40},[34,1845,1846],{"class":44}," Math.",[34,1848,1849],{"class":98},"min",[34,1851,410],{"class":44},[34,1853,1854],{"class":115},"1000",[34,1856,1857],{"class":40}," *",[34,1859,1860],{"class":115}," 2",[34,1862,1863],{"class":40}," **",[34,1865,1866],{"class":44}," reconnectAttempts, ",[34,1868,1869],{"class":115},"30_000",[34,1871,1124],{"class":44},[34,1873,1874,1877,1879,1882,1884,1886],{"class":36,"line":713},[34,1875,1876],{"class":44},"        reconnectTimer ",[34,1878,271],{"class":40},[34,1880,1881],{"class":98}," setTimeout",[34,1883,330],{"class":44},[34,1885,333],{"class":40},[34,1887,102],{"class":44},[34,1889,1890,1893],{"class":36,"line":731},[34,1891,1892],{"class":44},"          reconnectAttempts",[34,1894,1895],{"class":40},"++\n",[34,1897,1898,1901],{"class":36,"line":742},[34,1899,1900],{"class":98},"          connect",[34,1902,1280],{"class":44},[34,1904,1905],{"class":36,"line":747},[34,1906,1907],{"class":44},"        }, delay)\n",[34,1909,1910],{"class":36,"line":753},[34,1911,1912],{"class":44},"      }\n",[34,1914,1915],{"class":36,"line":776},[34,1916,464],{"class":44},[34,1918,1919],{"class":36,"line":806},[34,1920,829],{"class":44},[34,1922,1923],{"class":36,"line":820},[34,1924,65],{"emptyLinePlaceholder":64},[34,1926,1927,1929,1932,1934,1937,1939,1941],{"class":36,"line":826},[34,1928,496],{"class":40},[34,1930,1931],{"class":98}," send",[34,1933,410],{"class":44},[34,1935,1936],{"class":108},"text",[34,1938,112],{"class":40},[34,1940,137],{"class":115},[34,1942,530],{"class":44},[34,1944,1945,1947,1949,1951,1954,1956,1959,1961,1964,1967],{"class":36,"line":832},[34,1946,566],{"class":40},[34,1948,374],{"class":44},[34,1950,571],{"class":40},[34,1952,1953],{"class":44},"ws ",[34,1955,652],{"class":40},[34,1957,1958],{"class":44}," ws.readyState ",[34,1960,697],{"class":40},[34,1962,1963],{"class":44}," WebSocket.",[34,1965,1966],{"class":115},"OPEN",[34,1968,530],{"class":44},[34,1970,1971,1973,1975,1977,1979,1981,1984],{"class":36,"line":837},[34,1972,1659],{"class":44},[34,1974,271],{"class":40},[34,1976,349],{"class":40},[34,1978,1775],{"class":98},[34,1980,410],{"class":44},[34,1982,1983],{"class":57},"'Not connected'",[34,1985,1124],{"class":44},[34,1987,1988],{"class":36,"line":865},[34,1989,1990],{"class":40},"      return\n",[34,1992,1993],{"class":36,"line":880},[34,1994,464],{"class":44},[34,1996,1997,1999,2002,2004,2006,2008,2010,2013,2015],{"class":36,"line":892},[34,1998,1632],{"class":44},[34,2000,2001],{"class":98},"send",[34,2003,410],{"class":44},[34,2005,1041],{"class":115},[34,2007,1044],{"class":44},[34,2009,1047],{"class":98},[34,2011,2012],{"class":44},"({ text, timestamp: Date.",[34,2014,948],{"class":98},[34,2016,2017],{"class":44},"() }))\n",[34,2019,2020],{"class":36,"line":898},[34,2021,829],{"class":44},[34,2023,2024],{"class":36,"line":910},[34,2025,65],{"emptyLinePlaceholder":64},[34,2027,2028],{"class":36,"line":916},[34,2029,2030],{"class":71},"  \u002F\u002F ── Cleanup: close WebSocket + clear timer on scope dispose ──\n",[34,2032,2033,2036,2038,2040],{"class":36,"line":942},[34,2034,2035],{"class":98},"  onScopeDispose",[34,2037,330],{"class":44},[34,2039,333],{"class":40},[34,2041,102],{"class":44},[34,2043,2044,2046,2048],{"class":36,"line":953},[34,2045,1583],{"class":44},[34,2047,1586],{"class":98},[34,2049,1280],{"class":44},[34,2051,2052,2054,2057,2060],{"class":36,"line":958},[34,2053,566],{"class":40},[34,2055,2056],{"class":44}," (reconnectTimer) ",[34,2058,2059],{"class":98},"clearTimeout",[34,2061,2062],{"class":44},"(reconnectTimer)\n",[34,2064,2065],{"class":36,"line":970},[34,2066,479],{"class":44},[34,2068,2069],{"class":36,"line":996},[34,2070,65],{"emptyLinePlaceholder":64},[34,2072,2073,2076,2079],{"class":36,"line":1001},[34,2074,2075],{"class":98},"  connect",[34,2077,2078],{"class":44},"()  ",[34,2080,2081],{"class":71},"\u002F\u002F start immediately\n",[34,2083,2084],{"class":36,"line":1006},[34,2085,65],{"emptyLinePlaceholder":64},[34,2087,2088,2090],{"class":36,"line":1012},[34,2089,1291],{"class":40},[34,2091,2092],{"class":44}," { messages, connected, error, send, reconnect: connect }\n",[34,2094,2095],{"class":36,"line":1025},[34,2096,146],{"class":44},[15,2098,2100],{"id":2099},"project-3-ssr-blog-with-nuxt","Project 3 — SSR Blog with Nuxt",[20,2102,2105,2655,2829,2833],{"filename":2103,"language":2104},"blog\u002Fpages\u002F[slug].vue","vue",[25,2106,2109],{"className":2107,"code":2108,"language":2104,"meta":29,"style":29},"language-vue shiki shiki-themes github-light github-dark","\u003Cscript setup>\n\u002F\u002F ── Capstone: Nuxt SSR blog with dynamic routes, SEO, markdown ──\n\u002F\u002F Concepts: useAsyncData for SSR data fetching, useHead for SEO,\n\u002F\u002F markdown rendering, static generation (SSG).\n\nconst route = useRoute()\nconst { data: post } = await useAsyncData(`post-${route.params.slug}`, async () => {\n  \u002F\u002F ── Fetch from API during SSR, hydrate on client ──\n  const res = await $fetch(`\u002Fapi\u002Fposts\u002F${route.params.slug}`)\n  return res\n})\n\n\u002F\u002F ── SEO: dynamic meta tags from post data ──────────────\nuseHead({\n  title: () => post.value?.title ?? 'Loading...',\n  meta: [\n    { name: 'description', content: () => post.value?.excerpt ?? '' },\n    { property: 'og:title', content: () => post.value?.title ?? '' },\n    { property: 'og:type', content: 'article' },\n  ],\n})\n\n\u002F\u002F ── Markdown rendering with sanitization ──────────────\nimport { marked } from 'marked'\nimport DOMPurify from 'dompurify'\n\nconst renderedHtml = computed(() => {\n  if (!post.value?.content) return ''\n  return DOMPurify.sanitize(marked.parse(post.value.content))\n})\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Carticle v-if=\"post\">\n    \u003Ch1>{{ post.title }}\u003C\u002Fh1>\n    \u003Ctime :datetime=\"post.publishedAt\">{{ new Date(post.publishedAt).toLocaleDateString() }}\u003C\u002Ftime>\n\n    \u003C!-- Sanitized markdown content -->\n    \u003Cdiv class=\"prose\" v-html=\"renderedHtml\" \u002F>\n\n    \u003CRouterLink to=\"\u002F\">Back to all posts\u003C\u002FRouterLink>\n  \u003C\u002Farticle>\n\n  \u003Cdiv v-else>Post not found\u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[31,2110,2111,2125,2130,2135,2140,2144,2158,2217,2222,2255,2262,2267,2271,2276,2283,2303,2308,2336,2360,2375,2380,2384,2388,2393,2405,2417,2421,2438,2456,2474,2478,2487,2491,2500,2518,2532,2554,2558,2563,2592,2596,2618,2627,2631,2647],{"__ignoreMap":29},[34,2112,2113,2115,2119,2122],{"class":36,"line":37},[34,2114,262],{"class":44},[34,2116,2118],{"class":2117},"sk71V","script",[34,2120,2121],{"class":98}," setup",[34,2123,2124],{"class":44},">\n",[34,2126,2127],{"class":36,"line":61},[34,2128,2129],{"class":71},"\u002F\u002F ── Capstone: Nuxt SSR blog with dynamic routes, SEO, markdown ──\n",[34,2131,2132],{"class":36,"line":68},[34,2133,2134],{"class":71},"\u002F\u002F Concepts: useAsyncData for SSR data fetching, useHead for SEO,\n",[34,2136,2137],{"class":36,"line":75},[34,2138,2139],{"class":71},"\u002F\u002F markdown rendering, static generation (SSG).\n",[34,2141,2142],{"class":36,"line":81},[34,2143,65],{"emptyLinePlaceholder":64},[34,2145,2146,2148,2151,2153,2156],{"class":36,"line":87},[34,2147,377],{"class":40},[34,2149,2150],{"class":115}," route",[34,2152,324],{"class":40},[34,2154,2155],{"class":98}," useRoute",[34,2157,1280],{"class":44},[34,2159,2160,2162,2165,2168,2171,2174,2177,2179,2182,2185,2187,2190,2193,2195,2198,2200,2203,2206,2208,2211,2213,2215],{"class":36,"line":92},[34,2161,377],{"class":40},[34,2163,2164],{"class":44}," { ",[34,2166,2167],{"class":108},"data",[34,2169,2170],{"class":44},": ",[34,2172,2173],{"class":115},"post",[34,2175,2176],{"class":44}," } ",[34,2178,271],{"class":40},[34,2180,2181],{"class":40}," await",[34,2183,2184],{"class":98}," useAsyncData",[34,2186,410],{"class":44},[34,2188,2189],{"class":57},"`post-${",[34,2191,2192],{"class":44},"route",[34,2194,1044],{"class":57},[34,2196,2197],{"class":44},"params",[34,2199,1044],{"class":57},[34,2201,2202],{"class":44},"slug",[34,2204,2205],{"class":57},"}`",[34,2207,360],{"class":44},[34,2209,2210],{"class":40},"async",[34,2212,1640],{"class":44},[34,2214,333],{"class":40},[34,2216,102],{"class":44},[34,2218,2219],{"class":36,"line":105},[34,2220,2221],{"class":71},"  \u002F\u002F ── Fetch from API during SSR, hydrate on client ──\n",[34,2223,2224,2226,2229,2231,2233,2236,2238,2241,2243,2245,2247,2249,2251,2253],{"class":36,"line":119},[34,2225,251],{"class":40},[34,2227,2228],{"class":115}," res",[34,2230,324],{"class":40},[34,2232,2181],{"class":40},[34,2234,2235],{"class":98}," $fetch",[34,2237,410],{"class":44},[34,2239,2240],{"class":57},"`\u002Fapi\u002Fposts\u002F${",[34,2242,2192],{"class":44},[34,2244,1044],{"class":57},[34,2246,2197],{"class":44},[34,2248,1044],{"class":57},[34,2250,2202],{"class":44},[34,2252,2205],{"class":57},[34,2254,1124],{"class":44},[34,2256,2257,2259],{"class":36,"line":129},[34,2258,1291],{"class":40},[34,2260,2261],{"class":44}," res\n",[34,2263,2264],{"class":36,"line":143},[34,2265,2266],{"class":44},"})\n",[34,2268,2269],{"class":36,"line":149},[34,2270,65],{"emptyLinePlaceholder":64},[34,2272,2273],{"class":36,"line":154},[34,2274,2275],{"class":71},"\u002F\u002F ── SEO: dynamic meta tags from post data ──────────────\n",[34,2277,2278,2281],{"class":36,"line":164},[34,2279,2280],{"class":98},"useHead",[34,2282,1050],{"class":44},[34,2284,2285,2287,2290,2292,2295,2298,2301],{"class":36,"line":173},[34,2286,122],{"class":98},[34,2288,2289],{"class":44},": () ",[34,2291,333],{"class":40},[34,2293,2294],{"class":44}," post.value?.title ",[34,2296,2297],{"class":40},"??",[34,2299,2300],{"class":57}," 'Loading...'",[34,2302,907],{"class":44},[34,2304,2305],{"class":36,"line":182},[34,2306,2307],{"class":44},"  meta: [\n",[34,2309,2310,2313,2316,2318,2321,2323,2325,2328,2330,2333],{"class":36,"line":192},[34,2311,2312],{"class":44},"    { name: ",[34,2314,2315],{"class":57},"'description'",[34,2317,360],{"class":44},[34,2319,2320],{"class":98},"content",[34,2322,2289],{"class":44},[34,2324,333],{"class":40},[34,2326,2327],{"class":44}," post.value?.excerpt ",[34,2329,2297],{"class":40},[34,2331,2332],{"class":57}," ''",[34,2334,2335],{"class":44}," },\n",[34,2337,2338,2341,2344,2346,2348,2350,2352,2354,2356,2358],{"class":36,"line":202},[34,2339,2340],{"class":44},"    { property: ",[34,2342,2343],{"class":57},"'og:title'",[34,2345,360],{"class":44},[34,2347,2320],{"class":98},[34,2349,2289],{"class":44},[34,2351,333],{"class":40},[34,2353,2294],{"class":44},[34,2355,2297],{"class":40},[34,2357,2332],{"class":57},[34,2359,2335],{"class":44},[34,2361,2362,2364,2367,2370,2373],{"class":36,"line":213},[34,2363,2340],{"class":44},[34,2365,2366],{"class":57},"'og:type'",[34,2368,2369],{"class":44},", content: ",[34,2371,2372],{"class":57},"'article'",[34,2374,2335],{"class":44},[34,2376,2377],{"class":36,"line":223},[34,2378,2379],{"class":44},"  ],\n",[34,2381,2382],{"class":36,"line":228},[34,2383,2266],{"class":44},[34,2385,2386],{"class":36,"line":233},[34,2387,65],{"emptyLinePlaceholder":64},[34,2389,2390],{"class":36,"line":248},[34,2391,2392],{"class":71},"\u002F\u002F ── Markdown rendering with sanitization ──────────────\n",[34,2394,2395,2397,2400,2402],{"class":36,"line":280},[34,2396,41],{"class":40},[34,2398,2399],{"class":44}," { marked } ",[34,2401,54],{"class":40},[34,2403,2404],{"class":57}," 'marked'\n",[34,2406,2407,2409,2412,2414],{"class":36,"line":305},[34,2408,41],{"class":40},[34,2410,2411],{"class":44}," DOMPurify ",[34,2413,54],{"class":40},[34,2415,2416],{"class":57}," 'dompurify'\n",[34,2418,2419],{"class":36,"line":310},[34,2420,65],{"emptyLinePlaceholder":64},[34,2422,2423,2425,2428,2430,2432,2434,2436],{"class":36,"line":316},[34,2424,377],{"class":40},[34,2426,2427],{"class":115}," renderedHtml",[34,2429,324],{"class":40},[34,2431,327],{"class":98},[34,2433,330],{"class":44},[34,2435,333],{"class":40},[34,2437,102],{"class":44},[34,2439,2440,2443,2445,2447,2450,2453],{"class":36,"line":338},[34,2441,2442],{"class":40},"  if",[34,2444,374],{"class":44},[34,2446,571],{"class":40},[34,2448,2449],{"class":44},"post.value?.content) ",[34,2451,2452],{"class":40},"return",[34,2454,2455],{"class":57}," ''\n",[34,2457,2458,2460,2463,2466,2469,2471],{"class":36,"line":368},[34,2459,1291],{"class":40},[34,2461,2462],{"class":44}," DOMPurify.",[34,2464,2465],{"class":98},"sanitize",[34,2467,2468],{"class":44},"(marked.",[34,2470,1150],{"class":98},[34,2472,2473],{"class":44},"(post.value.content))\n",[34,2475,2476],{"class":36,"line":389},[34,2477,2266],{"class":44},[34,2479,2480,2483,2485],{"class":36,"line":401},[34,2481,2482],{"class":44},"\u003C\u002F",[34,2484,2118],{"class":2117},[34,2486,2124],{"class":44},[34,2488,2489],{"class":36,"line":428},[34,2490,65],{"emptyLinePlaceholder":64},[34,2492,2493,2495,2498],{"class":36,"line":461},[34,2494,262],{"class":44},[34,2496,2497],{"class":2117},"template",[34,2499,2124],{"class":44},[34,2501,2502,2505,2508,2511,2513,2516],{"class":36,"line":467},[34,2503,2504],{"class":44},"  \u003C",[34,2506,2507],{"class":2117},"article",[34,2509,2510],{"class":98}," v-if",[34,2512,271],{"class":44},[34,2514,2515],{"class":57},"\"post\"",[34,2517,2124],{"class":44},[34,2519,2520,2523,2525,2528,2530],{"class":36,"line":476},[34,2521,2522],{"class":44},"    \u003C",[34,2524,10],{"class":2117},[34,2526,2527],{"class":44},">{{ post.title }}\u003C\u002F",[34,2529,10],{"class":2117},[34,2531,2124],{"class":44},[34,2533,2534,2536,2539,2542,2544,2547,2550,2552],{"class":36,"line":482},[34,2535,2522],{"class":44},[34,2537,2538],{"class":2117},"time",[34,2540,2541],{"class":98}," :datetime",[34,2543,271],{"class":44},[34,2545,2546],{"class":57},"\"post.publishedAt\"",[34,2548,2549],{"class":44},">{{ new Date(post.publishedAt).toLocaleDateString() }}\u003C\u002F",[34,2551,2538],{"class":2117},[34,2553,2124],{"class":44},[34,2555,2556],{"class":36,"line":487},[34,2557,65],{"emptyLinePlaceholder":64},[34,2559,2560],{"class":36,"line":493},[34,2561,2562],{"class":71},"    \u003C!-- Sanitized markdown content -->\n",[34,2564,2565,2567,2570,2573,2575,2578,2581,2583,2586,2590],{"class":36,"line":533},[34,2566,2522],{"class":44},[34,2568,2569],{"class":2117},"div",[34,2571,2572],{"class":98}," class",[34,2574,271],{"class":44},[34,2576,2577],{"class":57},"\"prose\"",[34,2579,2580],{"class":98}," v-html",[34,2582,271],{"class":44},[34,2584,2585],{"class":57},"\"renderedHtml\"",[34,2587,2589],{"class":2588},"sMKGj"," \u002F",[34,2591,2124],{"class":44},[34,2593,2594],{"class":36,"line":563},[34,2595,65],{"emptyLinePlaceholder":64},[34,2597,2598,2600,2603,2606,2608,2611,2614,2616],{"class":36,"line":580},[34,2599,2522],{"class":44},[34,2601,2602],{"class":2117},"RouterLink",[34,2604,2605],{"class":98}," to",[34,2607,271],{"class":44},[34,2609,2610],{"class":57},"\"\u002F\"",[34,2612,2613],{"class":44},">Back to all posts\u003C\u002F",[34,2615,2602],{"class":2117},[34,2617,2124],{"class":44},[34,2619,2620,2623,2625],{"class":36,"line":585},[34,2621,2622],{"class":44},"  \u003C\u002F",[34,2624,2507],{"class":2117},[34,2626,2124],{"class":44},[34,2628,2629],{"class":36,"line":613},[34,2630,65],{"emptyLinePlaceholder":64},[34,2632,2633,2635,2637,2640,2643,2645],{"class":36,"line":640},[34,2634,2504],{"class":44},[34,2636,2569],{"class":2117},[34,2638,2639],{"class":98}," v-else",[34,2641,2642],{"class":44},">Post not found\u003C\u002F",[34,2644,2569],{"class":2117},[34,2646,2124],{"class":44},[34,2648,2649,2651,2653],{"class":36,"line":663},[34,2650,2482],{"class":44},[34,2652,2497],{"class":2117},[34,2654,2124],{"class":44},[20,2656,2658],{"filename":2657,"language":23},"blog\u002Fserver\u002Fapi\u002Fposts\u002F[slug].get.ts",[25,2659,2661],{"className":27,"code":2660,"language":23,"meta":29,"style":29},"\u002F\u002F ── Nuxt server route: API endpoint for blog posts ─────\n\u002F\u002F File-based: server\u002Fapi\u002Fposts\u002F[slug].get.ts → GET \u002Fapi\u002Fposts\u002F:slug\n\nexport default defineEventHandler(async (event) => {\n  const slug = getRouterParam(event, 'slug')\n\n  \u002F\u002F ── Fetch from database (parameterized query — injection-safe) ──\n  const post = await db.prepare('SELECT * FROM posts WHERE slug = ?').get(slug)\n\n  if (!post) {\n    throw createError({\n      statusCode: 404,\n      statusMessage: 'Post not found',\n    })\n  }\n\n  return post\n})\n",[31,2662,2663,2668,2673,2677,2701,2721,2725,2730,2761,2765,2776,2786,2796,2806,2810,2814,2818,2825],{"__ignoreMap":29},[34,2664,2665],{"class":36,"line":37},[34,2666,2667],{"class":71},"\u002F\u002F ── Nuxt server route: API endpoint for blog posts ─────\n",[34,2669,2670],{"class":36,"line":61},[34,2671,2672],{"class":71},"\u002F\u002F File-based: server\u002Fapi\u002Fposts\u002F[slug].get.ts → GET \u002Fapi\u002Fposts\u002F:slug\n",[34,2674,2675],{"class":36,"line":68},[34,2676,65],{"emptyLinePlaceholder":64},[34,2678,2679,2681,2684,2687,2689,2691,2693,2695,2697,2699],{"class":36,"line":75},[34,2680,236],{"class":40},[34,2682,2683],{"class":40}," default",[34,2685,2686],{"class":98}," defineEventHandler",[34,2688,410],{"class":44},[34,2690,2210],{"class":40},[34,2692,374],{"class":44},[34,2694,1698],{"class":108},[34,2696,447],{"class":44},[34,2698,333],{"class":40},[34,2700,102],{"class":44},[34,2702,2703,2705,2708,2710,2713,2716,2719],{"class":36,"line":81},[34,2704,251],{"class":40},[34,2706,2707],{"class":115}," slug",[34,2709,324],{"class":40},[34,2711,2712],{"class":98}," getRouterParam",[34,2714,2715],{"class":44},"(event, ",[34,2717,2718],{"class":57},"'slug'",[34,2720,1124],{"class":44},[34,2722,2723],{"class":36,"line":87},[34,2724,65],{"emptyLinePlaceholder":64},[34,2726,2727],{"class":36,"line":92},[34,2728,2729],{"class":71},"  \u002F\u002F ── Fetch from database (parameterized query — injection-safe) ──\n",[34,2731,2732,2734,2737,2739,2741,2744,2747,2749,2752,2755,2758],{"class":36,"line":105},[34,2733,251],{"class":40},[34,2735,2736],{"class":115}," post",[34,2738,324],{"class":40},[34,2740,2181],{"class":40},[34,2742,2743],{"class":44}," db.",[34,2745,2746],{"class":98},"prepare",[34,2748,410],{"class":44},[34,2750,2751],{"class":57},"'SELECT * FROM posts WHERE slug = ?'",[34,2753,2754],{"class":44},").",[34,2756,2757],{"class":98},"get",[34,2759,2760],{"class":44},"(slug)\n",[34,2762,2763],{"class":36,"line":119},[34,2764,65],{"emptyLinePlaceholder":64},[34,2766,2767,2769,2771,2773],{"class":36,"line":129},[34,2768,2442],{"class":40},[34,2770,374],{"class":44},[34,2772,571],{"class":40},[34,2774,2775],{"class":44},"post) {\n",[34,2777,2778,2781,2784],{"class":36,"line":143},[34,2779,2780],{"class":40},"    throw",[34,2782,2783],{"class":98}," createError",[34,2785,1050],{"class":44},[34,2787,2788,2791,2794],{"class":36,"line":149},[34,2789,2790],{"class":44},"      statusCode: ",[34,2792,2793],{"class":115},"404",[34,2795,907],{"class":44},[34,2797,2798,2801,2804],{"class":36,"line":154},[34,2799,2800],{"class":44},"      statusMessage: ",[34,2802,2803],{"class":57},"'Post not found'",[34,2805,907],{"class":44},[34,2807,2808],{"class":36,"line":164},[34,2809,823],{"class":44},[34,2811,2812],{"class":36,"line":173},[34,2813,829],{"class":44},[34,2815,2816],{"class":36,"line":182},[34,2817,65],{"emptyLinePlaceholder":64},[34,2819,2820,2822],{"class":36,"line":192},[34,2821,1291],{"class":40},[34,2823,2824],{"class":44}," post\n",[34,2826,2827],{"class":36,"line":202},[34,2828,2266],{"class":44},[15,2830,2832],{"id":2831},"project-4-component-library-with-typescript-generics","Project 4 — Component Library with TypeScript Generics",[20,2834,2836,3452],{"filename":2835,"language":2104},"library\u002FDataSelect.vue",[25,2837,2839],{"className":2107,"code":2838,"language":2104,"meta":29,"style":29},"\u003Cscript setup lang=\"ts\" generic=\"T extends { id: string }\">\n\u002F\u002F ── Capstone: reusable type-safe DataSelect component ──\n\u002F\u002F Concepts: generic components (3.3+), defineModel, defineEmits,\n\u002F\u002F typed props with generics, composable integration.\n\n\u002F\u002F ── Generic prop: T extends { id: string } constrains the item type ──\n\u002F\u002F Parent: \u003CDataSelect :items=\"users\" v-model=\"selectedUserId\" \u002F>\n\u002F\u002F T is inferred as User (where User has an id property).\nconst props = defineProps\u003C{\n  items: T[]\n  labelKey: keyof T  \u002F\u002F which property to display as the label\n  placeholder?: string\n}>()\n\nconst model = defineModel\u003Cstring>()  \u002F\u002F selected id (string)\n\n\u002F\u002F ── Computed: selected item object (derived from id + items) ──\nconst selectedItem = computed(() =>\n  props.items.find(item => item.id === model.value) ?? null\n)\n\n\u002F\u002F ── Computed: display labels ────────────────────────────\nconst labels = computed(() =>\n  props.items.map(item => ({\n    id: item.id,\n    label: String(item[props.labelKey]),  \u002F\u002F cast to string for display\n  }))\n)\n\nconst isOpen = ref(false)\n\nfunction select(id: string) {\n  model.value = id  \u002F\u002F defineModel handles the emit automatically\n  isOpen.value = false\n}\n\nfunction close() { isOpen.value = false }\n\n\u002F\u002F ── Click outside: close dropdown when clicking elsewhere ──\nconst rootRef = ref\u003CHTMLElement | null>(null)\nonClickOutside(rootRef, close)\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cdiv ref=\"rootRef\" class=\"data-select\">\n    \u003Cbutton @click=\"isOpen = !isOpen\">\n      {{ selectedItem ? selectedItem[labelKey] : placeholder ?? 'Select...' }}\n    \u003C\u002Fbutton>\n\n    \u003Cul v-if=\"isOpen\" class=\"options\">\n      \u003Cli\n        v-for=\"item in items\"\n        :key=\"item.id\"\n        @click=\"select(item.id)\"\n        :class=\"{ selected: item.id === model }\"\n      >\n        {{ item[labelKey] }}\n      \u003C\u002Fli>\n    \u003C\u002Ful>\n  \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n",[31,2840,2841,2886,2891,2896,2901,2905,2910,2915,2920,2935,2947,2962,2972,2977,2981,3003,3007,3012,3028,3054,3058,3062,3067,3082,3098,3103,3117,3122,3126,3130,3147,3151,3169,3182,3191,3195,3199,3217,3221,3226,3252,3260,3268,3272,3280,3302,3319,3324,3333,3337,3360,3368,3378,3388,3398,3408,3413,3418,3428,3436,3444],{"__ignoreMap":29},[34,2842,2843,2845,2847,2849,2852,2854,2857,2860,2862,2865,2868,2871,2873,2875,2877,2879,2882,2884],{"class":36,"line":37},[34,2844,262],{"class":44},[34,2846,2118],{"class":2117},[34,2848,2121],{"class":98},[34,2850,2851],{"class":98}," lang",[34,2853,271],{"class":44},[34,2855,2856],{"class":57},"\"ts\"",[34,2858,2859],{"class":98}," generic",[34,2861,271],{"class":44},[34,2863,2864],{"class":57},"\"",[34,2866,2867],{"class":98},"T",[34,2869,2870],{"class":40}," extends",[34,2872,2164],{"class":44},[34,2874,689],{"class":108},[34,2876,112],{"class":40},[34,2878,137],{"class":115},[34,2880,2881],{"class":44}," }",[34,2883,2864],{"class":57},[34,2885,2124],{"class":44},[34,2887,2888],{"class":36,"line":61},[34,2889,2890],{"class":71},"\u002F\u002F ── Capstone: reusable type-safe DataSelect component ──\n",[34,2892,2893],{"class":36,"line":68},[34,2894,2895],{"class":71},"\u002F\u002F Concepts: generic components (3.3+), defineModel, defineEmits,\n",[34,2897,2898],{"class":36,"line":75},[34,2899,2900],{"class":71},"\u002F\u002F typed props with generics, composable integration.\n",[34,2902,2903],{"class":36,"line":81},[34,2904,65],{"emptyLinePlaceholder":64},[34,2906,2907],{"class":36,"line":87},[34,2908,2909],{"class":71},"\u002F\u002F ── Generic prop: T extends { id: string } constrains the item type ──\n",[34,2911,2912],{"class":36,"line":92},[34,2913,2914],{"class":71},"\u002F\u002F Parent: \u003CDataSelect :items=\"users\" v-model=\"selectedUserId\" \u002F>\n",[34,2916,2917],{"class":36,"line":105},[34,2918,2919],{"class":71},"\u002F\u002F T is inferred as User (where User has an id property).\n",[34,2921,2922,2924,2927,2929,2932],{"class":36,"line":119},[34,2923,377],{"class":40},[34,2925,2926],{"class":115}," props",[34,2928,324],{"class":40},[34,2930,2931],{"class":98}," defineProps",[34,2933,2934],{"class":44},"\u003C{\n",[34,2936,2937,2940,2942,2945],{"class":36,"line":129},[34,2938,2939],{"class":108},"  items",[34,2941,112],{"class":40},[34,2943,2944],{"class":98}," T",[34,2946,140],{"class":44},[34,2948,2949,2952,2954,2957,2959],{"class":36,"line":143},[34,2950,2951],{"class":108},"  labelKey",[34,2953,112],{"class":40},[34,2955,2956],{"class":40}," keyof",[34,2958,2944],{"class":98},[34,2960,2961],{"class":71},"  \u002F\u002F which property to display as the label\n",[34,2963,2964,2967,2970],{"class":36,"line":149},[34,2965,2966],{"class":108},"  placeholder",[34,2968,2969],{"class":40},"?:",[34,2971,116],{"class":115},[34,2973,2974],{"class":36,"line":154},[34,2975,2976],{"class":44},"}>()\n",[34,2978,2979],{"class":36,"line":164},[34,2980,65],{"emptyLinePlaceholder":64},[34,2982,2983,2985,2988,2990,2993,2995,2997,3000],{"class":36,"line":173},[34,2984,377],{"class":40},[34,2986,2987],{"class":115}," model",[34,2989,324],{"class":40},[34,2991,2992],{"class":98}," defineModel",[34,2994,262],{"class":44},[34,2996,357],{"class":115},[34,2998,2999],{"class":44},">()  ",[34,3001,3002],{"class":71},"\u002F\u002F selected id (string)\n",[34,3004,3005],{"class":36,"line":182},[34,3006,65],{"emptyLinePlaceholder":64},[34,3008,3009],{"class":36,"line":192},[34,3010,3011],{"class":71},"\u002F\u002F ── Computed: selected item object (derived from id + items) ──\n",[34,3013,3014,3016,3019,3021,3023,3025],{"class":36,"line":202},[34,3015,377],{"class":40},[34,3017,3018],{"class":115}," selectedItem",[34,3020,324],{"class":40},[34,3022,327],{"class":98},[34,3024,330],{"class":44},[34,3026,3027],{"class":40},"=>\n",[34,3029,3030,3033,3035,3037,3040,3042,3045,3047,3050,3052],{"class":36,"line":213},[34,3031,3032],{"class":44},"  props.items.",[34,3034,546],{"class":98},[34,3036,410],{"class":44},[34,3038,3039],{"class":108},"item",[34,3041,416],{"class":40},[34,3043,3044],{"class":44}," item.id ",[34,3046,422],{"class":40},[34,3048,3049],{"class":44}," model.value) ",[34,3051,2297],{"class":40},[34,3053,1519],{"class":115},[34,3055,3056],{"class":36,"line":223},[34,3057,1124],{"class":44},[34,3059,3060],{"class":36,"line":228},[34,3061,65],{"emptyLinePlaceholder":64},[34,3063,3064],{"class":36,"line":233},[34,3065,3066],{"class":71},"\u002F\u002F ── Computed: display labels ────────────────────────────\n",[34,3068,3069,3071,3074,3076,3078,3080],{"class":36,"line":248},[34,3070,377],{"class":40},[34,3072,3073],{"class":115}," labels",[34,3075,324],{"class":40},[34,3077,327],{"class":98},[34,3079,330],{"class":44},[34,3081,3027],{"class":40},[34,3083,3084,3086,3089,3091,3093,3095],{"class":36,"line":280},[34,3085,3032],{"class":44},[34,3087,3088],{"class":98},"map",[34,3090,410],{"class":44},[34,3092,3039],{"class":108},[34,3094,416],{"class":40},[34,3096,3097],{"class":44}," ({\n",[34,3099,3100],{"class":36,"line":305},[34,3101,3102],{"class":44},"    id: item.id,\n",[34,3104,3105,3108,3111,3114],{"class":36,"line":310},[34,3106,3107],{"class":44},"    label: ",[34,3109,3110],{"class":98},"String",[34,3112,3113],{"class":44},"(item[props.labelKey]),  ",[34,3115,3116],{"class":71},"\u002F\u002F cast to string for display\n",[34,3118,3119],{"class":36,"line":316},[34,3120,3121],{"class":44},"  }))\n",[34,3123,3124],{"class":36,"line":338},[34,3125,1124],{"class":44},[34,3127,3128],{"class":36,"line":368},[34,3129,65],{"emptyLinePlaceholder":64},[34,3131,3132,3134,3137,3139,3141,3143,3145],{"class":36,"line":389},[34,3133,377],{"class":40},[34,3135,3136],{"class":115}," isOpen",[34,3138,324],{"class":40},[34,3140,274],{"class":98},[34,3142,410],{"class":44},[34,3144,1461],{"class":115},[34,3146,1124],{"class":44},[34,3148,3149],{"class":36,"line":401},[34,3150,65],{"emptyLinePlaceholder":64},[34,3152,3153,3156,3159,3161,3163,3165,3167],{"class":36,"line":428},[34,3154,3155],{"class":40},"function",[34,3157,3158],{"class":98}," select",[34,3160,410],{"class":44},[34,3162,689],{"class":108},[34,3164,112],{"class":40},[34,3166,137],{"class":115},[34,3168,530],{"class":44},[34,3170,3171,3174,3176,3179],{"class":36,"line":461},[34,3172,3173],{"class":44},"  model.value ",[34,3175,271],{"class":40},[34,3177,3178],{"class":44}," id  ",[34,3180,3181],{"class":71},"\u002F\u002F defineModel handles the emit automatically\n",[34,3183,3184,3187,3189],{"class":36,"line":467},[34,3185,3186],{"class":44},"  isOpen.value ",[34,3188,271],{"class":40},[34,3190,1814],{"class":115},[34,3192,3193],{"class":36,"line":476},[34,3194,146],{"class":44},[34,3196,3197],{"class":36,"line":482},[34,3198,65],{"emptyLinePlaceholder":64},[34,3200,3201,3203,3206,3209,3211,3214],{"class":36,"line":487},[34,3202,3155],{"class":40},[34,3204,3205],{"class":98}," close",[34,3207,3208],{"class":44},"() { isOpen.value ",[34,3210,271],{"class":40},[34,3212,3213],{"class":115}," false",[34,3215,3216],{"class":44}," }\n",[34,3218,3219],{"class":36,"line":493},[34,3220,65],{"emptyLinePlaceholder":64},[34,3222,3223],{"class":36,"line":533},[34,3224,3225],{"class":71},"\u002F\u002F ── Click outside: close dropdown when clicking elsewhere ──\n",[34,3227,3228,3230,3233,3235,3237,3239,3242,3244,3246,3248,3250],{"class":36,"line":563},[34,3229,377],{"class":40},[34,3231,3232],{"class":115}," rootRef",[34,3234,324],{"class":40},[34,3236,274],{"class":98},[34,3238,262],{"class":44},[34,3240,3241],{"class":98},"HTMLElement",[34,3243,1482],{"class":40},[34,3245,1485],{"class":115},[34,3247,1488],{"class":44},[34,3249,1491],{"class":115},[34,3251,1124],{"class":44},[34,3253,3254,3257],{"class":36,"line":580},[34,3255,3256],{"class":98},"onClickOutside",[34,3258,3259],{"class":44},"(rootRef, close)\n",[34,3261,3262,3264,3266],{"class":36,"line":585},[34,3263,2482],{"class":44},[34,3265,2118],{"class":2117},[34,3267,2124],{"class":44},[34,3269,3270],{"class":36,"line":613},[34,3271,65],{"emptyLinePlaceholder":64},[34,3273,3274,3276,3278],{"class":36,"line":640},[34,3275,262],{"class":44},[34,3277,2497],{"class":2117},[34,3279,2124],{"class":44},[34,3281,3282,3284,3286,3288,3290,3293,3295,3297,3300],{"class":36,"line":663},[34,3283,2504],{"class":44},[34,3285,2569],{"class":2117},[34,3287,274],{"class":98},[34,3289,271],{"class":44},[34,3291,3292],{"class":57},"\"rootRef\"",[34,3294,2572],{"class":98},[34,3296,271],{"class":44},[34,3298,3299],{"class":57},"\"data-select\"",[34,3301,2124],{"class":44},[34,3303,3304,3306,3309,3312,3314,3317],{"class":36,"line":668},[34,3305,2522],{"class":44},[34,3307,3308],{"class":2117},"button",[34,3310,3311],{"class":98}," @click",[34,3313,271],{"class":44},[34,3315,3316],{"class":57},"\"isOpen = !isOpen\"",[34,3318,2124],{"class":44},[34,3320,3321],{"class":36,"line":674},[34,3322,3323],{"class":44},"      {{ selectedItem ? selectedItem[labelKey] : placeholder ?? 'Select...' }}\n",[34,3325,3326,3329,3331],{"class":36,"line":702},[34,3327,3328],{"class":44},"    \u003C\u002F",[34,3330,3308],{"class":2117},[34,3332,2124],{"class":44},[34,3334,3335],{"class":36,"line":707},[34,3336,65],{"emptyLinePlaceholder":64},[34,3338,3339,3341,3344,3346,3348,3351,3353,3355,3358],{"class":36,"line":713},[34,3340,2522],{"class":44},[34,3342,3343],{"class":2117},"ul",[34,3345,2510],{"class":98},[34,3347,271],{"class":44},[34,3349,3350],{"class":57},"\"isOpen\"",[34,3352,2572],{"class":98},[34,3354,271],{"class":44},[34,3356,3357],{"class":57},"\"options\"",[34,3359,2124],{"class":44},[34,3361,3362,3365],{"class":36,"line":731},[34,3363,3364],{"class":44},"      \u003C",[34,3366,3367],{"class":2117},"li\n",[34,3369,3370,3373,3375],{"class":36,"line":742},[34,3371,3372],{"class":98},"        v-for",[34,3374,271],{"class":44},[34,3376,3377],{"class":57},"\"item in items\"\n",[34,3379,3380,3383,3385],{"class":36,"line":747},[34,3381,3382],{"class":98},"        :key",[34,3384,271],{"class":44},[34,3386,3387],{"class":57},"\"item.id\"\n",[34,3389,3390,3393,3395],{"class":36,"line":753},[34,3391,3392],{"class":98},"        @click",[34,3394,271],{"class":44},[34,3396,3397],{"class":57},"\"select(item.id)\"\n",[34,3399,3400,3403,3405],{"class":36,"line":776},[34,3401,3402],{"class":98},"        :class",[34,3404,271],{"class":44},[34,3406,3407],{"class":57},"\"{ selected: item.id === model }\"\n",[34,3409,3410],{"class":36,"line":806},[34,3411,3412],{"class":44},"      >\n",[34,3414,3415],{"class":36,"line":820},[34,3416,3417],{"class":44},"        {{ item[labelKey] }}\n",[34,3419,3420,3423,3426],{"class":36,"line":826},[34,3421,3422],{"class":44},"      \u003C\u002F",[34,3424,3425],{"class":2117},"li",[34,3427,2124],{"class":44},[34,3429,3430,3432,3434],{"class":36,"line":832},[34,3431,3328],{"class":44},[34,3433,3343],{"class":2117},[34,3435,2124],{"class":44},[34,3437,3438,3440,3442],{"class":36,"line":837},[34,3439,2622],{"class":44},[34,3441,2569],{"class":2117},[34,3443,2124],{"class":44},[34,3445,3446,3448,3450],{"class":36,"line":865},[34,3447,2482],{"class":44},[34,3449,2497],{"class":2117},[34,3451,2124],{"class":44},[20,3453,3455],{"filename":3454,"language":2104},"library\u002FDataTable.vue",[25,3456,3458],{"className":2107,"code":3457,"language":2104,"meta":29,"style":29},"\u003Cscript setup lang=\"ts\" generic=\"T extends Record\u003Cstring, any>\">\n\u002F\u002F ── Generic DataTable: type-safe columns + data ──\n\u002F\u002F Concepts: generic components, scoped slots with typed props,\n\u002F\u002F dynamic column configuration, renderless data layer.\n\ninterface Column\u003CK extends string> {\n  key: K\n  label: string\n  sortable?: boolean\n}\n\nconst props = defineProps\u003C{\n  data: T[]\n  columns: Column\u003Ckeyof T & string>[]\n}>()\n\nconst sortBy = ref\u003Ckeyof T | null>(null)\nconst sortDir = ref\u003C'asc' | 'desc'>('asc')\n\n\u002F\u002F ── Computed: sorted data (immutable — doesn't mutate prop) ──\nconst sortedData = computed(() => {\n  if (!sortBy.value) return props.data\n  return [...props.data].sort((a, b) => {\n    const av = a[sortBy.value!]\n    const bv = b[sortBy.value!]\n    const cmp = av \u003C bv ? -1 : av > bv ? 1 : 0\n    return sortDir.value === 'asc' ? cmp : -cmp\n  })\n})\n\nfunction toggleSort(col: Column\u003Ckeyof T & string>) {\n  if (!col.sortable) return\n  if (sortBy.value === col.key) {\n    sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'\n  } else {\n    sortBy.value = col.key\n    sortDir.value = 'asc'\n  }\n}\n\n\u002F\u002F ── Scoped slot: pass row + column data to parent for custom rendering ──\n\u002F\u002F Parent: \u003CDataTable :data=\"users\" :columns=\"cols\">\n\u002F\u002F   \u003Ctemplate #cell=\"{ row, column, value }\">{{ value }}\u003C\u002Ftemplate>\n\u002F\u002F \u003C\u002FDataTable>\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Ctable>\n    \u003Cthead>\n      \u003Ctr>\n        \u003Cth\n          v-for=\"col in columns\"\n          :key=\"col.key\"\n          @click=\"toggleSort(col)\"\n          :class=\"{ sortable: col.sortable }\"\n        >\n          {{ col.label }}\n          \u003Cspan v-if=\"sortBy === col.key\">{{ sortDir === 'asc' ? '↑' : '↓' }}\u003C\u002Fspan>\n        \u003C\u002Fth>\n      \u003C\u002Ftr>\n    \u003C\u002Fthead>\n    \u003Ctbody>\n      \u003Ctr v-for=\"(row, index) in sortedData\" :key=\"index\">\n        \u003Ctd v-for=\"col in columns\" :key=\"col.key\">\n          \u003C!-- Scoped slot: parent gets row, column, and value ── -->\n          \u003Cslot name=\"cell\" :row=\"row\" :column=\"col\" :value=\"row[col.key]\">\n            {{ row[col.key] }}  \u003C!-- default rendering if slot not filled -->\n          \u003C\u002Fslot>\n        \u003C\u002Ftd>\n      \u003C\u002Ftr>\n    \u003C\u002Ftbody>\n  \u003C\u002Ftable>\n\u003C\u002Ftemplate>\n",[31,3459,3460,3503,3508,3513,3518,3522,3540,3550,3559,3569,3573,3577,3589,3600,3624,3628,3632,3659,3686,3690,3695,3712,3728,3757,3774,3790,3834,3859,3863,3867,3871,3900,3913,3925,3947,3956,3966,3974,3978,3982,3986,3991,3996,4001,4006,4014,4018,4026,4035,4044,4053,4061,4071,4081,4091,4101,4106,4111,4132,4142,4150,4158,4167,4191,4214,4219,4260,4268,4277,4285,4293,4301,4309],{"__ignoreMap":29},[34,3461,3462,3464,3466,3468,3470,3472,3474,3476,3478,3480,3482,3484,3487,3489,3491,3493,3496,3499,3501],{"class":36,"line":37},[34,3463,262],{"class":44},[34,3465,2118],{"class":2117},[34,3467,2121],{"class":98},[34,3469,2851],{"class":98},[34,3471,271],{"class":44},[34,3473,2856],{"class":57},[34,3475,2859],{"class":98},[34,3477,271],{"class":44},[34,3479,2864],{"class":57},[34,3481,2867],{"class":98},[34,3483,2870],{"class":40},[34,3485,3486],{"class":98}," Record",[34,3488,262],{"class":44},[34,3490,357],{"class":115},[34,3492,360],{"class":44},[34,3494,3495],{"class":115},"any",[34,3497,3498],{"class":44},">",[34,3500,2864],{"class":57},[34,3502,2124],{"class":44},[34,3504,3505],{"class":36,"line":61},[34,3506,3507],{"class":71},"\u002F\u002F ── Generic DataTable: type-safe columns + data ──\n",[34,3509,3510],{"class":36,"line":68},[34,3511,3512],{"class":71},"\u002F\u002F Concepts: generic components, scoped slots with typed props,\n",[34,3514,3515],{"class":36,"line":75},[34,3516,3517],{"class":71},"\u002F\u002F dynamic column configuration, renderless data layer.\n",[34,3519,3520],{"class":36,"line":81},[34,3521,65],{"emptyLinePlaceholder":64},[34,3523,3524,3526,3528,3530,3533,3535,3537],{"class":36,"line":87},[34,3525,95],{"class":40},[34,3527,99],{"class":98},[34,3529,262],{"class":44},[34,3531,3532],{"class":98},"K",[34,3534,2870],{"class":40},[34,3536,137],{"class":115},[34,3538,3539],{"class":44},"> {\n",[34,3541,3542,3545,3547],{"class":36,"line":92},[34,3543,3544],{"class":108},"  key",[34,3546,112],{"class":40},[34,3548,3549],{"class":98}," K\n",[34,3551,3552,3555,3557],{"class":36,"line":105},[34,3553,3554],{"class":108},"  label",[34,3556,112],{"class":40},[34,3558,116],{"class":115},[34,3560,3561,3564,3566],{"class":36,"line":119},[34,3562,3563],{"class":108},"  sortable",[34,3565,2969],{"class":40},[34,3567,3568],{"class":115}," boolean\n",[34,3570,3571],{"class":36,"line":129},[34,3572,146],{"class":44},[34,3574,3575],{"class":36,"line":143},[34,3576,65],{"emptyLinePlaceholder":64},[34,3578,3579,3581,3583,3585,3587],{"class":36,"line":149},[34,3580,377],{"class":40},[34,3582,2926],{"class":115},[34,3584,324],{"class":40},[34,3586,2931],{"class":98},[34,3588,2934],{"class":44},[34,3590,3591,3594,3596,3598],{"class":36,"line":154},[34,3592,3593],{"class":108},"  data",[34,3595,112],{"class":40},[34,3597,2944],{"class":98},[34,3599,140],{"class":44},[34,3601,3602,3605,3607,3609,3611,3614,3616,3619,3621],{"class":36,"line":164},[34,3603,3604],{"class":108},"  columns",[34,3606,112],{"class":40},[34,3608,99],{"class":98},[34,3610,262],{"class":44},[34,3612,3613],{"class":40},"keyof",[34,3615,2944],{"class":98},[34,3617,3618],{"class":40}," &",[34,3620,137],{"class":115},[34,3622,3623],{"class":44},">[]\n",[34,3625,3626],{"class":36,"line":173},[34,3627,2976],{"class":44},[34,3629,3630],{"class":36,"line":182},[34,3631,65],{"emptyLinePlaceholder":64},[34,3633,3634,3636,3639,3641,3643,3645,3647,3649,3651,3653,3655,3657],{"class":36,"line":192},[34,3635,377],{"class":40},[34,3637,3638],{"class":115}," sortBy",[34,3640,324],{"class":40},[34,3642,274],{"class":98},[34,3644,262],{"class":44},[34,3646,3613],{"class":40},[34,3648,2944],{"class":98},[34,3650,1482],{"class":40},[34,3652,1485],{"class":115},[34,3654,1488],{"class":44},[34,3656,1491],{"class":115},[34,3658,1124],{"class":44},[34,3660,3661,3663,3666,3668,3670,3672,3675,3677,3680,3682,3684],{"class":36,"line":202},[34,3662,377],{"class":40},[34,3664,3665],{"class":115}," sortDir",[34,3667,324],{"class":40},[34,3669,274],{"class":98},[34,3671,262],{"class":44},[34,3673,3674],{"class":57},"'asc'",[34,3676,1482],{"class":40},[34,3678,3679],{"class":57}," 'desc'",[34,3681,1488],{"class":44},[34,3683,3674],{"class":57},[34,3685,1124],{"class":44},[34,3687,3688],{"class":36,"line":213},[34,3689,65],{"emptyLinePlaceholder":64},[34,3691,3692],{"class":36,"line":223},[34,3693,3694],{"class":71},"\u002F\u002F ── Computed: sorted data (immutable — doesn't mutate prop) ──\n",[34,3696,3697,3699,3702,3704,3706,3708,3710],{"class":36,"line":228},[34,3698,377],{"class":40},[34,3700,3701],{"class":115}," sortedData",[34,3703,324],{"class":40},[34,3705,327],{"class":98},[34,3707,330],{"class":44},[34,3709,333],{"class":40},[34,3711,102],{"class":44},[34,3713,3714,3716,3718,3720,3723,3725],{"class":36,"line":233},[34,3715,2442],{"class":40},[34,3717,374],{"class":44},[34,3719,571],{"class":40},[34,3721,3722],{"class":44},"sortBy.value) ",[34,3724,2452],{"class":40},[34,3726,3727],{"class":44}," props.data\n",[34,3729,3730,3732,3735,3738,3741,3743,3745,3747,3749,3751,3753,3755],{"class":36,"line":248},[34,3731,1291],{"class":40},[34,3733,3734],{"class":44}," [",[34,3736,3737],{"class":40},"...",[34,3739,3740],{"class":44},"props.data].",[34,3742,433],{"class":98},[34,3744,436],{"class":44},[34,3746,439],{"class":108},[34,3748,360],{"class":44},[34,3750,444],{"class":108},[34,3752,447],{"class":44},[34,3754,333],{"class":40},[34,3756,102],{"class":44},[34,3758,3759,3761,3764,3766,3769,3771],{"class":36,"line":280},[34,3760,341],{"class":40},[34,3762,3763],{"class":115}," av",[34,3765,324],{"class":40},[34,3767,3768],{"class":44}," a[sortBy.value",[34,3770,571],{"class":40},[34,3772,3773],{"class":44},"]\n",[34,3775,3776,3778,3781,3783,3786,3788],{"class":36,"line":305},[34,3777,341],{"class":40},[34,3779,3780],{"class":115}," bv",[34,3782,324],{"class":40},[34,3784,3785],{"class":44}," b[sortBy.value",[34,3787,571],{"class":40},[34,3789,3773],{"class":44},[34,3791,3792,3794,3797,3799,3802,3804,3807,3810,3813,3816,3819,3821,3823,3825,3827,3830,3832],{"class":36,"line":310},[34,3793,341],{"class":40},[34,3795,3796],{"class":115}," cmp",[34,3798,324],{"class":40},[34,3800,3801],{"class":44}," av ",[34,3803,262],{"class":40},[34,3805,3806],{"class":44}," bv ",[34,3808,3809],{"class":40},"?",[34,3811,3812],{"class":40}," -",[34,3814,3815],{"class":115},"1",[34,3817,3818],{"class":40}," :",[34,3820,3801],{"class":44},[34,3822,3498],{"class":40},[34,3824,3806],{"class":44},[34,3826,3809],{"class":40},[34,3828,3829],{"class":115}," 1",[34,3831,3818],{"class":40},[34,3833,1560],{"class":115},[34,3835,3836,3838,3841,3843,3846,3849,3852,3854,3856],{"class":36,"line":316},[34,3837,470],{"class":40},[34,3839,3840],{"class":44}," sortDir.value ",[34,3842,422],{"class":40},[34,3844,3845],{"class":57}," 'asc'",[34,3847,3848],{"class":40}," ?",[34,3850,3851],{"class":44}," cmp ",[34,3853,112],{"class":40},[34,3855,3812],{"class":40},[34,3857,3858],{"class":44},"cmp\n",[34,3860,3861],{"class":36,"line":338},[34,3862,479],{"class":44},[34,3864,3865],{"class":36,"line":368},[34,3866,2266],{"class":44},[34,3868,3869],{"class":36,"line":389},[34,3870,65],{"emptyLinePlaceholder":64},[34,3872,3873,3875,3878,3880,3883,3885,3887,3889,3891,3893,3895,3897],{"class":36,"line":401},[34,3874,3155],{"class":40},[34,3876,3877],{"class":98}," toggleSort",[34,3879,410],{"class":44},[34,3881,3882],{"class":108},"col",[34,3884,112],{"class":40},[34,3886,99],{"class":98},[34,3888,262],{"class":44},[34,3890,3613],{"class":40},[34,3892,2944],{"class":98},[34,3894,3618],{"class":40},[34,3896,137],{"class":115},[34,3898,3899],{"class":44},">) {\n",[34,3901,3902,3904,3906,3908,3911],{"class":36,"line":428},[34,3903,2442],{"class":40},[34,3905,374],{"class":44},[34,3907,571],{"class":40},[34,3909,3910],{"class":44},"col.sortable) ",[34,3912,577],{"class":40},[34,3914,3915,3917,3920,3922],{"class":36,"line":461},[34,3916,2442],{"class":40},[34,3918,3919],{"class":44}," (sortBy.value ",[34,3921,422],{"class":40},[34,3923,3924],{"class":44}," col.key) {\n",[34,3926,3927,3930,3932,3934,3936,3938,3940,3942,3944],{"class":36,"line":467},[34,3928,3929],{"class":44},"    sortDir.value ",[34,3931,271],{"class":40},[34,3933,3840],{"class":44},[34,3935,422],{"class":40},[34,3937,3845],{"class":57},[34,3939,3848],{"class":40},[34,3941,3679],{"class":57},[34,3943,3818],{"class":40},[34,3945,3946],{"class":57}," 'asc'\n",[34,3948,3949,3952,3954],{"class":36,"line":476},[34,3950,3951],{"class":44},"  } ",[34,3953,1184],{"class":40},[34,3955,102],{"class":44},[34,3957,3958,3961,3963],{"class":36,"line":482},[34,3959,3960],{"class":44},"    sortBy.value ",[34,3962,271],{"class":40},[34,3964,3965],{"class":44}," col.key\n",[34,3967,3968,3970,3972],{"class":36,"line":487},[34,3969,3929],{"class":44},[34,3971,271],{"class":40},[34,3973,3946],{"class":57},[34,3975,3976],{"class":36,"line":493},[34,3977,829],{"class":44},[34,3979,3980],{"class":36,"line":533},[34,3981,146],{"class":44},[34,3983,3984],{"class":36,"line":563},[34,3985,65],{"emptyLinePlaceholder":64},[34,3987,3988],{"class":36,"line":580},[34,3989,3990],{"class":71},"\u002F\u002F ── Scoped slot: pass row + column data to parent for custom rendering ──\n",[34,3992,3993],{"class":36,"line":585},[34,3994,3995],{"class":71},"\u002F\u002F Parent: \u003CDataTable :data=\"users\" :columns=\"cols\">\n",[34,3997,3998],{"class":36,"line":613},[34,3999,4000],{"class":71},"\u002F\u002F   \u003Ctemplate #cell=\"{ row, column, value }\">{{ value }}\u003C\u002Ftemplate>\n",[34,4002,4003],{"class":36,"line":640},[34,4004,4005],{"class":71},"\u002F\u002F \u003C\u002FDataTable>\n",[34,4007,4008,4010,4012],{"class":36,"line":663},[34,4009,2482],{"class":44},[34,4011,2118],{"class":2117},[34,4013,2124],{"class":44},[34,4015,4016],{"class":36,"line":668},[34,4017,65],{"emptyLinePlaceholder":64},[34,4019,4020,4022,4024],{"class":36,"line":674},[34,4021,262],{"class":44},[34,4023,2497],{"class":2117},[34,4025,2124],{"class":44},[34,4027,4028,4030,4033],{"class":36,"line":702},[34,4029,2504],{"class":44},[34,4031,4032],{"class":2117},"table",[34,4034,2124],{"class":44},[34,4036,4037,4039,4042],{"class":36,"line":707},[34,4038,2522],{"class":44},[34,4040,4041],{"class":2117},"thead",[34,4043,2124],{"class":44},[34,4045,4046,4048,4051],{"class":36,"line":713},[34,4047,3364],{"class":44},[34,4049,4050],{"class":2117},"tr",[34,4052,2124],{"class":44},[34,4054,4055,4058],{"class":36,"line":731},[34,4056,4057],{"class":44},"        \u003C",[34,4059,4060],{"class":2117},"th\n",[34,4062,4063,4066,4068],{"class":36,"line":742},[34,4064,4065],{"class":98},"          v-for",[34,4067,271],{"class":44},[34,4069,4070],{"class":57},"\"col in columns\"\n",[34,4072,4073,4076,4078],{"class":36,"line":747},[34,4074,4075],{"class":98},"          :key",[34,4077,271],{"class":44},[34,4079,4080],{"class":57},"\"col.key\"\n",[34,4082,4083,4086,4088],{"class":36,"line":753},[34,4084,4085],{"class":98},"          @click",[34,4087,271],{"class":44},[34,4089,4090],{"class":57},"\"toggleSort(col)\"\n",[34,4092,4093,4096,4098],{"class":36,"line":776},[34,4094,4095],{"class":98},"          :class",[34,4097,271],{"class":44},[34,4099,4100],{"class":57},"\"{ sortable: col.sortable }\"\n",[34,4102,4103],{"class":36,"line":806},[34,4104,4105],{"class":44},"        >\n",[34,4107,4108],{"class":36,"line":820},[34,4109,4110],{"class":44},"          {{ col.label }}\n",[34,4112,4113,4116,4118,4120,4122,4125,4128,4130],{"class":36,"line":826},[34,4114,4115],{"class":44},"          \u003C",[34,4117,34],{"class":2117},[34,4119,2510],{"class":98},[34,4121,271],{"class":44},[34,4123,4124],{"class":57},"\"sortBy === col.key\"",[34,4126,4127],{"class":44},">{{ sortDir === 'asc' ? '↑' : '↓' }}\u003C\u002F",[34,4129,34],{"class":2117},[34,4131,2124],{"class":44},[34,4133,4134,4137,4140],{"class":36,"line":832},[34,4135,4136],{"class":44},"        \u003C\u002F",[34,4138,4139],{"class":2117},"th",[34,4141,2124],{"class":44},[34,4143,4144,4146,4148],{"class":36,"line":837},[34,4145,3422],{"class":44},[34,4147,4050],{"class":2117},[34,4149,2124],{"class":44},[34,4151,4152,4154,4156],{"class":36,"line":865},[34,4153,3328],{"class":44},[34,4155,4041],{"class":2117},[34,4157,2124],{"class":44},[34,4159,4160,4162,4165],{"class":36,"line":880},[34,4161,2522],{"class":44},[34,4163,4164],{"class":2117},"tbody",[34,4166,2124],{"class":44},[34,4168,4169,4171,4173,4176,4178,4181,4184,4186,4189],{"class":36,"line":892},[34,4170,3364],{"class":44},[34,4172,4050],{"class":2117},[34,4174,4175],{"class":98}," v-for",[34,4177,271],{"class":44},[34,4179,4180],{"class":57},"\"(row, index) in sortedData\"",[34,4182,4183],{"class":98}," :key",[34,4185,271],{"class":44},[34,4187,4188],{"class":57},"\"index\"",[34,4190,2124],{"class":44},[34,4192,4193,4195,4198,4200,4202,4205,4207,4209,4212],{"class":36,"line":898},[34,4194,4057],{"class":44},[34,4196,4197],{"class":2117},"td",[34,4199,4175],{"class":98},[34,4201,271],{"class":44},[34,4203,4204],{"class":57},"\"col in columns\"",[34,4206,4183],{"class":98},[34,4208,271],{"class":44},[34,4210,4211],{"class":57},"\"col.key\"",[34,4213,2124],{"class":44},[34,4215,4216],{"class":36,"line":910},[34,4217,4218],{"class":71},"          \u003C!-- Scoped slot: parent gets row, column, and value ── -->\n",[34,4220,4221,4223,4226,4229,4231,4234,4237,4239,4242,4245,4247,4250,4253,4255,4258],{"class":36,"line":916},[34,4222,4115],{"class":44},[34,4224,4225],{"class":2117},"slot",[34,4227,4228],{"class":98}," name",[34,4230,271],{"class":44},[34,4232,4233],{"class":57},"\"cell\"",[34,4235,4236],{"class":98}," :row",[34,4238,271],{"class":44},[34,4240,4241],{"class":57},"\"row\"",[34,4243,4244],{"class":98}," :column",[34,4246,271],{"class":44},[34,4248,4249],{"class":57},"\"col\"",[34,4251,4252],{"class":98}," :value",[34,4254,271],{"class":44},[34,4256,4257],{"class":57},"\"row[col.key]\"",[34,4259,2124],{"class":44},[34,4261,4262,4265],{"class":36,"line":942},[34,4263,4264],{"class":44},"            {{ row[col.key] }}  ",[34,4266,4267],{"class":71},"\u003C!-- default rendering if slot not filled -->\n",[34,4269,4270,4273,4275],{"class":36,"line":953},[34,4271,4272],{"class":44},"          \u003C\u002F",[34,4274,4225],{"class":2117},[34,4276,2124],{"class":44},[34,4278,4279,4281,4283],{"class":36,"line":958},[34,4280,4136],{"class":44},[34,4282,4197],{"class":2117},[34,4284,2124],{"class":44},[34,4286,4287,4289,4291],{"class":36,"line":970},[34,4288,3422],{"class":44},[34,4290,4050],{"class":2117},[34,4292,2124],{"class":44},[34,4294,4295,4297,4299],{"class":36,"line":996},[34,4296,3328],{"class":44},[34,4298,4164],{"class":2117},[34,4300,2124],{"class":44},[34,4302,4303,4305,4307],{"class":36,"line":1001},[34,4304,2622],{"class":44},[34,4306,4032],{"class":2117},[34,4308,2124],{"class":44},[34,4310,4311,4313,4315],{"class":36,"line":1006},[34,4312,2482],{"class":44},[34,4314,2497],{"class":2117},[34,4316,2124],{"class":44},[15,4318,4320],{"id":4319},"tips-tricks","💡 Tips & Tricks",[20,4322,4324],{"filename":4323,"language":23},"tips.ts",[25,4325,4327],{"className":27,"code":4326,"language":23,"meta":29,"style":29},"\u002F\u002F ── 1. Generic components: use \u003Cscript setup generic=\"T\"> for type-safe reuse ──\n\u002F\u002F T is inferred from props — no manual type annotation needed at call site.\n\u002F\u002F Constraint: generic=\"T extends { id: string }\" ensures T has an id property.\n\n\u002F\u002F ── 2. Persistence with watch deep:true — auto-save on any nested change ──\n\u002F\u002F watch(state, saveToStorage, { deep: true }) — fires on any nested mutation.\n\u002F\u002F Debounce the save for frequent updates (don't write to localStorage per keystroke).\n\n\u002F\u002F ── 3. WebSocket reconnection: exponential backoff with max attempts ──\n\u002F\u002F delay = min(1000 * 2^attempts, 30_000) — caps at 30s, gives up after N tries.\n\u002F\u002F Reset attempts to 0 on successful connection.\n\n\u002F\u002F ── 4. Nuxt useAsyncData: unique key per route for cache isolation ──\n\u002F\u002F useAsyncData(`post-${slug}`, fetcher) — each post has its own cache entry.\n\u002F\u002F Without unique keys, navigating between posts shows stale data.\n\n\u002F\u002F ── 5. Component library: expose API via defineExpose + TypeScript ──\n\u002F\u002F Generic components can expose typed methods (validate, focus, reset) for\n\u002F\u002F parent use via template refs — fully type-checked at the call site.\n",[31,4328,4329,4334,4339,4344,4348,4353,4358,4363,4367,4372,4377,4382,4386,4391,4396,4401,4405,4410,4415],{"__ignoreMap":29},[34,4330,4331],{"class":36,"line":37},[34,4332,4333],{"class":71},"\u002F\u002F ── 1. Generic components: use \u003Cscript setup generic=\"T\"> for type-safe reuse ──\n",[34,4335,4336],{"class":36,"line":61},[34,4337,4338],{"class":71},"\u002F\u002F T is inferred from props — no manual type annotation needed at call site.\n",[34,4340,4341],{"class":36,"line":68},[34,4342,4343],{"class":71},"\u002F\u002F Constraint: generic=\"T extends { id: string }\" ensures T has an id property.\n",[34,4345,4346],{"class":36,"line":75},[34,4347,65],{"emptyLinePlaceholder":64},[34,4349,4350],{"class":36,"line":81},[34,4351,4352],{"class":71},"\u002F\u002F ── 2. Persistence with watch deep:true — auto-save on any nested change ──\n",[34,4354,4355],{"class":36,"line":87},[34,4356,4357],{"class":71},"\u002F\u002F watch(state, saveToStorage, { deep: true }) — fires on any nested mutation.\n",[34,4359,4360],{"class":36,"line":92},[34,4361,4362],{"class":71},"\u002F\u002F Debounce the save for frequent updates (don't write to localStorage per keystroke).\n",[34,4364,4365],{"class":36,"line":105},[34,4366,65],{"emptyLinePlaceholder":64},[34,4368,4369],{"class":36,"line":119},[34,4370,4371],{"class":71},"\u002F\u002F ── 3. WebSocket reconnection: exponential backoff with max attempts ──\n",[34,4373,4374],{"class":36,"line":129},[34,4375,4376],{"class":71},"\u002F\u002F delay = min(1000 * 2^attempts, 30_000) — caps at 30s, gives up after N tries.\n",[34,4378,4379],{"class":36,"line":143},[34,4380,4381],{"class":71},"\u002F\u002F Reset attempts to 0 on successful connection.\n",[34,4383,4384],{"class":36,"line":149},[34,4385,65],{"emptyLinePlaceholder":64},[34,4387,4388],{"class":36,"line":154},[34,4389,4390],{"class":71},"\u002F\u002F ── 4. Nuxt useAsyncData: unique key per route for cache isolation ──\n",[34,4392,4393],{"class":36,"line":164},[34,4394,4395],{"class":71},"\u002F\u002F useAsyncData(`post-${slug}`, fetcher) — each post has its own cache entry.\n",[34,4397,4398],{"class":36,"line":173},[34,4399,4400],{"class":71},"\u002F\u002F Without unique keys, navigating between posts shows stale data.\n",[34,4402,4403],{"class":36,"line":182},[34,4404,65],{"emptyLinePlaceholder":64},[34,4406,4407],{"class":36,"line":192},[34,4408,4409],{"class":71},"\u002F\u002F ── 5. Component library: expose API via defineExpose + TypeScript ──\n",[34,4411,4412],{"class":36,"line":202},[34,4413,4414],{"class":71},"\u002F\u002F Generic components can expose typed methods (validate, focus, reset) for\n",[34,4416,4417],{"class":36,"line":213},[34,4418,4419],{"class":71},"\u002F\u002F parent use via template refs — fully type-checked at the call site.\n",[15,4421,4423],{"id":4422},"️-edge-cases-gotchas","⚠️ Edge Cases & Gotchas",[20,4425,4427],{"filename":4426,"language":23},"edge-cases.ts",[25,4428,4430],{"className":27,"code":4429,"language":23,"meta":29,"style":29},"\u002F\u002F ── 1. Generic components: T is erased at runtime — no instanceof checks ──\n\u002F\u002F generic=\"T\" exists only at compile time. At runtime, all generics are `any`.\n\u002F\u002F Don't rely on T for runtime logic — use props for runtime values.\n\n\u002F\u002F ── 2. WebSocket: onclose fires even on intentional close() ──\n\u002F\u002F If you close() on unmount, onclose still fires → triggers reconnection.\n\u002F\u002F Set a flag (intentionalClose = true) before close() and check in onclose.\n\n\u002F\u002F ── 3. localStorage persistence: non-serializable values break ──\n\u002F\u002F Date objects → strings, Map\u002FSet → {}, class instances lose methods.\n\u002F\u002F Use a replacer\u002Freviver in JSON.stringify\u002Fparse, or store primitives only.\n\n\u002F\u002F ── 4. Nuxt useAsyncData: errors during SSR crash the page ──\n\u002F\u002F If the fetcher throws on server, the entire page render fails (500).\n\u002F\u002F Wrap in try\u002Fcatch, or use the `default` option for fallback data.\n\n\u002F\u002F ── 5. Kanban drag-and-drop: HTML5 drag events are finicky ──\n\u002F\u002F dragstart, dragover (preventDefault!), drop — order and preventDefault matter.\n\u002F\u002F Use a library (vuedraggable, @vueuse\u002Fintegrations\u002FuseSortable) for reliability.\n\n\u002F\u002F ── 6. Component library generics: scoped slot props are NOT typed with T ──\n\u002F\u002F \u003Cslot :row=\"row\" \u002F> — row is T, but the parent's slot prop is `any` unless\n\u002F\u002F the parent explicitly types it. Vue 3.4+ improves this with defineSlots.\n",[31,4431,4432,4437,4442,4447,4451,4456,4461,4466,4470,4475,4480,4485,4489,4494,4499,4504,4508,4513,4518,4523,4527,4532,4537],{"__ignoreMap":29},[34,4433,4434],{"class":36,"line":37},[34,4435,4436],{"class":71},"\u002F\u002F ── 1. Generic components: T is erased at runtime — no instanceof checks ──\n",[34,4438,4439],{"class":36,"line":61},[34,4440,4441],{"class":71},"\u002F\u002F generic=\"T\" exists only at compile time. At runtime, all generics are `any`.\n",[34,4443,4444],{"class":36,"line":68},[34,4445,4446],{"class":71},"\u002F\u002F Don't rely on T for runtime logic — use props for runtime values.\n",[34,4448,4449],{"class":36,"line":75},[34,4450,65],{"emptyLinePlaceholder":64},[34,4452,4453],{"class":36,"line":81},[34,4454,4455],{"class":71},"\u002F\u002F ── 2. WebSocket: onclose fires even on intentional close() ──\n",[34,4457,4458],{"class":36,"line":87},[34,4459,4460],{"class":71},"\u002F\u002F If you close() on unmount, onclose still fires → triggers reconnection.\n",[34,4462,4463],{"class":36,"line":92},[34,4464,4465],{"class":71},"\u002F\u002F Set a flag (intentionalClose = true) before close() and check in onclose.\n",[34,4467,4468],{"class":36,"line":105},[34,4469,65],{"emptyLinePlaceholder":64},[34,4471,4472],{"class":36,"line":119},[34,4473,4474],{"class":71},"\u002F\u002F ── 3. localStorage persistence: non-serializable values break ──\n",[34,4476,4477],{"class":36,"line":129},[34,4478,4479],{"class":71},"\u002F\u002F Date objects → strings, Map\u002FSet → {}, class instances lose methods.\n",[34,4481,4482],{"class":36,"line":143},[34,4483,4484],{"class":71},"\u002F\u002F Use a replacer\u002Freviver in JSON.stringify\u002Fparse, or store primitives only.\n",[34,4486,4487],{"class":36,"line":149},[34,4488,65],{"emptyLinePlaceholder":64},[34,4490,4491],{"class":36,"line":154},[34,4492,4493],{"class":71},"\u002F\u002F ── 4. Nuxt useAsyncData: errors during SSR crash the page ──\n",[34,4495,4496],{"class":36,"line":164},[34,4497,4498],{"class":71},"\u002F\u002F If the fetcher throws on server, the entire page render fails (500).\n",[34,4500,4501],{"class":36,"line":173},[34,4502,4503],{"class":71},"\u002F\u002F Wrap in try\u002Fcatch, or use the `default` option for fallback data.\n",[34,4505,4506],{"class":36,"line":182},[34,4507,65],{"emptyLinePlaceholder":64},[34,4509,4510],{"class":36,"line":192},[34,4511,4512],{"class":71},"\u002F\u002F ── 5. Kanban drag-and-drop: HTML5 drag events are finicky ──\n",[34,4514,4515],{"class":36,"line":202},[34,4516,4517],{"class":71},"\u002F\u002F dragstart, dragover (preventDefault!), drop — order and preventDefault matter.\n",[34,4519,4520],{"class":36,"line":213},[34,4521,4522],{"class":71},"\u002F\u002F Use a library (vuedraggable, @vueuse\u002Fintegrations\u002FuseSortable) for reliability.\n",[34,4524,4525],{"class":36,"line":223},[34,4526,65],{"emptyLinePlaceholder":64},[34,4528,4529],{"class":36,"line":228},[34,4530,4531],{"class":71},"\u002F\u002F ── 6. Component library generics: scoped slot props are NOT typed with T ──\n",[34,4533,4534],{"class":36,"line":233},[34,4535,4536],{"class":71},"\u002F\u002F \u003Cslot :row=\"row\" \u002F> — row is T, but the parent's slot prop is `any` unless\n",[34,4538,4539],{"class":36,"line":248},[34,4540,4541],{"class":71},"\u002F\u002F the parent explicitly types it. Vue 3.4+ improves this with defineSlots.\n",[15,4543,4545],{"id":4544},"spot-the-bug","🧠 Spot the Bug",[4547,4548,4549],"p",{},"A WebSocket chat reconnects even after the user intentionally navigates away.",[20,4551,4553],{"filename":4552,"language":23},"WSBug.ts",[25,4554,4556],{"className":27,"code":4555,"language":23,"meta":29,"style":29},"let ws: WebSocket | null = null\n\nfunction connect() {\n  ws = new WebSocket(url)\n  ws.onclose = () => {\n    \u002F\u002F ⚠️ Reconnects even after intentional close() on unmount\n    setTimeout(connect, 1000)\n  }\n}\n\nonScopeDispose(() => {\n  ws?.close()  \u002F\u002F this triggers onclose → reconnection!\n})\n",[31,4557,4558,4577,4581,4589,4602,4617,4622,4634,4638,4642,4646,4657,4669],{"__ignoreMap":29},[34,4559,4560,4563,4565,4567,4569,4571,4573,4575],{"class":36,"line":37},[34,4561,4562],{"class":40},"let",[34,4564,1505],{"class":44},[34,4566,112],{"class":40},[34,4568,1510],{"class":98},[34,4570,1482],{"class":40},[34,4572,1485],{"class":115},[34,4574,324],{"class":40},[34,4576,1519],{"class":115},[34,4578,4579],{"class":36,"line":61},[34,4580,65],{"emptyLinePlaceholder":64},[34,4582,4583,4585,4587],{"class":36,"line":68},[34,4584,3155],{"class":40},[34,4586,1571],{"class":98},[34,4588,245],{"class":44},[34,4590,4591,4594,4596,4598,4600],{"class":36,"line":75},[34,4592,4593],{"class":44},"  ws ",[34,4595,271],{"class":40},[34,4597,349],{"class":40},[34,4599,1510],{"class":98},[34,4601,1623],{"class":44},[34,4603,4604,4607,4609,4611,4613,4615],{"class":36,"line":81},[34,4605,4606],{"class":44},"  ws.",[34,4608,1797],{"class":98},[34,4610,324],{"class":40},[34,4612,1640],{"class":44},[34,4614,333],{"class":40},[34,4616,102],{"class":44},[34,4618,4619],{"class":36,"line":87},[34,4620,4621],{"class":71},"    \u002F\u002F ⚠️ Reconnects even after intentional close() on unmount\n",[34,4623,4624,4627,4630,4632],{"class":36,"line":92},[34,4625,4626],{"class":98},"    setTimeout",[34,4628,4629],{"class":44},"(connect, ",[34,4631,1854],{"class":115},[34,4633,1124],{"class":44},[34,4635,4636],{"class":36,"line":105},[34,4637,829],{"class":44},[34,4639,4640],{"class":36,"line":119},[34,4641,146],{"class":44},[34,4643,4644],{"class":36,"line":129},[34,4645,65],{"emptyLinePlaceholder":64},[34,4647,4648,4651,4653,4655],{"class":36,"line":143},[34,4649,4650],{"class":98},"onScopeDispose",[34,4652,330],{"class":44},[34,4654,333],{"class":40},[34,4656,102],{"class":44},[34,4658,4659,4662,4664,4666],{"class":36,"line":149},[34,4660,4661],{"class":44},"  ws?.",[34,4663,1586],{"class":98},[34,4665,2078],{"class":44},[34,4667,4668],{"class":71},"\u002F\u002F this triggers onclose → reconnection!\n",[34,4670,4671],{"class":36,"line":154},[34,4672,2266],{"class":44},[4674,4675,4676,4680,4689,4698,4846],"details",{},[4677,4678,4679],"summary",{},"Answer",[4547,4681,4682,4685,4686,4688],{},[31,4683,4684],{},"ws.close()"," triggers the ",[31,4687,1797],{}," handler, which schedules a reconnection. The component is unmounted, but the WebSocket reconnects in the background — wasting resources and potentially causing errors when it tries to update unmounted reactive state.",[4547,4690,4691,4695,4696,112],{},[4692,4693,4694],"strong",{},"Fix"," — set a flag before closing and check it in ",[31,4697,1797],{},[20,4699,4701],{"filename":4700,"language":23},"WSFixed.ts",[25,4702,4704],{"className":27,"code":4703,"language":23,"meta":29,"style":29},"let ws: WebSocket | null = null\nlet intentionalClose = false\n\nfunction connect() {\n  ws = new WebSocket(url)\n  ws.onclose = () => {\n    if (intentionalClose) return  \u002F\u002F don't reconnect on intentional close\n    setTimeout(connect, 1000)\n  }\n}\n\nonScopeDispose(() => {\n  intentionalClose = true  \u002F\u002F set flag before close\n  ws?.close()               \u002F\u002F onclose sees the flag → no reconnect\n})\n",[31,4705,4706,4724,4735,4739,4747,4759,4773,4785,4795,4799,4803,4807,4817,4830,4842],{"__ignoreMap":29},[34,4707,4708,4710,4712,4714,4716,4718,4720,4722],{"class":36,"line":37},[34,4709,4562],{"class":40},[34,4711,1505],{"class":44},[34,4713,112],{"class":40},[34,4715,1510],{"class":98},[34,4717,1482],{"class":40},[34,4719,1485],{"class":115},[34,4721,324],{"class":40},[34,4723,1519],{"class":115},[34,4725,4726,4728,4731,4733],{"class":36,"line":61},[34,4727,4562],{"class":40},[34,4729,4730],{"class":44}," intentionalClose ",[34,4732,271],{"class":40},[34,4734,1814],{"class":115},[34,4736,4737],{"class":36,"line":68},[34,4738,65],{"emptyLinePlaceholder":64},[34,4740,4741,4743,4745],{"class":36,"line":75},[34,4742,3155],{"class":40},[34,4744,1571],{"class":98},[34,4746,245],{"class":44},[34,4748,4749,4751,4753,4755,4757],{"class":36,"line":81},[34,4750,4593],{"class":44},[34,4752,271],{"class":40},[34,4754,349],{"class":40},[34,4756,1510],{"class":98},[34,4758,1623],{"class":44},[34,4760,4761,4763,4765,4767,4769,4771],{"class":36,"line":87},[34,4762,4606],{"class":44},[34,4764,1797],{"class":98},[34,4766,324],{"class":40},[34,4768,1640],{"class":44},[34,4770,333],{"class":40},[34,4772,102],{"class":44},[34,4774,4775,4777,4780,4782],{"class":36,"line":92},[34,4776,566],{"class":40},[34,4778,4779],{"class":44}," (intentionalClose) ",[34,4781,2452],{"class":40},[34,4783,4784],{"class":71},"  \u002F\u002F don't reconnect on intentional close\n",[34,4786,4787,4789,4791,4793],{"class":36,"line":105},[34,4788,4626],{"class":98},[34,4790,4629],{"class":44},[34,4792,1854],{"class":115},[34,4794,1124],{"class":44},[34,4796,4797],{"class":36,"line":119},[34,4798,829],{"class":44},[34,4800,4801],{"class":36,"line":129},[34,4802,146],{"class":44},[34,4804,4805],{"class":36,"line":143},[34,4806,65],{"emptyLinePlaceholder":64},[34,4808,4809,4811,4813,4815],{"class":36,"line":149},[34,4810,4650],{"class":98},[34,4812,330],{"class":44},[34,4814,333],{"class":40},[34,4816,102],{"class":44},[34,4818,4819,4822,4824,4827],{"class":36,"line":154},[34,4820,4821],{"class":44},"  intentionalClose ",[34,4823,271],{"class":40},[34,4825,4826],{"class":115}," true",[34,4828,4829],{"class":71},"  \u002F\u002F set flag before close\n",[34,4831,4832,4834,4836,4839],{"class":36,"line":164},[34,4833,4661],{"class":44},[34,4835,1586],{"class":98},[34,4837,4838],{"class":44},"()               ",[34,4840,4841],{"class":71},"\u002F\u002F onclose sees the flag → no reconnect\n",[34,4843,4844],{"class":36,"line":173},[34,4845,2266],{"class":44},[4547,4847,4848,2170,4851,4854,4855,4857,4858,4861],{},[4692,4849,4850],{},"The lesson",[31,4852,4853],{},"WebSocket.close()"," triggers ",[31,4856,1797],{},", which runs reconnection logic. Set an ",[31,4859,4860],{},"intentionalClose"," flag before closing to prevent the reconnect handler from firing after the component unmounts.",[4863,4864,4865],"style",{},"html pre.shiki code .svdQ7, html code.shiki .svdQ7{--shiki-default:#D73A49;--shiki-github-dark:#F97583}html pre.shiki code .ssxIu, html code.shiki .ssxIu{--shiki-default:#24292E;--shiki-github-dark:#E1E4E8}html pre.shiki code .sJ6F3, html code.shiki .sJ6F3{--shiki-default:#032F62;--shiki-github-dark:#9ECBFF}html pre.shiki code .sdCPZ, html code.shiki .sdCPZ{--shiki-default:#6A737D;--shiki-github-dark:#6A737D}html pre.shiki code .sIsaT, html code.shiki .sIsaT{--shiki-default:#6F42C1;--shiki-github-dark:#B392F0}html pre.shiki code .sCrzJ, html code.shiki .sCrzJ{--shiki-default:#E36209;--shiki-github-dark:#FFAB70}html pre.shiki code .snvgF, html code.shiki .snvgF{--shiki-default:#005CC5;--shiki-github-dark:#79B8FF}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 .github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html.github-dark .shiki span {color: var(--shiki-github-dark);background: var(--shiki-github-dark-bg);font-style: var(--shiki-github-dark-font-style);font-weight: var(--shiki-github-dark-font-weight);text-decoration: var(--shiki-github-dark-text-decoration);}html pre.shiki code .sk71V, html code.shiki .sk71V{--shiki-default:#22863A;--shiki-github-dark:#85E89D}html pre.shiki code .sMKGj, html code.shiki .sMKGj{--shiki-default:#B31D28;--shiki-default-font-style:italic;--shiki-github-dark:#FDAEB7;--shiki-github-dark-font-style:italic}",{"title":29,"searchDepth":61,"depth":61,"links":4867},[4868,4869,4870,4871,4872,4873,4874],{"id":17,"depth":61,"text":18},{"id":1302,"depth":61,"text":1303},{"id":2099,"depth":61,"text":2100},{"id":2831,"depth":61,"text":2832},{"id":4319,"depth":61,"text":4320},{"id":4422,"depth":61,"text":4423},{"id":4544,"depth":61,"text":4545},"Capstone projects building production-grade Vue 3 apps — reactive Kanban board, real-time chat with WebSocket, SSR blog with Nuxt, and component library with TypeScript generics.","md",{},"\u002Fvue\u002F24-exercises-and-projects",{"title":5,"description":4875},"vue\u002F24-exercises-and-projects","7wzQSz_-Tbga0EsGiXBciHLYxhCutx8eLtUsitE1q-o",1789924655887]