1 <!DOCTYPE html>2 <html lang="es" class="h-full">3 <head>4 <meta charset="utf-8" />5 <meta name="viewport" content="width=device-width, initial-scale=1" />6 <title>InceptionDB • UI (SPA)</title>7 <!-- TailwindCSS CDN -->8 <script src="https://cdn.tailwindcss.com"></script>9 <script>10 tailwind.config = {11 darkMode: 'class',12 theme: {13 extend: {14 colors: {15 primary: {16 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1',17 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81'18 }19 }20 }21 }22 }23 </script>24 <!-- Vue 3 CDN -->25 <script src="https://unpkg.com/vue@3.5.12/dist/vue.global.prod.js"></script>26 <style>27 html, body, #app { height: 100%; }28 .scrollbar-thin::-webkit-scrollbar { height: 8px; width: 8px; }29 .scrollbar-thin::-webkit-scrollbar-thumb { background: #c7d2fe; border-radius: 9999px; }30 .scrollbar-thin::-webkit-scrollbar-track { background: transparent; }31 </style>32 </head>33 <body class="h-full bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">34 <div id="app" class="h-full">35 <!-- Shell -->36 <div class="flex h-full">37 <!-- Sidebar -->38 <aside class="hidden md:flex md:w-72 flex-col gap-2 p-4 border-r border-gray-200 dark:border-gray-800 bg-white/70 backdrop-blur dark:bg-gray-900/50">39 <div class="flex items-center gap-2">40 <div class="h-9 w-9 rounded-2xl bg-primary-600 grid place-content-center text-white font-bold">IDB</div>41 <div>42 <div class="font-semibold">InceptionDB</div>43 <div class="text-xs text-gray-500">UI • SPA</div>44 </div>45 </div>46 47 <nav class="mt-4 text-sm">48 <p class="px-2 text-xs uppercase tracking-wider text-gray-500">Navegación</p>49 <ul class="mt-2 grid gap-1">50 <li><a :class="navClass('/')" href="#/">🏠 <span class="ml-2">Inicio</span></a></li>51 <li><a :class="navClass('/dbs')" href="#/dbs">🗄️ <span class="ml-2">Bases de datos</span></a></li>52 <li><a :class="navClass('/queries')" href="#/queries">🧪 <span class="ml-2">Consultas</span></a></li>53 <li><a :class="navClass('/release')" href="#/release">🏷️ <span class="ml-2">Release</span></a></li>54 <li><a :class="navClass('/docs')" href="#/docs">📖 <span class="ml-2">Docs</span></a></li>55 <li><button @click="toggleTheme" class="w-full text-left rounded-xl px-3 py-2 hover:bg-primary-50 dark:hover:bg-primary-900/40">🌓 <span class="ml-2">Tema</span></button></li>56 </ul>57 </nav>58 59 <div class="mt-auto text-xs text-gray-500">60 <div>Servidor por defecto:</div>61 <div class="truncate">{{ servers[0] }}</div>62 </div>63 </aside>64 65 <!-- Main -->66 <section class="flex-1 flex flex-col min-w-0">67 <!-- Topbar -->68 <header class="sticky top-0 z-10 bg-white/70 backdrop-blur border-b border-gray-200 dark:bg-gray-900/50 dark:border-gray-800">69 <div class="px-4 py-3 flex items-center gap-3">70 <button class="md:hidden rounded-lg px-3 py-2 border border-gray-200 dark:border-gray-700" @click="mobileOpen = !mobileOpen">☰</button>71 <h1 class="text-lg font-semibold">{{ pageTitle }}</h1>72 <div class="ml-auto flex items-center gap-2 text-xs">73 <span class="hidden sm:block text-gray-500">Entorno</span>74 <select v-model="activeServer" class="rounded-lg border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-800/70">75 <option v-for="s in servers" :key="s" :value="s">{{ s }}</option>76 </select>77 </div>78 </div>79 </header>80 81 <!-- Mobile drawer -->82 <transition name="fade">83 <div v-if="mobileOpen" class="md:hidden p-4 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">84 <nav class="grid gap-1 text-sm">85 <a :class="navClass('/')" href="#/" @click="mobileOpen=false">🏠 Inicio</a>86 <a :class="navClass('/dbs')" href="#/dbs" @click="mobileOpen=false">🗄️ Bases de datos</a>87 <a :class="navClass('/queries')" href="#/queries" @click="mobileOpen=false">🧪 Consultas</a>88 <a :class="navClass('/release')" href="#/release" @click="mobileOpen=false">🏷️ Release</a>89 <a :class="navClass('/docs')" href="#/docs" @click="mobileOpen=false">📖 Docs</a>90 </nav>91 </div>92 </transition>93 94 <!-- Content -->95 <main class="flex-1 overflow-auto p-4 md:p-6">96 <component :is="currentView" :state="state" @goto="goto" />97 </main>98 99 <!-- Footer -->100 <footer class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 text-xs text-gray-500">101 API: OpenAPI 3.1 • Datos fake, wiring real más adelante.102 </footer>103 </section>104 </div>105 </div>106 107 <script>108 const Dashboard = {109 props: ['state'],110 template: `111 <div class="grid gap-6">112 <div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">113 <StatCard title="Bases de datos" :value="state.dbs.length" subtitle="/v1/databases" />114 <StatCard title="Colecciones (demo)" :value="totalCollections" subtitle="Colecciones simuladas" />115 <StatCard title="API Keys (demo)" :value="totalApiKeys" subtitle="Por base de datos" />116 <StatCard title="Owners (demo)" :value="totalOwners" subtitle="Acumulado" />117 </div>118 <div class="grid lg:grid-cols-2 gap-6">119 <Panel title="Bases de datos recientes">120 <table class="w-full text-sm">121 <thead class="text-left text-gray-500">122 <tr><th class="py-2">Nombre</th><th>Id</th><th class="text-right">Owners</th></tr>123 </thead>124 <tbody>125 <tr v-for="db in state.dbs.slice(0,6)" :key="db.id" class="border-t border-gray-200 dark:border-gray-800">126 <td class="py-2"><a class="text-primary-600 hover:underline" :href="'#/db/'+db.id">{{ db.name }}</a></td>127 <td class="truncate max-w-[12rem] text-xs">{{ db.id }}</td>128 <td class="text-right">{{ db.owners_length }}</td>129 </tr>130 </tbody>131 </table>132 </Panel>133 <Panel title="Servidores">134 <ul class="text-sm list-disc pl-5">135 <li v-for="s in state.servers" :key="s">{{ s }}</li>136 </ul>137 </Panel>138 </div>139 </div>140 `,141 computed: {142 totalCollections(){143 return this.state.collectionsDemo.reduce((a, c) => a + c.items.length, 0)144 },145 totalApiKeys(){146 return this.state.dbDetails.reduce((a, d) => a + d.api_keys.length, 0)147 },148 totalOwners(){149 return this.state.dbDetails.reduce((a, d) => a + d.owners.length, 0)150 },151 }152 }153 154 const Databases = {155 props: ['state'],156 emits: ['goto'],157 data(){ return { q: '' } },158 computed: {159 filtered(){160 const q = this.q.toLowerCase();161 return this.state.dbs.filter(d => d.name.toLowerCase().includes(q) || d.id.includes(this.q));162 }163 },164 template: `165 <div class="grid gap-4">166 <div class="flex flex-wrap gap-2 items-center">167 <input v-model="q" placeholder="Buscar..." class="px-3 py-2 rounded-xl border border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-800/70"/>168 <button class="px-3 py-2 rounded-xl bg-primary-600 text-white" @click="$emit('goto','/db/new')">+ Crear DB</button>169 </div>170 <div class="grid gap-3">171 <div v-for="db in filtered" :key="db.id" class="rounded-2xl border border-gray-200 dark:border-gray-800 p-4 bg-white/70 dark:bg-gray-900/50">172 <div class="flex gap-3 items-start">173 <div class="h-10 w-10 rounded-xl bg-primary-600/10 grid place-content-center">🗄️</div>174 <div class="min-w-0 flex-1">175 <div class="flex items-center gap-2">176 <a class="font-semibold hover:underline" :href="'#/db/'+db.id">{{ db.name }}</a>177 <span class="text-xs text-gray-500">{{ db.id }}</span>178 </div>179 <div class="text-sm text-gray-500">Owners: {{ db.owners_length }}</div>180 </div>181 <div class="flex gap-2">182 <a :href="'#/db/'+db.id" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-700">Abrir</a>183 <button disabled class="px-3 py-1.5 text-sm rounded-lg bg-red-600/10 text-red-700 dark:text-red-300">Eliminar</button>184 </div>185 </div>186 </div>187 </div>188 </div>189 `190 }191 192 const DatabaseDetail = {193 props: ['state'],194 computed:{195 db(){ return this.state.dbDetails.find(d=>d.id===this.state.route.params.id) },196 collections(){ return this.state.collectionsDemo.find(c=>c.dbId===this.state.route.params.id)?.items || [] },197 },198 template: `199 <div v-if="db" class="grid gap-6">200 <div class="flex flex-wrap items-center gap-3">201 <h2 class="text-xl font-semibold">{{ db.name }}</h2>202 <span class="text-xs text-gray-500">{{ db.id }}</span>203 <span class="ml-auto text-xs text-gray-500">Creada: {{ db.creation_date }}</span>204 </div>205 206 <div class="grid md:grid-cols-3 gap-4">207 <Panel title="Resumen">208 <ul class="text-sm grid gap-1">209 <li>Owners: <b>{{ db.owners.length }}</b></li>210 <li>API Keys: <b>{{ db.api_keys.length }}</b></li>211 <li>Colecciones (demo): <b>{{ collections.length }}</b></li>212 </ul>213 </Panel>214 <Panel title="Owners">215 <ul class="text-sm list-disc pl-5">216 <li v-for="o in db.owners" :key="o">{{ o }}</li>217 </ul>218 </Panel>219 <Panel title="API Keys">220 <div class="grid gap-2 text-sm">221 <div v-for="k in db.api_keys" :key="k.key" class="rounded-lg border border-gray-200 dark:border-gray-800 p-2">222 <div class="font-mono text-xs truncate">{{ k.key }}</div>223 <div class="text-xs text-gray-500 flex justify-between"><span>{{ k.name }}</span><span>{{ k.creation_date }}</span></div>224 </div>225 <button disabled class="mt-1 px-3 py-2 rounded-xl bg-primary-600 text-white">+ Crear API Key</button>226 </div>227 </Panel>228 </div>229 230 <Panel title="Colecciones">231 <div class="grid gap-2">232 <table class="w-full text-sm">233 <thead class="text-left text-gray-500">234 <tr><th class="py-2">Nombre</th><th>Total</th><th>Índices</th><th>Acciones</th></tr>235 </thead>236 <tbody>237 <tr v-for="c in collections" :key="c.name" class="border-t border-gray-200 dark:border-gray-800">238 <td class="py-2">{{ c.name }}</td>239 <td>{{ c.total }}</td>240 <td>{{ c.indexes }}</td>241 <td>242 <a :href="'#/db/'+db.id+'/col/'+c.name" class="px-2 py-1 rounded-lg border text-xs">Abrir</a>243 </td>244 </tr>245 </tbody>246 </table>247 <div class="pt-2">248 <button disabled class="px-3 py-2 rounded-xl bg-primary-600 text-white">+ Crear colección</button>249 </div>250 </div>251 </Panel>252 </div>253 <div v-else class="text-sm text-gray-500">DB no encontrada</div>254 `255 }256 257 const CollectionDetail = {258 props: ['state'],259 computed: {260 db(){ return this.state.dbDetails.find(d=>d.id===this.state.route.params.id) },261 col(){262 const list = this.state.collectionsDemo.find(c=>c.dbId===this.state.route.params.id)?.items || []263 return list.find(x=>x.name===this.state.route.params.col)264 },265 indexes(){266 const key = `${this.state.route.params.id}/${this.state.route.params.col}`267 return this.state.indexesDemo[key] || []268 }269 },270 template: `271 <div v-if="db && col" class="grid gap-6">272 <div class="flex flex-wrap items-center gap-3">273 <h2 class="text-xl font-semibold">{{ db.name }} / {{ col.name }}</h2>274 <span class="ml-auto text-xs text-gray-500">Docs: {{ col.total }}</span>275 </div>276 277 <div class="grid md:grid-cols-3 gap-4">278 <Panel title="Defaults"><pre class="text-xs">{{ JSON.stringify(col.defaults || {}, null, 2) }}</pre></Panel>279 <Panel title="Índices">280 <ul class="text-sm grid gap-1">281 <li v-for="i in indexes" :key="i.name" class="flex justify-between">282 <span class="font-mono">{{ i.name }}</span>283 <span class="text-xs text-gray-500">{{ i.type }}</span>284 </li>285 </ul>286 </Panel>287 <Panel title="Acciones">288 <div class="grid gap-2 text-sm">289 <button disabled class="px-3 py-2 rounded-xl border">Crear índice</button>290 <button disabled class="px-3 py-2 rounded-xl border">Drop índice</button>291 <button disabled class="px-3 py-2 rounded-xl border">Insertar documento</button>292 </div>293 </Panel>294 </div>295 296 <Panel title="Explorar (demo)">297 <div class="text-sm text-gray-500">Aquí irá /find con filtros. Por ahora mostramos datos fake.</div>298 <div class="mt-3 grid gap-2">299 <div v-for="n in 5" :key="n" class="rounded-xl border border-gray-200 dark:border-gray-800 p-3 bg-white/70 dark:bg-gray-900/50">300 <div class="text-xs text-gray-500">doc_{{n}}</div>301 <pre class="text-xs overflow-auto">{{ JSON.stringify({ _id: 'doc_'+n, value: Math.floor(Math.random()*1000), updated_at: new Date().toISOString() }, null, 2) }}</pre>302 </div>303 </div>304 </Panel>305 </div>306 <div v-else class="text-sm text-gray-500">Colección no encontrada</div>307 `308 }309 310 const Queries = {311 props: ['state'],312 data(){ return { mode: 'find', payload: '{"limit": 10}', response: null } },313 template: `314 <div class="grid gap-4">315 <div class="grid md:grid-cols-4 gap-3">316 <Panel title="Contexto">317 <div class="grid gap-2 text-sm">318 <label class="text-xs text-gray-500">Base de datos</label>319 <select class="rounded-xl border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-800/70">320 <option v-for="d in state.dbs" :key="d.id">{{ d.name }}</option>321 </select>322 <label class="text-xs text-gray-500">Colección</label>323 <input placeholder="orders" class="rounded-xl border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-800/70"/>324 </div>325 </Panel>326 <Panel class="md:col-span-3" title="Constructor (demo)">327 <div class="flex gap-2 text-sm">328 <select v-model="mode" class="rounded-xl border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-800/70">329 <option value="find">/find</option>330 <option value="insert">/insert</option>331 <option value="remove">/remove</option>332 <option value="patch">/patch</option>333 <option value="size">/size</option>334 </select>335 <button disabled class="px-3 py-2 rounded-xl bg-primary-600 text-white">Ejecutar</button>336 </div>337 <div class="mt-3 grid gap-2">338 <label class="text-xs text-gray-500">Payload JSON</label>339 <textarea v-model="payload" rows="8" class="font-mono text-xs rounded-xl border-gray-300 dark:border-gray-700 bg-white/70 dark:bg-gray-800/70"></textarea>340 <label class="text-xs text-gray-500">Respuesta</label>341 <pre class="text-xs rounded-xl border border-gray-200 dark:border-gray-800 p-3 bg-white/70 dark:bg-gray-900/50">{{ response || '{ /* respuesta fake */ }' }}</pre>342 </div>343 </Panel>344 </div>345 </div>346 `347 }348 349 const Release = { props:['state'], template:`350 <Panel title="Release">351 <div class="text-sm">Endpoint <code class='font-mono'>/release</code> — aquí mostraremos la versión real más adelante.</div>352 <div class="mt-4 rounded-xl border border-gray-200 dark:border-gray-800 p-3 bg-white/70 dark:bg-gray-900/50">353 <div class="text-xs text-gray-500">Versión (fake)</div>354 <div class="font-mono">"1.2.3-demo"</div>355 </div>356 </Panel>357 `}358 359 const Docs = { props:['state'], template:`360 <div class="grid gap-4">361 <Panel title="Documentación">362 <ul class="list-disc pl-5 text-sm">363 <li>OpenAPI 3.1</li>364 <li><code class='font-mono'>/doc</code> (documentación integrada)</li>365 <li>Servidores:366 <ul class="list-disc pl-5">367 <li v-for="s in state.servers" :key="s">{{ s }}</li>368 </ul>369 </li>370 </ul>371 </Panel>372 <Panel title="Siguientes pasos">373 <ol class="list-decimal pl-5 text-sm grid gap-1">374 <li>Conectar acciones a <code class='font-mono'>fetch</code> con la cabecera <code class='font-mono'>X-Api-Key</code> (cuando aplique).</li>375 <li>Gestionar estados de carga/errores.</li>376 <li>Paginar listados grandes.</li>377 <li>Añadir formularios para crear DB/colecciones, índices, etc.</li>378 </ol>379 </Panel>380 </div>381 `}382 383 // UI atoms384 const Panel = {385 props: ['title'],386 template: `387 <div class="rounded-2xl border border-gray-200 dark:border-gray-800 p-4 bg-white/70 dark:bg-gray-900/50">388 <div class="font-semibold mb-2">{{ title }}</div>389 <div><slot /></div>390 </div>391 `392 }393 394 const StatCard = {395 props: ['title','value','subtitle'],396 template: `397 <div class="rounded-2xl border border-gray-200 dark:border-gray-800 p-4 bg-white/70 dark:bg-gray-900/50">398 <div class="text-sm text-gray-500">{{ title }}</div>399 <div class="text-2xl font-semibold">{{ value }}</div>400 <div class="text-xs text-gray-500 mt-1">{{ subtitle }}</div>401 </div>402 `403 }404 405 // Minimal hash router406 function parseHash(){407 const h = location.hash.slice(1) || '/'408 const parts = h.split('/').filter(Boolean)409 const match = (arr)=>arr.every((a,i)=>a===parts[i])410 if (h==='/' || parts.length===0) return { view:'Dashboard', params:{} }411 if (match(['dbs'])) return { view:'Databases', params:{} }412 if (match(['queries'])) return { view:'Queries', params:{} }413 if (match(['release'])) return { view:'Release', params:{} }414 if (match(['docs'])) return { view:'Docs', params:{} }415 if (parts[0]==='db' && parts[1]){416 if (parts[2]==='col' && parts[3]) return { view:'CollectionDetail', params:{ id: parts[1], col: parts[3] } }417 return { view:'DatabaseDetail', params:{ id: parts[1] } }418 }419 return { view:'Dashboard', params:{} }420 }421 422 const App = {423 components: { Panel, StatCard, Dashboard, Databases, DatabaseDetail, CollectionDetail, Queries, Release, Docs },424 data(){425 return {426 servers: [427 'https://inceptiondb.hola.cloud',428 'http://inceptiondb.hola.cloud'429 ],430 activeServer: 'https://inceptiondb.hola.cloud',431 mobileOpen: false,432 state: {433 servers: [434 'https://inceptiondb.hola.cloud',435 'http://inceptiondb.hola.cloud'436 ],437 route: parseHash(),438 // Fake data según el esquema OpenAPI439 dbs: [440 { id: 'db_9f7a12', name: 'production', owners_length: 3 },441 { id: 'db_a13c55', name: 'staging', owners_length: 2 },442 { id: 'db_55aa01', name: 'analytics', owners_length: 1 },443 { id: 'db_f00baa', name: 'playground', owners_length: 1 },444 { id: 'db_999999', name: 'demo', owners_length: 2 },445 { id: 'db_beef00', name: 'events', owners_length: 2 },446 ],447 dbDetails: [448 {449 id: 'db_9f7a12', name: 'production', creation_date: '2025-09-01T10:00:00Z',450 api_keys: [451 { key: 'prod_pk_01', name: 'backend', creation_date: '2025-08-01T10:00:00Z' },452 { key: 'prod_pk_02', name: 'etl', creation_date: '2025-08-15T10:00:00Z' },453 ],454 owners: ['alice@hola.cloud','bob@hola.cloud','carol@hola.cloud']455 },456 {457 id: 'db_a13c55', name: 'staging', creation_date: '2025-08-21T09:00:00Z',458 api_keys: [ { key: 'stg_pk_01', name: 'web', creation_date: '2025-08-21T09:00:00Z' } ],459 owners: ['alice@hola.cloud','qa@hola.cloud']460 },461 { id: 'db_55aa01', name: 'analytics', creation_date: '2025-08-05T12:00:00Z', api_keys: [], owners: ['data@hola.cloud'] },462 { id: 'db_f00baa', name: 'playground', creation_date: '2025-07-10T08:00:00Z', api_keys: [], owners: ['dev@hola.cloud'] },463 { id: 'db_999999', name: 'demo', creation_date: '2025-06-01T08:00:00Z', api_keys: [], owners: ['gerardo@hola.cloud','demo@hola.cloud'] },464 { id: 'db_beef00', name: 'events', creation_date: '2025-08-30T08:00:00Z', api_keys: [], owners: ['stream@hola.cloud','ops@hola.cloud'] },465 ],466 collectionsDemo: [467 { dbId: 'db_9f7a12', items: [468 { name: 'users', total: 120034, indexes: 3, defaults: { plan: 'free' } },469 { name: 'orders', total: 41234, indexes: 4, defaults: { status: 'new' } },470 { name: 'invoices', total: 876, indexes: 2, defaults: {} },471 ]},472 { dbId: 'db_a13c55', items: [473 { name: 'users', total: 4521, indexes: 3, defaults: {} },474 { name: 'orders', total: 1280, indexes: 2, defaults: {} },475 ]},476 { dbId: 'db_55aa01', items: [477 { name: 'events', total: 2500000, indexes: 2, defaults: {} },478 ]},479 { dbId: 'db_f00baa', items: [ { name: 'play', total: 42, indexes: 1, defaults: {} } ] },480 { dbId: 'db_999999', items: [ { name: 'samples', total: 12, indexes: 1, defaults: {} } ] },481 { dbId: 'db_beef00', items: [ { name: 'kafka', total: 999999, indexes: 2, defaults: {} } ] },482 ],483 indexesDemo: {484 'db_9f7a12/users': [ { name: 'pk_id', type: 'hash' }, { name: 'email_idx', type: 'btree' } ],485 'db_9f7a12/orders': [ { name: 'pk_id', type: 'hash' }, { name: 'created_at', type: 'btree' }, { name: 'user_id', type: 'btree' } ],486 'db_a13c55/users': [ { name: 'pk_id', type: 'hash' } ],487 'db_55aa01/events': [ { name: 'pk_id', type: 'hash' }, { name: 'ts', type: 'btree' } ],488 },489 }490 }491 },492 computed: {493 pageTitle(){494 const r = this.state.route495 if (r.view==='Dashboard') return 'Inicio'496 if (r.view==='Databases') return 'Bases de datos'497 if (r.view==='Queries') return 'Consultas'498 if (r.view==='Release') return 'Release'499 if (r.view==='Docs') return 'Documentación'500 if (r.view==='DatabaseDetail') return `Base de datos: ${r.params.id}`501 if (r.view==='CollectionDetail') return `Colección: ${r.params.col}`502 return 'InceptionDB'503 }504 },505 mounted(){506 window.addEventListener('hashchange', ()=>{ this.state.route = parseHash() })507 // Dark mode, respeta preferencia previa508 if (localStorage.getItem('theme')==='dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {509 document.documentElement.classList.add('dark')510 }511 },512 methods: {513 goto(path){ location.hash = '#' + path },514 navClass(path){515 const active = location.hash.startsWith('#'+path)516 return [517 'flex items-center rounded-xl px-3 py-2 hover:bg-primary-50 dark:hover:bg-primary-900/40',518 active ? 'bg-primary-50 dark:bg-primary-900/40 text-primary-700 dark:text-primary-200' : 'text-gray-700 dark:text-gray-200'519 ]520 },521 toggleTheme(){522 const isDark = document.documentElement.classList.toggle('dark')523 localStorage.setItem('theme', isDark ? 'dark' : 'light')524 }525 }526 }527 528 Vue.createApp(App).mount('#app')529 </script>530 </body>531 </html>532 Enlace
El enlace para compartir es:

