@luis-angel-martin-dzul/vue3-tmpl 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ # vue3-tmpl
2
+
3
+ Generador de plantillas para proyectos Vue 3 con Router, Pinia, Tailwind CSS y FontAwesome preconfigurados.
4
+
5
+ ---
6
+
7
+ ## Requisitos
8
+
9
+ - Node.js 18+
10
+ - npm 7+
11
+
12
+ ---
13
+
14
+ ## Uso
15
+
16
+ ```bash
17
+ npx @luis-angel-martin-dzul/vue3-tmpl <nombre-del-proyecto>
18
+ ```
19
+
20
+ ### Ejemplo
21
+
22
+ ```bash
23
+ npx @luis-angel-martin-dzul/vue3-tmpl mi-proyecto
24
+ ```
25
+
26
+ ---
27
+
28
+ ## Primeros pasos
29
+
30
+ ```bash
31
+ cd mi-proyecto
32
+ npm install
33
+ npm run dev
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Estructura generada
39
+
40
+ ```
41
+ mi-proyecto/
42
+ ├── index.html
43
+ ├── vite.config.js ← alias @ → src/
44
+ ├── postcss.config.js ← Tailwind + autoprefixer
45
+ ├── package.json
46
+ ├── .gitignore
47
+ └── src/
48
+ ├── main.js ← Vue + Pinia + Router + FontAwesome
49
+ ├── style.css ← Tailwind + clases utilitarias (.btnGreen, .btnBlue, etc.)
50
+ ├── App.vue ← solo <RouterView />
51
+ ├── router/
52
+ │ └── index.js
53
+ ├── stores/
54
+ │ └── auth.js ← store de ejemplo con Pinia
55
+ ├── models/
56
+ │ └── Auth.js ← clase Auth con ping y updatePassword
57
+ ├── utils/
58
+ │ └── static.js ← BASE url + apiServerRequest
59
+ └── views/
60
+ ├── auth/
61
+ │ └── LoginView.vue ← formulario de login
62
+ ├── menu/
63
+ │ └── BaseLayout.vue ← sidebar colapsable + header
64
+ ├── home/
65
+ │ └── HomeView.vue
66
+ ├── dashboard/
67
+ │ └── DashboardView.vue
68
+ ├── modulo/ ← ejemplo de submenu
69
+ │ ├── Opcion1View.vue
70
+ │ └── Opcion2View.vue
71
+ └── errors/
72
+ └── 404NotFound.vue
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Rutas incluidas
78
+
79
+ | Ruta | Componente | Descripción |
80
+ |---|---|---|
81
+ | `/login` | `LoginView` | Pantalla de inicio de sesión |
82
+ | `/` | `HomeView` | Inicio (dentro de BaseLayout) |
83
+ | `/dashboard` | `DashboardView` | Dashboard (dentro de BaseLayout) |
84
+ | `/modulo/opcion-1` | `Opcion1View` | Ejemplo submenu opción 1 |
85
+ | `/modulo/opcion-2` | `Opcion2View` | Ejemplo submenu opción 2 |
86
+ | `/:pathMatch(.*)` | `404NotFound` | Página no encontrada |
87
+
88
+ ---
89
+
90
+ ## Incluido por defecto
91
+
92
+ | Paquete | Uso |
93
+ |---|---|
94
+ | **Vue 3** | Framework |
95
+ | **Vue Router 4** | Navegación entre vistas |
96
+ | **Pinia** | Estado global |
97
+ | **Tailwind CSS v4** | Estilos utilitarios |
98
+ | **FontAwesome 6** | Iconos |
99
+
100
+ ---
101
+
102
+ ## Layout (BaseLayout)
103
+
104
+ El sidebar incluye:
105
+ - **Colapso** en desktop (icono + texto → solo icono)
106
+ - **Overlay** en mobile con botón hamburguesa
107
+ - **Estado activo** calculado por computed según la ruta actual
108
+ - **Submenus** con animación de apertura/cierre y apertura automática al navegar directo a una ruta hija
109
+
110
+ Para agregar un nuevo submenu, replica el bloque `Módulo` en `BaseLayout.vue` y añade las rutas correspondientes en `router/index.js`.
111
+
112
+ ---
113
+
114
+ ## Clases de botones
115
+
116
+ Definidas en `src/style.css` listas para usar en cualquier vista:
117
+
118
+ ```html
119
+ <button class="btnGreen px-4 py-2">Guardar</button>
120
+ <button class="btnBlue px-4 py-2">Buscar</button>
121
+ <button class="btnOrange px-4 py-2">Editar</button>
122
+ <button class="btnRose px-4 py-2">Eliminar</button>
123
+ <button class="btnWhite px-4 py-2">Cancelar</button>
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Patrón de modelos
129
+
130
+ Los modelos en `src/models/` usan `src/utils/static.js` como base para las peticiones HTTP:
131
+
132
+ ```js
133
+ // src/utils/static.js
134
+ export const BASE = 'https://tu-api.com/'
135
+
136
+ export async function apiServerRequest(url, options = {}) {
137
+ try {
138
+ return await fetch(url, options)
139
+ } catch (error) {
140
+ console.error('Request error:', error)
141
+ return null
142
+ }
143
+ }
144
+ ```
145
+
146
+ ```js
147
+ // src/models/MiModelo.js
148
+ import * as Static from '@/utils/static'
149
+
150
+ class MiModelo {
151
+ #endpoint = 'mi-endpoint'
152
+
153
+ constructor() {
154
+ this.#endpoint = Static.BASE + this.#endpoint
155
+ }
156
+
157
+ async getAll() {
158
+ const request = await Static.apiServerRequest(this.#endpoint)
159
+ if (request?.status === 200) return await request.json()
160
+ return null
161
+ }
162
+
163
+ async create(data) {
164
+ const request = await Static.apiServerRequest(this.#endpoint, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify(data),
168
+ })
169
+ if (request?.status === 200) return await request.json()
170
+ return null
171
+ }
172
+ }
173
+
174
+ export default new MiModelo()
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Patrón de stores (Pinia)
180
+
181
+ ```js
182
+ // src/stores/miStore.js
183
+ import { defineStore } from 'pinia'
184
+ import { ref } from 'vue'
185
+ import MiModelo from '@/models/MiModelo'
186
+
187
+ export const useMiStore = defineStore('mi-store', () => {
188
+ const datos = ref([])
189
+ const loading = ref(false)
190
+
191
+ async function cargar() {
192
+ loading.value = true
193
+ datos.value = await MiModelo.getAll()
194
+ loading.value = false
195
+ }
196
+
197
+ return { datos, loading, cargar }
198
+ })
199
+ ```
200
+
201
+ ---
202
+
203
+ ## Scripts disponibles
204
+
205
+ | Comando | Descripción |
206
+ |---|---|
207
+ | `npm run dev` | Servidor de desarrollo |
208
+ | `npm run build` | Build de producción en `dist/` |
209
+ | `npm run preview` | Vista previa del build |
210
+
211
+ ---
212
+
213
+ ## Publicar el CLI en npm
214
+
215
+ ```bash
216
+ npm publish --access public
217
+ ```
218
+
219
+ ---
220
+
221
+ ## Autor
222
+
223
+ Luis A Martin Dzul — luis.martin@gamasis.mx
package/bin/create.js ADDED
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ mkdirSync, cpSync, readFileSync, writeFileSync,
5
+ existsSync, renameSync, readdirSync, statSync,
6
+ } from 'node:fs'
7
+ import { resolve, dirname, join, extname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url))
11
+
12
+ const TEXT_EXTENSIONS = new Set(['.js', '.ts', '.vue', '.json', '.html', '.css', '.md', ''])
13
+
14
+ function toPascalCase(str) {
15
+ return str
16
+ .replace(/[@/]/g, '-')
17
+ .split(/[-_]/)
18
+ .filter(Boolean)
19
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
20
+ .join('')
21
+ }
22
+
23
+ function replaceInFile(filePath, projectName, libName) {
24
+ const ext = extname(filePath)
25
+ if (!TEXT_EXTENSIONS.has(ext)) return
26
+ const content = readFileSync(filePath, 'utf-8')
27
+ const updated = content
28
+ .replaceAll('{{PROJECT_NAME}}', projectName)
29
+ .replaceAll('{{LIB_NAME}}', libName)
30
+ writeFileSync(filePath, updated, 'utf-8')
31
+ }
32
+
33
+ function walkAndReplace(dir, projectName, libName) {
34
+ for (const entry of readdirSync(dir)) {
35
+ const full = join(dir, entry)
36
+ if (statSync(full).isDirectory()) {
37
+ walkAndReplace(full, projectName, libName)
38
+ } else {
39
+ replaceInFile(full, projectName, libName)
40
+ }
41
+ }
42
+ }
43
+
44
+ // ── Main ────────────────────────────────────────────────────────
45
+ const projectName = process.argv[2]
46
+
47
+ if (!projectName) {
48
+ console.log('\nUso: npx @luis-angel-martin-dzul/vue3-tmpl <nombre-del-proyecto>\n')
49
+ console.log('Ejemplo:')
50
+ console.log(' npx @luis-angel-martin-dzul/vue3-tmpl mi-proyecto\n')
51
+ process.exit(1)
52
+ }
53
+
54
+ const targetDir = resolve(process.cwd(), projectName)
55
+
56
+ if (existsSync(targetDir)) {
57
+ console.error(`\nError: el directorio "${projectName}" ya existe.\n`)
58
+ process.exit(1)
59
+ }
60
+
61
+ const templateDir = resolve(__dirname, '../template')
62
+ const libName = toPascalCase(projectName)
63
+
64
+ console.log(`\nCreando proyecto "${projectName}"...`)
65
+
66
+ mkdirSync(targetDir, { recursive: true })
67
+ cpSync(templateDir, targetDir, { recursive: true })
68
+
69
+ // Restaurar .gitignore (npm lo elimina al publicar)
70
+ const gitignoreSrc = join(targetDir, '_gitignore')
71
+ const gitignoreDst = join(targetDir, '.gitignore')
72
+ if (existsSync(gitignoreSrc)) renameSync(gitignoreSrc, gitignoreDst)
73
+
74
+ // Reemplazar placeholders en todos los archivos de texto
75
+ walkAndReplace(targetDir, projectName, libName)
76
+
77
+ console.log(`\n✓ Listo\n`)
78
+ console.log(`Siguientes pasos:\n`)
79
+ console.log(` cd ${projectName}`)
80
+ console.log(` npm install`)
81
+ console.log(` npm run dev\n`)
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@luis-angel-martin-dzul/vue3-tmpl",
3
+ "version": "0.0.4",
4
+ "type": "module",
5
+ "description": "Generador de plantillas para proyectos Vue 3 con Router, Pinia, Tailwind y FontAwesome",
6
+ "bin": {
7
+ "vue3-tmpl": "./bin/create.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "template"
12
+ ],
13
+ "scripts": {
14
+ "build": "npm pack",
15
+ "publish:npm": "npm publish --access public"
16
+ },
17
+ "license": "MIT"
18
+ }
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ dist
3
+ .DS_Store
4
+ *.local
5
+ .env
6
+ .env.*
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{PROJECT_NAME}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.js"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@fortawesome/fontawesome-free": "^6.7.2",
13
+ "pinia": "^2.3.1",
14
+ "vue": "^3.5.24",
15
+ "vue-router": "^4.5.0"
16
+ },
17
+ "devDependencies": {
18
+ "@tailwindcss/postcss": "^4.1.8",
19
+ "@vitejs/plugin-vue": "^6.0.1",
20
+ "autoprefixer": "^10.4.21",
21
+ "postcss": "^8.5.3",
22
+ "tailwindcss": "^4.1.8",
23
+ "vite": "^7.2.4"
24
+ }
25
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ autoprefixer: {},
5
+ },
6
+ }
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <RouterView />
3
+ </template>
@@ -0,0 +1,12 @@
1
+ import { createApp } from 'vue'
2
+ import { createPinia } from 'pinia'
3
+ import '@fortawesome/fontawesome-free/css/all.min.css'
4
+ import './style.css'
5
+ import App from './App.vue'
6
+ import router from './router'
7
+
8
+ const app = createApp(App)
9
+
10
+ app.use(createPinia())
11
+ app.use(router)
12
+ app.mount('#app')
@@ -0,0 +1,32 @@
1
+ import * as Static from '@/utils/static'
2
+
3
+ class Auth {
4
+ #endpoint = 'auth'
5
+
6
+ constructor() {
7
+ this.#endpoint = Static.BASE + this.#endpoint
8
+ }
9
+
10
+ async ping() {
11
+ const request = await Static.apiServerRequest(Static.BASE, {
12
+ method: 'GET',
13
+ mode: 'no-cors',
14
+ })
15
+ return request !== null
16
+ }
17
+
18
+ async updatePassword(data) {
19
+ const request = await Static.apiServerRequest(Static.BASE + 'user/update_password', {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify(data),
23
+ })
24
+ let response = null
25
+ if (request?.status === 200) {
26
+ response = await request.json()
27
+ }
28
+ return response
29
+ }
30
+ }
31
+
32
+ export default new Auth()
@@ -0,0 +1,56 @@
1
+ import { createRouter, createWebHistory } from 'vue-router'
2
+
3
+ import LoginView from '@/views/auth/LoginView.vue'
4
+ import BaseLayout from '@/views/menu/BaseLayout.vue'
5
+ import HomeView from '@/views/home/HomeView.vue'
6
+ import E404 from '@/views/errors/404NotFound.vue'
7
+
8
+ const router = createRouter({
9
+ history: createWebHistory(import.meta.env.BASE_URL),
10
+ routes: [
11
+
12
+ {
13
+ path: '/login',
14
+ name: 'Login',
15
+ component: LoginView,
16
+ },
17
+
18
+ {
19
+ path: '/',
20
+ component: BaseLayout,
21
+ children: [
22
+ {
23
+ path: '',
24
+ name: 'Home',
25
+ component: HomeView,
26
+ },
27
+ {
28
+ path: '/dashboard',
29
+ name: 'Dashboard',
30
+ component: () => import('@/views/dashboard/DashboardView.vue'),
31
+ },
32
+
33
+ // Módulo (submenu)
34
+ {
35
+ path: '/modulo/opcion-1',
36
+ name: 'ModuloOpcion1',
37
+ component: () => import('@/views/modulo/Opcion1View.vue'),
38
+ },
39
+ {
40
+ path: '/modulo/opcion-2',
41
+ name: 'ModuloOpcion2',
42
+ component: () => import('@/views/modulo/Opcion2View.vue'),
43
+ },
44
+ ],
45
+ },
46
+
47
+ {
48
+ path: '/:pathMatch(.*)*',
49
+ name: 'NotFound',
50
+ component: E404,
51
+ },
52
+
53
+ ],
54
+ })
55
+
56
+ export default router
@@ -0,0 +1,25 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import Auth from '@/models/Auth'
4
+
5
+ export const useAuthStore = defineStore('auth', () => {
6
+ const user = ref(null)
7
+ const loading = ref(false)
8
+ const online = ref(null)
9
+
10
+ async function ping() {
11
+ loading.value = true
12
+ online.value = await Auth.ping()
13
+ loading.value = false
14
+ return online.value
15
+ }
16
+
17
+ async function updatePassword(data) {
18
+ loading.value = true
19
+ const response = await Auth.updatePassword(data)
20
+ loading.value = false
21
+ return response
22
+ }
23
+
24
+ return { user, loading, online, ping, updatePassword }
25
+ })
@@ -0,0 +1,33 @@
1
+ @import "tailwindcss";
2
+
3
+ .scroller {
4
+ @apply overflow-y-auto;
5
+ }
6
+ .scroller::-webkit-scrollbar {
7
+ width: 6px;
8
+ }
9
+ .scroller::-webkit-scrollbar-thumb {
10
+ background-color: #a5b4fc;
11
+ border-radius: 9999px;
12
+ }
13
+ .scroller::-webkit-scrollbar-track {
14
+ background-color: #f3f4f6;
15
+ }
16
+
17
+ @layer components {
18
+ .btnGreen {
19
+ @apply bg-emerald-600 hover:bg-emerald-500 text-white rounded-sm disabled:bg-emerald-300 disabled:text-gray-500 cursor-pointer disabled:cursor-not-allowed;
20
+ }
21
+ .btnOrange {
22
+ @apply bg-orange-500 hover:bg-orange-400 text-white rounded-sm disabled:bg-orange-200 disabled:text-gray-500 cursor-pointer disabled:cursor-not-allowed;
23
+ }
24
+ .btnRose {
25
+ @apply bg-rose-500 hover:bg-rose-400 text-white rounded-sm disabled:bg-rose-200 disabled:text-gray-500 cursor-pointer disabled:cursor-not-allowed;
26
+ }
27
+ .btnBlue {
28
+ @apply bg-sky-600 hover:bg-sky-500 text-white rounded-sm disabled:bg-sky-300 disabled:text-gray-500 cursor-pointer disabled:cursor-not-allowed;
29
+ }
30
+ .btnWhite {
31
+ @apply bg-white hover:bg-gray-100 text-gray-700 border border-gray-300 rounded-sm disabled:bg-gray-300 disabled:text-gray-500 cursor-pointer disabled:cursor-not-allowed;
32
+ }
33
+ }
@@ -0,0 +1,11 @@
1
+ export const BASE = 'https://www.google.com.mx/'
2
+
3
+ export async function apiServerRequest(url, options = {}) {
4
+ try {
5
+ const response = await fetch(url, options)
6
+ return response
7
+ } catch (error) {
8
+ console.error('Request error:', error)
9
+ return null
10
+ }
11
+ }
@@ -0,0 +1,60 @@
1
+ <script>
2
+ export default {
3
+ name: 'LoginView',
4
+ data() {
5
+ return {
6
+ form: {
7
+ user: '',
8
+ pass: '',
9
+ },
10
+ }
11
+ },
12
+ methods: {
13
+ async handleLogin() {
14
+ this.$router.push('/')
15
+ },
16
+ },
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <div class="min-h-screen flex items-center justify-center bg-gray-100">
22
+ <div class="w-full p-8 bg-white border border-gray-100 rounded-lg shadow-md max-w-md">
23
+
24
+ <h2 class="text-2xl font-bold text-center mb-6 text-gray-800">Iniciar sesión</h2>
25
+
26
+ <form @submit.prevent="handleLogin" class="space-y-3">
27
+
28
+ <div class="flex flex-col gap-1">
29
+ <label class="text-sm font-medium text-gray-700">Usuario</label>
30
+ <input
31
+ v-model="form.user"
32
+ type="text"
33
+ placeholder="Usuario"
34
+ required
35
+ class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
36
+ />
37
+ </div>
38
+
39
+ <div class="flex flex-col gap-1">
40
+ <label class="text-sm font-medium text-gray-700">Contraseña</label>
41
+ <input
42
+ v-model="form.pass"
43
+ type="password"
44
+ placeholder="••••••••"
45
+ required
46
+ class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
47
+ />
48
+ </div>
49
+
50
+ <button
51
+ type="submit"
52
+ class="mt-2 w-full bg-indigo-500 text-white py-2 rounded-lg hover:bg-indigo-600 transition text-sm font-medium cursor-pointer"
53
+ >
54
+ Ingresar
55
+ </button>
56
+
57
+ </form>
58
+ </div>
59
+ </div>
60
+ </template>
@@ -0,0 +1,9 @@
1
+ <script>
2
+ export default {
3
+ name: 'DashboardView',
4
+ }
5
+ </script>
6
+
7
+ <template>
8
+ <div class="text-gray-700 font-semibold">Dashboard</div>
9
+ </template>
@@ -0,0 +1,16 @@
1
+ <script>
2
+ export default {
3
+ name: 'NotFound',
4
+ }
5
+ </script>
6
+
7
+ <template>
8
+ <div class="min-h-screen flex flex-col items-center justify-center bg-gray-100 text-gray-500">
9
+ <i class="fa-solid fa-triangle-exclamation text-5xl mb-4 text-gray-300" />
10
+ <p class="text-6xl font-bold text-gray-300">404</p>
11
+ <p class="mt-2 text-sm">Página no encontrada</p>
12
+ <router-link to="/" class="mt-6 text-sm text-sky-600 hover:underline">
13
+ Volver al inicio
14
+ </router-link>
15
+ </div>
16
+ </template>
@@ -0,0 +1,9 @@
1
+ <script>
2
+ export default {
3
+ name: 'HomeView',
4
+ }
5
+ </script>
6
+
7
+ <template>
8
+ <div class="text-gray-700 font-semibold">Home</div>
9
+ </template>
@@ -0,0 +1,205 @@
1
+ <script>
2
+ export default {
3
+ name: 'BaseLayout',
4
+ data() {
5
+ return {
6
+ sidebarOpen: false,
7
+ collapsed: false,
8
+ openMenus: {},
9
+ }
10
+ },
11
+ computed: {
12
+ currentRoute() {
13
+ return this.$route.path
14
+ },
15
+ activeItem() {
16
+ const path = this.currentRoute
17
+ if (path === '/') return 'home'
18
+ if (path === '/dashboard') return 'dashboard'
19
+ if (path === '/modulo/opcion-1') return 'modulo-opcion-1'
20
+ if (path === '/modulo/opcion-2') return 'modulo-opcion-2'
21
+ return ''
22
+ },
23
+ },
24
+ watch: {
25
+ currentRoute: {
26
+ handler(path) {
27
+ if (path.startsWith('/modulo/')) {
28
+ this.openMenus = { ...this.openMenus, modulo: true }
29
+ }
30
+ },
31
+ immediate: true,
32
+ },
33
+ },
34
+ methods: {
35
+ toggleSidebar() {
36
+ if (window.innerWidth < 768) {
37
+ this.sidebarOpen = !this.sidebarOpen
38
+ } else {
39
+ this.collapsed = !this.collapsed
40
+ }
41
+ },
42
+ toggleMenu(menu) {
43
+ this.openMenus[menu] = !this.openMenus[menu]
44
+ },
45
+ isMenuOpen(menu) {
46
+ return this.openMenus[menu]
47
+ },
48
+ enter(el) {
49
+ el.style.height = '0'
50
+ el.style.opacity = '0'
51
+ requestAnimationFrame(() => {
52
+ el.style.height = el.scrollHeight + 'px'
53
+ el.style.opacity = '1'
54
+ })
55
+ },
56
+ leave(el) {
57
+ el.style.height = el.scrollHeight + 'px'
58
+ el.style.opacity = '1'
59
+ requestAnimationFrame(() => {
60
+ el.style.height = '0'
61
+ el.style.opacity = '0'
62
+ })
63
+ },
64
+ },
65
+ }
66
+ </script>
67
+
68
+ <template>
69
+ <div class="flex h-screen bg-white">
70
+
71
+ <!-- Sidebar -->
72
+ <aside :class="[
73
+ 'fixed md:static z-40 top-0 left-0 h-full border-r border-gray-200 bg-white transition-all duration-300',
74
+ sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
75
+ collapsed ? 'w-20' : 'w-64',
76
+ ]">
77
+
78
+ <!-- Logo -->
79
+ <div class="p-4 h-16 border-b border-gray-200 flex items-center justify-center">
80
+ <span v-if="!collapsed" class="text-xl font-bold text-sky-600">
81
+ {{PROJECT_NAME}}
82
+ </span>
83
+ <span v-else class="text-xl font-bold text-sky-600">
84
+ <i class="fa-solid fa-cube" />
85
+ </span>
86
+ </div>
87
+
88
+ <!-- Menu -->
89
+ <nav class="p-4">
90
+ <ul class="space-y-1">
91
+
92
+ <!-- Home -->
93
+ <li>
94
+ <router-link to="/" :class="[
95
+ 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition cursor-pointer',
96
+ activeItem === 'home'
97
+ ? 'bg-sky-200 text-sky-700'
98
+ : 'hover:bg-sky-100 text-slate-500',
99
+ ]">
100
+ <span class="text-xl"><i class="fa-solid fa-house" /></span>
101
+ <span v-if="!collapsed">Home</span>
102
+ </router-link>
103
+ </li>
104
+
105
+ <!-- Dashboard -->
106
+ <li>
107
+ <router-link to="/dashboard" :class="[
108
+ 'flex items-center gap-3 px-3 py-2.5 rounded-lg transition cursor-pointer',
109
+ activeItem === 'dashboard'
110
+ ? 'bg-sky-200 text-sky-700'
111
+ : 'hover:bg-sky-100 text-slate-500',
112
+ ]">
113
+ <span class="text-xl"><i class="fa-solid fa-chart-line" /></span>
114
+ <span v-if="!collapsed">Dashboard</span>
115
+ </router-link>
116
+ </li>
117
+
118
+ <!-- Módulo (submenu) -->
119
+ <li>
120
+ <button @click="toggleMenu('modulo')" :class="[
121
+ 'w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition cursor-pointer',
122
+ activeItem.startsWith('modulo-')
123
+ ? 'bg-sky-200 text-sky-700'
124
+ : 'hover:bg-sky-100 text-slate-500',
125
+ ]">
126
+ <div class="flex items-center gap-3">
127
+ <span class="text-xl"><i class="fa-solid fa-layer-group" /></span>
128
+ <span v-if="!collapsed">Módulo</span>
129
+ </div>
130
+ <span
131
+ v-if="!collapsed"
132
+ :class="isMenuOpen('modulo') ? 'rotate-90' : ''"
133
+ class="transition-transform"
134
+ >
135
+ <i class="fa-solid fa-angle-right" />
136
+ </span>
137
+ </button>
138
+
139
+ <!-- Submenu -->
140
+ <transition @enter="enter" @leave="leave">
141
+ <ul v-if="isMenuOpen('modulo') && !collapsed" class="mt-1 space-y-1 pl-9">
142
+ <li>
143
+ <router-link to="/modulo/opcion-1" :class="[
144
+ 'block p-2 rounded-lg text-sm transition cursor-pointer',
145
+ activeItem === 'modulo-opcion-1'
146
+ ? 'bg-sky-100 text-sky-600'
147
+ : 'hover:bg-sky-50 text-slate-500',
148
+ ]">
149
+ Opción 1
150
+ </router-link>
151
+ </li>
152
+ <li>
153
+ <router-link to="/modulo/opcion-2" :class="[
154
+ 'block p-2 rounded-lg text-sm transition cursor-pointer',
155
+ activeItem === 'modulo-opcion-2'
156
+ ? 'bg-sky-100 text-sky-600'
157
+ : 'hover:bg-sky-50 text-slate-500',
158
+ ]">
159
+ Opción 2
160
+ </router-link>
161
+ </li>
162
+ </ul>
163
+ </transition>
164
+ </li>
165
+
166
+ </ul>
167
+ </nav>
168
+
169
+ </aside>
170
+
171
+ <!-- Overlay mobile -->
172
+ <div v-if="sidebarOpen" @click="sidebarOpen = false" class="fixed inset-0 bg-black/40 z-30 md:hidden" />
173
+
174
+ <!-- Main -->
175
+ <div class="flex-1 flex flex-col bg-gray-100 overflow-hidden">
176
+
177
+ <!-- Header -->
178
+ <header class="bg-white h-16 px-4 border-b border-gray-200 flex items-center justify-between shrink-0">
179
+ <button @click="toggleSidebar" class="text-gray-400 text-xl cursor-pointer">
180
+ <i class="fa-solid fa-bars" />
181
+ </button>
182
+ <div class="flex items-center gap-3">
183
+ <div class="flex flex-col leading-tight text-right">
184
+ <span class="hidden sm:block text-sm font-bold text-gray-800">Usuario</span>
185
+ <span class="hidden sm:block text-xs font-semibold text-gray-500">Administrador</span>
186
+ </div>
187
+ <img src="https://i.pravatar.cc/40" class="w-9 h-9 rounded-full object-cover" />
188
+ </div>
189
+ </header>
190
+
191
+ <!-- Content -->
192
+ <main class="p-4 scroller flex-1">
193
+ <router-view />
194
+ </main>
195
+
196
+ </div>
197
+ </div>
198
+ </template>
199
+
200
+ <style scoped>
201
+ ul {
202
+ overflow: hidden;
203
+ transition: height 0.3s ease, opacity 0.2s ease;
204
+ }
205
+ </style>
@@ -0,0 +1,9 @@
1
+ <script>
2
+ export default {
3
+ name: 'Opcion1View',
4
+ }
5
+ </script>
6
+
7
+ <template>
8
+ <div class="text-gray-700 font-semibold">Opción 1</div>
9
+ </template>
@@ -0,0 +1,9 @@
1
+ <script>
2
+ export default {
3
+ name: 'Opcion2View',
4
+ }
5
+ </script>
6
+
7
+ <template>
8
+ <div class="text-gray-700 font-semibold">Opción 2</div>
9
+ </template>
@@ -0,0 +1,15 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+ import { resolve } from 'path'
4
+ import { fileURLToPath } from 'url'
5
+
6
+ const __dirname = fileURLToPath(new URL('.', import.meta.url))
7
+
8
+ export default defineConfig({
9
+ plugins: [vue()],
10
+ resolve: {
11
+ alias: {
12
+ '@': resolve(__dirname, './src'),
13
+ },
14
+ },
15
+ })