refactoring
This commit is contained in:
@@ -123,6 +123,18 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
||||
icon: 'i-lucide-table',
|
||||
to: '/explorer',
|
||||
active: route.path === '/explorer'
|
||||
},
|
||||
{
|
||||
label: 'Metadatos',
|
||||
icon: 'i-lucide-database',
|
||||
to: '/metadatos',
|
||||
active: route.path === '/metadatos'
|
||||
},
|
||||
{
|
||||
label: 'Explorador de datos raw',
|
||||
icon: 'i-lucide-table',
|
||||
to: '/rawExplorer',
|
||||
active: route.path === '/rawExplorer'
|
||||
}
|
||||
])
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
219
nuxt4-app/app/pages/metadatos.vue
Normal file
219
nuxt4-app/app/pages/metadatos.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<UCard class="brand-card border border-transparent backdrop-blur-sm">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-xl font-semibold text-[var(--brand-text)]">Metadatos de Tablas</h2>
|
||||
<p class="text-sm text-[var(--brand-text-muted)]">
|
||||
Información detallada sobre las tablas disponibles en la base de datos.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Refresh Controls -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-sm font-medium text-[var(--brand-text)]">
|
||||
Última actualización: {{ metadataStore.formattedLastUpdated }}
|
||||
</span>
|
||||
<span v-if="metadataStore.isStale" class="text-xs text-yellow-400">
|
||||
⚠️ Los metadatos pueden estar desactualizados
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
:loading="metadataStore.loading"
|
||||
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||
@click="refreshMetadata"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-refresh-cw" :class="{ 'animate-spin': metadataStore.loading }" />
|
||||
</template>
|
||||
Actualizar metadatos
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
|
||||
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ metadataStore.totalTables }}</div>
|
||||
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Tablas totales</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
|
||||
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ formatNumber(metadataStore.totalRecords) }}</div>
|
||||
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Registros totales</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
|
||||
<div class="text-2xl font-bold text-[var(--brand-text)]">{{ formatSize(totalSize) }}</div>
|
||||
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide">Tamaño aproximado</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="metadataStore.hasError" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
|
||||
{{ metadataStore.error }}
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<UCard v-if="metadataStore.loading && !metadataStore.hasMetadata" class="brand-card border border-transparent">
|
||||
<div class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
|
||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
|
||||
<span class="text-sm uppercase tracking-[0.3em]">Cargando metadatos...</span>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Metadata Cards -->
|
||||
<section v-if="metadataStore.hasMetadata" class="flex flex-col gap-5">
|
||||
<div v-if="metadataStore.allTables.length" class="grid gap-5 md:grid-cols-2">
|
||||
<UCard v-for="meta in metadataStore.allTables" :key="meta.table" class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold brand-section-title">Tabla {{ meta.table }}</h2>
|
||||
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
|
||||
{{ formatNumber(meta.rowCount) }} registros
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<dl class="grid grid-cols-2 gap-3 text-sm text-[var(--brand-text-muted)]">
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Clave primaria</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ meta.primaryKey || '—' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Tamaño aprox.</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatSize(meta.approxSizeBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Creación desde</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(meta.createdAtRange?.from) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Creación hasta</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(meta.createdAtRange?.to) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<template #footer>
|
||||
<div class="brand-divider pt-3 text-xs text-[var(--brand-text-muted)]">
|
||||
Columnas detectadas ({{ meta.columns?.length || 0 }}): {{ (meta.columns || []).join(', ') || 'Ninguna' }}
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Sample Row Card (if available) -->
|
||||
<template v-for="meta in metadataStore.allTables.filter(m => m.sampleRow)" :key="`sample-${meta.table}`">
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold brand-section-title">Registro de ejemplo - {{ meta.table }}</h2>
|
||||
</template>
|
||||
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">{{ formatSample(meta.sampleRow) }}</pre>
|
||||
</UCard>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Empty State -->
|
||||
<UCard v-else-if="!metadataStore.loading" class="brand-card border border-transparent">
|
||||
<div class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
|
||||
<UIcon name="i-lucide-database-zap" class="mx-auto mb-4 size-12 text-[var(--brand-text-muted)]" />
|
||||
<h3 class="text-lg font-semibold text-[var(--brand-text)] mb-2">No hay metadatos disponibles</h3>
|
||||
<p class="text-sm text-[var(--brand-text-muted)] mb-4">
|
||||
Haz clic en "Actualizar metadatos" para cargar la información de las tablas.
|
||||
</p>
|
||||
<UButton
|
||||
:loading="metadataStore.loading"
|
||||
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||
@click="refreshMetadata"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-refresh-cw" />
|
||||
</template>
|
||||
Cargar metadatos
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMetadataStore } from '~/stores/metadata'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Metadatos'
|
||||
})
|
||||
|
||||
const metadataStore = useMetadataStore()
|
||||
|
||||
// Computed properties
|
||||
const totalSize = computed(() => {
|
||||
return metadataStore.allTables.reduce((sum, meta) => sum + (meta.approxSizeBytes || 0), 0)
|
||||
})
|
||||
|
||||
// Methods
|
||||
async function refreshMetadata() {
|
||||
await metadataStore.refreshMetadata()
|
||||
}
|
||||
|
||||
function formatSize(bytes: number | null | undefined): string {
|
||||
if (!bytes) {
|
||||
return 'No disponible'
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes / 1024
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('es-ES').format(value)
|
||||
}
|
||||
|
||||
function formatSample(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize store on component mount
|
||||
onMounted(async () => {
|
||||
await metadataStore.initialize()
|
||||
})
|
||||
</script>
|
||||
795
nuxt4-app/app/pages/rawExplorer.vue
Normal file
795
nuxt4-app/app/pages/rawExplorer.vue
Normal file
@@ -0,0 +1,795 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<UCard class="brand-card border border-transparent backdrop-blur-sm">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-xl font-semibold text-[var(--brand-text)]">Constructor de consultas</h2>
|
||||
<p class="text-sm text-[var(--brand-text-muted)]">
|
||||
Arma solicitudes a los endpoints del backend y visualiza los resultados en modo lectura.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<form class="flex flex-col gap-6" @submit.prevent="executeRequest">
|
||||
<div class="grid gap-4 lg:grid-cols-4">
|
||||
<UFormField label="Tipo de consulta" name="type">
|
||||
<USelectMenu v-model="request.type" :items="requestTypeOptions" value-key="value" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Ámbito" name="scope">
|
||||
<USelectMenu v-model="request.scope" :items="scopeOptions" value-key="value" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField v-if="requiresTable" label="Tabla" name="table">
|
||||
<USelectMenu
|
||||
v-model="request.table"
|
||||
:items="tableOptions"
|
||||
value-key="value"
|
||||
placeholder="Selecciona una tabla"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField v-if="showsLimit" label="Límite" name="limit">
|
||||
<UInput v-model.number="request.limit" type="number" min="1" max="500" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField v-if="requiresRecordId" label="ID del registro" name="recordId">
|
||||
<UInput v-model="request.recordId" placeholder="Introduce el ID exacto" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField v-if="showsIdFilter" label="Filtrar por ID" name="filterId">
|
||||
<UInput v-model="request.filterId" placeholder="Opcional" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField v-if="showsDateFilters" label="Fecha desde" name="createdFrom">
|
||||
<UInput v-model="request.createdFrom" type="date" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField v-if="showsDateFilters" label="Fecha hasta" name="createdTo">
|
||||
<UInput v-model="request.createdTo" type="date" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div v-if="showQueryJson" class="space-y-2">
|
||||
<UFormField label="Consulta avanzada (JSON)" name="queryJson">
|
||||
<UTextarea
|
||||
v-model="request.queryJson"
|
||||
:rows="5"
|
||||
placeholder='{ "filters": [{ "field": "estado", "operator": "eq", "value": "activo" }] }'
|
||||
/>
|
||||
</UFormField>
|
||||
<p v-if="queryState.error" class="text-sm text-red-300">{{ queryState.error }}</p>
|
||||
<p v-else-if="queryState.encoded" class="text-xs text-[var(--brand-text-muted)]">
|
||||
Segmento codificado:
|
||||
<code class="rounded bg-[#2a2014] px-2 py-1 text-[var(--brand-accent)]">{{ queryState.encoded }}</code>
|
||||
</p>
|
||||
<p class="text-xs text-[var(--brand-text-muted)]">
|
||||
Se codifica automáticamente en base64-url para construir la ruta
|
||||
<code class="rounded bg-[#2a2014] px-2 py-1 text-[var(--brand-accent)]">
|
||||
/api/data/{{ request.table || ':tabla' }}/{{ queryState.encoded || ':query' }}
|
||||
</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3 shadow-inner shadow-black/30">
|
||||
<span class="text-xs font-semibold uppercase tracking-[0.28em] text-[var(--brand-text-muted)]">
|
||||
Solicitud generada
|
||||
</span>
|
||||
<code class="break-all text-sm text-[var(--brand-accent)]">GET {{ requestPreview }}</code>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton
|
||||
variant="soft"
|
||||
:ui="{ base: 'bg-transparent border border-[#3a2a16] text-[var(--brand-text-muted)] hover:bg-[#2a2014] hover:border-[#c08040]/60' }"
|
||||
@click="resetForm"
|
||||
:disabled="loading"
|
||||
>
|
||||
Limpiar
|
||||
</UButton>
|
||||
<UButton
|
||||
type="submit"
|
||||
:loading="loading"
|
||||
:ui="{ base: 'bg-[#c08040] text-[#1b1209] border border-[#d99a56] hover:bg-[#d99a56] hover:border-[#f0c07c]' }"
|
||||
>
|
||||
Ejecutar consulta
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</UCard>
|
||||
|
||||
<div v-if="errorMessage" class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<section v-if="hasMetadataResponse" class="flex flex-col gap-5">
|
||||
<div v-if="metadataCollection.length" class="grid gap-5 md:grid-cols-2">
|
||||
<UCard v-for="meta in metadataCollection" :key="meta.table" class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold brand-section-title">Tabla {{ meta.table }}</h2>
|
||||
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
|
||||
{{ meta.rowCount }} registros
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<dl class="grid grid-cols-2 gap-3 text-sm text-[var(--brand-text-muted)]">
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Clave primaria</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ meta.primaryKey }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Tamaño aprox.</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatSize(meta.approxSizeBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Creación desde</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(meta.createdAtRange?.from) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Creación hasta</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(meta.createdAtRange?.to) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<template #footer>
|
||||
<div class="brand-divider pt-3 text-xs text-[var(--brand-text-muted)]">
|
||||
Columnas detectadas: {{ meta.columns.join(', ') }}
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeMetadata" class="grid gap-5 md:grid-cols-2">
|
||||
<UCard class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold brand-section-title">Resumen de {{ activeMetadata.table }}</h2>
|
||||
<span class="brand-badge inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold tracking-wide">
|
||||
{{ activeMetadata.rowCount }} registros
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<dl class="grid grid-cols-2 gap-3 text-sm text-[var(--brand-text-muted)]">
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Clave primaria</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ activeMetadata.primaryKey }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Última consulta</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatDate(activeMetadata.lastRefreshed) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Tamaño aprox.</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">{{ formatSize(activeMetadata.approxSizeBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="uppercase tracking-wide text-xs">Rango de creación</dt>
|
||||
<dd class="font-medium text-[var(--brand-text)]">
|
||||
{{ formatDate(activeMetadata.createdAtRange?.from) }} — {{ formatDate(activeMetadata.createdAtRange?.to) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<template #footer>
|
||||
<div class="brand-divider pt-3 text-xs text-[var(--brand-text-muted)]">
|
||||
Columnas detectadas: {{ activeMetadata.columns.join(', ') }}
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="activeMetadata.sampleRow" class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold brand-section-title">Registro de ejemplo</h2>
|
||||
</template>
|
||||
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
|
||||
{{ formatSample(activeMetadata.sampleRow) }}
|
||||
</pre>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<UCard v-if="metadataRecord" class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold brand-section-title">Metadata del registro {{ metadataRecord.id }}</h2>
|
||||
</template>
|
||||
<pre class="overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
|
||||
{{ formatSample(metadataRecord.metadata) }}
|
||||
</pre>
|
||||
</UCard>
|
||||
</section>
|
||||
|
||||
<UCard v-if="request.type === 'data' || hasDataResponse" class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold brand-section-title">Datos</h2>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-[var(--brand-text-muted)]">
|
||||
<template v-if="dataStats">
|
||||
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
|
||||
{{ dataStats.table }}: {{ dataStats.count }} registros (límite {{ dataStats.limit ?? 's/d' }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="dataStatsCollection.length">
|
||||
<span
|
||||
v-for="item in dataStatsCollection"
|
||||
:key="item.table"
|
||||
class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs"
|
||||
>
|
||||
{{ item.table }}: {{ item.count }} registros (límite {{ item.limit ?? 's/d' }})
|
||||
</span>
|
||||
</template>
|
||||
<span v-else-if="tableData.length" class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
|
||||
{{ tableData.length }} registros visibles
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
|
||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
|
||||
<span class="text-sm uppercase tracking-[0.3em]">Procesando…</span>
|
||||
</div>
|
||||
<div v-else-if="!hasDataResponse" class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
|
||||
Ejecuta una consulta de datos para ver resultados aquí.
|
||||
</div>
|
||||
<div v-else-if="tableData.length === 0" class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
|
||||
No se encontraron registros para los criterios seleccionados.
|
||||
</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<table class="brand-table min-w-full divide-y divide-[#3a2a16]/60 text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in visibleColumns"
|
||||
:key="column"
|
||||
class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.18em] text-[var(--brand-text-muted)]"
|
||||
>
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="brand-table divide-y divide-[#3a2a16]/40">
|
||||
<tr v-for="(row, index) in tableData" :key="index" class="transition-colors">
|
||||
<td v-for="column in visibleColumns" :key="column" class="px-4 py-2 text-sm text-[var(--brand-text-muted)]">
|
||||
{{ formatCell(row[column]) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="rawResponse" class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold brand-section-title">Respuesta cruda (JSON)</h2>
|
||||
</template>
|
||||
<pre class="max-h-96 overflow-auto rounded bg-[#22180f] p-4 text-sm text-[var(--brand-text-muted)]">
|
||||
{{ formatSample(rawResponse) }}
|
||||
</pre>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRequestFetch } from '#imports'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Explorador de datos'
|
||||
})
|
||||
|
||||
type RequestType = 'data' | 'metadata'
|
||||
type MetadataScope = 'all' | 'table' | 'record'
|
||||
type DataScope = 'all' | 'table' | 'record' | 'query'
|
||||
type RequestScope = MetadataScope | DataScope
|
||||
|
||||
interface Option<T extends string> {
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
||||
const requestTypeOptions: Option<RequestType>[] = [
|
||||
{ label: 'Datos', value: 'data' },
|
||||
{ label: 'Metadatos', value: 'metadata' }
|
||||
]
|
||||
|
||||
const metadataScopeOptions: Option<MetadataScope>[] = [
|
||||
{ label: 'Todas las tablas', value: 'all' },
|
||||
{ label: 'Por tabla', value: 'table' },
|
||||
{ label: 'Registro específico', value: 'record' }
|
||||
]
|
||||
|
||||
const dataScopeOptions: Option<DataScope>[] = [
|
||||
{ label: 'Todas las tablas', value: 'all' },
|
||||
{ label: 'Por tabla', value: 'table' },
|
||||
{ label: 'Registro específico', value: 'record' },
|
||||
{ label: 'Consulta avanzada', value: 'query' }
|
||||
]
|
||||
|
||||
const DEFAULT_METADATA_SCOPE: MetadataScope = 'all'
|
||||
const DEFAULT_DATA_SCOPE: DataScope = 'table'
|
||||
|
||||
const requestFetch = useRequestFetch()
|
||||
|
||||
const request = reactive<{
|
||||
type: RequestType
|
||||
scope: RequestScope
|
||||
table: string
|
||||
recordId: string
|
||||
filterId: string
|
||||
createdFrom: string
|
||||
createdTo: string
|
||||
limit: number
|
||||
queryJson: string
|
||||
}>(
|
||||
{
|
||||
type: 'data',
|
||||
scope: DEFAULT_DATA_SCOPE,
|
||||
table: '',
|
||||
recordId: '',
|
||||
filterId: '',
|
||||
createdFrom: '',
|
||||
createdTo: '',
|
||||
limit: 100,
|
||||
queryJson: ''
|
||||
}
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref<string | null>(null)
|
||||
const rawResponse = ref<unknown>(null)
|
||||
|
||||
const availableMetadata = ref<any[]>([])
|
||||
const metadataCollection = ref<any[]>([])
|
||||
const metadataRecord = ref<any | null>(null)
|
||||
const activeMetadata = ref<any | null>(null)
|
||||
|
||||
const tableData = ref<Record<string, unknown>[]>([])
|
||||
const dataStats = ref<{ table: string; count: number; limit?: number | null } | null>(null)
|
||||
const dataStatsCollection = ref<{ table: string; count: number; limit?: number | null }[]>([])
|
||||
|
||||
const hasDataResponse = ref(false)
|
||||
const hasMetadataResponse = ref(false)
|
||||
|
||||
const scopeOptions = computed(() => (request.type === 'metadata' ? metadataScopeOptions : dataScopeOptions))
|
||||
|
||||
const requiresTable = computed(() => request.scope !== 'all')
|
||||
const requiresRecordId = computed(() => request.scope === 'record')
|
||||
|
||||
const showsLimit = computed(
|
||||
() => request.type === 'data' && (request.scope === 'all' || request.scope === 'table' || request.scope === 'query')
|
||||
)
|
||||
|
||||
const showsDateFilters = computed(() => request.type === 'data' && request.scope === 'table')
|
||||
const showsIdFilter = computed(() => request.type === 'data' && request.scope === 'table')
|
||||
const showQueryJson = computed(() => request.type === 'data' && request.scope === 'query')
|
||||
|
||||
const tableOptions = computed(() =>
|
||||
availableMetadata.value.map((meta) => ({
|
||||
label: `${meta.table} (${meta.rowCount ?? '—'})`,
|
||||
value: meta.table
|
||||
}))
|
||||
)
|
||||
|
||||
const visibleColumns = computed(() => (tableData.value[0] ? Object.keys(tableData.value[0]) : []))
|
||||
|
||||
const queryState = computed(() => {
|
||||
if (!showQueryJson.value) {
|
||||
return { encoded: '', error: null as string | null }
|
||||
}
|
||||
|
||||
const trimmed = request.queryJson.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { encoded: '', error: null as string | null }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
const normalized = JSON.stringify(parsed)
|
||||
return { encoded: encodeBase64Url(normalized), error: null as string | null }
|
||||
} catch (error) {
|
||||
return { encoded: '', error: 'El JSON proporcionado no es válido.' }
|
||||
}
|
||||
})
|
||||
|
||||
const requestPreview = computed(() => {
|
||||
const base = request.type === 'metadata' ? '/api/metadata' : '/api/data'
|
||||
let path = base
|
||||
const params = new URLSearchParams()
|
||||
const limit = sanitizeLimit(request.limit)
|
||||
|
||||
if (request.type === 'metadata') {
|
||||
if (request.scope === 'table') {
|
||||
path += `/${request.table || ':tabla'}`
|
||||
} else if (request.scope === 'record') {
|
||||
path += `/${request.table || ':tabla'}/${request.recordId || ':id'}`
|
||||
}
|
||||
} else {
|
||||
if (request.scope === 'all') {
|
||||
params.set('limit', String(limit))
|
||||
} else if (request.scope === 'table') {
|
||||
path += `/${request.table || ':tabla'}`
|
||||
params.set('limit', String(limit))
|
||||
if (request.filterId) params.set('id', request.filterId.trim())
|
||||
if (request.createdFrom) params.set('created_from', request.createdFrom)
|
||||
if (request.createdTo) params.set('created_to', request.createdTo)
|
||||
} else if (request.scope === 'record') {
|
||||
path += `/${request.table || ':tabla'}/${request.recordId || ':id'}`
|
||||
} else if (request.scope === 'query') {
|
||||
const segment = queryState.value.encoded || ':query-base64'
|
||||
path += `/${request.table || ':tabla'}/${segment}`
|
||||
params.set('limit', String(limit))
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${path}?${queryString}` : path
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAvailableMetadata()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => request.type,
|
||||
(type) => {
|
||||
request.scope = type === 'metadata' ? DEFAULT_METADATA_SCOPE : DEFAULT_DATA_SCOPE
|
||||
request.recordId = ''
|
||||
request.filterId = ''
|
||||
request.createdFrom = ''
|
||||
request.createdTo = ''
|
||||
request.queryJson = ''
|
||||
clearResults()
|
||||
|
||||
if (requiresTable.value && !request.table && availableMetadata.value.length > 0) {
|
||||
request.table = availableMetadata.value[0].table
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => request.scope,
|
||||
() => {
|
||||
if (!requiresTable.value) {
|
||||
request.table = ''
|
||||
} else if (!request.table && availableMetadata.value.length > 0) {
|
||||
request.table = availableMetadata.value[0].table
|
||||
}
|
||||
|
||||
if (!requiresRecordId.value) {
|
||||
request.recordId = ''
|
||||
}
|
||||
|
||||
if (!showsIdFilter.value) {
|
||||
request.filterId = ''
|
||||
}
|
||||
|
||||
if (!showsDateFilters.value) {
|
||||
request.createdFrom = ''
|
||||
request.createdTo = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function loadAvailableMetadata() {
|
||||
try {
|
||||
const metadata = await requestFetch('/api/metadata')
|
||||
if (Array.isArray(metadata)) {
|
||||
availableMetadata.value = metadata
|
||||
useState('availableMetadataSummary', () => metadata).value = metadata
|
||||
useState('dashboardLastUpdated', () => new Date().toISOString()).value = new Date().toISOString()
|
||||
if (!request.table && requiresTable.value && metadata.length > 0) {
|
||||
request.table = metadata[0].table
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = extractErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLimit(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 100
|
||||
}
|
||||
return Math.max(1, Math.min(500, Math.trunc(value)))
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string) {
|
||||
if (typeof globalThis !== 'undefined' && typeof globalThis.btoa === 'function') {
|
||||
const encoded = globalThis.btoa(
|
||||
encodeURIComponent(value).replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
)
|
||||
|
||||
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
return Buffer.from(value, 'utf-8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
metadataCollection.value = []
|
||||
metadataRecord.value = null
|
||||
activeMetadata.value = null
|
||||
tableData.value = []
|
||||
dataStats.value = null
|
||||
dataStatsCollection.value = []
|
||||
rawResponse.value = null
|
||||
hasDataResponse.value = false
|
||||
hasMetadataResponse.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
request.scope = request.type === 'metadata' ? DEFAULT_METADATA_SCOPE : DEFAULT_DATA_SCOPE
|
||||
request.table = availableMetadata.value[0]?.table ?? ''
|
||||
request.recordId = ''
|
||||
request.filterId = ''
|
||||
request.createdFrom = ''
|
||||
request.createdTo = ''
|
||||
request.limit = 100
|
||||
request.queryJson = ''
|
||||
clearResults()
|
||||
}
|
||||
|
||||
function buildExecutableRequest() {
|
||||
const base = request.type === 'metadata' ? '/api/metadata' : '/api/data'
|
||||
let path = base
|
||||
const query: Record<string, string> = {}
|
||||
|
||||
if (request.type === 'metadata') {
|
||||
if (request.scope === 'all') {
|
||||
return { url: path, query, error: null as string | null }
|
||||
}
|
||||
|
||||
if (!request.table) {
|
||||
return { url: '', query, error: 'Selecciona una tabla antes de ejecutar la consulta.' }
|
||||
}
|
||||
|
||||
path += `/${request.table}`
|
||||
|
||||
if (request.scope === 'record') {
|
||||
if (!request.recordId.trim()) {
|
||||
return { url: '', query, error: 'Introduce el ID del registro que deseas consultar.' }
|
||||
}
|
||||
|
||||
path += `/${request.recordId.trim()}`
|
||||
}
|
||||
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
if (request.scope === 'all') {
|
||||
query.limit = String(sanitizeLimit(request.limit))
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
if (!request.table) {
|
||||
return { url: '', query, error: 'Selecciona una tabla antes de ejecutar la consulta.' }
|
||||
}
|
||||
|
||||
if (request.scope === 'table') {
|
||||
path += `/${request.table}`
|
||||
query.limit = String(sanitizeLimit(request.limit))
|
||||
if (request.filterId) {
|
||||
query.id = request.filterId.trim()
|
||||
}
|
||||
if (request.createdFrom) {
|
||||
query.created_from = request.createdFrom
|
||||
}
|
||||
if (request.createdTo) {
|
||||
query.created_to = request.createdTo
|
||||
}
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
if (request.scope === 'record') {
|
||||
if (!request.recordId.trim()) {
|
||||
return { url: '', query, error: 'Introduce el ID del registro que deseas consultar.' }
|
||||
}
|
||||
|
||||
path += `/${request.table}/${request.recordId.trim()}`
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
if (!request.queryJson.trim()) {
|
||||
return { url: '', query, error: 'Introduce un JSON para la consulta avanzada.' }
|
||||
}
|
||||
|
||||
if (queryState.value.error) {
|
||||
return { url: '', query, error: queryState.value.error }
|
||||
}
|
||||
|
||||
path += `/${request.table}/${queryState.value.encoded}`
|
||||
query.limit = String(sanitizeLimit(request.limit))
|
||||
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
async function executeRequest() {
|
||||
const requestConfig = buildExecutableRequest()
|
||||
|
||||
if (requestConfig.error) {
|
||||
errorMessage.value = requestConfig.error
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
const response = await requestFetch(requestConfig.url, {
|
||||
query: requestConfig.query
|
||||
})
|
||||
|
||||
processResponse(response)
|
||||
rawResponse.value = response
|
||||
} catch (error) {
|
||||
clearResults()
|
||||
errorMessage.value = extractErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function processResponse(response: unknown) {
|
||||
metadataCollection.value = []
|
||||
metadataRecord.value = null
|
||||
activeMetadata.value = null
|
||||
tableData.value = []
|
||||
dataStats.value = null
|
||||
dataStatsCollection.value = []
|
||||
|
||||
if (request.type === 'metadata') {
|
||||
hasMetadataResponse.value = true
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
metadataCollection.value = response
|
||||
availableMetadata.value = response
|
||||
useState('availableMetadataSummary', () => response).value = response
|
||||
useState('dashboardLastUpdated', () => new Date().toISOString()).value = new Date().toISOString()
|
||||
} else if (request.scope === 'record' && response && typeof response === 'object') {
|
||||
metadataRecord.value = response
|
||||
} else {
|
||||
activeMetadata.value = response
|
||||
|
||||
if (response && typeof response === 'object' && 'table' in response) {
|
||||
const index = availableMetadata.value.findIndex((item) => item.table === (response as any).table)
|
||||
if (index >= 0) {
|
||||
availableMetadata.value[index] = response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hasDataResponse.value = true
|
||||
|
||||
if (request.scope === 'all' && Array.isArray(response)) {
|
||||
const datasets = response as Array<{
|
||||
table: string
|
||||
count?: number | null
|
||||
limit?: number | null
|
||||
records?: Record<string, unknown>[]
|
||||
}>
|
||||
|
||||
dataStatsCollection.value = datasets.map((item) => ({
|
||||
table: item.table,
|
||||
count: item.count ?? item.records?.length ?? 0,
|
||||
limit: item.limit ?? null
|
||||
}))
|
||||
|
||||
tableData.value = datasets.flatMap((item) => {
|
||||
const rows = Array.isArray(item.records) ? item.records : []
|
||||
return rows.map((row) => ({ __tabla: item.table, ...row }))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (request.scope === 'record') {
|
||||
tableData.value = response ? [response as Record<string, unknown>] : []
|
||||
dataStats.value = {
|
||||
table: request.table,
|
||||
count: tableData.value.length,
|
||||
limit: null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (response && typeof response === 'object' && 'records' in response) {
|
||||
const dataset = response as {
|
||||
table: string
|
||||
count?: number | null
|
||||
limit?: number | null
|
||||
records?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const rows = Array.isArray(dataset.records) ? dataset.records : []
|
||||
tableData.value = rows
|
||||
dataStats.value = {
|
||||
table: dataset.table,
|
||||
count: dataset.count ?? rows.length,
|
||||
limit: dataset.limit ?? null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
tableData.value = Array.isArray(response) ? (response as Record<string, unknown>[]) : []
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown) {
|
||||
if (error && typeof error === 'object' && 'statusMessage' in error) {
|
||||
return String((error as { statusMessage: string }).statusMessage)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return 'Ocurrió un error inesperado al consultar los datos.'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number | null | undefined) {
|
||||
if (!bytes) {
|
||||
return 'No disponible'
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`
|
||||
}
|
||||
|
||||
const units = ['KB', 'MB', 'GB']
|
||||
let size = bytes / 1024
|
||||
let unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024
|
||||
unitIndex += 1
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function formatCell(value: unknown) {
|
||||
if (value === null || value === undefined) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return '[objeto]'
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatSample(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
252
nuxt4-app/app/stores/metadata.ts
Normal file
252
nuxt4-app/app/stores/metadata.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { useRequestFetch } from '#imports'
|
||||
|
||||
export interface TableMetadata {
|
||||
table: string
|
||||
rowCount: number
|
||||
primaryKey: string
|
||||
approxSizeBytes: number
|
||||
columns: string[]
|
||||
createdAtRange?: {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
lastRefreshed?: string
|
||||
sampleRow?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface MetadataState {
|
||||
metadata: TableMetadata[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
lastUpdated: string | null
|
||||
initialized: boolean
|
||||
}
|
||||
|
||||
export const useMetadataStore = defineStore('metadata', {
|
||||
state: (): MetadataState => ({
|
||||
metadata: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdated: null,
|
||||
initialized: false
|
||||
}),
|
||||
|
||||
getters: {
|
||||
/**
|
||||
* Get metadata for all tables
|
||||
*/
|
||||
allTables: (state): TableMetadata[] => state.metadata,
|
||||
|
||||
/**
|
||||
* Get metadata for a specific table
|
||||
*/
|
||||
getTableMetadata: (state) => (tableName: string): TableMetadata | undefined => {
|
||||
return state.metadata.find(meta => meta.table === tableName)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get total number of tables
|
||||
*/
|
||||
totalTables: (state): number => state.metadata.length,
|
||||
|
||||
/**
|
||||
* Get total number of records across all tables
|
||||
*/
|
||||
totalRecords: (state): number => {
|
||||
return state.metadata.reduce((sum, meta) => sum + (meta.rowCount || 0), 0)
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of all table names
|
||||
*/
|
||||
tableNames: (state): string[] => {
|
||||
return state.metadata.map(meta => meta.table)
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if metadata is available
|
||||
*/
|
||||
hasMetadata: (state): boolean => state.metadata.length > 0,
|
||||
|
||||
/**
|
||||
* Check if metadata is currently loading
|
||||
*/
|
||||
isLoading: (state): boolean => state.loading,
|
||||
|
||||
/**
|
||||
* Check if there's an error
|
||||
*/
|
||||
hasError: (state): boolean => !!state.error,
|
||||
|
||||
/**
|
||||
* Get formatted last updated time
|
||||
*/
|
||||
formattedLastUpdated: (state): string => {
|
||||
if (!state.lastUpdated) return 'Nunca'
|
||||
|
||||
try {
|
||||
return new Date(state.lastUpdated).toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return 'Fecha inválida'
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if metadata is stale (older than 5 minutes)
|
||||
*/
|
||||
isStale: (state): boolean => {
|
||||
if (!state.lastUpdated) return true
|
||||
|
||||
const lastUpdate = new Date(state.lastUpdated)
|
||||
const now = new Date()
|
||||
const fiveMinutes = 5 * 60 * 1000
|
||||
|
||||
return (now.getTime() - lastUpdate.getTime()) > fiveMinutes
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Load metadata lazily (only if not already loaded)
|
||||
*/
|
||||
async loadMetadata(force = false): Promise<void> {
|
||||
// Don't load if already loading
|
||||
if (this.loading) return
|
||||
|
||||
// Don't load if already initialized and not forced
|
||||
if (this.initialized && !force && !this.isStale) return
|
||||
|
||||
await this.fetchMetadata()
|
||||
},
|
||||
|
||||
/**
|
||||
* Force refresh metadata
|
||||
*/
|
||||
async refreshMetadata(): Promise<void> {
|
||||
await this.fetchMetadata()
|
||||
},
|
||||
|
||||
/**
|
||||
* Internal method to fetch metadata from API
|
||||
*/
|
||||
async fetchMetadata(): Promise<void> {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const requestFetch = useRequestFetch()
|
||||
const response = await requestFetch('/api/metadata')
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
this.metadata = response.map((meta: any) => ({
|
||||
...meta,
|
||||
lastRefreshed: meta.lastRefreshed || new Date().toISOString()
|
||||
}))
|
||||
this.lastUpdated = new Date().toISOString()
|
||||
this.initialized = true
|
||||
|
||||
// Persist to localStorage for offline access
|
||||
if (process.client) {
|
||||
try {
|
||||
localStorage.setItem('metadata-cache', JSON.stringify({
|
||||
metadata: this.metadata,
|
||||
lastUpdated: this.lastUpdated
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist metadata to localStorage:', error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid metadata response format')
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.error = this.extractErrorMessage(error)
|
||||
console.error('Error fetching metadata:', error)
|
||||
|
||||
// Try to load from cache if available
|
||||
if (process.client && !this.hasMetadata) {
|
||||
this.loadFromCache()
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load metadata from localStorage cache
|
||||
*/
|
||||
loadFromCache(): void {
|
||||
if (!process.client) return
|
||||
|
||||
try {
|
||||
const cached = localStorage.getItem('metadata-cache')
|
||||
if (cached) {
|
||||
const parsedCache = JSON.parse(cached)
|
||||
this.metadata = parsedCache.metadata || []
|
||||
this.lastUpdated = parsedCache.lastUpdated || null
|
||||
this.initialized = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load metadata from cache:', error)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all metadata
|
||||
*/
|
||||
clearMetadata(): void {
|
||||
this.metadata = []
|
||||
this.error = null
|
||||
this.lastUpdated = null
|
||||
this.initialized = false
|
||||
|
||||
if (process.client) {
|
||||
try {
|
||||
localStorage.removeItem('metadata-cache')
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear metadata cache:', error)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Extract error message from various error types
|
||||
*/
|
||||
extractErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'statusMessage' in error) {
|
||||
return String((error as { statusMessage: string }).statusMessage)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return 'Error inesperado al cargar metadatos'
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize store (called on app startup)
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Load from cache first for immediate availability
|
||||
this.loadFromCache()
|
||||
|
||||
// Then try to fetch fresh data
|
||||
await this.loadMetadata()
|
||||
}
|
||||
},
|
||||
|
||||
// Enable persistence for better UX
|
||||
persist: process.client ? {
|
||||
key: 'metadata-store',
|
||||
storage: localStorage,
|
||||
pick: ['metadata', 'lastUpdated', 'initialized']
|
||||
} : false
|
||||
})
|
||||
@@ -4,7 +4,7 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
css: ['~/assets/css/main.css'],
|
||||
modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils', '@vite-pwa/nuxt'],
|
||||
modules: ['@nuxt/image', '@nuxt/ui', '@nuxt/test-utils', '@vite-pwa/nuxt', '@pinia/nuxt'],
|
||||
app: {
|
||||
head: {
|
||||
link: [
|
||||
|
||||
12396
nuxt4-app/package-lock.json
generated
12396
nuxt4-app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,12 @@
|
||||
"dependencies": {
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@vite-pwa/nuxt": "^0.9.1",
|
||||
"@nuxt/ui": "^4.0.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@supabase/supabase-js": "^2.48.0",
|
||||
"@vite-pwa/nuxt": "^0.9.1",
|
||||
"nuxt": "^4.1.2",
|
||||
"pinia": "^3.0.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
|
||||
553
package-lock.json
generated
553
package-lock.json
generated
@@ -2024,6 +2024,21 @@
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pinia/nuxt": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.2.tgz",
|
||||
"integrity": "sha512-CgvSWpbktxxWBV7ModhAcsExsQZqpPq6vMYEe9DexmmY6959ev8ukL4iFhr/qov2Nb9cQAWd7niFDnaWkN+FHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nuxt/kit": "^3.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pinia": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/plugin-node-resolve": {
|
||||
"version": "15.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
|
||||
@@ -2446,6 +2461,157 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.22.tgz",
|
||||
"integrity": "sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/shared": "3.5.22",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.22.tgz",
|
||||
"integrity": "sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
|
||||
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.19",
|
||||
"postcss": "^8.5.6",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.22.tgz",
|
||||
"integrity": "sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "7.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz",
|
||||
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-kit": "^7.7.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz",
|
||||
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.7",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"speakingurl": "^14.0.1",
|
||||
"superjson": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-kit/node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.7",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz",
|
||||
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
|
||||
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
|
||||
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
|
||||
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
|
||||
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.22"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.22.tgz",
|
||||
"integrity": "sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@@ -2622,6 +2788,15 @@
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/birpc": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.6.1.tgz",
|
||||
"integrity": "sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -2838,6 +3013,21 @@
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/copy-anything": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz",
|
||||
"integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.45.1",
|
||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.45.1.tgz",
|
||||
@@ -2860,6 +3050,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-view-buffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
|
||||
@@ -3030,6 +3226,18 @@
|
||||
"integrity": "sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/errx": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/errx/-/errx-0.1.0.tgz",
|
||||
@@ -3652,6 +3860,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||
@@ -4071,6 +4285,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
|
||||
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
@@ -4264,6 +4490,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz",
|
||||
@@ -4482,6 +4714,27 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz",
|
||||
"integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^7.7.2"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.4.4",
|
||||
"vue": "^2.7.0 || ^3.5.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||
@@ -4729,6 +4982,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.52.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.3.tgz",
|
||||
@@ -5041,6 +5300,15 @@
|
||||
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
|
||||
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
|
||||
@@ -5178,6 +5446,18 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/superjson": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",
|
||||
"integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -5678,6 +5958,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.22",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
|
||||
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
"@vue/runtime-dom": "3.5.22",
|
||||
"@vue/server-renderer": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||
@@ -6109,9 +6410,11 @@
|
||||
"@nuxt/image": "^1.11.0",
|
||||
"@nuxt/test-utils": "^3.19.2",
|
||||
"@nuxt/ui": "^4.0.0",
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
"@supabase/supabase-js": "^2.48.0",
|
||||
"@vite-pwa/nuxt": "^0.9.1",
|
||||
"nuxt": "^4.1.2",
|
||||
"pinia": "^3.0.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
@@ -6284,31 +6587,6 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@emnapi/core": {
|
||||
"version": "1.5.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@emnapi/runtime": {
|
||||
"version": "1.5.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@fastify/accept-negotiator": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
@@ -6487,16 +6765,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.0.5",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.5.0",
|
||||
"@emnapi/runtime": "^1.5.0",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"license": "MIT",
|
||||
@@ -7454,7 +7722,6 @@
|
||||
},
|
||||
"nuxt4-app/node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": {
|
||||
"version": "1.1.0",
|
||||
"extraneous": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7863,14 +8130,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"extraneous": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@types/node": {
|
||||
"version": "24.6.0",
|
||||
"license": "MIT",
|
||||
@@ -8055,56 +8314,6 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/shared": "3.5.22",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/compiler-core/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.4",
|
||||
"@vue/compiler-core": "3.5.22",
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.19",
|
||||
"postcss": "^8.5.6",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/compiler-sfc/node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"license": "MIT"
|
||||
@@ -8124,30 +8333,6 @@
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/devtools-kit": {
|
||||
"version": "7.7.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/devtools-shared": "^7.7.7",
|
||||
"birpc": "^2.3.0",
|
||||
"hookable": "^5.5.3",
|
||||
"mitt": "^3.0.1",
|
||||
"perfect-debounce": "^1.0.0",
|
||||
"speakingurl": "^14.0.1",
|
||||
"superjson": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/devtools-kit/node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/devtools-shared": {
|
||||
"version": "7.7.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rfdc": "^1.4.1"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/language-core": {
|
||||
"version": "3.1.0",
|
||||
"license": "MIT",
|
||||
@@ -8169,46 +8354,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/reactivity": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.22",
|
||||
"@vue/runtime-core": "3.5.22",
|
||||
"@vue/shared": "3.5.22",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.22"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/@vue/shared": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/@vueuse/core": {
|
||||
"version": "13.9.0",
|
||||
"license": "MIT",
|
||||
@@ -8641,13 +8786,6 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/birpc": {
|
||||
"version": "2.6.1",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
@@ -9001,19 +9139,6 @@
|
||||
"version": "2.0.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/copy-anything": {
|
||||
"version": "3.0.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-what": "^4.1.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"license": "MIT"
|
||||
@@ -9246,10 +9371,6 @@
|
||||
"version": "2.0.28",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"nuxt4-app/node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/db0": {
|
||||
"version": "0.3.2",
|
||||
"license": "MIT",
|
||||
@@ -9556,16 +9677,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/error-stack-parser-es": {
|
||||
"version": "1.0.5",
|
||||
"license": "MIT",
|
||||
@@ -9942,10 +10053,6 @@
|
||||
"version": "1.0.8",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
@@ -10269,16 +10376,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/is-what": {
|
||||
"version": "4.1.16",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.13"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mesqueeb"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/is-wsl": {
|
||||
"version": "3.1.0",
|
||||
"license": "MIT",
|
||||
@@ -10651,10 +10748,6 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"license": "MIT",
|
||||
@@ -11956,10 +12049,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/rollup-plugin-visualizer": {
|
||||
"version": "6.0.3",
|
||||
"license": "MIT",
|
||||
@@ -12236,13 +12325,6 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/speakingurl": {
|
||||
"version": "14.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"license": "MIT"
|
||||
@@ -12386,16 +12468,6 @@
|
||||
"postcss": "^8.4.32"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/superjson": {
|
||||
"version": "2.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"copy-anything": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/supports-color": {
|
||||
"version": "10.2.2",
|
||||
"license": "MIT",
|
||||
@@ -13382,25 +13454,6 @@
|
||||
"version": "3.1.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"nuxt4-app/node_modules/vue": {
|
||||
"version": "3.5.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.22",
|
||||
"@vue/compiler-sfc": "3.5.22",
|
||||
"@vue/runtime-dom": "3.5.22",
|
||||
"@vue/server-renderer": "3.5.22",
|
||||
"@vue/shared": "3.5.22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"nuxt4-app/node_modules/vue-bundle-renderer": {
|
||||
"version": "2.1.2",
|
||||
"license": "MIT",
|
||||
|
||||
Reference in New Issue
Block a user