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:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
data
|
||||||
|
.git
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
data
|
||||||
|
*.db
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:24-alpine AS build
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable pnpm
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
FROM node:24-alpine AS run
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
COPY --from=build /app/.output ./.output
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=15s \
|
||||||
|
CMD wget -q --spider http://localhost:3000/api/health || exit 1
|
||||||
|
ENTRYPOINT ["node", ".output/server/index.mjs"]
|
||||||
10
app/app.vue
Normal file
10
app/app.vue
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { check } = useAuth()
|
||||||
|
onMounted(() => check())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<NuxtPage />
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
20
app/composables/useAuth.ts
Normal file
20
app/composables/useAuth.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export function useAuth() {
|
||||||
|
const admin = useState('admin', () => false)
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const data = await $fetch('/api/auth/me')
|
||||||
|
admin.value = data.admin
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(password: string) {
|
||||||
|
await $fetch('/api/auth/login', { method: 'POST', body: { password } })
|
||||||
|
admin.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' })
|
||||||
|
admin.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return { admin, check, login, logout }
|
||||||
|
}
|
||||||
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>
|
||||||
14
nuxt.config.ts
Normal file
14
nuxt.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-01-01',
|
||||||
|
future: { compatibilityVersion: 4 },
|
||||||
|
modules: ['@nuxt/ui'],
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
adminPassword: process.env.ADMIN_PASSWORD || 'admin',
|
||||||
|
dbPath: process.env.DB_PATH || './data/brainstorm.db',
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
experimental: { tasks: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "brainstorming",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt build",
|
||||||
|
"preview": "nuxt preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.9.1",
|
||||||
|
"nuxt": "^3.17.2",
|
||||||
|
"@nuxt/ui": "^3.1.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.11.0"
|
||||||
|
}
|
||||||
8451
pnpm-lock.yaml
generated
Normal file
8451
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
server/api/analysis/index.post.ts
Normal file
44
server/api/analysis/index.post.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import db from '../../utils/db'
|
||||||
|
import { requireAdmin } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdmin(event)
|
||||||
|
|
||||||
|
const ideas = db.prepare('SELECT text, category FROM ideas WHERE hidden = 0').all() as any[]
|
||||||
|
if (ideas.length < 3) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Need at least 3 ideas' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { apiKey } = await readBody(event)
|
||||||
|
if (!apiKey) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'API key required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `Analiza las siguientes ideas de automatización propuestas por un equipo de ingeniería:
|
||||||
|
${ideas.map((i: any) => `- [${i.category}]: ${i.text}`).join('\n')}
|
||||||
|
|
||||||
|
Por favor, genera un resumen ejecutivo en formato JSON con:
|
||||||
|
1. "topFrictionPoints": Los 3 problemas más recurrentes.
|
||||||
|
2. "quickWins": Automatizaciones fáciles de implementar con alto impacto.
|
||||||
|
3. "strategicAdvice": Recomendación sobre qué priorizar.
|
||||||
|
Responde SOLO con el JSON.`
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview-05-20:generateContent?key=${apiKey}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
generationConfig: { responseMimeType: 'application/json' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'AI API failed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return JSON.parse(data.candidates[0].content.parts[0].text)
|
||||||
|
})
|
||||||
17
server/api/auth/login.post.ts
Normal file
17
server/api/auth/login.post.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { checkPassword, createSession } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { password } = await readBody(event)
|
||||||
|
if (!password || !checkPassword(password)) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Invalid password' })
|
||||||
|
}
|
||||||
|
const token = createSession()
|
||||||
|
setCookie(event, 'session', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 86400,
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
8
server/api/auth/logout.post.ts
Normal file
8
server/api/auth/logout.post.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { destroySession } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const token = getCookie(event, 'session')
|
||||||
|
if (token) destroySession(token)
|
||||||
|
deleteCookie(event, 'session', { path: '/' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
5
server/api/auth/me.get.ts
Normal file
5
server/api/auth/me.get.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { isAdmin } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
return { admin: isAdmin(event) }
|
||||||
|
})
|
||||||
1
server/api/health.get.ts
Normal file
1
server/api/health.get.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default defineEventHandler(() => ({ status: 'ok' }))
|
||||||
9
server/api/ideas/[id].delete.ts
Normal file
9
server/api/ideas/[id].delete.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import db from '../../utils/db'
|
||||||
|
import { requireAdmin } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
requireAdmin(event)
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
db.prepare('DELETE FROM ideas WHERE id = ?').run(id)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
20
server/api/ideas/[id].patch.ts
Normal file
20
server/api/ideas/[id].patch.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import db from '../../utils/db'
|
||||||
|
import { requireAdmin } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
requireAdmin(event)
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (body.hidden !== undefined) {
|
||||||
|
db.prepare('UPDATE ideas SET hidden = ? WHERE id = ?').run(body.hidden ? 1 : 0, id)
|
||||||
|
}
|
||||||
|
if (body.text !== undefined) {
|
||||||
|
db.prepare('UPDATE ideas SET text = ? WHERE id = ?').run(body.text, id)
|
||||||
|
}
|
||||||
|
if (body.category !== undefined) {
|
||||||
|
db.prepare('UPDATE ideas SET category = ? WHERE id = ?').run(body.category, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
10
server/api/ideas/index.get.ts
Normal file
10
server/api/ideas/index.get.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import db from '../../utils/db'
|
||||||
|
import { isAdmin } from '../../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const admin = isAdmin(event)
|
||||||
|
const rows = admin
|
||||||
|
? db.prepare('SELECT * FROM ideas ORDER BY created_at DESC').all()
|
||||||
|
: db.prepare('SELECT * FROM ideas WHERE hidden = 0 ORDER BY created_at DESC').all()
|
||||||
|
return rows
|
||||||
|
})
|
||||||
11
server/api/ideas/index.post.ts
Normal file
11
server/api/ideas/index.post.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import db from '../../utils/db'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { text, category } = await readBody(event)
|
||||||
|
if (!text?.trim()) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Text is required' })
|
||||||
|
}
|
||||||
|
const cat = category || 'General'
|
||||||
|
const result = db.prepare('INSERT INTO ideas (text, category) VALUES (?, ?)').run(text.trim(), cat)
|
||||||
|
return { id: result.lastInsertRowid }
|
||||||
|
})
|
||||||
19
server/api/votes/[id].post.ts
Normal file
19
server/api/votes/[id].post.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
import db from '../../utils/db'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
|
||||||
|
const voterHash = createHash('sha256').update(ip + ':' + id).digest('hex')
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare('INSERT INTO vote_log (idea_id, voter_hash) VALUES (?, ?)').run(id, voterHash)
|
||||||
|
db.prepare('UPDATE ideas SET votes = votes + 1 WHERE id = ?').run(id)
|
||||||
|
} catch {
|
||||||
|
// UNIQUE constraint = already voted
|
||||||
|
throw createError({ statusCode: 409, statusMessage: 'Already voted' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare('SELECT votes FROM ideas WHERE id = ?').get(id) as any
|
||||||
|
return { votes: row?.votes ?? 0 }
|
||||||
|
})
|
||||||
6
server/middleware/auth.ts
Normal file
6
server/middleware/auth.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { validateSession } from '../utils/auth'
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const token = getCookie(event, 'session')
|
||||||
|
event.context.isAdmin = token ? validateSession(token) : false
|
||||||
|
})
|
||||||
41
server/utils/auth.ts
Normal file
41
server/utils/auth.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { randomBytes, timingSafeEqual } from 'crypto'
|
||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import db from './db'
|
||||||
|
|
||||||
|
export function createSession(): string {
|
||||||
|
const token = randomBytes(32).toString('hex')
|
||||||
|
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
db.prepare('INSERT INTO sessions (token, expires_at) VALUES (?, ?)').run(token, expires)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSession(token: string): boolean {
|
||||||
|
const row = db.prepare('SELECT expires_at FROM sessions WHERE token = ?').get(token) as any
|
||||||
|
if (!row) return false
|
||||||
|
if (new Date(row.expires_at) < new Date()) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroySession(token: string) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkPassword(input: string): boolean {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const expected = config.adminPassword as string
|
||||||
|
if (input.length !== expected.length) return false
|
||||||
|
return timingSafeEqual(Buffer.from(input), Buffer.from(expected))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAdmin(event: H3Event): boolean {
|
||||||
|
return event.context.isAdmin === true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAdmin(event: H3Event) {
|
||||||
|
if (!isAdmin(event)) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
||||||
|
}
|
||||||
|
}
|
||||||
39
server/utils/db.ts
Normal file
39
server/utils/db.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { mkdirSync } from 'fs'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const dbPath = config.dbPath as string
|
||||||
|
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true })
|
||||||
|
|
||||||
|
const db = new Database(dbPath)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
db.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ideas (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'General',
|
||||||
|
votes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
hidden INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vote_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
idea_id INTEGER NOT NULL REFERENCES ideas(id) ON DELETE CASCADE,
|
||||||
|
voter_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(idea_id, voter_hash)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
export default db
|
||||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user