first commit

This commit is contained in:
alexandrump
2026-02-09 01:02:53 +01:00
commit 82f3464565
90 changed files with 4788 additions and 0 deletions

13
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:18-alpine
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
ENV NODE_ENV=production
EXPOSE 3000
CMD ["npm", "run", "start"]

5
apps/web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# apps/web
Directorio para la aplicación Nuxt (frontend dashboard).
Para crear el esqueleto de Nuxt (si deseas que lo genere automáticamente), puedo ejecutar los comandos del creador de Nuxt y añadir la configuración inicial.

View File

@@ -0,0 +1,90 @@
<template>
<header class="site-header">
<div class="wrap">
<div class="brand">
<div class="logo">ga</div>
<div>
<h2>gob-alert</h2>
<span>Radar de datos públicos</span>
</div>
</div>
<nav>
<NuxtLink to="/landing">Inicio</NuxtLink>
<NuxtLink to="/">Dashboard</NuxtLink>
<NuxtLink to="/discover">Descubrir</NuxtLink>
<NuxtLink to="/catalog">Catálogo</NuxtLink>
<NuxtLink to="/alerts">Alertas</NuxtLink>
<NuxtLink to="/plans">Planes</NuxtLink>
<NuxtLink to="/admin">Admin</NuxtLink>
</nav>
</div>
</header>
</template>
<script setup>
</script>
<style scoped>
.site-header {
background: #0f172a;
color: #fff;
padding: 1.2rem 0;
position: sticky;
top: 0;
z-index: 10;
}
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
align-items: center;
gap: 2rem;
justify-content: space-between;
}
.brand {
display: flex;
align-items: center;
gap: 0.8rem;
}
.logo {
width: 42px;
height: 42px;
border-radius: 14px;
background: #38bdf8;
color: #0f172a;
font-weight: 700;
display: grid;
place-items: center;
}
.brand h2 {
margin: 0;
font-size: 1.1rem;
}
.brand span {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.65);
}
nav {
display: flex;
gap: 1.2rem;
font-weight: 600;
}
nav a {
color: rgba(255, 255, 255, 0.75);
text-decoration: none;
}
nav a.router-link-active {
color: #fff;
}
@media (max-width: 800px) {
.wrap {
flex-direction: column;
align-items: flex-start;
}
nav {
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,12 @@
<template>
<div>
<slot />
</div>
</template>
<script setup>
</script>
<style>
body { margin: 0; padding: 0; }
</style>

21
apps/web/nuxt.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineNuxtConfig } from 'nuxt'
export default defineNuxtConfig({
ssr: false,
app: {
head: {
title: 'gob-alert',
link: [
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap',
},
],
},
},
runtimeConfig: {
public: {
apiBase: process.env.API_URL || 'http://localhost:3000'
}
}
})

13
apps/web/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"start": "nuxt start"
},
"dependencies": {
"nuxt": "^3.8.0"
}
}

549
apps/web/pages/admin.vue Normal file
View File

@@ -0,0 +1,549 @@
<template>
<div class="page">
<Header />
<main class="container">
<header class="page-header">
<div>
<h1>Admin Control de ingesta</h1>
<p>Gestiona el scheduler y encola la ingesta manualmente.</p>
</div>
</header>
<section class="panel">
<div class="grid">
<div>
<label>Email (admin)</label>
<input v-model="email" placeholder="admin@example.com" />
<label>Password</label>
<input type="password" v-model="password" placeholder="password" />
<div class="row">
<button class="btn" @click="login">Login</button>
<button class="btn ghost" @click="logout">Logout</button>
</div>
</div>
<div>
<label>API Key (fallback)</label>
<input v-model="key" placeholder="Introduce API key" />
</div>
</div>
</section>
<section class="panel">
<div class="row">
<button class="btn" @click="pause">Pause scheduler</button>
<button class="btn ghost" @click="resume">Resume scheduler</button>
<button class="btn" @click="queue">Enqueue ingest</button>
<button class="btn ghost" @click="run">Run now (sync)</button>
</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Monitorización</h2>
<button class="btn ghost" @click="loadStatus">Refresh</button>
</header>
<div v-if="status" class="status-grid">
<div class="status-card">
<strong>Catálogo</strong>
<span>Items: {{ status.counts.catalogItems }}</span>
<span>Versiones: {{ status.counts.catalogVersions }}</span>
</div>
<div class="status-card">
<strong>Alertas</strong>
<span>Perfiles: {{ status.counts.alertProfiles }}</span>
<span>Entregas: {{ status.counts.alertDeliveries }}</span>
</div>
<div class="status-card">
<strong>Usuarios</strong>
<span>Total: {{ status.counts.users }}</span>
</div>
<div class="status-card">
<strong>Última ingesta</strong>
<span>{{ status.lastIngest?.status || '—' }}</span>
<span>{{ formatDate(status.lastIngest?.startedAt) }}</span>
</div>
<div class="status-card">
<strong>Últimas alertas</strong>
<span>{{ status.lastAlerts?.status || '—' }}</span>
<span>{{ formatDate(status.lastAlerts?.startedAt) }}</span>
</div>
</div>
<div v-else class="muted">Sin datos de monitorización.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Backups</h2>
<div class="row">
<button class="btn" @click="runBackup">Run backup</button>
<button class="btn ghost" @click="loadBackups">Refresh</button>
</div>
</header>
<ul v-if="backups.length" class="backup-list">
<li v-for="file in backups" :key="file">{{ file }}</li>
</ul>
<div v-else class="muted">No hay backups aún.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Ingest runs</h2>
<button class="btn ghost" @click="loadRuns">Refresh</button>
</header>
<div v-if="runs.length" class="runs">
<div v-for="run in runs" :key="run.id" class="run-row">
<div>
<strong>{{ run.status }}</strong>
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
</div>
<div class="metrics">
<span>Importados: {{ run.imported }}</span>
<span>Actualizados: {{ run.updated }}</span>
<span>Errores: {{ run.errorCount }}</span>
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
</div>
</div>
</div>
<div v-else class="muted">Sin ejecuciones recientes.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Planes</h2>
<button class="btn ghost" @click="loadPlans">Refresh</button>
</header>
<div v-if="plans.length" class="plans">
<div v-for="plan in plans" :key="plan.id" class="plan-row">
<div>
<strong>{{ plan.name }}</strong>
<div class="muted">{{ plan.code }} · {{ plan.priceMonthly }} {{ plan.currency }}/mes</div>
</div>
<div class="muted">{{ plan.description }}</div>
</div>
</div>
<div v-else class="muted">Sin planes.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Usuarios</h2>
<button class="btn ghost" @click="loadUsers">Refresh</button>
</header>
<div v-if="users.length" class="users">
<div v-for="user in users" :key="user.id" class="user-row">
<div>
<strong>{{ user.email }}</strong>
<div class="muted">{{ user.role }} · {{ formatDate(user.createdAt) }}</div>
</div>
<div class="user-actions">
<select v-model="user.planId" @change="updateUserPlan(user)">
<option value="">Sin plan</option>
<option v-for="plan in plans" :key="plan.id" :value="plan.id">
{{ plan.name }}
</option>
</select>
<select v-model="user.role" @change="updateUserRole(user)">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
</div>
</div>
<div v-else class="muted">Sin usuarios.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Alert runs</h2>
<button class="btn ghost" @click="loadAlertRuns">Refresh</button>
</header>
<div v-if="alertRuns.length" class="runs">
<div v-for="run in alertRuns" :key="run.id" class="run-row">
<div>
<strong>{{ run.status }}</strong>
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
</div>
<div class="metrics">
<span>Perfiles: {{ run.profiles }}</span>
<span>Enviadas: {{ run.sent }}</span>
<span>Fallidas: {{ run.failed }}</span>
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
</div>
</div>
</div>
<div v-else class="muted">Sin ejecuciones recientes.</div>
</section>
<section class="panel" v-if="msg">
<pre>{{ msg }}</pre>
</section>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
import { ref } from 'vue'
const config = useRuntimeConfig()
const key = ref(localStorage.getItem('admin_key') || '')
const token = ref(localStorage.getItem('admin_token') || '')
const email = ref('')
const password = ref('')
const msg = ref('')
const runs = ref([])
const alertRuns = ref([])
const plans = ref([])
const users = ref([])
const status = ref(null)
const backups = ref([])
function saveKey() {
localStorage.setItem('admin_key', key.value)
}
function saveToken(t) {
token.value = t
if (t) localStorage.setItem('admin_token', t)
else localStorage.removeItem('admin_token')
}
async function login() {
try {
const res = await $fetch(`${config.public.apiBase}/auth/login`, { method: 'POST', body: { email: email.value, password: password.value } })
saveToken(res.access_token)
msg.value = 'Logged in'
await loadStatus()
await loadBackups()
await loadRuns()
await loadAlertRuns()
await loadPlans()
await loadUsers()
} catch (e) { msg.value = 'Login failed: ' + String(e) }
}
function logout() {
saveToken('')
msg.value = 'Logged out'
}
async function pause() {
saveKey()
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
const res = await $fetch(`${config.public.apiBase}/admin/ingest/pause`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function resume() {
saveKey()
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
const res = await $fetch(`${config.public.apiBase}/admin/ingest/resume`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function queue() {
saveKey()
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
const res = await $fetch(`${config.public.apiBase}/ingest/queue`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function run() {
try {
const res = await $fetch(`${config.public.apiBase}/ingest/run`)
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function loadStatus() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/monitor/status`, { headers })
status.value = res
} catch (e) {
msg.value = String(e)
}
}
async function loadBackups() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/backup/list`, { headers })
backups.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function runBackup() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/backup/run`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadBackups()
} catch (e) {
msg.value = String(e)
}
}
async function loadRuns() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/ingest/runs`, { headers })
runs.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function loadPlans() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/plans`, { headers })
plans.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function loadUsers() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/users`, { headers })
users.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function updateUserPlan(user) {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/plan`, {
method: 'POST',
headers,
body: { planId: user.planId }
})
messageToast('Plan actualizado')
} catch (e) {
msg.value = String(e)
}
}
async function updateUserRole(user) {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/role`, {
method: 'POST',
headers,
body: { role: user.role }
})
messageToast('Rol actualizado')
} catch (e) {
msg.value = String(e)
}
}
async function loadAlertRuns() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/alerts/runs`, { headers })
alertRuns.value = res || []
} catch (e) {
msg.value = String(e)
}
}
function formatDate(value) {
if (!value) return '—'
return new Date(value).toLocaleString('es-ES')
}
function messageToast(text) {
msg.value = text
}
onMounted(() => {
loadStatus()
loadBackups()
loadRuns()
loadAlertRuns()
loadPlans()
loadUsers()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f8fafc;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.page-header {
margin-bottom: 2rem;
}
.panel {
background: #fff;
border-radius: 20px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.runs {
display: grid;
gap: 1rem;
}
.run-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem;
border-radius: 14px;
background: #f8fafc;
}
.metrics {
display: grid;
gap: 0.3rem;
text-align: right;
font-size: 0.85rem;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
.plans,
.users {
display: grid;
gap: 1rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.status-card {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border-radius: 14px;
background: #f8fafc;
}
.backup-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.4rem;
}
.plan-row,
.user-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem;
border-radius: 14px;
background: #f8fafc;
align-items: center;
}
.user-actions {
display: flex;
gap: 0.6rem;
align-items: center;
}
select {
padding: 0.4rem 0.6rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
background: #fff;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 0.8rem;
}
label {
font-weight: 600;
margin-top: 0.6rem;
display: block;
}
input {
padding: 0.5rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
width: 100%;
}
.btn {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: #0f172a;
color: #fff;
font-weight: 600;
}
.btn.ghost {
background: transparent;
border: 1px solid #0f172a;
color: #0f172a;
}
</style>

290
apps/web/pages/alerts.vue Normal file
View File

@@ -0,0 +1,290 @@
<template>
<div class="page">
<Header />
<main class="container">
<header class="page-header">
<div>
<h1>Perfiles de alerta</h1>
<p>Define reglas para recibir novedades por email o Telegram.</p>
</div>
<button class="btn" @click="openCreate">Nuevo perfil</button>
</header>
<section class="profiles">
<article v-for="profile in profiles" :key="profile.id" class="profile-card">
<div>
<h3>{{ profile.name }}</h3>
<p class="muted">{{ profile.channel }} · {{ profile.frequency }}</p>
<p v-if="profile.queryText" class="rule">Texto: {{ profile.queryText }}</p>
<p v-if="profile.rules?.publishers?.length" class="rule">Publicadores: {{ profile.rules.publishers.join(', ') }}</p>
<p v-if="profile.rules?.formats?.length" class="rule">Formatos: {{ profile.rules.formats.join(', ') }}</p>
<p v-if="profile.rules?.topics?.length" class="rule">Temas: {{ profile.rules.topics.join(', ') }}</p>
<p v-if="profile.rules?.territories?.length" class="rule">Territorios: {{ profile.rules.territories.join(', ') }}</p>
<p v-if="profile.rules?.updatedWithinDays" class="rule">Actualizados en {{ profile.rules.updatedWithinDays }} días</p>
</div>
<div class="actions">
<button class="btn ghost" @click="runProfile(profile.id)">Probar</button>
<button class="btn" @click="editProfile(profile)">Editar</button>
<button class="btn danger" @click="removeProfile(profile.id)">Eliminar</button>
</div>
</article>
</section>
<dialog ref="dialog" class="dialog">
<form method="dialog" @submit.prevent="save">
<h2>{{ editing ? 'Editar perfil' : 'Nuevo perfil' }}</h2>
<label>Nombre</label>
<input v-model="form.name" required />
<label>Canal</label>
<select v-model="form.channel">
<option value="email">Email</option>
<option value="telegram">Telegram</option>
</select>
<label>Destino (email o chat id)</label>
<input v-model="form.channelTarget" />
<label>Frecuencia</label>
<select v-model="form.frequency">
<option value="instant">Instant</option>
<option value="daily">Daily</option>
</select>
<label>Texto libre</label>
<input v-model="form.queryText" placeholder="subvenciones, licitaciones..." />
<label>Publicadores (coma)</label>
<input v-model="publishers" />
<label>Formatos (coma)</label>
<input v-model="formats" />
<label>Temas (coma)</label>
<input v-model="topics" />
<label>Territorios (coma)</label>
<input v-model="territories" />
<label>Actualizados en los últimos días</label>
<input v-model.number="updatedWithinDays" type="number" min="1" />
<div class="dialog-actions">
<button class="btn ghost" @click="closeDialog">Cancelar</button>
<button class="btn" type="submit">Guardar</button>
</div>
</form>
</dialog>
<div v-if="message" class="toast">{{ message }}</div>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
const config = useRuntimeConfig()
const dialog = ref(null)
const message = ref('')
const editing = ref(false)
const profiles = ref([])
const form = reactive({
id: '',
name: '',
channel: 'email',
channelTarget: '',
frequency: 'daily',
queryText: '',
rules: { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
})
const publishers = ref('')
const formats = ref('')
const topics = ref('')
const territories = ref('')
const updatedWithinDays = ref(null)
async function load() {
const res = await $fetch(`${config.public.apiBase}/alerts/profiles`)
profiles.value = res || []
}
function openCreate() {
editing.value = false
resetForm()
dialog.value?.showModal()
}
function editProfile(profile) {
editing.value = true
form.id = profile.id
form.name = profile.name
form.channel = profile.channel
form.channelTarget = profile.channelTarget
form.frequency = profile.frequency
form.queryText = profile.queryText
form.rules = profile.rules || { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
publishers.value = (form.rules.publishers || []).join(', ')
formats.value = (form.rules.formats || []).join(', ')
topics.value = (form.rules.topics || []).join(', ')
territories.value = (form.rules.territories || []).join(', ')
updatedWithinDays.value = form.rules.updatedWithinDays ?? null
dialog.value?.showModal()
}
function resetForm() {
form.id = ''
form.name = ''
form.channel = 'email'
form.channelTarget = ''
form.frequency = 'daily'
form.queryText = ''
form.rules = { publishers: [], formats: [], topics: [], territories: [], updatedWithinDays: undefined }
publishers.value = ''
formats.value = ''
topics.value = ''
territories.value = ''
updatedWithinDays.value = null
}
function closeDialog() {
dialog.value?.close()
}
async function save() {
const days = Number(updatedWithinDays.value)
form.rules = {
publishers: splitList(publishers.value),
formats: splitList(formats.value),
topics: splitList(topics.value),
territories: splitList(territories.value),
updatedWithinDays: Number.isFinite(days) && days > 0 ? days : undefined
}
if (editing.value) {
await $fetch(`${config.public.apiBase}/alerts/profiles/${form.id}`, {
method: 'POST',
body: form
})
} else {
await $fetch(`${config.public.apiBase}/alerts/profiles`, {
method: 'POST',
body: form
})
}
message.value = 'Perfil guardado'
dialog.value?.close()
await load()
}
async function runProfile(id) {
const res = await $fetch(`${config.public.apiBase}/alerts/run/${id}`, { method: 'POST' })
message.value = `Alertas enviadas: ${res.sent || 0}`
}
async function removeProfile(id) {
await $fetch(`${config.public.apiBase}/alerts/profiles/${id}`, { method: 'DELETE' })
message.value = 'Perfil eliminado'
await load()
}
function splitList(value) {
return value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
}
onMounted(load)
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f1f5f9;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.btn {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: #0f172a;
color: #fff;
font-weight: 600;
}
.btn.ghost {
background: transparent;
border: 1px solid #0f172a;
color: #0f172a;
}
.btn.danger {
background: #dc2626;
}
.profiles {
display: grid;
gap: 1.2rem;
}
.profile-card {
background: #fff;
border-radius: 20px;
padding: 1.5rem;
display: flex;
justify-content: space-between;
gap: 1.5rem;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.08);
}
.actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule {
color: #475569;
font-size: 0.9rem;
}
.muted {
color: #64748b;
}
.dialog {
border: none;
border-radius: 20px;
padding: 2rem;
width: min(420px, 90vw);
}
.dialog form {
display: grid;
gap: 0.6rem;
}
.dialog input,
.dialog select {
padding: 0.5rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.toast {
margin-top: 1.5rem;
background: #0f172a;
color: #fff;
padding: 0.8rem 1.4rem;
border-radius: 999px;
display: inline-block;
}
@media (max-width: 700px) {
.profile-card {
flex-direction: column;
}
.actions {
flex-direction: row;
flex-wrap: wrap;
}
}
</style>

129
apps/web/pages/catalog.vue Normal file
View File

@@ -0,0 +1,129 @@
<template>
<div class="page">
<Header />
<main class="container">
<header class="page-header">
<div>
<h1>Catálogo</h1>
<p>Datasets ingestados desde datos.gob.es con clasificación básica.</p>
</div>
<button class="btn" @click="refresh">Actualizar</button>
</header>
<div v-if="pending" class="state">Cargando...</div>
<div v-else-if="error" class="state error">Error: {{ error.message }}</div>
<section v-else class="grid">
<article v-for="item in items" :key="item.id" class="card">
<div class="card-header">
<h3>{{ item.title }}</h3>
<span class="pill">{{ item.format || 'Sin formato' }}</span>
</div>
<p class="publisher">{{ item.publisher || 'Sin publicador' }}</p>
<p class="desc" v-if="item.description">{{ item.description }}</p>
<div class="card-footer">
<small>Actualizado: {{ formatDate(item.updatedAt) }}</small>
<a v-if="item.sourceUrl" :href="item.sourceUrl" target="_blank">Fuente</a>
</div>
</article>
</section>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
const config = useRuntimeConfig()
const { data, pending, error, refresh } = await useFetch(`${config.public.apiBase}/catalog`)
const items = computed(() => data.value ?? [])
function formatDate(value) {
if (!value) return '—'
return new Date(value).toLocaleDateString('es-ES')
}
</script>
<style scoped>
.page {
background: #f8fafc;
min-height: 100vh;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.btn {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: 1px solid #0f172a;
background: #0f172a;
color: #fff;
font-weight: 600;
}
.state {
padding: 1.5rem;
background: #fff;
border-radius: 16px;
}
.state.error {
border: 1px solid #fecaca;
color: #b91c1c;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
}
.card {
background: #fff;
border-radius: 20px;
padding: 1.5rem;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.card-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.card-header h3 {
margin: 0;
font-size: 1.1rem;
}
.pill {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
background: #e2e8f0;
border-radius: 999px;
}
.publisher {
color: #475569;
font-weight: 600;
}
.desc {
color: #475569;
font-size: 0.95rem;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8rem;
color: #64748b;
}
.card-footer a {
color: #0f172a;
font-weight: 600;
}
</style>

150
apps/web/pages/discover.vue Normal file
View File

@@ -0,0 +1,150 @@
<template>
<div class="page">
<Header />
<main class="container">
<header class="page-header">
<div>
<h1>Descubrimiento</h1>
<p>Últimos cambios detectados en el catálogo.</p>
</div>
<div class="filters">
<label>Días</label>
<select v-model.number="days" @change="refresh">
<option :value="3">3</option>
<option :value="7">7</option>
<option :value="14">14</option>
<option :value="30">30</option>
</select>
</div>
</header>
<div v-if="pending" class="panel">Cargando...</div>
<div v-else-if="error" class="panel error">Error: {{ error.message }}</div>
<section v-else class="grid">
<article v-for="change in changes" :key="change.id" class="card">
<div class="card-header">
<div>
<h3>{{ change.snapshot?.title || 'Sin título' }}</h3>
<p class="muted">{{ change.snapshot?.publisher || 'Sin publicador' }}</p>
</div>
<span class="pill">{{ change.eventType }}</span>
</div>
<p class="desc" v-if="change.snapshot?.description">{{ change.snapshot.description }}</p>
<div class="tags">
<span v-for="tag in change.tags?.topics || []" :key="tag" class="tag">{{ tag }}</span>
<span v-for="tag in change.tags?.territories || []" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="card-footer">
<small>{{ formatDate(change.createdAt) }}</small>
<a v-if="change.snapshot?.sourceUrl" :href="change.snapshot.sourceUrl" target="_blank">Fuente</a>
</div>
</article>
</section>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
const config = useRuntimeConfig()
const days = ref(7)
const { data, pending, error, refresh } = await useFetch(() => `${config.public.apiBase}/discover/changes?days=${days.value}&limit=30`)
const changes = computed(() => data.value ?? [])
function formatDate(value) {
if (!value) return ''
return new Date(value).toLocaleString('es-ES')
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #eef2f9;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.filters {
display: flex;
align-items: center;
gap: 0.6rem;
}
select {
padding: 0.4rem 0.6rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
background: #fff;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1.5rem;
}
.card {
background: #fff;
border-radius: 20px;
padding: 1.5rem;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
display: grid;
gap: 0.8rem;
}
.card-header {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.card-header h3 {
margin: 0;
}
.pill {
font-size: 0.75rem;
padding: 0.2rem 0.6rem;
background: #0f172a;
color: #fff;
border-radius: 999px;
height: fit-content;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag {
padding: 0.2rem 0.6rem;
border-radius: 999px;
background: #e2e8f0;
font-size: 0.75rem;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.panel {
background: #fff;
border-radius: 16px;
padding: 1.5rem;
}
.panel.error {
border: 1px solid #fecaca;
color: #b91c1c;
}
.muted {
color: #64748b;
}
.desc {
color: #475569;
}
</style>

271
apps/web/pages/index.vue Normal file
View File

@@ -0,0 +1,271 @@
<template>
<div class="page">
<Header />
<main class="container">
<section class="hero">
<div>
<p class="kicker">Radar automático de datos públicos</p>
<h1>Detecta cambios en datos.gob.es antes que nadie</h1>
<p class="lead">Monitoriza datasets, normaliza formatos y recibe alertas por email o Telegram. Configura perfiles sin complicaciones.</p>
<div class="hero-actions">
<NuxtLink to="/catalog" class="btn primary">Ver catálogo</NuxtLink>
<NuxtLink to="/alerts" class="btn ghost">Crear alertas</NuxtLink>
</div>
</div>
<div class="hero-card" v-if="summary">
<div class="metric">
<span class="metric-value">{{ summary.totals.items }}</span>
<span class="metric-label">Datasets</span>
</div>
<div class="metric">
<span class="metric-value">{{ summary.totals.changes }}</span>
<span class="metric-label">Cambios recientes</span>
</div>
<div class="metric">
<span class="metric-value">{{ summary.totals.alertProfiles }}</span>
<span class="metric-label">Perfiles activos</span>
</div>
<div class="card-footer">Actualizado automáticamente</div>
</div>
</section>
<section class="trend" v-if="summary?.trend?.length">
<h2>Tendencia (últimos {{ summary.trend.length }} días)</h2>
<div class="trend-chart">
<div v-for="point in summary.trend" :key="point.date" class="trend-bar">
<span class="bar-fill" :style="{ height: barHeight(point.total) }"></span>
<small>{{ formatShortDate(point.date) }}</small>
</div>
</div>
</section>
<section class="grid">
<article class="panel">
<h2>Cambios recientes</h2>
<ul>
<li v-for="change in summary?.latestChanges || []" :key="change.id">
<strong>{{ change.snapshot?.title || 'Sin título' }}</strong>
<span class="muted">{{ change.snapshot?.publisher || '—' }}</span>
<small class="muted">{{ formatDate(change.createdAt) }}</small>
</li>
</ul>
</article>
<article class="panel">
<h2>Top publicadores</h2>
<ul>
<li v-for="pub in summary?.topPublishers || []" :key="pub.label">
<span>{{ pub.label }}</span>
<strong>{{ pub.value }}</strong>
</li>
</ul>
</article>
<article class="panel">
<h2>Formatos más comunes</h2>
<ul>
<li v-for="fmt in summary?.topFormats || []" :key="fmt.label">
<span>{{ fmt.label }}</span>
<strong>{{ fmt.value }}</strong>
</li>
</ul>
</article>
<article class="panel">
<h2>Top temas</h2>
<ul>
<li v-for="topic in summary?.topTopics || []" :key="topic.label">
<span>{{ topic.label }}</span>
<strong>{{ topic.value }}</strong>
</li>
</ul>
</article>
<article class="panel">
<h2>Top territorios</h2>
<ul>
<li v-for="territory in summary?.topTerritories || []" :key="territory.label">
<span>{{ territory.label }}</span>
<strong>{{ territory.value }}</strong>
</li>
</ul>
</article>
</section>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
const config = useRuntimeConfig()
const { data: summary } = await useFetch(`${config.public.apiBase}/dashboard/summary`)
const maxTrend = computed(() => {
const values = summary.value?.trend?.map((p) => p.total) || []
return Math.max(...values, 1)
})
function formatDate(value) {
if (!value) return ''
return new Date(value).toLocaleDateString('es-ES')
}
function formatShortDate(value) {
if (!value) return ''
const date = new Date(value)
return date.toLocaleDateString('es-ES', { day: '2-digit', month: 'short' })
}
function barHeight(value) {
return `${Math.round((value / maxTrend.value) * 100)}%`
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: radial-gradient(circle at top, #f5f7ff 0%, #eef2f9 45%, #e5ecf5 100%);
color: #0f172a;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.hero {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 2rem;
align-items: center;
}
.kicker {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.75rem;
color: #475569;
margin-bottom: 0.6rem;
}
.hero h1 {
font-size: clamp(2.2rem, 3vw, 3rem);
margin: 0 0 1rem;
}
.lead {
font-size: 1.05rem;
color: #334155;
}
.hero-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.btn {
padding: 0.7rem 1.4rem;
border-radius: 999px;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn.primary {
background: #0f172a;
color: #fff;
}
.btn.ghost {
border: 1px solid #0f172a;
color: #0f172a;
}
.hero-card {
background: #0f172a;
color: #fff;
padding: 1.5rem;
border-radius: 24px;
display: grid;
gap: 1rem;
}
.metric {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.metric-value {
font-size: 1.8rem;
font-weight: 700;
}
.metric-label {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
}
.card-footer {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
}
.grid {
margin-top: 3rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
.trend {
margin-top: 2.5rem;
background: #ffffff;
border-radius: 18px;
padding: 1.5rem;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
}
.trend h2 {
margin-top: 0;
font-size: 1.1rem;
}
.trend-chart {
margin-top: 1.2rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(32px, 1fr));
gap: 0.6rem;
align-items: end;
}
.trend-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
height: 140px;
}
.bar-fill {
width: 100%;
max-width: 18px;
background: linear-gradient(180deg, #38bdf8 0%, #0f172a 100%);
border-radius: 999px;
display: block;
min-height: 8px;
}
.panel {
background: #ffffff;
border-radius: 18px;
padding: 1.5rem;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.08);
}
.panel h2 {
margin-top: 0;
font-size: 1.1rem;
}
.panel ul {
list-style: none;
padding: 0;
margin: 1rem 0 0;
display: grid;
gap: 0.9rem;
}
.panel li {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
@media (max-width: 720px) {
.hero-actions {
flex-direction: column;
align-items: stretch;
}
}
</style>

110
apps/web/pages/ingest.vue Normal file
View File

@@ -0,0 +1,110 @@
<template>
<div class="page">
<Header />
<main class="container">
<header class="page-header">
<div>
<h1>Ingesta manual</h1>
<p>Crea un item de catálogo para pruebas rápidas.</p>
</div>
</header>
<form class="panel" @submit.prevent="submit">
<div class="grid">
<div>
<label>Título</label>
<input v-model="form.title" required />
</div>
<div>
<label>Publisher</label>
<input v-model="form.publisher" />
</div>
<div>
<label>Source URL</label>
<input v-model="form.sourceUrl" />
</div>
<div>
<label>Formato</label>
<input v-model="form.format" />
</div>
</div>
<button class="btn" type="submit">Ingestar</button>
</form>
<div v-if="result" class="panel">Creado: {{ result.item?.id || result.id }}</div>
<div v-if="err" class="panel error">Error: {{ err }}</div>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
import { reactive, ref } from 'vue'
const config = useRuntimeConfig()
const form = reactive({ title: '', publisher: '', sourceUrl: '', format: '' })
const result = ref(null)
const err = ref(null)
async function submit() {
err.value = null
result.value = null
try {
const res = await $fetch(`${config.public.apiBase}/catalog/ingest`, {
method: 'POST',
body: form
})
result.value = res
} catch (e) {
err.value = e.message || String(e)
}
}
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f8fafc;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.page-header {
margin-bottom: 2rem;
}
.panel {
background: #fff;
border-radius: 20px;
padding: 1.5rem;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.panel.error {
border: 1px solid #fecaca;
color: #b91c1c;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
label {
display: block;
font-weight: 600;
margin-bottom: 0.4rem;
}
input {
width: 100%;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
}
.btn {
margin-top: 1.2rem;
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: #0f172a;
color: #fff;
font-weight: 600;
}
</style>

163
apps/web/pages/landing.vue Normal file
View File

@@ -0,0 +1,163 @@
<template>
<div class="page">
<Header />
<main class="container">
<section class="hero">
<div class="hero-text">
<p class="kicker">Radar automático</p>
<h1>Convierte datos públicos en señales accionables</h1>
<p class="lead">gob-alert detecta cambios en datos.gob.es, normaliza formatos y alerta a tu equipo en minutos.</p>
<div class="hero-actions">
<NuxtLink to="/plans" class="btn primary">Ver planes</NuxtLink>
<NuxtLink to="/discover" class="btn ghost">Explorar cambios</NuxtLink>
</div>
</div>
<div class="hero-card">
<div class="metric">
<span class="metric-label">Detección</span>
<span class="metric-value">< 1h</span>
</div>
<div class="metric">
<span class="metric-label">Formatos</span>
<span class="metric-value">CSV · JSON</span>
</div>
<div class="metric">
<span class="metric-label">Alertas</span>
<span class="metric-value">Email · Telegram</span>
</div>
</div>
</section>
<section class="features">
<article>
<h3>Ingesta automatizada</h3>
<p>Agenda ingestas periódicas y guarda histórico de cambios por dataset.</p>
</article>
<article>
<h3>Clasificación inteligente</h3>
<p>Detecta organismos, territorios y temas para segmentar alertas.</p>
</article>
<article>
<h3>Panel de control</h3>
<p>Visualiza novedades, tendencias y métricas clave en tiempo real.</p>
</article>
</section>
<section class="cta">
<div>
<h2>Listo para tu piloto en 30 días</h2>
<p>Integra alertas personalizadas para consultoras, pymes y universidades.</p>
</div>
<NuxtLink to="/admin" class="btn primary">Ver demo</NuxtLink>
</section>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
</script>
<style scoped>
.page {
min-height: 100vh;
background: radial-gradient(circle at top left, #f4f7ff 0%, #eef2f9 40%, #e2e8f0 100%);
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.hero {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 2rem;
align-items: center;
}
.kicker {
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.75rem;
color: #475569;
}
.hero h1 {
font-size: clamp(2.4rem, 3vw, 3.2rem);
margin: 0.6rem 0 1rem;
}
.lead {
color: #334155;
font-size: 1.1rem;
}
.hero-actions {
display: flex;
gap: 1rem;
margin-top: 1.6rem;
}
.btn {
padding: 0.7rem 1.5rem;
border-radius: 999px;
text-decoration: none;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn.primary {
background: #0f172a;
color: #fff;
}
.btn.ghost {
border: 1px solid #0f172a;
color: #0f172a;
}
.hero-card {
background: #0f172a;
color: #fff;
padding: 1.8rem;
border-radius: 24px;
display: grid;
gap: 1rem;
}
.metric {
display: flex;
justify-content: space-between;
font-weight: 600;
}
.metric-label {
color: rgba(255, 255, 255, 0.7);
}
.features {
margin-top: 3rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
.features article {
background: #fff;
padding: 1.4rem;
border-radius: 18px;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
}
.cta {
margin-top: 3rem;
background: #38bdf8;
padding: 2rem;
border-radius: 24px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
color: #0f172a;
}
@media (max-width: 720px) {
.hero-actions {
flex-direction: column;
}
.cta {
flex-direction: column;
align-items: flex-start;
}
}
</style>

95
apps/web/pages/plans.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div class="page">
<Header />
<main class="container">
<header class="page-header">
<h1>Planes y precios</h1>
<p>Elige el plan que mejor se adapta a tu equipo.</p>
</header>
<section class="grid">
<article v-for="plan in plans" :key="plan.id" class="card">
<h2>{{ plan.name }}</h2>
<p class="muted">{{ plan.description }}</p>
<div class="price">
<span class="value">{{ plan.priceMonthly }}</span>
<span class="currency">{{ plan.currency }}/mes</span>
</div>
<ul>
<li v-for="(value, key) in plan.features || {}" :key="key">
<strong>{{ key }}</strong>: {{ value }}
</li>
</ul>
<button class="btn">Solicitar acceso</button>
</article>
</section>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
const config = useRuntimeConfig()
const { data } = await useFetch(`${config.public.apiBase}/plans`)
const plans = computed(() => data.value ?? [])
</script>
<style scoped>
.page {
min-height: 100vh;
background: #eef2f9;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.page-header {
margin-bottom: 2rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
.card {
background: #fff;
border-radius: 20px;
padding: 1.8rem;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.08);
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.price {
display: flex;
align-items: baseline;
gap: 0.4rem;
}
.value {
font-size: 2rem;
font-weight: 700;
}
.currency {
color: #64748b;
}
ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.4rem;
}
.btn {
margin-top: auto;
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: none;
background: #0f172a;
color: #fff;
font-weight: 600;
}
.muted {
color: #64748b;
}
</style>