feat(frontend): rediseño completo de vistas y componentes UI

Actualiza todas las vistas principales (Dashboard, Analysis, Scripts, Generate),
barra lateral, topbar y agrega sistema de toasts con composable useToast.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 06:41:45 -05:00
parent 6982f1d4d2
commit 6fe118bc26
11 changed files with 1141 additions and 323 deletions

View File

@ -2,9 +2,15 @@
<div class="bg-canvas min-h-screen text-ink selection:bg-accent/20 selection:text-accent">
<template v-if="isAuthenticated">
<SideNavBar />
<TopAppBar />
<main class="ml-60 pt-16 pb-12 px-8 min-h-screen">
<SideNavBar :open="sidebarOpen" @close="sidebarOpen = false" />
<!-- Mobile backdrop -->
<div
v-if="sidebarOpen"
class="fixed inset-0 z-30 bg-black/50 md:hidden"
@click="sidebarOpen = false"
/>
<TopAppBar @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<main class="md:ml-60 pt-16 pb-12 px-4 md:px-8 min-h-screen">
<div class="max-w-7xl mx-auto pt-8">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
@ -19,23 +25,27 @@
<router-view />
</template>
<ToastContainer />
</div>
</template>
<script setup>
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useAuthStore } from './stores/auth.js'
import SideNavBar from './components/SideNavBar.vue'
import TopAppBar from './components/TopAppBar.vue'
import ToastContainer from './components/ToastContainer.vue'
const auth = useAuthStore()
const { isAuthenticated } = storeToRefs(auth)
const sidebarOpen = ref(false)
</script>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
transition: opacity 0.15s cubic-bezier(0.23, 1, 0.32, 1);
}
.fade-enter-from,
.fade-leave-to {

View File

@ -1,10 +1,14 @@
<template>
<aside class="fixed left-0 top-0 h-full z-40 flex flex-col w-60 bg-surface border-r border-border font-body">
<aside
class="fixed left-0 top-0 h-full z-40 flex flex-col w-60 bg-surface border-r border-border font-body
transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]"
:class="open ? 'translate-x-0' : '-translate-x-full md:translate-x-0'"
>
<!-- Logo -->
<div class="px-5 py-5 border-b border-border">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-lg bg-accent flex items-center justify-center shrink-0">
<div class="w-8 h-8 rounded-lg bg-accent flex items-center justify-center shrink-0 shadow-sm shadow-accent/30">
<span class="material-symbols-outlined text-white text-[18px]" style="font-variation-settings:'FILL' 1;">psychology</span>
</div>
<div>
@ -15,40 +19,49 @@
</div>
<!-- Navegación -->
<nav class="flex-1 px-3 py-4 space-y-0.5">
<nav class="flex-1 px-3 py-4 space-y-0.5" role="navigation" aria-label="Navegación principal">
<router-link
to="/"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium"
active-class="bg-accent-subtle !text-accent font-semibold"
class="relative flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium group"
active-class="bg-accent-subtle !text-accent font-semibold is-active"
exact
@click="$emit('close')"
>
<span class="material-symbols-outlined text-[20px]">dashboard</span>
<span class="active-bar absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent rounded-full opacity-0 transition-opacity"></span>
<span class="material-symbols-outlined text-[20px] transition-all group-hover:scale-110">dashboard</span>
<span>Dashboard</span>
</router-link>
<router-link
to="/analysis"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium"
active-class="bg-accent-subtle !text-accent font-semibold"
class="relative flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium group"
active-class="bg-accent-subtle !text-accent font-semibold is-active"
@click="$emit('close')"
>
<span class="material-symbols-outlined text-[20px]">analytics</span>
<span class="active-bar absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent rounded-full opacity-0 transition-opacity"></span>
<span class="material-symbols-outlined text-[20px] transition-all group-hover:scale-110">analytics</span>
<span>Análisis</span>
</router-link>
<router-link
to="/scripts"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium"
active-class="bg-accent-subtle !text-accent font-semibold"
class="relative flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium group"
active-class="bg-accent-subtle !text-accent font-semibold is-active"
@click="$emit('close')"
>
<span class="material-symbols-outlined text-[20px]">description</span>
<span class="active-bar absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent rounded-full opacity-0 transition-opacity"></span>
<span class="material-symbols-outlined text-[20px] transition-all group-hover:scale-110">description</span>
<span>Guiones</span>
</router-link>
<router-link
to="/generate"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium"
active-class="bg-accent-subtle !text-accent font-semibold"
class="relative flex items-center gap-3 px-3 py-2.5 rounded-lg text-ink-2 hover:bg-surface-muted hover:text-ink transition-all text-sm font-medium group"
active-class="bg-accent-subtle !text-accent font-semibold is-active"
@click="$emit('close')"
>
<span class="material-symbols-outlined text-[20px]">auto_fix_high</span>
<span class="active-bar absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent rounded-full opacity-0 transition-opacity"></span>
<span class="material-symbols-outlined text-[20px] transition-all group-hover:scale-110">auto_fix_high</span>
<span>Generar</span>
</router-link>
</nav>
@ -57,7 +70,10 @@
<div class="px-3 pb-5">
<router-link
to="/new-analysis"
class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-accent hover:bg-accent-hover text-white rounded-lg text-sm font-semibold transition-colors shadow-sm"
class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-accent hover:bg-accent-hover
text-white rounded-lg text-sm font-semibold transition-all shadow-sm
active:scale-[0.97] hover:shadow-md hover:shadow-accent/20"
@click="$emit('close')"
>
<span class="material-symbols-outlined text-[18px]">add</span>
Nuevo Análisis
@ -67,4 +83,13 @@
</template>
<script setup>
defineProps({ open: Boolean })
defineEmits(['close'])
</script>
<style scoped>
.router-link-active.is-active .active-bar,
.router-link-exact-active.is-active .active-bar {
opacity: 1;
}
</style>

View File

@ -0,0 +1,54 @@
<template>
<Teleport to="body">
<div class="fixed bottom-5 right-5 z-[9999] flex flex-col gap-2 pointer-events-none" aria-live="polite">
<TransitionGroup
enter-active-class="transition-all duration-300"
enter-from-class="opacity-0 translate-y-2 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-1 scale-95"
>
<div
v-for="t in toasts"
:key="t.id"
class="pointer-events-auto flex items-center gap-3 px-4 py-3
bg-surface-muted border border-border rounded-xl shadow-2xl shadow-black/40
min-w-[260px] max-w-[360px]"
>
<span
class="material-symbols-outlined text-[18px] shrink-0"
:class="{
'text-success': t.type === 'success',
'text-error': t.type === 'error',
'text-accent': t.type === 'info',
'text-warn': t.type === 'warn',
'text-ink-2': t.type === 'default',
}"
style="font-variation-settings:'FILL' 1;"
>
{{ iconForType(t.type) }}
</span>
<p class="text-sm text-ink font-medium leading-snug">{{ t.message }}</p>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<script setup>
import { useToast } from '../composables/useToast.js'
const { toasts } = useToast()
function iconForType(type) {
const map = {
success: 'check_circle',
error: 'error',
info: 'info',
warn: 'warning',
default: 'notifications',
}
return map[type] ?? map.default
}
</script>

View File

@ -1,35 +1,45 @@
<template>
<header class="fixed top-0 right-0 left-60 h-16 flex items-center justify-between px-8 z-30 bg-surface border-b border-border">
<header class="fixed top-0 right-0 left-0 md:left-60 h-16 flex items-center justify-between px-4 md:px-8 z-30
bg-surface/90 backdrop-blur-md border-b border-border">
<!-- Hamburger (mobile only) -->
<button
class="flex md:hidden items-center justify-center w-8 h-8 rounded-lg text-ink-3 hover:text-ink hover:bg-surface-muted transition-all active:scale-95 mr-2"
@click="$emit('toggle-sidebar')"
aria-label="Abrir navegación"
>
<span class="material-symbols-outlined text-[22px]">menu</span>
</button>
<!-- Búsqueda -->
<div class="relative w-80">
<div class="relative w-72 hidden sm:block">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-ink-3 text-[18px]">search</span>
<input
class="w-full bg-canvas border border-border rounded-lg pl-9 pr-4 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/40 transition-all"
v-model="query"
class="w-full bg-canvas border border-border rounded-lg pl-9 pr-4 py-2 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30
focus:border-accent/40 transition-all"
placeholder="Buscar guiones..."
type="text"
@keydown.enter="irABusqueda"
/>
</div>
<!-- Acciones -->
<div class="flex items-center gap-4">
<button class="relative text-ink-3 hover:text-ink-2 transition-colors p-1.5 rounded-lg hover:bg-surface-muted">
<span class="material-symbols-outlined text-[22px]">notifications</span>
<span class="absolute top-1 right-1 w-1.5 h-1.5 bg-accent rounded-full"></span>
</button>
<div class="flex items-center gap-4 ml-auto">
<div class="flex items-center gap-3 pl-4 border-l border-border">
<div class="w-8 h-8 rounded-full bg-accent-subtle border border-accent-border flex items-center justify-center">
<span class="material-symbols-outlined text-accent text-[18px]" style="font-variation-settings:'FILL' 1;">account_circle</span>
</div>
<div class="text-right">
<div class="text-right hidden sm:block">
<p class="text-xs font-semibold text-ink leading-none">Marketing Pro</p>
<p class="text-[10px] text-ink-3 mt-0.5">Administrador</p>
</div>
<button
@click="handleLogout"
title="Cerrar sesión"
class="ml-1 text-ink-3 hover:text-red-400 transition-colors p-1.5 rounded-lg hover:bg-surface-muted"
class="ml-1 text-ink-3 hover:text-red-400 transition-colors p-1.5 rounded-lg hover:bg-surface-muted active:scale-95"
aria-label="Cerrar sesión"
>
<span class="material-symbols-outlined text-[20px]">logout</span>
</button>
@ -39,14 +49,24 @@
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'
defineEmits(['toggle-sidebar'])
const router = useRouter()
const auth = useAuthStore()
const query = ref('')
function handleLogout() {
auth.logout()
router.push('/login')
}
function irABusqueda() {
if (!query.value.trim()) return
router.push({ path: '/scripts', query: { q: query.value.trim() } })
query.value = ''
}
</script>

View File

@ -0,0 +1,22 @@
import { ref } from 'vue'
const toasts = ref([])
let nextId = 0
export function useToast() {
function add(message, { type = 'default', duration = 3500 } = {}) {
const id = nextId++
toasts.value.push({ id, message, type })
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id)
}, duration)
}
return {
toasts,
success: (msg) => add(msg, { type: 'success' }),
error: (msg) => add(msg, { type: 'error', duration: 5000 }),
info: (msg) => add(msg, { type: 'info' }),
warn: (msg) => add(msg, { type: 'warn' }),
}
}

View File

@ -9,12 +9,14 @@
</div>
<div class="flex items-center gap-3">
<button
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm flex items-center gap-2 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm
flex items-center gap-2 transition-all shadow-sm active:scale-[0.97]
disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-md hover:shadow-accent/20"
:disabled="analizando"
@click="iniciarAnalisis"
>
<span class="material-symbols-outlined text-[18px]" :class="analizando ? 'animate-spin' : ''">
{{ analizando ? 'hourglass_top' : 'play_arrow' }}
{{ analizando ? 'progress_activity' : 'play_arrow' }}
</span>
{{ analizando ? 'Analizando…' : 'Iniciar Análisis' }}
</button>
@ -23,7 +25,7 @@
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
<!-- Columna del Formulario -->
<!-- Formulario -->
<div class="xl:col-span-7 flex flex-col gap-6">
<!-- Paso 1: Fuente del Video -->
@ -34,18 +36,40 @@
</div>
<div class="p-6 space-y-5">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">URL del Video</label>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
URL del Video
<span class="text-error font-normal normal-case ml-1 text-[11px]">obligatorio</span>
</label>
<div class="relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-ink-3 text-[18px]">link</span>
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-ink-3 text-[18px] transition-colors"
:class="urlState === 'valid' ? 'text-success' : urlState === 'invalid' ? 'text-error' : ''">link</span>
<input
v-model="form.url"
@input="validarUrl"
type="url"
placeholder="https://www.tiktok.com/@usuario/video/..."
class="w-full bg-canvas border border-border rounded-lg pl-10 pr-4 py-2.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/40 transition-all"
class="w-full bg-canvas border rounded-lg pl-10 pr-10 py-2.5 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 transition-all"
:class="urlState === 'valid' ? 'border-success/50 focus:ring-success/20' :
urlState === 'invalid' ? 'border-error/50 focus:ring-error/20' :
'border-border focus:ring-accent/30 focus:border-accent/40'"
:disabled="analizando"
/>
<span v-if="urlState !== 'idle'"
class="material-symbols-outlined absolute right-3 top-1/2 -translate-y-1/2 text-[18px] transition-all"
:class="urlState === 'valid' ? 'text-success' : 'text-error'"
style="font-variation-settings:'FILL' 1;">
{{ urlState === 'valid' ? 'check_circle' : 'cancel' }}
</span>
</div>
<p class="text-[11px] text-ink-3">Compatible con TikTok, Instagram Reels y YouTube Shorts.</p>
<p class="text-[11px] transition-colors"
:class="urlState === 'valid' ? 'text-success' :
urlState === 'invalid' ? 'text-error' :
'text-ink-3'">
{{ urlState === 'valid' ? 'URL compatible detectada' :
urlState === 'invalid' ? 'Solo TikTok, Instagram Reels o YouTube Shorts' :
'Compatible con TikTok, Instagram Reels y YouTube Shorts.' }}
</p>
</div>
<div class="grid grid-cols-2 gap-4">
@ -53,7 +77,8 @@
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Cliente</label>
<select
v-model="form.cliente_id"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all"
:disabled="analizando"
>
<option :value="null">Interno / Sin cliente</option>
@ -61,12 +86,13 @@
</select>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Proyecto (opcional)</label>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Proyecto <span class="text-ink-3 font-normal normal-case">(opcional)</span></label>
<input
v-model="form.proyecto_nombre"
type="text"
placeholder="Ej. Campaña Q1"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
:disabled="analizando"
/>
</div>
@ -83,12 +109,16 @@
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Nicho Principal</label>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
Nicho Principal
<span class="text-error font-normal normal-case ml-1 text-[11px]">obligatorio</span>
</label>
<input
v-model="form.niche"
list="nichos-list"
placeholder="Seleccionar o escribir…"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
:disabled="analizando"
/>
<datalist id="nichos-list">
@ -101,7 +131,8 @@
v-model="form.mercado_objetivo"
type="text"
placeholder="Ej. Emprendedoras 25-35 años"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
:disabled="analizando"
/>
</div>
@ -109,16 +140,29 @@
<div class="grid grid-cols-3 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Vistas <span class="text-error normal-case font-normal">*</span></label>
<input v-model.number="form.vistas" type="number" min="1" placeholder="Ej. 250000" class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink text-center focus:outline-none focus:ring-2 focus:ring-accent/30" :disabled="analizando"/>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
Vistas <span class="text-error ml-1 text-[11px] font-normal normal-case">obligatorio</span>
</label>
<input v-model.number="form.vistas" type="number" min="1" placeholder="250000"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink text-center
focus:outline-none focus:ring-2 focus:ring-accent/30 tabular-nums"
:disabled="analizando"/>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Likes <span class="text-error normal-case font-normal">*</span></label>
<input v-model.number="form.likes" type="number" min="1" placeholder="Ej. 18000" class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink text-center focus:outline-none focus:ring-2 focus:ring-accent/30" :disabled="analizando"/>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
Likes <span class="text-error ml-1 text-[11px] font-normal normal-case">obligatorio</span>
</label>
<input v-model.number="form.likes" type="number" min="1" placeholder="18000"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink text-center
focus:outline-none focus:ring-2 focus:ring-accent/30 tabular-nums"
:disabled="analizando"/>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Compartidos</label>
<input v-model.number="form.compartidos" type="number" min="0" placeholder="Ej. 3200" class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink text-center focus:outline-none focus:ring-2 focus:ring-accent/30" :disabled="analizando"/>
<input v-model.number="form.compartidos" type="number" min="0" placeholder="3200"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink text-center
focus:outline-none focus:ring-2 focus:ring-accent/30 tabular-nums"
:disabled="analizando"/>
</div>
</div>
@ -126,7 +170,7 @@
<input
v-model="form.competidor_referente"
type="checkbox"
class="w-4 h-4 rounded border-border text-accent focus:ring-accent/30 focus:ring-offset-0 transition-all cursor-pointer"
class="w-4 h-4 rounded border-border text-accent focus:ring-accent/30 focus:ring-offset-0 cursor-pointer"
:disabled="analizando"
/>
<span class="text-sm text-ink-2 group-hover:text-ink transition-colors select-none">
@ -142,18 +186,20 @@
<span class="w-6 h-6 rounded-md bg-warn text-white text-xs font-bold flex items-center justify-center">3</span>
<h2 class="text-sm font-semibold text-ink">
Contexto del Video
<span class="text-ink-3 font-normal ml-1">(opcional)</span>
<span class="text-ink-3 font-normal ml-1 text-xs">(opcional)</span>
</h2>
</div>
<div class="p-6">
<p class="text-xs text-ink-3 leading-relaxed mb-3">
Describe la intención del creador o cualquier contexto que la transcripción no capture. GPT-4o lo usará para enriquecer el análisis.
Describe la intención del creador o cualquier contexto que la transcripción no capture.
</p>
<textarea
v-model="form.contexto_video"
rows="3"
placeholder="Ej. Este video responde a una tendencia viral usando humor sarcástico dirigido a emprendedores…"
class="w-full bg-canvas border border-border rounded-lg px-4 py-3 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 resize-none leading-relaxed transition-all"
placeholder="Ej. Este video responde a una tendencia viral usando humor sarcástico…"
class="w-full bg-canvas border border-border rounded-lg px-4 py-3 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30
resize-none leading-relaxed transition-all"
:disabled="analizando"
></textarea>
</div>
@ -178,23 +224,22 @@
<div class="p-6">
<div class="space-y-5 relative">
<!-- Línea vertical conectora -->
<div class="absolute left-[14px] top-4 bottom-4 w-px bg-border z-0"></div>
<div
v-for="(s, idx) in pasosVisibles"
:key="s.id"
class="flex gap-4 relative z-10"
class="flex gap-4 relative z-10 transition-opacity duration-300"
:class="idx > currentStepIdx && analizando ? 'opacity-40' : 'opacity-100'"
>
<div
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all duration-300 bg-surface"
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0 transition-all duration-300"
:class="
idx < currentStepIdx
? 'bg-success border-2 border-success text-white step-pulse'
? 'bg-success border-2 border-success text-white step-complete'
: idx === currentStepIdx && analizando
? 'bg-accent border-2 border-accent text-white step-pulse'
: 'border-2 border-border text-ink-3'
: 'bg-surface border-2 border-border text-ink-3'
"
>
<span class="material-symbols-outlined text-[14px]">{{ idx < currentStepIdx ? 'check' : s.icon }}</span>
@ -206,27 +251,44 @@
</div>
</div>
<!-- Progreso -->
<!-- Progreso con tiempo transcurrido -->
<div v-if="analizando" class="mt-6 pt-5 border-t border-border space-y-2">
<div class="w-full bg-surface-subtle h-1.5 rounded-full overflow-hidden">
<div
class="bg-accent h-full rounded-full transition-all duration-500"
class="bg-accent h-full rounded-full transition-all duration-700"
:style="{ width: ((currentStepIdx / 3) * 100) + '%' }"
></div>
</div>
<p class="text-[11px] text-ink-3 text-center">Tiempo estimado: ~15 segundos · GPT-4o + Whisper</p>
<div class="flex items-center justify-between">
<p class="text-[11px] text-ink-3">GPT-4o + Whisper</p>
<p class="text-[11px] text-ink-3 tabular-nums font-medium">{{ elapsed }}s</p>
</div>
</div>
<!-- Error -->
<div v-if="error" class="mt-5 p-4 rounded-lg bg-error-subtle border border-error-border text-error text-sm">
<p class="font-semibold mb-1">Error en el pipeline</p>
<p class="text-[12px] leading-relaxed">{{ error }}</p>
<!-- Error con retry -->
<div v-if="error" class="mt-5 p-4 rounded-lg bg-error-subtle border border-error-border">
<div class="flex items-start gap-3 mb-3">
<span class="material-symbols-outlined text-error text-[18px] shrink-0 mt-0.5"
style="font-variation-settings:'FILL' 1;">error</span>
<div>
<p class="text-sm font-semibold text-error mb-1">Error en el pipeline</p>
<p class="text-[12px] text-error/80 leading-relaxed">{{ error }}</p>
</div>
</div>
<button
@click="error = null; iniciarAnalisis()"
class="w-full px-3 py-2 bg-error/10 border border-error-border text-error text-xs font-semibold
rounded-lg hover:bg-error/20 transition-colors flex items-center justify-center gap-1.5 active:scale-[0.97]"
>
<span class="material-symbols-outlined text-[14px]">refresh</span>
Reintentar análisis
</button>
</div>
<!-- Estado inicial -->
<div v-if="!analizando && !error" class="mt-6 pt-5 border-t border-border">
<p class="text-[11px] text-ink-3 leading-relaxed">
Completa la URL y el nicho para iniciar. El pipeline extrae audio, transcribe y analiza los patrones de neuromarketing automáticamente.
Completa la URL y el nicho para iniciar. El pipeline extrae audio, transcribe con Whisper y analiza los patrones de neuromarketing automáticamente.
</p>
</div>
</div>
@ -237,7 +299,7 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
@ -245,6 +307,11 @@ const router = useRouter()
const analizando = ref(false)
const error = ref(null)
const paso = ref('inicio')
const elapsed = ref(0)
let elapsedTimer = null
const urlState = ref('idle') // 'idle' | 'valid' | 'invalid'
const URL_REGEX = /^https?:\/\/(www\.)?(tiktok\.com|vm\.tiktok\.com|instagram\.com|youtube\.com|youtu\.be)/
const nichos = ref([])
const clientes = ref([])
@ -267,7 +334,7 @@ const pasosVisibles = [
{ id: 'extraccion', label: 'Extracción de Audio', icon: 'downloading', desc: 'Descargando el video y extrayendo el audio.' },
{ id: 'transcripcion', label: 'Transcripción Whisper', icon: 'mic', desc: 'Convirtiendo audio a texto con precisión.' },
{ id: 'analisis', label: 'Análisis con GPT-4o', icon: 'psychology', desc: 'Identificando patrones de neuromarketing.' },
{ id: 'embedding', label: 'Codificación Vectorial', icon: 'hub', desc: 'Guardando en la base de datos.' },
{ id: 'embedding', label: 'Codificación Vectorial', icon: 'hub', desc: 'Guardando análisis en la base de datos.' },
]
const currentStepIdx = computed(() => {
@ -275,6 +342,11 @@ const currentStepIdx = computed(() => {
return pasosVisibles.findIndex(p => p.id === paso.value)
})
function validarUrl() {
if (!form.value.url) { urlState.value = 'idle'; return }
urlState.value = URL_REGEX.test(form.value.url) ? 'valid' : 'invalid'
}
onMounted(async () => {
try {
const [n, c] = await Promise.all([api.nichos(), api.clientes()])
@ -292,35 +364,50 @@ async function iniciarAnalisis() {
error.value = "Vistas y Likes son obligatorios. Cópialos directamente del video antes de analizar."
return
}
const URL_SOPORTADAS = /^https?:\/\/(www\.)?(tiktok\.com|vm\.tiktok\.com|instagram\.com|youtube\.com|youtu\.be)/
if (!URL_SOPORTADAS.test(form.value.url)) {
if (!URL_REGEX.test(form.value.url)) {
error.value = "URL no soportada. Solo se aceptan TikTok, Instagram Reels y YouTube Shorts."
urlState.value = 'invalid'
return
}
analizando.value = true
error.value = null
paso.value = 'extraccion'
elapsed.value = 0
elapsedTimer = setInterval(() => elapsed.value++, 1000)
const fakeInterval = setInterval(() => {
if (paso.value === 'extraccion') paso.value = 'transcripcion';
else if (paso.value === 'transcripcion') paso.value = 'analisis';
else if (paso.value === 'analisis') paso.value = 'embedding';
if (paso.value === 'extraccion') paso.value = 'transcripcion'
else if (paso.value === 'transcripcion') paso.value = 'analisis'
else if (paso.value === 'analisis') paso.value = 'embedding'
}, 4000)
try {
const res = await api.analizar(form.value)
clearInterval(fakeInterval)
clearInterval(elapsedTimer)
paso.value = 'embedding'
setTimeout(() => {
router.push({ name: 'AnalysisDetail', params: { id: res.guion_id } })
}, 1000)
}, 800)
} catch (err) {
clearInterval(fakeInterval)
clearInterval(elapsedTimer)
error.value = err.message
} finally {
analizando.value = false
}
}
</script>
<style>
@keyframes step-complete {
0% { transform: scale(1); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.step-complete {
animation: step-complete 0.3s cubic-bezier(0.23, 1, 0.32, 1);
}
</style>

View File

@ -5,15 +5,20 @@
<p class="text-sm text-ink-3">Cargando análisis</p>
</div>
<div v-else-if="guion" class="flex flex-col gap-8 pb-12">
<div v-else-if="guion" class="flex flex-col gap-6 pb-12">
<!-- Breadcrumbs -->
<nav class="flex items-center gap-1.5 text-xs text-ink-3">
<router-link to="/" class="hover:text-ink transition-colors">Dashboard</router-link>
<span class="material-symbols-outlined text-[14px] text-border-strong">chevron_right</span>
<router-link to="/analysis" class="hover:text-ink transition-colors">Análisis</router-link>
<span class="material-symbols-outlined text-[14px] text-border-strong">chevron_right</span>
<span class="text-ink-2 font-medium truncate max-w-48">{{ guion.tema_principal || 'Detalle' }}</span>
</nav>
<!-- Encabezado -->
<header class="flex flex-col md:flex-row md:items-start justify-between gap-6 pb-6 border-b border-border">
<div class="flex-1">
<router-link to="/analysis" class="inline-flex items-center gap-1.5 text-xs font-medium text-ink-3 hover:text-accent transition-colors mb-4 uppercase tracking-wider">
<span class="material-symbols-outlined text-base">west</span> Volver al historial
</router-link>
<div class="flex items-center gap-2 flex-wrap mb-3">
<span class="text-[11px] font-semibold px-2.5 py-1 bg-surface-muted border border-border rounded-md text-ink-2 uppercase tracking-wide">{{ guion.niche }}</span>
<span v-if="guion.sub_niche" class="text-[11px] px-2.5 py-1 bg-canvas border border-border rounded-md text-ink-3">{{ guion.sub_niche }}</span>
@ -22,7 +27,6 @@
Replicabilidad {{ guion.replicabilidad }}
</span>
</div>
<h1 class="text-3xl font-bold font-headline text-ink mb-2 leading-tight max-w-2xl">
{{ guion.tema_principal || 'Análisis sin título' }}
</h1>
@ -31,23 +35,43 @@
{{ guion.angulo_unico || 'Ángulo único no especificado' }}
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button
v-if="guion.url_origen"
class="h-9 w-9 rounded-lg bg-canvas border border-border flex items-center justify-center text-ink-2 hover:bg-surface-muted hover:text-ink transition-colors"
class="h-9 w-9 rounded-lg bg-canvas border border-border flex items-center justify-center text-ink-2
hover:bg-surface-muted hover:text-ink transition-all active:scale-95"
title="Ver video original"
aria-label="Ver video original"
@click="openUrl(guion.url_origen)"
>
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
</button>
<router-link to="/generate" class="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm flex items-center gap-1.5 transition-colors shadow-sm">
<router-link
to="/generate"
class="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm
flex items-center gap-1.5 transition-all shadow-sm active:scale-[0.97]"
>
<span class="material-symbols-outlined text-[16px]">auto_fix_high</span>
Generar Guion
</router-link>
</div>
</header>
<!-- Sticky Tab Bar -->
<nav class="sticky top-16 z-20 bg-canvas/90 backdrop-blur-md border-b border-border -mx-4 md:-mx-8 px-4 md:px-8 flex gap-0 overflow-x-auto scrollbar-hide">
<a
v-for="tab in tabs"
:key="tab.id"
:href="'#' + tab.id"
class="px-3 py-3 text-xs font-medium text-ink-3 hover:text-ink border-b-2 border-transparent
hover:border-border-strong transition-all whitespace-nowrap shrink-0"
:class="tabActivo === tab.id ? '!text-accent !border-accent' : ''"
@click.prevent="scrollToSection(tab.id)"
>
{{ tab.label }}
</a>
</nav>
<!-- Cuadrícula principal -->
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6">
@ -55,7 +79,7 @@
<div class="xl:col-span-4 flex flex-col gap-4">
<!-- Métricas Sociales -->
<div v-if="guion.vistas || guion.likes || guion.compartidos" class="bg-surface rounded-xl border border-border shadow-sm p-5">
<div v-if="guion.vistas || guion.likes || guion.compartidos" id="metricas" class="bg-surface rounded-xl border border-border shadow-sm p-5">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-ink-3 text-[16px]">bar_chart</span>
Métricas del Video
@ -64,61 +88,66 @@
<div class="text-center p-3 rounded-lg bg-surface-muted border border-border">
<span class="material-symbols-outlined text-blue-500 text-lg block mb-1">visibility</span>
<p class="text-[9px] text-ink-3 font-semibold uppercase tracking-wider mb-1">Vistas</p>
<p class="text-base font-bold text-ink">{{ formatNum(guion.vistas) }}</p>
<p class="text-base font-bold text-ink tabular-nums">{{ formatNum(guion.vistas) }}</p>
</div>
<div class="text-center p-3 rounded-lg bg-surface-muted border border-border">
<span class="material-symbols-outlined text-red-500 text-lg block mb-1" style="font-variation-settings:'FILL' 1;">favorite</span>
<p class="text-[9px] text-ink-3 font-semibold uppercase tracking-wider mb-1">Likes</p>
<p class="text-base font-bold text-ink">{{ formatNum(guion.likes) }}</p>
<p class="text-base font-bold text-ink tabular-nums">{{ formatNum(guion.likes) }}</p>
</div>
<div class="text-center p-3 rounded-lg bg-surface-muted border border-border">
<span class="material-symbols-outlined text-success text-lg block mb-1">share</span>
<p class="text-[9px] text-ink-3 font-semibold uppercase tracking-wider mb-1">Compartidos</p>
<p class="text-base font-bold text-ink">{{ formatNum(guion.compartidos) }}</p>
<p class="text-base font-bold text-ink tabular-nums">{{ formatNum(guion.compartidos) }}</p>
</div>
</div>
<div v-if="guion.score_engagement" class="mt-3 flex items-center justify-between px-3 py-2 rounded-lg bg-success-subtle border border-success-border">
<span class="text-[11px] text-success font-semibold">Engagement Rate</span>
<span class="text-sm font-bold text-success">{{ (guion.score_engagement * 1).toFixed(2) }}%</span>
<span class="text-sm font-bold text-success tabular-nums">{{ (guion.score_engagement * 1).toFixed(2) }}%</span>
</div>
</div>
<!-- Puntaje Circular -->
<div class="bg-surface rounded-xl border border-border shadow-sm p-6">
<div id="resumen" class="bg-surface rounded-xl border border-border shadow-sm p-6">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-5 flex items-center gap-1.5">
<span class="material-symbols-outlined text-accent text-[16px]">analytics</span>
Puntaje de Viralidad
</h3>
<div class="flex justify-center mb-5 relative">
<svg class="w-40 h-40 transform -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke="#e5e3de" stroke-width="7"/>
<circle cx="50" cy="50" r="42" fill="none" stroke="currentColor" stroke-width="7" class="text-surface-subtle"/>
<circle
cx="50" cy="50" r="42" fill="none"
class="stroke-accent transition-all duration-1000 ease-out"
:class="scoreArcColor(guion.score_virabilidad)"
stroke-width="7"
:stroke-dasharray="`${(guion.score_virabilidad || 0)/100 * 264} 264`"
stroke-linecap="round"
class="transition-all duration-1000 ease-out"
:style="(guion.score_virabilidad || 0) >= 80 ? 'filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))' :
(guion.score_virabilidad || 0) >= 60 ? 'filter: drop-shadow(0 0 6px rgba(59,130,246,0.4))' : ''"
/>
</svg>
<div class="absolute flex flex-col items-center justify-center" style="margin-top: 30px;">
<span class="text-5xl font-bold font-headline text-ink">{{ guion.score_virabilidad || 0 }}</span>
<span class="text-5xl font-bold font-headline tabular-nums" :class="scoreTextColor(guion.score_virabilidad)">
{{ guion.score_virabilidad || 0 }}
</span>
<span class="text-xs text-ink-3 font-medium">/ 100</span>
</div>
</div>
<div class="grid grid-cols-2 gap-3 pt-4 border-t border-border">
<div class="text-center p-3 rounded-lg bg-surface-muted">
<p class="text-[10px] text-ink-3 font-medium mb-1">Cialdini</p>
<p class="text-xl font-bold text-ink">{{ guion.score_cialdini ?? 0 }}<span class="text-sm text-ink-3">/7</span></p>
<p class="text-xl font-bold text-ink tabular-nums">{{ guion.score_cialdini ?? 0 }}<span class="text-sm text-ink-3">/7</span></p>
</div>
<div class="text-center p-3 rounded-lg bg-surface-muted">
<p class="text-[10px] text-ink-3 font-medium mb-1">Intensidad</p>
<p class="text-xl font-bold text-warn">{{ guion.intensidad_emocional || 0 }}<span class="text-sm text-ink-3">/10</span></p>
<p class="text-xl font-bold text-warn tabular-nums">{{ guion.intensidad_emocional || 0 }}<span class="text-sm text-ink-3">/10</span></p>
</div>
</div>
</div>
<!-- Ganchos Semánticos -->
<div class="bg-surface rounded-xl border border-border shadow-sm p-5">
<div id="ganchos" class="bg-surface rounded-xl border border-border shadow-sm p-5">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-success text-[16px]">psychology_alt</span>
Ganchos Semánticos
@ -145,14 +174,14 @@
<div class="px-3 py-2 rounded-lg border border-accent-border bg-accent-subtle flex items-center gap-2">
<span class="material-symbols-outlined text-accent text-[16px]">repeat</span>
<span class="text-sm font-medium text-accent">{{ guion.tecnica_retencion || '—' }}</span>
<span v-if="guion.momento_pico_seg" class="ml-auto text-[10px] text-ink-3">Pico: {{ guion.momento_pico_seg }}s</span>
<span v-if="guion.momento_pico_seg" class="ml-auto text-[10px] text-ink-3 tabular-nums">Pico: {{ guion.momento_pico_seg }}s</span>
</div>
</div>
</div>
</div>
<!-- Avatar y Consciencia -->
<div class="bg-surface rounded-xl border border-border shadow-sm p-5">
<div id="avatar" class="bg-surface rounded-xl border border-border shadow-sm p-5">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-warn text-[16px]">person_search</span>
Avatar & Copywriting
@ -205,7 +234,7 @@
</div>
<!-- Patrón Ganador -->
<div class="bg-surface rounded-xl border border-accent-border shadow-sm p-6 bg-accent-subtle/30">
<div id="patron" class="bg-surface rounded-xl border border-accent-border shadow-sm p-6 bg-accent-subtle/30">
<p class="text-[10px] font-semibold text-accent uppercase tracking-wider mb-3">Síntesis del Patrón Ganador</p>
<p class="text-base text-ink leading-relaxed max-w-2xl">{{ guion.resumen_patron }}</p>
</div>
@ -227,7 +256,7 @@
</div>
<!-- Ingredientes Clave -->
<div v-if="guion.ingredientes_clave?.length" class="bg-surface rounded-xl border border-border shadow-sm p-6">
<div v-if="guion.ingredientes_clave?.length" id="estructura" class="bg-surface rounded-xl border border-border shadow-sm p-6">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-warn text-[16px]">key</span>
Ingredientes Clave para Replicar
@ -241,7 +270,7 @@
</div>
<!-- Emocional + Cialdini -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="emocional" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-surface rounded-xl border border-border shadow-sm p-5">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-orange-500 text-[16px]">local_fire_department</span>
@ -250,10 +279,10 @@
<div class="mb-4">
<div class="flex justify-between text-xs font-medium mb-1.5">
<span class="text-ink-3">Intensidad</span>
<span class="text-orange-500 font-semibold">{{ guion.intensidad_emocional || 0 }}/10</span>
<span class="text-orange-500 font-semibold tabular-nums">{{ guion.intensidad_emocional || 0 }}/10</span>
</div>
<div class="w-full bg-surface-subtle h-1.5 rounded-full overflow-hidden">
<div class="bg-orange-400 h-full rounded-full" :style="{ width: ((guion.intensidad_emocional||0)*10) + '%' }"></div>
<div class="bg-orange-400 h-full rounded-full transition-all duration-700" :style="{ width: ((guion.intensidad_emocional||0)*10) + '%' }"></div>
</div>
</div>
<div class="space-y-2">
@ -264,7 +293,7 @@
</div>
</div>
<div class="bg-surface rounded-xl border border-border shadow-sm p-5 flex flex-col">
<div id="cialdini" class="bg-surface rounded-xl border border-border shadow-sm p-5 flex flex-col">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-indigo-500 text-[16px]">group_work</span>
Principios de Cialdini
@ -282,7 +311,7 @@
</div>
<!-- Neuromarketing + Entrega -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div id="neuromarketing" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-surface rounded-xl border border-border shadow-sm p-5">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-fuchsia-500 text-[16px]">biotech</span>
@ -338,7 +367,7 @@
</div>
<!-- Fortalezas / Debilidades -->
<div v-if="guion.fortalezas?.length || guion.debilidades?.length" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-if="guion.fortalezas?.length || guion.debilidades?.length" id="fortalezas" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-surface rounded-xl border border-success-border p-5">
<h3 class="text-xs font-semibold text-success uppercase tracking-wider mb-4 flex items-center gap-1.5">
<span class="material-symbols-outlined text-[16px]" style="font-variation-settings:'FILL' 1;">thumb_up</span> Fortalezas
@ -373,7 +402,7 @@
</div>
<!-- Hashtags -->
<div v-if="guion.hashtags_sugeridos?.length" class="bg-surface rounded-xl border border-border shadow-sm p-5">
<div v-if="guion.hashtags_sugeridos?.length" id="hashtags" class="bg-surface rounded-xl border border-border shadow-sm p-5">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<span class="material-symbols-outlined text-ink-3 text-[16px]">tag</span> Hashtags Sugeridos
</h3>
@ -381,32 +410,35 @@
<span
v-for="tag in guion.hashtags_sugeridos"
:key="tag"
class="px-3 py-1.5 bg-accent-subtle border border-accent-border rounded-full text-xs font-medium text-accent cursor-pointer hover:bg-accent hover:text-white transition-colors"
class="px-3 py-1.5 bg-accent-subtle border border-accent-border rounded-full text-xs font-medium text-accent
cursor-pointer hover:bg-accent hover:text-white transition-all active:scale-95"
:title="tagCopiado === tag ? 'Copiado!' : 'Clic para copiar'"
@click="copiarTag(tag)"
>#{{ tag.replace(/^#/, '') }}</span>
>
{{ tagCopiado === tag ? '✓' : '#' }}{{ tag.replace(/^#/, '') }}
</span>
</div>
<p class="text-[10px] text-ink-3 mt-2">Haz clic en un hashtag para copiarlo</p>
</div>
<!-- Contexto ingresado -->
<div v-if="guion.contexto_video" class="bg-surface rounded-xl border border-warn-border p-5">
<p class="text-[10px] font-semibold text-warn uppercase tracking-wider mb-2 flex items-center gap-1.5">
<span class="material-symbols-outlined text-[14px]">lightbulb</span> Contexto ingresado
</p>
<p class="text-sm text-ink-2 leading-relaxed italic">{{ guion.contexto_video }}</p>
</div>
<!-- Transcripción -->
<div class="bg-surface rounded-xl border border-border shadow-sm p-5">
<div id="transcripcion" class="bg-surface rounded-xl border border-border shadow-sm p-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-xs font-semibold text-ink-2 uppercase tracking-wider flex items-center gap-1.5">
<span class="material-symbols-outlined text-ink-3 text-[16px]">notes</span> Transcripción Completa
</h3>
<button @click="showTranscript = !showTranscript" class="text-xs font-medium text-accent hover:text-accent-hover transition-colors">
<button
@click="showTranscript = !showTranscript"
class="text-xs font-medium text-accent hover:text-accent-hover transition-colors flex items-center gap-1"
>
<span class="material-symbols-outlined text-[14px]">{{ showTranscript ? 'expand_less' : 'expand_more' }}</span>
{{ showTranscript ? 'Colapsar' : 'Expandir' }}
</button>
</div>
<div :class="showTranscript ? 'max-h-[600px]' : 'max-h-20'" class="overflow-hidden relative transition-all duration-400 ease-in-out">
<div
:class="showTranscript ? 'max-h-[600px]' : 'max-h-20'"
class="overflow-hidden relative transition-all duration-400 ease-in-out"
>
<div v-if="!showTranscript" class="absolute inset-0 bg-gradient-to-t from-surface to-transparent z-10 pointer-events-none"></div>
<p class="text-sm text-ink-2 leading-relaxed whitespace-pre-wrap">
{{ guion.transcript || 'Video sin transcripción disponible.' }}
@ -420,16 +452,31 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '../lib/api.js'
import { useToast } from '../composables/useToast.js'
import CialdiniItem from '../components/CialdiniItem.vue'
import DataRow from '../components/DataRow.vue'
const route = useRoute()
const toast = useToast()
const guion = ref(null)
const cargando = ref(true)
const showTranscript = ref(false)
const tabActivo = ref('resumen')
const tagCopiado = ref(null)
const tabs = [
{ id: 'resumen', label: 'Resumen' },
{ id: 'ganchos', label: 'Ganchos' },
{ id: 'patron', label: 'Patrón' },
{ id: 'emocional', label: 'Emocional' },
{ id: 'cialdini', label: 'Cialdini' },
{ id: 'neuromarketing', label: 'Neuromarketing' },
{ id: 'fortalezas', label: 'FODA' },
{ id: 'transcripcion', label: 'Transcripción' },
]
const nivelesConciencia = [
{ key: 'inconsciente' },
@ -454,6 +501,22 @@ const ratioIcon = computed(() => {
return map[guion.value?.ratio_emocion_logica] || 'help'
})
function scoreArcColor(score) {
if (!score) return 'stroke-border'
if (score >= 80) return 'stroke-success'
if (score >= 60) return 'stroke-accent'
if (score >= 40) return 'stroke-warn'
return 'stroke-error'
}
function scoreTextColor(score) {
if (!score) return 'text-ink-3'
if (score >= 80) return 'text-success'
if (score >= 60) return 'text-accent'
if (score >= 40) return 'text-warn'
return 'text-error'
}
function openUrl(url) {
if (url) window.open(url, '_blank')
}
@ -466,7 +529,19 @@ function formatNum(n) {
}
function copiarTag(tag) {
navigator.clipboard.writeText('#' + tag.replace(/^#/, ''))
const text = '#' + tag.replace(/^#/, '')
navigator.clipboard.writeText(text)
tagCopiado.value = tag
toast.success(`${text} copiado al portapapeles`)
setTimeout(() => { tagCopiado.value = null }, 2000)
}
function scrollToSection(id) {
const el = document.getElementById(id)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
tabActivo.value = id
}
}
function plataformaBadge(p) {
@ -485,11 +560,36 @@ function replicabilidadBadge(r) {
}[r] ?? 'bg-surface-muted text-ink-3 border border-border'
}
// IntersectionObserver para actualizar tab activo al scrollear
let observer = null
function setupObserver() {
observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) tabActivo.value = entry.target.id
})
},
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 }
)
tabs.forEach(tab => {
const el = document.getElementById(tab.id)
if (el) observer.observe(el)
})
}
onMounted(async () => {
try {
guion.value = await api.guiones.obtener(route.params.id)
} finally {
cargando.value = false
}
setTimeout(setupObserver, 300)
})
onUnmounted(() => observer?.disconnect())
</script>
<style>
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
</style>

View File

@ -13,14 +13,18 @@
</div>
<div class="flex items-center gap-3">
<button
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm hover:bg-surface-muted transition-colors"
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm
hover:bg-surface-muted transition-all active:scale-[0.97] flex items-center gap-2 disabled:opacity-40"
:disabled="cargando"
@click="cargarDatos"
>
<span class="material-symbols-outlined text-[16px]" :class="cargando ? 'animate-spin' : ''">refresh</span>
Actualizar
</button>
<router-link
to="/new-analysis"
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm flex items-center gap-2 transition-colors shadow-sm"
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm
flex items-center gap-2 transition-all shadow-sm active:scale-[0.97]"
>
<span class="material-symbols-outlined text-[18px]">add</span>
Nuevo Análisis
@ -35,7 +39,7 @@
v-for="f in filtrosEstado"
:key="f.valor"
@click="filtroActivo = f.valor; filtros.page = 1; cargarDatos()"
class="px-3 py-1.5 rounded-md text-xs font-medium transition-all"
class="px-3 py-1.5 rounded-md text-xs font-medium transition-all active:scale-[0.97]"
:class="filtroActivo === f.valor
? 'bg-accent text-white shadow-sm'
: 'text-ink-2 hover:bg-surface-muted'"
@ -48,7 +52,8 @@
<select
v-model="filtros.niche"
@change="filtros.page = 1; cargarDatos()"
class="bg-surface border border-border rounded-lg px-3 py-2 text-xs text-ink-2 focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none"
class="bg-surface border border-border rounded-lg px-3 py-2 text-xs text-ink-2
focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none"
>
<option value="">Todos los nichos</option>
<option v-for="n in nichos" :key="n" :value="n">{{ n }}</option>
@ -58,51 +63,106 @@
<!-- Tabla -->
<div class="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<!-- Header tabla -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between bg-surface/90 backdrop-blur-sm">
<div class="flex items-center gap-3">
<h2 class="text-sm font-semibold text-ink">Registro completo</h2>
<span v-if="!cargando && totalGuiones > 0" class="text-[11px] text-ink-3 font-medium tabular-nums">
{{ (filtros.page - 1) * filtros.limit + 1 }}{{ Math.min(filtros.page * filtros.limit, totalGuiones) }} de {{ totalGuiones }}
</span>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md hover:bg-surface-muted transition-colors disabled:opacity-40"
:disabled="filtros.page <= 1"
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
:disabled="filtros.page <= 1 || cargando"
@click="cambiarPagina(filtros.page - 1)"
>Anterior</button>
<span class="text-[11px] text-ink-3 font-medium px-2">Pág. {{ filtros.page }}</span>
<span class="text-[11px] text-ink-3 font-medium px-2 tabular-nums">Pág. {{ filtros.page }}</span>
<button
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md hover:bg-surface-muted transition-colors disabled:opacity-40"
:disabled="guiones.length < filtros.limit"
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
:disabled="guiones.length < filtros.limit || cargando"
@click="cambiarPagina(filtros.page + 1)"
>Siguiente</button>
</div>
</div>
<!-- Loading -->
<div v-if="cargando" class="py-16 flex items-center justify-center gap-3">
<div class="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin"></div>
<span class="text-sm text-ink-3">Cargando historial</span>
<!-- Loading skeleton -->
<div v-if="cargando" class="divide-y divide-border">
<div v-for="i in 8" :key="i" class="px-6 py-4 animate-pulse flex items-center gap-6">
<div class="w-20 space-y-1.5">
<div class="h-3 w-3 rounded-full bg-surface-subtle"></div>
</div>
<div class="flex-1 space-y-2">
<div class="h-4 w-14 bg-surface-subtle rounded"></div>
<div class="h-3.5 w-40 bg-surface-subtle rounded"></div>
</div>
<div class="w-20 h-3 bg-surface-subtle rounded"></div>
<div class="flex gap-4">
<div class="space-y-1"><div class="h-3 w-8 bg-surface-subtle rounded"></div><div class="h-4 w-6 bg-surface-subtle rounded"></div></div>
<div class="space-y-1"><div class="h-3 w-12 bg-surface-subtle rounded"></div><div class="h-4 w-8 bg-surface-subtle rounded"></div></div>
</div>
</div>
</div>
<!-- Vacío -->
<div v-else-if="guiones.length === 0" class="py-20 flex flex-col items-center gap-3 text-ink-3">
<span class="material-symbols-outlined text-4xl">manage_search</span>
<p class="text-sm font-medium">Sin registros para este filtro</p>
<div v-else-if="guiones.length === 0" class="py-24 flex flex-col items-center gap-4 text-center">
<div class="w-14 h-14 rounded-2xl bg-surface-muted border border-border flex items-center justify-center">
<span class="material-symbols-outlined text-3xl text-ink-3">manage_search</span>
</div>
<div>
<p class="text-sm font-semibold text-ink mb-1">Sin registros para este filtro</p>
<p class="text-xs text-ink-3">Prueba cambiando el filtro o el nicho seleccionado.</p>
</div>
<button
@click="filtroActivo = 'todos'; filtros.niche = ''; filtros.page = 1; cargarDatos()"
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm hover:bg-surface-muted transition-colors"
>
Limpiar filtros
</button>
</div>
<!-- Tabla con datos -->
<div v-else class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-border bg-surface-muted/60">
<thead class="sticky top-0 bg-surface/90 backdrop-blur-sm border-b border-border z-10">
<tr>
<th class="px-6 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Estado</th>
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Video / Fuente</th>
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Niche</th>
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Puntajes</th>
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Fecha</th>
<!-- Columna Viralidad: ordenable -->
<th
class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider cursor-pointer hover:text-ink-2 select-none group"
@click="toggleSort('score_virabilidad')"
>
<div class="flex items-center gap-1">
Puntajes
<span class="material-symbols-outlined text-[14px] transition-colors"
:class="sortField === 'score_virabilidad' ? 'text-accent' : 'text-border-strong group-hover:text-ink-3'">
{{ sortField === 'score_virabilidad' ? (sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}
</span>
</div>
</th>
<!-- Columna Fecha: ordenable -->
<th
class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider cursor-pointer hover:text-ink-2 select-none group"
@click="toggleSort('fecha_analisis')"
>
<div class="flex items-center gap-1">
Fecha
<span class="material-symbols-outlined text-[14px] transition-colors"
:class="sortField === 'fecha_analisis' ? 'text-accent' : 'text-border-strong group-hover:text-ink-3'">
{{ sortField === 'fecha_analisis' ? (sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}
</span>
</div>
</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="g in guiones"
v-for="g in guionesOrdenados"
:key="g.id"
class="group hover:bg-surface-muted/60 transition-colors"
:class="g.procesado_ok ? 'cursor-pointer' : 'opacity-60'"
@ -116,7 +176,11 @@
{{ g.procesado_ok ? 'Completado' : 'Fallido' }}
</span>
</div>
<p v-if="!g.procesado_ok && g.error_detalle" class="text-[10px] text-error/70 mt-0.5 max-w-[140px] truncate">{{ g.error_detalle }}</p>
<p
v-if="!g.procesado_ok && g.error_detalle"
class="text-[10px] text-error/70 mt-1 max-w-[180px] leading-snug"
:title="g.error_detalle"
>{{ g.error_detalle.substring(0, 80) }}{{ g.error_detalle.length > 80 ? '…' : '' }}</p>
</td>
<!-- Fuente -->
@ -124,7 +188,7 @@
<div class="flex flex-col gap-1">
<span :class="plataformaBadge(g.plataforma)" class="platform-badge w-fit">{{ g.plataforma || '—' }}</span>
<p class="text-sm font-medium text-ink line-clamp-1 max-w-52">{{ g.tema_principal || g.url_origen }}</p>
<p class="text-[10px] text-ink-3 truncate max-w-52">{{ g.url_origen }}</p>
<p class="text-[10px] text-ink-3 truncate max-w-52" :title="g.url_origen">{{ g.url_origen }}</p>
</div>
</td>
@ -139,15 +203,15 @@
<div v-if="g.procesado_ok" class="flex items-center gap-4">
<div>
<p class="text-[10px] text-ink-3 font-medium">Viral</p>
<p class="text-sm font-bold text-ink">{{ g.score_virabilidad || 0 }}</p>
<p class="text-sm font-bold tabular-nums" :class="scoreColor(g.score_virabilidad)">{{ g.score_virabilidad || 0 }}</p>
</div>
<div>
<p class="text-[10px] text-ink-3 font-medium">Cialdini</p>
<p class="text-sm font-bold text-ink">{{ g.score_cialdini || 0 }}/7</p>
<p class="text-sm font-bold text-ink tabular-nums">{{ g.score_cialdini || 0 }}/7</p>
</div>
<div>
<p class="text-[10px] text-ink-3 font-medium">Eng.</p>
<p class="text-sm font-bold text-success">{{ (g.score_engagement || 0).toFixed(1) }}%</p>
<p class="text-sm font-bold text-success tabular-nums">{{ (g.score_engagement || 0).toFixed(1) }}%</p>
</div>
</div>
<span v-else class="text-[11px] text-ink-3"></span>
@ -159,14 +223,29 @@
</td>
<!-- Acción -->
<td class="px-6 py-4 text-right">
<td class="px-6 py-4 text-right" @click.stop>
<div class="relative" v-if="g.procesado_ok">
<button
v-if="g.procesado_ok"
@click.stop="verDetalle(g.id)"
class="p-1.5 rounded-lg text-ink-3 hover:text-accent hover:bg-accent-subtle transition-all opacity-0 group-hover:opacity-100"
@click="menuAbierto = menuAbierto === g.id ? null : g.id"
class="p-1.5 rounded-lg text-ink-3 hover:text-ink hover:bg-surface-muted transition-all
opacity-0 group-hover:opacity-100 active:scale-95"
aria-label="Acciones"
>
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
<span class="material-symbols-outlined text-[18px]">more_horiz</span>
</button>
<div
v-if="menuAbierto === g.id"
class="absolute right-0 top-9 z-30 w-44 bg-surface-muted border border-border rounded-xl shadow-xl py-1 overflow-hidden"
>
<button
@click="verDetalle(g.id); menuAbierto = null"
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-ink-2 hover:bg-surface-subtle hover:text-ink transition-colors text-left"
>
<span class="material-symbols-outlined text-[16px]">open_in_new</span>
Ver análisis
</button>
</div>
</div>
</td>
</tr>
</tbody>
@ -177,7 +256,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
@ -186,6 +265,10 @@ const guiones = ref([])
const nichos = ref([])
const cargando = ref(true)
const filtroActivo = ref('todos')
const menuAbierto = ref(null)
const sortField = ref('fecha_analisis')
const sortDir = ref('desc')
const totalGuiones = ref(0)
const filtros = ref({ page: 1, limit: 20, niche: '' })
@ -198,11 +281,36 @@ const filtrosEstado = [
const totalOk = ref(0)
const totalFallidos = ref(0)
const guionesOrdenados = computed(() => {
const lista = [...guiones.value]
lista.sort((a, b) => {
const va = sortField.value === 'fecha_analisis'
? new Date(a[sortField.value] || 0).getTime()
: (a[sortField.value] || 0)
const vb = sortField.value === 'fecha_analisis'
? new Date(b[sortField.value] || 0).getTime()
: (b[sortField.value] || 0)
return sortDir.value === 'desc' ? vb - va : va - vb
})
return lista
})
function toggleSort(field) {
if (sortField.value === field) {
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
} else {
sortField.value = field
sortDir.value = 'desc'
}
}
async function cargarDatos() {
cargando.value = true
try {
const params = { page: filtros.value.page, limit: filtros.value.limit }
if (filtros.value.niche) params.niche = filtros.value.niche
if (filtroActivo.value === 'exitosos') params.procesado_ok = true
if (filtroActivo.value === 'fallidos') params.procesado_ok = false
const [dg, dn, okReq, allReq] = await Promise.all([
api.guiones.listarTodos(params),
@ -219,6 +327,7 @@ async function cargarDatos() {
if (filtroActivo.value === 'fallidos') lista = lista.filter(g => !g.procesado_ok)
guiones.value = lista
totalGuiones.value = dg.total || lista.length
nichos.value = dn
} catch (e) {
console.error(e)
@ -251,5 +360,21 @@ function plataformaBadge(p) {
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
}
onMounted(cargarDatos)
function scoreColor(score) {
if (!score) return 'text-ink-3'
if (score >= 80) return 'text-success'
if (score >= 60) return 'text-accent'
if (score >= 40) return 'text-warn'
return 'text-error'
}
function cerrarMenu(e) {
if (!e.target.closest('[data-menu]')) menuAbierto.value = null
}
onMounted(() => {
cargarDatos()
document.addEventListener('click', cerrarMenu)
})
onUnmounted(() => document.removeEventListener('click', cerrarMenu))
</script>

View File

@ -9,14 +9,18 @@
</div>
<div class="flex items-center gap-3">
<button
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm hover:bg-surface-muted transition-colors"
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm
hover:bg-surface-muted transition-all active:scale-[0.97] flex items-center gap-2 disabled:opacity-40"
:disabled="cargando"
@click="cargarDatos"
>
<span class="material-symbols-outlined text-[16px]" :class="cargando ? 'animate-spin' : ''">refresh</span>
Actualizar
</button>
<router-link
to="/new-analysis"
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm flex items-center gap-2 transition-colors shadow-sm"
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm
flex items-center gap-2 transition-all shadow-sm active:scale-[0.97] hover:shadow-md hover:shadow-accent/20"
>
<span class="material-symbols-outlined text-[18px]">add</span>
Nuevo Análisis
@ -29,26 +33,27 @@
<div
v-for="stat in stats"
:key="stat.niche"
class="bg-surface rounded-xl border border-border p-5 shadow-sm"
class="bg-surface rounded-xl border border-border p-5 shadow-sm hover:border-border-strong transition-all"
>
<p class="text-[11px] font-semibold text-ink-3 uppercase tracking-wider mb-3">{{ stat.niche }}</p>
<div class="flex items-end justify-between">
<span class="text-3xl font-bold font-headline text-ink">{{ stat.total_guiones }}</span>
<div class="text-right">
<p class="text-[10px] text-ink-3 mb-0.5">Puntaje prom.</p>
<p class="text-lg font-bold text-accent">{{ (stat.avg_score || 0).toFixed(1) }}</p>
<p class="text-lg font-bold" :class="scoreColor(stat.avg_score)">{{ (stat.avg_score || 0).toFixed(1) }}</p>
</div>
</div>
<div class="mt-3 w-full bg-surface-subtle h-1 rounded-full overflow-hidden">
<div
class="bg-accent h-full transition-all duration-700 rounded-full"
class="h-full transition-all duration-700 rounded-full"
:class="scoreBarColor(stat.avg_score)"
:style="{ width: (stat.avg_score || 0) + '%' }"
></div>
</div>
</div>
<div
v-if="stats.length === 0"
v-if="stats.length === 0 && !cargando"
class="lg:col-span-4 bg-surface border border-dashed border-border rounded-xl p-8 flex items-center justify-center text-sm text-ink-3 italic"
>
Conecta la base de datos para ver el rendimiento por niche.
@ -60,35 +65,70 @@
<!-- Tabla de guiones -->
<div class="xl:col-span-8 bg-surface rounded-xl border border-border shadow-sm overflow-hidden flex flex-col">
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div class="px-6 py-4 border-b border-border flex items-center justify-between bg-surface/90 backdrop-blur-sm">
<div class="flex items-center gap-3">
<h2 class="text-sm font-semibold text-ink">Guiones Analizados</h2>
<span v-if="!cargando" class="text-[11px] text-ink-3 font-medium tabular-nums">
{{ (filtros.page - 1) * filtros.limit + 1 }}{{ Math.min(filtros.page * filtros.limit, totalGuiones) }} de {{ totalGuiones }}
</span>
</div>
<div class="flex gap-2">
<button
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md hover:bg-surface-muted transition-colors disabled:opacity-40"
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
@click="cambiarPagina(filtros.page - 1)"
:disabled="filtros.page <= 1"
:disabled="filtros.page <= 1 || cargando"
>Anterior</button>
<button
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md hover:bg-surface-muted transition-colors disabled:opacity-40"
class="px-3 py-1.5 bg-canvas border border-border text-[11px] font-medium text-ink-2 rounded-md
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
@click="cambiarPagina(filtros.page + 1)"
:disabled="guiones.length < filtros.limit"
:disabled="guiones.length < filtros.limit || cargando"
>Siguiente</button>
</div>
</div>
<div class="overflow-x-auto max-h-[520px]">
<!-- Skeleton loading -->
<div v-if="cargando" class="divide-y divide-border">
<div v-for="i in 5" :key="i" class="px-6 py-4 animate-pulse flex items-center gap-4">
<div class="flex-1 space-y-2">
<div class="flex gap-2">
<div class="h-4 w-12 bg-surface-subtle rounded"></div>
<div class="h-4 w-16 bg-surface-subtle rounded"></div>
</div>
<div class="h-3.5 w-48 bg-surface-subtle rounded"></div>
</div>
<div class="space-y-1.5">
<div class="h-3.5 w-16 bg-surface-subtle rounded"></div>
<div class="h-1 w-24 bg-surface-subtle rounded-full"></div>
</div>
</div>
</div>
<div v-else class="overflow-x-auto max-h-[520px]">
<table class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-surface-muted z-10">
<tr class="border-b border-border">
<thead class="sticky top-0 bg-surface/90 backdrop-blur-sm z-10 border-b border-border">
<tr>
<th class="px-6 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Video</th>
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Viralidad</th>
<th
class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider cursor-pointer hover:text-ink-2 select-none group"
@click="toggleSort('score_virabilidad')"
>
<div class="flex items-center gap-1">
Viralidad
<span class="material-symbols-outlined text-[14px] transition-colors"
:class="sortField === 'score_virabilidad' ? 'text-accent' : 'text-border-strong group-hover:text-ink-3'">
{{ sortField === 'score_virabilidad' ? (sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}
</span>
</div>
</th>
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Gancho</th>
<th class="px-6 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="g in guiones"
v-for="g in guionesOrdenados"
:key="g.id"
class="group hover:bg-surface-muted/60 transition-colors cursor-pointer"
@click="verDetalle(g.id)"
@ -109,12 +149,13 @@
<td class="px-4 py-4">
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2 text-[11px]">
<span class="font-bold text-ink">{{ g.score_virabilidad || 0 }}/100</span>
<span class="text-success text-[10px]">{{ (g.score_engagement || 0).toFixed(1) }}%</span>
<span class="font-bold tabular-nums" :class="scoreColor(g.score_virabilidad)">{{ g.score_virabilidad || 0 }}</span>
<span class="text-success text-[10px] tabular-nums">{{ (g.score_engagement || 0).toFixed(1) }}%</span>
</div>
<div class="w-24 bg-surface-subtle h-1 rounded-full overflow-hidden">
<div
class="bg-accent h-full rounded-full"
class="h-full rounded-full transition-all duration-500"
:class="scoreBarColor(g.score_virabilidad)"
:style="{ width: (g.score_virabilidad || 0) + '%' }"
></div>
</div>
@ -129,17 +170,30 @@
</td>
<td class="px-6 py-4 text-right">
<button class="p-1.5 rounded-lg text-ink-3 hover:text-accent hover:bg-accent-subtle transition-all opacity-0 group-hover:opacity-100">
<button
class="p-1.5 rounded-lg text-ink-3 hover:text-accent hover:bg-accent-subtle transition-all
opacity-0 group-hover:opacity-100 active:scale-95"
aria-label="Ver análisis"
>
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
</button>
</td>
</tr>
<tr v-if="guiones.length === 0 && !cargando">
<tr v-if="guiones.length === 0">
<td colspan="4" class="py-20 text-center">
<div class="flex flex-col items-center gap-2 text-ink-3">
<span class="material-symbols-outlined text-4xl">inbox</span>
<p class="text-sm font-medium">Sin guiones analizados</p>
<div class="flex flex-col items-center gap-3 text-ink-3">
<div class="w-14 h-14 rounded-2xl bg-surface-muted border border-border flex items-center justify-center">
<span class="material-symbols-outlined text-3xl">inbox</span>
</div>
<div>
<p class="text-sm font-semibold text-ink mb-1">Sin guiones analizados</p>
<p class="text-xs text-ink-3">Analiza tu primer video viral para empezar.</p>
</div>
<router-link to="/new-analysis" class="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm flex items-center gap-2 transition-colors mt-1">
<span class="material-symbols-outlined text-[16px]">add</span>
Nuevo análisis
</router-link>
</div>
</td>
</tr>
@ -152,14 +206,27 @@
<div class="xl:col-span-4 bg-surface rounded-xl border border-border shadow-sm p-6 flex flex-col gap-5">
<div>
<div class="flex items-center gap-1.5 text-success text-[11px] font-semibold uppercase tracking-wider mb-2">
<span class="material-symbols-outlined text-sm">star</span>
<span class="material-symbols-outlined text-sm" style="font-variation-settings:'FILL' 1;">star</span>
Mejor Rendimiento
</div>
<h2 class="text-lg font-bold font-headline text-ink">Guión Destacado</h2>
<p class="text-xs text-ink-3 mt-0.5">Mayor puntaje de viralidad en tu biblioteca</p>
</div>
<div v-if="guionTop" class="flex flex-col gap-4 flex-1">
<!-- Skeleton -->
<div v-if="cargando" class="flex flex-col gap-4 flex-1 animate-pulse">
<div class="h-20 bg-surface-subtle rounded-lg"></div>
<div class="space-y-2">
<div class="h-3 w-full bg-surface-subtle rounded"></div>
<div class="h-3 w-3/4 bg-surface-subtle rounded"></div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="h-16 bg-surface-subtle rounded-lg"></div>
<div class="h-16 bg-surface-subtle rounded-lg"></div>
</div>
</div>
<div v-else-if="guionTop" class="flex flex-col gap-4 flex-1">
<div class="p-4 rounded-lg bg-surface-muted border border-border">
<p class="text-[10px] font-semibold text-success uppercase tracking-wider mb-1">
{{ guionTop.niche }}{{ guionTop.sub_niche ? ` · ${guionTop.sub_niche}` : '' }}
@ -175,24 +242,34 @@
<div class="grid grid-cols-2 gap-3">
<div class="p-3 rounded-lg bg-surface-muted border border-border text-center">
<p class="text-[10px] text-ink-3 font-medium mb-1">Cialdini</p>
<p class="text-xl font-bold text-ink">{{ guionTop.score_cialdini }}<span class="text-xs text-ink-3">/7</span></p>
<p class="text-xl font-bold text-ink tabular-nums">{{ guionTop.score_cialdini }}<span class="text-xs text-ink-3">/7</span></p>
</div>
<div class="p-3 rounded-lg bg-accent-subtle border border-accent-border text-center">
<p class="text-[10px] text-accent font-medium mb-1">Viralidad</p>
<p class="text-xl font-bold text-accent">{{ guionTop.score_virabilidad }}<span class="text-xs">%</span></p>
<div class="p-3 rounded-lg border text-center"
:class="guionTop.score_virabilidad >= 80 ? 'bg-success-subtle border-success-border' :
guionTop.score_virabilidad >= 60 ? 'bg-accent-subtle border-accent-border' : 'bg-warn-subtle border-warn-border'">
<p class="text-[10px] font-medium mb-1"
:class="guionTop.score_virabilidad >= 80 ? 'text-success' :
guionTop.score_virabilidad >= 60 ? 'text-accent' : 'text-warn'">Viralidad</p>
<p class="text-xl font-bold tabular-nums"
:class="guionTop.score_virabilidad >= 80 ? 'text-success' :
guionTop.score_virabilidad >= 60 ? 'text-accent' : 'text-warn'">
{{ guionTop.score_virabilidad }}<span class="text-xs">%</span>
</p>
</div>
</div>
<button
@click="verDetalle(guionTop.id)"
class="w-full py-2.5 bg-ink hover:bg-ink-2 text-surface rounded-lg font-semibold text-sm transition-colors mt-auto"
class="w-full py-2.5 bg-ink hover:bg-ink-2 text-surface rounded-lg font-semibold text-sm
transition-all mt-auto active:scale-[0.97]"
>
Ver análisis completo
</button>
</div>
<div v-else class="flex flex-col items-center justify-center flex-1 py-8 text-ink-3 text-sm italic">
Aún no hay guiones analizados.
<div v-else class="flex flex-col items-center justify-center flex-1 py-8 gap-3 text-center">
<span class="material-symbols-outlined text-3xl text-ink-3">bar_chart</span>
<p class="text-sm text-ink-3 italic">Aún no hay guiones analizados.</p>
</div>
</div>
</div>
@ -200,7 +277,7 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../lib/api.js'
@ -208,17 +285,52 @@ const router = useRouter()
const guiones = ref([])
const stats = ref([])
const cargando = ref(true)
const totalGuiones = ref(0)
const sortField = ref('score_virabilidad')
const sortDir = ref('desc')
const filtros = ref({
page: 1,
limit: 20
})
const filtros = ref({ page: 1, limit: 20 })
const guionTop = computed(() => {
if (guiones.value.length === 0) return null
return [...guiones.value].sort((a,b) => (b.score_virabilidad || 0) - (a.score_virabilidad || 0))[0]
return [...guiones.value].sort((a, b) => (b.score_virabilidad || 0) - (a.score_virabilidad || 0))[0]
})
const guionesOrdenados = computed(() => {
const lista = [...guiones.value]
lista.sort((a, b) => {
const va = a[sortField.value] || 0
const vb = b[sortField.value] || 0
return sortDir.value === 'desc' ? vb - va : va - vb
})
return lista
})
function toggleSort(field) {
if (sortField.value === field) {
sortDir.value = sortDir.value === 'desc' ? 'asc' : 'desc'
} else {
sortField.value = field
sortDir.value = 'desc'
}
}
function scoreColor(score) {
if (!score) return 'text-ink-3'
if (score >= 80) return 'text-success'
if (score >= 60) return 'text-accent'
if (score >= 40) return 'text-warn'
return 'text-error'
}
function scoreBarColor(score) {
if (!score) return 'bg-border'
if (score >= 80) return 'bg-success'
if (score >= 60) return 'bg-accent'
if (score >= 40) return 'bg-warn'
return 'bg-error'
}
async function cargarDatos() {
cargando.value = true
try {
@ -227,6 +339,7 @@ async function cargarDatos() {
api.stats()
])
guiones.value = dg.guiones
totalGuiones.value = dg.total || dg.guiones.length
stats.value = ds
} catch (e) {
console.error(e)
@ -254,7 +367,5 @@ function plataformaBadge(p) {
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
}
onMounted(() => {
cargarDatos()
})
onMounted(cargarDatos)
</script>

View File

@ -9,7 +9,7 @@
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
<!-- Formulario (izquierda) -->
<!-- Formulario -->
<div class="xl:col-span-7 flex flex-col gap-6">
<!-- Paso 1: Contexto -->
@ -21,14 +21,23 @@
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Nicho</label>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
Nicho
<span class="text-error font-normal normal-case ml-1 text-[11px]">obligatorio</span>
</label>
<input
v-model="form.niche"
list="nichos-gen"
placeholder="ej. fitness, finanzas…"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/40 transition-all"
class="w-full bg-canvas border rounded-lg px-3 py-2.5 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 transition-all"
:class="campoTocado.niche && !form.niche
? 'border-error/50 focus:ring-error/20'
: 'border-border focus:ring-accent/30 focus:border-accent/40'"
:disabled="generando"
@blur="campoTocado.niche = true"
/>
<p v-if="campoTocado.niche && !form.niche" class="text-[11px] text-error">Este campo es obligatorio</p>
<datalist id="nichos-gen">
<option v-for="n in nichos" :key="n" :value="n" />
</datalist>
@ -37,7 +46,8 @@
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Plataforma</label>
<select
v-model="form.plataforma"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all"
:disabled="generando"
>
<option value="tiktok">TikTok</option>
@ -48,25 +58,43 @@
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Tema del Video</label>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
Tema del Video
<span class="text-error font-normal normal-case ml-1 text-[11px]">obligatorio</span>
</label>
<input
v-model="form.tema"
type="text"
placeholder="ej. Cómo perder 5kg en 30 días sin pasar hambre"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
class="w-full bg-canvas border rounded-lg px-3 py-2.5 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 transition-all"
:class="campoTocado.tema && !form.tema
? 'border-error/50 focus:ring-error/20'
: 'border-border focus:ring-accent/30 focus:border-accent/40'"
:disabled="generando"
@blur="campoTocado.tema = true"
/>
<p v-if="campoTocado.tema && !form.tema" class="text-[11px] text-error">Este campo es obligatorio</p>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Audiencia Objetivo</label>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
Audiencia Objetivo
<span class="text-error font-normal normal-case ml-1 text-[11px]">obligatorio</span>
</label>
<input
v-model="form.audiencia"
type="text"
placeholder="ej. Mujeres de 25-40 años con poco tiempo para el gimnasio"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
class="w-full bg-canvas border rounded-lg px-3 py-2.5 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 transition-all"
:class="campoTocado.audiencia && !form.audiencia
? 'border-error/50 focus:ring-error/20'
: 'border-border focus:ring-accent/30 focus:border-accent/40'"
:disabled="generando"
@blur="campoTocado.audiencia = true"
/>
<p v-if="campoTocado.audiencia && !form.audiencia" class="text-[11px] text-error">Este campo es obligatorio</p>
</div>
</div>
</section>
@ -81,7 +109,10 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Estructura Narrativa</label>
<select v-model="form.estructura" class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all" :disabled="generando">
<select v-model="form.estructura"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all"
:disabled="generando">
<option value="AIDA">AIDA Atención · Interés · Deseo · Acción</option>
<option value="PAS">PAS Problema · Agitación · Solución</option>
<option value="hero_journey">Hero's Journey</option>
@ -91,7 +122,10 @@
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Objetivo</label>
<select v-model="form.objetivo" class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all" :disabled="generando">
<select v-model="form.objetivo"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all"
:disabled="generando">
<option value="engagement">Engagement (likes, comentarios)</option>
<option value="awareness">Awareness (alcance)</option>
<option value="conversion">Conversión (ventas, leads)</option>
@ -104,7 +138,10 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Tono</label>
<select v-model="form.tono" class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all" :disabled="generando">
<select v-model="form.tono"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
focus:outline-none focus:ring-2 focus:ring-accent/30 appearance-none transition-all"
:disabled="generando">
<option value="educativo">Educativo</option>
<option value="entretenimiento">Entretenimiento</option>
<option value="inspiracional">Inspiracional</option>
@ -118,43 +155,84 @@
<input
v-model.number="form.duracion_objetivo"
type="number" min="15" max="180"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink font-medium text-center focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all"
class="w-full bg-canvas border border-border rounded-lg px-3 py-2.5 text-sm text-ink
font-medium text-center focus:outline-none focus:ring-2 focus:ring-accent/30
transition-all tabular-nums"
:disabled="generando"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Instrucciones adicionales <span class="font-normal text-ink-3">(opcional)</span></label>
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">
Instrucciones adicionales
<span class="font-normal text-ink-3 normal-case ml-1">(opcional)</span>
</label>
<textarea
v-model="form.instrucciones_extra"
rows="3"
placeholder="ej. Incluir una estadística de estudio, no mencionar competidores, usar lenguaje informal…"
class="w-full bg-canvas border border-border rounded-lg px-3 py-3 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 resize-none leading-relaxed transition-all"
placeholder="ej. Incluir una estadística de estudio, no mencionar competidores…"
class="w-full bg-canvas border border-border rounded-lg px-3 py-3 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30
resize-none leading-relaxed transition-all"
:disabled="generando"
></textarea>
</div>
</div>
</section>
<!-- Botón Generar -->
<!-- Botón Generar con tooltip de campos faltantes -->
<div class="relative group/btn">
<button
@click="generar"
:disabled="generando || !form.niche || !form.tema || !form.audiencia"
class="w-full py-3 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm flex items-center justify-center gap-2 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full py-3 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm
flex items-center justify-center gap-2 transition-all shadow-sm
active:scale-[0.98] hover:shadow-md hover:shadow-accent/20
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none disabled:active:scale-100"
>
<span class="material-symbols-outlined text-[18px]" :class="generando ? 'animate-spin' : ''">
{{ generando ? 'hourglass_top' : 'auto_fix_high' }}
{{ generando ? 'progress_activity' : 'auto_fix_high' }}
</span>
{{ generando ? 'Generando con GPT-4o' : 'Generar Guion' }}
</button>
<div v-if="error" class="p-4 rounded-lg bg-error-subtle border border-error-border text-error text-sm">
{{ error }}
<!-- Tooltip de campos faltantes -->
<div
v-if="!generando && (!form.niche || !form.tema || !form.audiencia)"
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5
bg-surface-muted border border-border rounded-lg text-[11px] text-ink-2
whitespace-nowrap opacity-0 group-hover/btn:opacity-100 pointer-events-none
transition-opacity z-10 shadow-lg"
>
<span class="material-symbols-outlined text-[12px] text-warn align-middle mr-1">warning</span>
Falta: {{ camposFaltantes.join(', ') }}
<div class="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-border"></div>
</div>
</div>
<!-- Panel de resultados (derecha) -->
<!-- Error -->
<div v-if="error" class="p-4 rounded-lg bg-error-subtle border border-error-border">
<div class="flex items-start gap-3">
<span class="material-symbols-outlined text-error text-[18px] shrink-0 mt-0.5"
style="font-variation-settings:'FILL' 1;">error</span>
<div>
<p class="text-sm font-semibold text-error mb-1">Error al generar</p>
<p class="text-xs text-error/80 leading-relaxed">{{ error }}</p>
</div>
</div>
<button
@click="error = null; generar()"
class="mt-3 w-full px-3 py-2 bg-error/10 border border-error-border text-error text-xs
font-semibold rounded-lg hover:bg-error/20 transition-colors flex items-center
justify-center gap-1.5 active:scale-[0.97]"
>
<span class="material-symbols-outlined text-[14px]">refresh</span>
Reintentar
</button>
</div>
</div>
<!-- Panel de resultados -->
<div class="xl:col-span-5 flex flex-col gap-5">
<!-- Resultado -->
@ -164,29 +242,38 @@
<div class="bg-surface rounded-xl border border-success-border shadow-sm p-5">
<div class="flex items-start justify-between mb-3">
<h3 class="text-sm font-semibold text-ink leading-snug">{{ resultado.guion.titulo_sugerido }}</h3>
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-success-subtle border border-success-border rounded-full ml-3 shrink-0">
<div class="flex items-center gap-1.5 px-2.5 py-1 bg-success-subtle border border-success-border
rounded-full ml-3 shrink-0">
<span class="material-symbols-outlined text-success text-[14px]" style="font-variation-settings:'FILL' 1;">bolt</span>
<span class="text-xs font-bold text-success">{{ resultado.guion.score_estimado }}/100</span>
<span class="text-xs font-bold text-success tabular-nums">{{ resultado.guion.score_estimado }}/100</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5 mb-3">
<span
v-for="t in resultado.guion.tecnicas_aplicadas"
:key="t"
class="text-[9px] font-semibold uppercase tracking-wider px-2 py-0.5 bg-surface-muted border border-border rounded text-ink-3"
class="text-[9px] font-semibold uppercase tracking-wider px-2 py-0.5
bg-surface-muted border border-border rounded text-ink-3"
>{{ t }}</span>
</div>
<div class="flex items-center gap-3">
<p class="text-[10px] text-ink-3 font-medium uppercase tracking-wide">Duración estimada</p>
<p class="text-base font-bold text-ink">{{ resultado.guion.duracion_estimada_seg }}s</p>
<p class="text-base font-bold text-ink tabular-nums">{{ resultado.guion.duracion_estimada_seg }}s</p>
</div>
</div>
<!-- Guion completo -->
<div class="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
<div class="px-5 py-3.5 border-b border-border flex items-center justify-between">
<div class="px-5 py-3.5 border-b border-border flex items-center justify-between bg-surface/90 backdrop-blur-sm">
<h3 class="text-xs font-semibold text-ink uppercase tracking-wider">Guion Completo</h3>
<button @click="copiarGuion" class="text-xs font-medium text-accent hover:text-accent-hover transition-colors flex items-center gap-1">
<button
@click="copiarGuion"
class="text-xs font-medium flex items-center gap-1 transition-all active:scale-95 px-2.5 py-1
rounded-lg border"
:class="copiado
? 'text-success border-success-border bg-success-subtle'
: 'text-accent border-accent-border hover:bg-accent-subtle'"
>
<span class="material-symbols-outlined text-[14px]">{{ copiado ? 'check' : 'content_copy' }}</span>
{{ copiado ? 'Copiado' : 'Copiar' }}
</button>
@ -213,7 +300,11 @@
<span class="material-symbols-outlined text-ink-3 text-[16px]">shuffle</span> Variantes del Gancho
</h3>
<div class="space-y-2">
<div v-for="(v, i) in resultado.guion.variantes_gancho" :key="i" class="flex items-start gap-2.5 p-3 rounded-lg bg-surface-muted border border-border">
<div
v-for="(v, i) in resultado.guion.variantes_gancho"
:key="i"
class="flex items-start gap-2.5 p-3 rounded-lg bg-surface-muted border border-border"
>
<span class="text-[9px] font-bold text-ink-3 uppercase tracking-wider pt-0.5 shrink-0">V{{ i + 1 }}</span>
<span class="text-sm text-ink-2 italic leading-relaxed">"{{ v }}"</span>
</div>
@ -228,10 +319,11 @@
<p class="text-sm text-ink-2 leading-relaxed">{{ resultado.guion.notas_produccion }}</p>
</div>
<!-- Nuevo guion -->
<!-- Generar otro -->
<button
@click="resultado = null; form.tema = ''; form.instrucciones_extra = ''"
class="w-full py-2.5 bg-canvas border border-border text-ink-2 font-medium rounded-lg text-sm hover:bg-surface-muted transition-colors"
@click="resetForm"
class="w-full py-2.5 bg-canvas border border-border text-ink-2 font-medium rounded-lg text-sm
hover:bg-surface-muted transition-all active:scale-[0.97]"
>
Generar otro guion
</button>
@ -239,12 +331,20 @@
<!-- Estado vacío / Info -->
<div v-else class="bg-surface rounded-xl border border-border shadow-sm p-6 sticky top-24">
<h3 class="text-sm font-semibold text-ink mb-3">¿Cómo funciona?</h3>
<h3 class="text-sm font-semibold text-ink mb-3 flex items-center gap-2">
<span class="material-symbols-outlined text-accent text-[18px]">auto_fix_high</span>
¿Cómo funciona?
</h3>
<p class="text-xs text-ink-3 leading-relaxed mb-5">
El generador analiza los guiones de mayor rendimiento de tu base de datos y aplica sus patrones estructurales, técnicas de retención y triggers emocionales al nuevo contenido.
</p>
<div class="space-y-4">
<div v-for="paso in pasoInfo" :key="paso.label" class="flex items-start gap-3">
<div
v-for="(paso, i) in pasoInfo"
:key="paso.label"
class="flex items-start gap-3"
:style="{ animationDelay: `${i * 60}ms` }"
>
<div class="w-7 h-7 rounded-full bg-accent-subtle border border-accent-border flex items-center justify-center shrink-0">
<span class="material-symbols-outlined text-accent text-[14px]">{{ paso.icon }}</span>
</div>
@ -254,8 +354,34 @@
</div>
</div>
</div>
<div class="mt-5 pt-4 border-t border-border">
<p class="text-[11px] text-ink-3">Tiempo estimado: 510 segundos · Modelo: GPT-4o</p>
<div class="mt-5 pt-4 border-t border-border flex items-center justify-between">
<p class="text-[11px] text-ink-3">Tiempo estimado: 510s</p>
<span class="text-[10px] font-semibold px-2 py-0.5 bg-accent-subtle border border-accent-border text-accent rounded-full">GPT-4o</span>
</div>
<!-- Indicador de campos completados -->
<div class="mt-4 pt-4 border-t border-border">
<div class="flex items-center justify-between mb-2">
<p class="text-[11px] font-medium text-ink-3">Progreso del formulario</p>
<p class="text-[11px] font-bold tabular-nums" :class="camposCompletos === 3 ? 'text-success' : 'text-ink-3'">
{{ camposCompletos }}/3
</p>
</div>
<div class="flex gap-1.5">
<div
v-for="(campo, i) in ['niche', 'tema', 'audiencia']"
:key="campo"
class="flex-1 h-1 rounded-full transition-all duration-300"
:class="form[campo] ? 'bg-success' : 'bg-border'"
></div>
</div>
<div class="flex gap-1.5 mt-1.5">
<p v-for="(campo, i) in camposLabels" :key="i"
class="flex-1 text-center text-[9px] transition-colors"
:class="form[camposKeys[i]] ? 'text-success' : 'text-ink-3'">
{{ campo }}
</p>
</div>
</div>
</div>
</div>
@ -264,15 +390,19 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { api } from '../lib/api.js'
import { useToast } from '../composables/useToast.js'
const toast = useToast()
const generando = ref(false)
const error = ref(null)
const resultado = ref(null)
const copiado = ref(false)
const nichos = ref([])
const campoTocado = ref({ niche: false, tema: false, audiencia: false })
const form = ref({
niche: '',
tema: '',
@ -285,6 +415,21 @@ const form = ref({
instrucciones_extra: '',
})
const camposKeys = ['niche', 'tema', 'audiencia']
const camposLabels = ['Nicho', 'Tema', 'Audiencia']
const camposCompletos = computed(() =>
camposKeys.filter(k => !!form.value[k]).length
)
const camposFaltantes = computed(() => {
const faltan = []
if (!form.value.niche) faltan.push('nicho')
if (!form.value.tema) faltan.push('tema')
if (!form.value.audiencia) faltan.push('audiencia')
return faltan
})
const pasoInfo = [
{ icon: 'search', label: 'Busca patrones', desc: 'Selecciona los mejores guiones del niche en tu biblioteca' },
{ icon: 'psychology', label: 'Extrae técnicas', desc: 'Identifica estructura, triggers y principios Cialdini activos' },
@ -293,15 +438,19 @@ const pasoInfo = [
]
async function generar() {
campoTocado.value = { niche: true, tema: true, audiencia: true }
if (!form.value.niche || !form.value.tema || !form.value.audiencia) return
generando.value = true
error.value = null
resultado.value = null
try {
resultado.value = await api.generar(form.value)
toast.success('Guion generado correctamente')
} catch (e) {
error.value = e.message
toast.error('Error al generar el guion')
} finally {
generando.value = false
}
@ -311,7 +460,15 @@ async function copiarGuion() {
if (!resultado.value?.guion?.guion_completo) return
await navigator.clipboard.writeText(resultado.value.guion.guion_completo)
copiado.value = true
setTimeout(() => { copiado.value = false }, 2000)
toast.success('Guion copiado al portapapeles')
setTimeout(() => { copiado.value = false }, 2500)
}
function resetForm() {
resultado.value = null
form.value.tema = ''
form.value.instrucciones_extra = ''
campoTocado.value = { niche: false, tema: false, audiencia: false }
}
onMounted(async () => {

View File

@ -6,7 +6,7 @@
<div>
<h1 class="text-3xl font-bold font-headline text-ink mb-1">Biblioteca de Guiones</h1>
<p class="text-sm text-ink-3">
{{ totalGuiones }} guiones
<span class="tabular-nums font-medium text-ink-2">{{ totalGuiones }}</span> guiones
<span class="font-medium" :class="filtros.tipo === 'analizados' ? 'text-accent' : 'text-success'">
{{ filtros.tipo }}
</span>
@ -15,14 +15,18 @@
</div>
<div class="flex items-center gap-3">
<button
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm hover:bg-surface-muted transition-colors"
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm
hover:bg-surface-muted transition-all active:scale-[0.97] flex items-center gap-2 disabled:opacity-40"
:disabled="cargando"
@click="cargarDatos"
>
<span class="material-symbols-outlined text-[16px]" :class="cargando ? 'animate-spin' : ''">refresh</span>
Actualizar
</button>
<router-link
to="/new-analysis"
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm flex items-center gap-2 transition-colors shadow-sm"
class="px-5 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm
flex items-center gap-2 transition-all shadow-sm active:scale-[0.97]"
>
<span class="material-symbols-outlined text-[18px]">add</span>
Nuevo Análisis
@ -32,13 +36,12 @@
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-3">
<!-- Toggle Analizados / Generados -->
<!-- Toggle -->
<div class="flex items-center bg-surface border border-border rounded-lg p-1 gap-1">
<button
@click="cambiarTipo('analizados')"
:class="filtros.tipo === 'analizados' ? 'bg-accent text-white shadow-sm' : 'text-ink-2 hover:bg-surface-muted'"
class="px-3 py-1.5 rounded-md text-xs font-semibold transition-all flex items-center gap-1.5"
class="px-3 py-1.5 rounded-md text-xs font-semibold transition-all flex items-center gap-1.5 active:scale-[0.97]"
>
<span class="material-symbols-outlined text-[14px]">analytics</span>
Analizados
@ -46,7 +49,7 @@
<button
@click="cambiarTipo('generados')"
:class="filtros.tipo === 'generados' ? 'bg-success text-white shadow-sm' : 'text-ink-2 hover:bg-surface-muted'"
class="px-3 py-1.5 rounded-md text-xs font-semibold transition-all flex items-center gap-1.5"
class="px-3 py-1.5 rounded-md text-xs font-semibold transition-all flex items-center gap-1.5 active:scale-[0.97]"
>
<span class="material-symbols-outlined text-[14px]">auto_fix_high</span>
Generados
@ -60,7 +63,9 @@
v-model="filtros.busqueda"
type="text"
placeholder="Buscar por tema, gancho…"
class="w-full bg-surface border border-border rounded-lg pl-9 pr-4 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/40 transition-all"
class="w-full bg-surface border border-border rounded-lg pl-9 pr-4 py-2 text-sm text-ink
placeholder:text-ink-3 focus:outline-none focus:ring-2 focus:ring-accent/30
focus:border-accent/40 transition-all"
/>
</div>
@ -88,15 +93,60 @@
</select>
</div>
<!-- Skeleton loading -->
<!-- Skeleton loading shape-matching -->
<div v-if="cargando" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
<div v-for="i in 6" :key="i" class="bg-surface rounded-xl border border-border h-56 animate-pulse"></div>
<div v-for="i in 6" :key="i" class="bg-surface rounded-xl border border-border p-5 flex flex-col gap-4 animate-pulse">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="h-4 w-12 bg-surface-subtle rounded-md"></div>
<div class="h-3 w-16 bg-surface-subtle rounded"></div>
</div>
<div class="h-5 w-10 bg-surface-subtle rounded-full"></div>
</div>
<div class="space-y-1.5">
<div class="h-4 w-full bg-surface-subtle rounded"></div>
<div class="h-4 w-3/4 bg-surface-subtle rounded"></div>
</div>
<div class="flex-1 space-y-1.5">
<div class="h-3 w-10 bg-surface-subtle rounded"></div>
<div class="h-3 w-full bg-surface-subtle rounded"></div>
<div class="h-3 w-5/6 bg-surface-subtle rounded"></div>
<div class="h-3 w-2/3 bg-surface-subtle rounded"></div>
</div>
<div class="flex items-center justify-between pt-3 border-t border-border">
<div class="h-6 w-12 bg-surface-subtle rounded"></div>
<div class="h-6 w-12 bg-surface-subtle rounded"></div>
<div class="h-6 w-16 bg-surface-subtle rounded"></div>
</div>
</div>
</div>
<!-- Vacío -->
<div v-else-if="guionesFiltrados.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-ink-3">
<span class="material-symbols-outlined text-4xl">inventory_2</span>
<p class="text-sm font-medium">No hay guiones que coincidan con los filtros</p>
<!-- Vacío con CTA -->
<div v-else-if="guionesFiltrados.length === 0" class="flex flex-col items-center justify-center py-24 gap-4 text-center max-w-xs mx-auto">
<div class="w-14 h-14 rounded-2xl bg-surface-muted border border-border flex items-center justify-center mb-2">
<span class="material-symbols-outlined text-3xl text-ink-3">inventory_2</span>
</div>
<div>
<p class="text-sm font-semibold text-ink mb-1">
{{ filtros.busqueda ? 'Sin resultados para tu búsqueda' : 'Biblioteca vacía' }}
</p>
<p class="text-xs text-ink-3 leading-relaxed">
{{ filtros.busqueda
? `No encontramos guiones con "${filtros.busqueda}". Prueba con otras palabras.`
: 'Analiza tu primer video viral para comenzar a construir tu biblioteca de patrones.' }}
</p>
</div>
<router-link v-if="!filtros.busqueda" to="/new-analysis"
class="px-4 py-2 bg-accent hover:bg-accent-hover text-white font-semibold rounded-lg text-sm
flex items-center gap-2 transition-all active:scale-[0.97]">
<span class="material-symbols-outlined text-[16px]">add</span>
Analizar primer video
</router-link>
<button v-else @click="filtros.busqueda = ''"
class="px-4 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm
hover:bg-surface-muted transition-colors">
Limpiar búsqueda
</button>
</div>
<!-- Grid de Guiones -->
@ -105,7 +155,8 @@
v-for="g in guionesFiltrados"
:key="g.id"
@click="filtros.tipo === 'analizados' ? verDetalle(g.id) : verGenerado(g.id)"
class="bg-surface rounded-xl border border-border shadow-sm p-5 flex flex-col gap-4 cursor-pointer transition-all group hover:shadow-md"
class="bg-surface rounded-xl border border-border shadow-sm p-5 flex flex-col gap-4 cursor-pointer
transition-all duration-200 group hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/20"
:class="filtros.tipo === 'analizados' ? 'hover:border-accent/30' : 'hover:border-success/30'"
>
<!-- Badges + Score -->
@ -116,12 +167,10 @@
</div>
<div
class="flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-bold border"
:class="filtros.tipo === 'analizados'
? 'bg-accent-subtle border-accent-border text-accent'
: 'bg-success-subtle border-success-border text-success'"
:class="scoreScoreBadge(filtros.tipo === 'analizados' ? g.score_virabilidad : g.score_estimado)"
>
<span class="material-symbols-outlined text-[12px]" style="font-variation-settings:'FILL' 1;">bolt</span>
{{ filtros.tipo === 'analizados' ? (g.score_virabilidad || 0) : (g.score_estimado || 0) }}
<span class="tabular-nums">{{ filtros.tipo === 'analizados' ? (g.score_virabilidad || 0) : (g.score_estimado || 0) }}</span>
</div>
</div>
@ -133,7 +182,7 @@
>
{{ filtros.tipo === 'analizados' ? (g.tema_principal || 'Sin título detectado') : (g.titulo_sugerido || g.tema || 'Sin título') }}
</p>
<p v-if="filtros.tipo === 'analizados'" class="text-[10px] text-ink-3 truncate">{{ g.url_origen }}</p>
<p v-if="filtros.tipo === 'analizados'" class="text-[10px] text-ink-3 truncate" :title="g.url_origen">{{ g.url_origen }}</p>
<p v-else class="text-[10px] text-ink-3 truncate">{{ g.tono }} · {{ g.objetivo }}</p>
</div>
@ -150,27 +199,31 @@
<template v-if="filtros.tipo === 'analizados'">
<div class="text-center">
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Engagement</p>
<p class="text-xs font-bold text-success">{{ (g.score_engagement || 0).toFixed(1) }}%</p>
<p class="text-xs font-bold text-success tabular-nums">{{ (g.score_engagement || 0).toFixed(1) }}%</p>
</div>
<div class="text-center">
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Cialdini</p>
<p class="text-xs font-bold text-ink">{{ g.score_cialdini || 0 }}/7</p>
<p class="text-xs font-bold text-ink tabular-nums">{{ g.score_cialdini || 0 }}/7</p>
</div>
<div class="text-center">
<p class="text-[9px] text-ink-3 font-medium mb-1">Viralidad</p>
<div class="w-16 bg-surface-subtle h-1 rounded-full overflow-hidden">
<div class="bg-accent h-full rounded-full transition-all" :style="{ width: (g.score_virabilidad || 0) + '%' }"></div>
<div
class="h-full rounded-full transition-all"
:class="scoreBarColor(g.score_virabilidad)"
:style="{ width: (g.score_virabilidad || 0) + '%' }"
></div>
</div>
</div>
</template>
<template v-else>
<div class="text-center">
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Score</p>
<p class="text-xs font-bold text-success">{{ g.score_estimado || 0 }}/100</p>
<p class="text-xs font-bold text-success tabular-nums">{{ g.score_estimado || 0 }}/100</p>
</div>
<div class="text-center">
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Duración</p>
<p class="text-xs font-bold text-ink">{{ g.duracion_estimada_seg || '—' }}s</p>
<p class="text-xs font-bold text-ink tabular-nums">{{ g.duracion_estimada_seg || '—' }}s</p>
</div>
<div class="text-center">
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Estructura</p>
@ -178,18 +231,40 @@
</div>
</template>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
<button class="p-1.5 rounded-lg text-ink-3 hover:text-accent hover:bg-accent-subtle transition-all">
<!-- Acciones hover: confirmación inline de borrado -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all" @click.stop>
<button
class="p-1.5 rounded-lg text-ink-3 hover:text-accent hover:bg-accent-subtle transition-all active:scale-95"
aria-label="Ver detalle"
>
<span class="material-symbols-outlined text-[16px]">open_in_new</span>
</button>
<!-- Delete: 1er clic muestra confirmación, 2do borra -->
<template v-if="confirmDeleteId !== g.id">
<button
@click.stop="confirmarEliminar(g)"
@click.stop="confirmDeleteId = g.id"
:disabled="eliminandoId === g.id"
class="p-1.5 rounded-lg text-ink-3 hover:text-red-400 hover:bg-red-950/40 transition-all disabled:opacity-50"
class="p-1.5 rounded-lg text-ink-3 hover:text-red-400 hover:bg-red-950/40 transition-all active:scale-95 disabled:opacity-50"
aria-label="Eliminar"
>
<span v-if="eliminandoId === g.id" class="material-symbols-outlined text-[16px] animate-spin">progress_activity</span>
<span v-else class="material-symbols-outlined text-[16px]">delete</span>
</button>
</template>
<template v-else>
<div class="flex items-center gap-1 bg-error-subtle border border-error-border rounded-lg px-2 py-1">
<span class="text-[10px] font-semibold text-error">¿Eliminar?</span>
<button
@click.stop="ejecutarEliminar(g); confirmDeleteId = null"
class="text-[10px] font-bold text-error hover:text-white hover:bg-error px-1.5 py-0.5 rounded transition-colors"
></button>
<button
@click.stop="confirmDeleteId = null"
class="text-[10px] text-ink-3 hover:text-ink px-1 py-0.5 rounded transition-colors"
>No</button>
</div>
</template>
</div>
</div>
</div>
@ -202,24 +277,28 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@click.self="modalGenerado = null"
>
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm"></div>
<div class="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-surface rounded-xl border border-border shadow-xl flex flex-col">
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm"></div>
<div class="relative z-10 w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-surface rounded-xl border border-border shadow-2xl flex flex-col">
<!-- Header del modal -->
<div class="sticky top-0 bg-surface px-6 py-5 border-b border-border flex items-start justify-between gap-4 z-10">
<!-- Header sticky del modal -->
<div class="sticky top-0 bg-surface/95 backdrop-blur-sm px-6 py-5 border-b border-border flex items-start justify-between gap-4 z-10">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span :class="plataformaBadge(modalGenerado.plataforma)" class="platform-badge">{{ modalGenerado.plataforma }}</span>
<span class="text-[10px] font-semibold text-ink-3 uppercase tracking-wide">{{ modalGenerado.niche }}</span>
<div class="flex items-center gap-1 px-2 py-0.5 bg-success-subtle border border-success-border rounded-full">
<span class="material-symbols-outlined text-success text-[12px]" style="font-variation-settings:'FILL' 1;">auto_fix_high</span>
<span class="text-[10px] font-bold text-success">{{ modalGenerado.score_estimado }}/100</span>
<span class="text-[10px] font-bold text-success tabular-nums">{{ modalGenerado.score_estimado }}/100</span>
</div>
</div>
<h2 class="text-base font-bold font-headline text-ink leading-tight">{{ modalGenerado.titulo_sugerido }}</h2>
<p class="text-[11px] text-ink-3 mt-0.5">{{ modalGenerado.tono }} · {{ modalGenerado.objetivo }} · {{ modalGenerado.duracion_estimada_seg }}s estimados</p>
</div>
<button @click="modalGenerado = null" class="p-1.5 rounded-lg text-ink-3 hover:text-ink hover:bg-surface-muted transition-colors shrink-0">
<button
@click="modalGenerado = null"
class="p-1.5 rounded-lg text-ink-3 hover:text-ink hover:bg-surface-muted transition-colors shrink-0 active:scale-95"
aria-label="Cerrar"
>
<span class="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
@ -266,15 +345,20 @@
<button
@click="copiarGuionModal"
class="w-full py-2.5 bg-success-subtle border border-success-border text-success font-semibold rounded-lg text-sm hover:bg-success hover:text-white transition-colors flex items-center justify-center gap-2"
class="w-full py-2.5 border font-semibold rounded-lg text-sm hover:text-white transition-all
flex items-center justify-center gap-2 active:scale-[0.97]"
:class="copiadoModal
? 'bg-success border-success-border text-white'
: 'bg-success-subtle border-success-border text-success hover:bg-success'"
>
<span class="material-symbols-outlined text-[16px]">{{ copiadoModal ? 'check' : 'content_copy' }}</span>
{{ copiadoModal ? 'Copiado' : 'Copiar guion completo' }}
{{ copiadoModal ? 'Copiado al portapapeles' : 'Copiar guion completo' }}
</button>
</div>
<div v-else class="p-8 flex items-center justify-center">
<div v-else class="p-8 flex items-center justify-center gap-3">
<div class="w-6 h-6 border-2 border-success border-t-transparent rounded-full animate-spin"></div>
<span class="text-sm text-ink-3">Cargando guion</span>
</div>
</div>
</div>
@ -283,13 +367,15 @@
<!-- Paginación -->
<div v-if="!cargando && guionesFiltrados.length > 0" class="flex items-center justify-center gap-3 pt-2">
<button
class="px-5 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm hover:bg-surface-muted transition-colors disabled:opacity-40"
class="px-5 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
:disabled="filtros.page <= 1"
@click="cambiarPagina(filtros.page - 1)"
>Anterior</button>
<span class="text-sm font-medium text-ink-3">Página {{ filtros.page }}</span>
<span class="text-sm font-medium text-ink-3 tabular-nums">Página {{ filtros.page }}</span>
<button
class="px-5 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm hover:bg-surface-muted transition-colors disabled:opacity-40"
class="px-5 py-2 bg-surface border border-border text-ink-2 font-medium rounded-lg text-sm
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
:disabled="guiones.length < filtros.limit"
@click="cambiarPagina(filtros.page + 1)"
>Siguiente</button>
@ -300,10 +386,14 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { api } from '../lib/api.js'
import { useToast } from '../composables/useToast.js'
const router = useRouter()
const route = useRoute()
const toast = useToast()
const guiones = ref([])
const nichos = ref([])
const cargando = ref(true)
@ -312,6 +402,7 @@ const modalGenerado = ref(null)
const cargandoModal = ref(false)
const copiadoModal = ref(false)
const eliminandoId = ref(null)
const confirmDeleteId = ref(null)
const filtros = ref({
tipo: 'analizados',
@ -319,13 +410,12 @@ const filtros = ref({
limit: 18,
niche: '',
plataforma: '',
busqueda: '',
busqueda: route.query.q || '',
orden: 'fecha_desc',
})
const guionesFiltrados = computed(() => {
let lista = [...guiones.value]
if (filtros.value.busqueda.trim()) {
const q = filtros.value.busqueda.toLowerCase()
if (filtros.value.tipo === 'analizados') {
@ -342,19 +432,18 @@ const guionesFiltrados = computed(() => {
)
}
}
switch (filtros.value.orden) {
case 'viralidad_desc': lista.sort((a, b) => (b.score_virabilidad || 0) - (a.score_virabilidad || 0)); break
case 'engagement_desc': lista.sort((a, b) => (b.score_engagement || 0) - (a.score_engagement || 0)); break
case 'cialdini_desc': lista.sort((a, b) => (b.score_cialdini || 0) - (a.score_cialdini || 0)); break
case 'score_desc': lista.sort((a, b) => (b.score_estimado || 0) - (a.score_estimado || 0)); break
}
return lista
})
async function cargarDatos() {
cargando.value = true
confirmDeleteId.value = null
try {
const params = { page: filtros.value.page, limit: filtros.value.limit }
if (filtros.value.niche) params.niche = filtros.value.niche
@ -373,6 +462,7 @@ async function cargarDatos() {
}
} catch (e) {
console.error(e)
toast.error('Error al cargar los guiones')
} finally {
cargando.value = false
}
@ -408,6 +498,7 @@ async function verGenerado(id) {
modalGenerado.value = generado
} catch (e) {
console.error(e)
toast.error('No se pudo cargar el guion')
} finally {
cargandoModal.value = false
}
@ -418,16 +509,15 @@ async function copiarGuionModal() {
if (!texto) return
await navigator.clipboard.writeText(texto)
copiadoModal.value = true
setTimeout(() => { copiadoModal.value = false }, 2000)
toast.success('Guion copiado al portapapeles')
setTimeout(() => { copiadoModal.value = false }, 2500)
}
async function confirmarEliminar(g) {
async function ejecutarEliminar(g) {
const titulo = filtros.value.tipo === 'analizados'
? (g.tema_principal || 'este guion')
: (g.titulo_sugerido || 'este guion')
if (!confirm(`¿Eliminar "${titulo}"? Esta acción no se puede deshacer.`)) return
eliminandoId.value = g.id
try {
if (filtros.value.tipo === 'analizados') {
@ -437,9 +527,10 @@ async function confirmarEliminar(g) {
}
guiones.value = guiones.value.filter(x => x.id !== g.id)
totalGuiones.value = Math.max(0, totalGuiones.value - 1)
toast.success(`"${titulo.substring(0, 40)}${titulo.length > 40 ? '…' : ''}" eliminado`)
} catch (e) {
console.error(e)
alert('No se pudo eliminar: ' + e.message)
toast.error('No se pudo eliminar: ' + e.message)
} finally {
eliminandoId.value = null
}
@ -454,5 +545,21 @@ function plataformaBadge(p) {
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
}
function scoreBarColor(score) {
if (!score) return 'bg-border'
if (score >= 80) return 'bg-success'
if (score >= 60) return 'bg-accent'
if (score >= 40) return 'bg-warn'
return 'bg-error'
}
function scoreScoreBadge(score) {
if (!score) return 'bg-surface-muted border-border text-ink-3'
if (score >= 80) return 'bg-success-subtle border-success-border text-success'
if (score >= 60) return 'bg-accent-subtle border-accent-border text-accent'
if (score >= 40) return 'bg-warn-subtle border-warn-border text-warn'
return 'bg-error-subtle border-error-border text-error'
}
onMounted(cargarDatos)
</script>