feat: initial brainstorming app — Nuxt 3 + SQLite + admin auth
Nuxt 3 app with: - SQLite (better-sqlite3) for persistence - Anonymous idea submission and voting - Admin auth with session cookies - AI analysis via Gemini API - Nuxt UI components + Tailwind Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
143
app/pages/admin.vue
Normal file
143
app/pages/admin.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
const { admin, logout } = useAuth()
|
||||
const toast = useToast()
|
||||
const analysisKey = ref('')
|
||||
const analysis = ref<any>(null)
|
||||
const analyzing = ref(false)
|
||||
|
||||
const { data: ideas, refresh } = useFetch<any[]>('/api/ideas')
|
||||
|
||||
watch(admin, (v) => {
|
||||
if (!v) navigateTo('/login')
|
||||
}, { immediate: true })
|
||||
|
||||
async function deleteIdea(id: number) {
|
||||
await $fetch(`/api/ideas/${id}`, { method: 'DELETE' })
|
||||
toast.add({ title: 'Idea eliminada', color: 'success' })
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function toggleHide(idea: any) {
|
||||
await $fetch(`/api/ideas/${idea.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { hidden: !idea.hidden },
|
||||
})
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
if (!analysisKey.value) {
|
||||
toast.add({ title: 'Introduce la API key de Gemini', color: 'warning' })
|
||||
return
|
||||
}
|
||||
analyzing.value = true
|
||||
try {
|
||||
analysis.value = await $fetch('/api/analysis', {
|
||||
method: 'POST',
|
||||
body: { apiKey: analysisKey.value },
|
||||
})
|
||||
} catch (e: any) {
|
||||
toast.add({ title: e.data?.message || 'Error en análisis', color: 'error' })
|
||||
} finally {
|
||||
analyzing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogout() {
|
||||
await logout()
|
||||
navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-50 p-4 md:p-8">
|
||||
<header class="max-w-5xl mx-auto flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Panel de Administración</h1>
|
||||
<p class="text-neutral-500 text-sm">Gestiona ideas y genera análisis IA.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/">
|
||||
<UButton variant="ghost">Ver tablón</UButton>
|
||||
</NuxtLink>
|
||||
<UButton color="error" variant="soft" @click="doLogout">Cerrar sesión</UButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-5xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- AI Analysis -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h2 class="font-semibold">Análisis IA</h2>
|
||||
</template>
|
||||
<div class="space-y-3">
|
||||
<UFormField label="Gemini API Key">
|
||||
<UInput v-model="analysisKey" type="password" placeholder="AIza..." />
|
||||
</UFormField>
|
||||
<UButton block :loading="analyzing" :disabled="analyzing" @click="analyze">
|
||||
Generar Análisis
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="analysis">
|
||||
<template #header>
|
||||
<h3 class="font-semibold">Resultados</h3>
|
||||
</template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<div>
|
||||
<h4 class="font-bold text-primary mb-1">Fricción Principal</h4>
|
||||
<ul class="space-y-1 text-neutral-600">
|
||||
<li v-for="(p, i) in analysis.topFrictionPoints" :key="i">• {{ p }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-amber-600 mb-1">Quick Wins</h4>
|
||||
<ul class="space-y-1 text-neutral-600">
|
||||
<li v-for="(w, i) in analysis.quickWins" :key="i">• {{ w }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-neutral-500 mb-1">Visión Estratégica</h4>
|
||||
<p class="text-neutral-500 italic">{{ analysis.strategicAdvice }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Ideas management -->
|
||||
<div class="lg:col-span-2">
|
||||
<h2 class="text-lg font-bold mb-4">Todas las ideas ({{ ideas?.length ?? 0 }})</h2>
|
||||
<div class="space-y-3">
|
||||
<UCard v-for="idea in ideas" :key="idea.id" :class="{ 'opacity-50': idea.hidden }">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<UBadge color="primary" variant="subtle" size="xs">{{ idea.category }}</UBadge>
|
||||
<UBadge v-if="idea.hidden" color="warning" variant="subtle" size="xs">Oculta</UBadge>
|
||||
<span class="text-xs text-neutral-400">{{ new Date(idea.created_at).toLocaleString() }}</span>
|
||||
<UBadge color="neutral" size="xs">{{ idea.votes }} votos</UBadge>
|
||||
</div>
|
||||
<p class="text-sm">{{ idea.text }}</p>
|
||||
</div>
|
||||
<div class="flex gap-1 ml-4 shrink-0">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:color="idea.hidden ? 'success' : 'warning'"
|
||||
@click="toggleHide(idea)"
|
||||
>
|
||||
{{ idea.hidden ? 'Mostrar' : 'Ocultar' }}
|
||||
</UButton>
|
||||
<UButton variant="ghost" size="xs" color="error" @click="deleteIdea(idea.id)">
|
||||
Borrar
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
157
app/pages/index.vue
Normal file
157
app/pages/index.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
const { admin } = useAuth()
|
||||
|
||||
const categories = ['UTAM/Playwright', 'Jira Automation', 'Documentation', 'Testing', 'Dev Workflow', 'General']
|
||||
const newIdea = ref('')
|
||||
const category = ref('General')
|
||||
const hasSubmitted = ref(false)
|
||||
const revealAll = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const { data: ideas, refresh } = useFetch<any[]>('/api/ideas')
|
||||
|
||||
async function submitIdea() {
|
||||
if (!newIdea.value.trim()) return
|
||||
await $fetch('/api/ideas', {
|
||||
method: 'POST',
|
||||
body: { text: newIdea.value, category: category.value },
|
||||
})
|
||||
newIdea.value = ''
|
||||
hasSubmitted.value = true
|
||||
revealAll.value = true
|
||||
toast.add({ title: 'Idea guardada', color: 'success' })
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function vote(id: number) {
|
||||
try {
|
||||
await $fetch(`/api/votes/${id}`, { method: 'POST' })
|
||||
refresh()
|
||||
} catch {
|
||||
toast.add({ title: 'Ya has votado esta idea', color: 'warning' })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIdea(id: number) {
|
||||
await $fetch(`/api/ideas/${id}`, { method: 'DELETE' })
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function toggleHide(idea: any) {
|
||||
await $fetch(`/api/ideas/${idea.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { hidden: !idea.hidden },
|
||||
})
|
||||
refresh()
|
||||
}
|
||||
|
||||
function isHidden() {
|
||||
return !revealAll.value && !hasSubmitted.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-neutral-50 text-neutral-900 p-4 md:p-8">
|
||||
<header class="max-w-6xl mx-auto flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold flex items-center gap-3">
|
||||
Automation Brainstorming
|
||||
</h1>
|
||||
<p class="text-neutral-500 mt-1">Propuestas sin fricción, 100% anónimo.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UBadge color="success" variant="subtle">Interno</UBadge>
|
||||
<NuxtLink v-if="!admin" to="/login">
|
||||
<UBadge color="neutral" variant="subtle" class="cursor-pointer">Admin</UBadge>
|
||||
</NuxtLink>
|
||||
<NuxtLink v-else to="/admin">
|
||||
<UBadge color="primary" variant="subtle" class="cursor-pointer">Panel Admin</UBadge>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- Submit form -->
|
||||
<div class="lg:col-span-1 space-y-6">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Nueva Iniciativa</h2>
|
||||
</template>
|
||||
<form class="space-y-4" @submit.prevent="submitIdea">
|
||||
<UFormField label="Categoría">
|
||||
<USelect v-model="category" :items="categories" />
|
||||
</UFormField>
|
||||
<UFormField label="¿Qué te gustaría automatizar?">
|
||||
<UTextarea v-model="newIdea" :rows="4" placeholder="Ej: Automatizar el mapeo de tests en Jira..." />
|
||||
</UFormField>
|
||||
<UButton type="submit" block :disabled="!newIdea.trim()">
|
||||
Enviar Idea
|
||||
</UButton>
|
||||
</form>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Ideas wall -->
|
||||
<div class="lg:col-span-2 space-y-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-xl font-bold">
|
||||
Tablón de Ideas ({{ ideas?.length ?? 0 }})
|
||||
</h2>
|
||||
<UButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@click="revealAll = !revealAll"
|
||||
>
|
||||
{{ revealAll ? 'Ocultar por anonimato' : 'Revelar todas' }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<UCard v-for="idea in ideas" :key="idea.id">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<UBadge v-if="!isHidden()" color="primary" variant="subtle" size="xs">
|
||||
{{ idea.category }}
|
||||
</UBadge>
|
||||
<UBadge v-else color="neutral" variant="subtle" size="xs">???</UBadge>
|
||||
<span class="text-xs text-neutral-400">{{ new Date(idea.created_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-sm leading-relaxed mb-4"
|
||||
:class="isHidden() ? 'blur-sm select-none' : ''"
|
||||
>
|
||||
{{ isHidden() ? 'Envía tu idea primero para ver las demás.' : idea.text }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-neutral-100">
|
||||
<UButton variant="ghost" size="xs" @click="vote(idea.id)">
|
||||
+1 Votar
|
||||
<UBadge v-if="idea.votes > 0" color="primary" size="xs" class="ml-1">{{ idea.votes }}</UBadge>
|
||||
</UButton>
|
||||
|
||||
<!-- Admin actions -->
|
||||
<div v-if="admin" class="flex gap-1">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:color="idea.hidden ? 'success' : 'warning'"
|
||||
@click="toggleHide(idea)"
|
||||
>
|
||||
{{ idea.hidden ? 'Mostrar' : 'Ocultar' }}
|
||||
</UButton>
|
||||
<UButton variant="ghost" size="xs" color="error" @click="deleteIdea(idea.id)">
|
||||
Borrar
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-if="!ideas?.length" class="text-center py-20 bg-white rounded-2xl border-2 border-dashed border-neutral-200">
|
||||
<h3 class="text-neutral-500 font-medium">Aún no hay ideas.</h3>
|
||||
<p class="text-neutral-400 text-sm">¡Sé el primero!</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
37
app/pages/login.vue
Normal file
37
app/pages/login.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const { login } = useAuth()
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
error.value = ''
|
||||
await login(password.value)
|
||||
navigateTo('/admin')
|
||||
} catch {
|
||||
error.value = 'Contraseña incorrecta'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-neutral-50 p-4">
|
||||
<UCard class="w-full max-w-sm">
|
||||
<template #header>
|
||||
<h1 class="text-xl font-bold text-center">Admin Login</h1>
|
||||
</template>
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<UFormField label="Contraseña">
|
||||
<UInput v-model="password" type="password" placeholder="Contraseña de admin" />
|
||||
</UFormField>
|
||||
<p v-if="error" class="text-sm text-red-500">{{ error }}</p>
|
||||
<UButton type="submit" block :disabled="!password">
|
||||
Entrar
|
||||
</UButton>
|
||||
</form>
|
||||
<template #footer>
|
||||
<NuxtLink to="/" class="text-sm text-neutral-500 hover:underline">Volver al tablón</NuxtLink>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user