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:
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
54
frontend/src/components/ToastContainer.vue
Normal file
54
frontend/src/components/ToastContainer.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
22
frontend/src/composables/useToast.js
Normal file
22
frontend/src/composables/useToast.js
Normal 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' }),
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
<h2 class="text-sm font-semibold text-ink">Registro completo</h2>
|
||||
<!-- 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">
|
||||
<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"
|
||||
>
|
||||
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
|
||||
</button>
|
||||
<td class="px-6 py-4 text-right" @click.stop>
|
||||
<div class="relative" v-if="g.procesado_ok">
|
||||
<button
|
||||
@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]">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),
|
||||
@ -211,15 +319,16 @@ async function cargarDatos() {
|
||||
api.guiones.listarTodos({ limit: 1, ...(params.niche ? { niche: params.niche } : {}) }),
|
||||
])
|
||||
|
||||
totalOk.value = okReq.total || 0
|
||||
totalOk.value = okReq.total || 0
|
||||
totalFallidos.value = (allReq.total || 0) - (okReq.total || 0)
|
||||
|
||||
let lista = dg.guiones
|
||||
if (filtroActivo.value === 'exitosos') lista = lista.filter(g => g.procesado_ok)
|
||||
if (filtroActivo.value === 'fallidos') lista = lista.filter(g => !g.procesado_ok)
|
||||
|
||||
guiones.value = lista
|
||||
nichos.value = dn
|
||||
guiones.value = lista
|
||||
totalGuiones.value = dg.total || lista.length
|
||||
nichos.value = dn
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
<h2 class="text-sm font-semibold text-ink">Guiones Analizados</h2>
|
||||
<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 {
|
||||
@ -226,8 +338,9 @@ async function cargarDatos() {
|
||||
api.guiones.listar(filtros.value),
|
||||
api.stats()
|
||||
])
|
||||
guiones.value = dg.guiones
|
||||
stats.value = ds
|
||||
guiones.value = dg.guiones
|
||||
totalGuiones.value = dg.total || dg.guiones.length
|
||||
stats.value = ds
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@ -254,7 +367,5 @@ function plataformaBadge(p) {
|
||||
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
cargarDatos()
|
||||
})
|
||||
onMounted(cargarDatos)
|
||||
</script>
|
||||
|
||||
@ -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 -->
|
||||
<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"
|
||||
>
|
||||
<span class="material-symbols-outlined text-[18px]" :class="generando ? 'animate-spin' : ''">
|
||||
{{ generando ? 'hourglass_top' : 'auto_fix_high' }}
|
||||
</span>
|
||||
{{ generando ? 'Generando con GPT-4o…' : 'Generar Guion' }}
|
||||
</button>
|
||||
<!-- 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-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 ? 'progress_activity' : 'auto_fix_high' }}
|
||||
</span>
|
||||
{{ generando ? 'Generando con GPT-4o…' : 'Generar Guion' }}
|
||||
</button>
|
||||
<!-- 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>
|
||||
|
||||
<div v-if="error" class="p-4 rounded-lg bg-error-subtle border border-error-border text-error text-sm">
|
||||
{{ error }}
|
||||
<!-- 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 (derecha) -->
|
||||
<!-- 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: 5–10 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: 5–10s</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,27 +390,46 @@
|
||||
</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: '',
|
||||
audiencia: '',
|
||||
plataforma: 'tiktok',
|
||||
estructura: 'AIDA',
|
||||
objetivo: 'engagement',
|
||||
tono: 'educativo',
|
||||
duracion_objetivo: 60,
|
||||
niche: '',
|
||||
tema: '',
|
||||
audiencia: '',
|
||||
plataforma: 'tiktok',
|
||||
estructura: 'AIDA',
|
||||
objetivo: 'engagement',
|
||||
tono: 'educativo',
|
||||
duracion_objetivo: 60,
|
||||
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
|
||||
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 () => {
|
||||
|
||||
@ -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>
|
||||
<button
|
||||
@click.stop="confirmarEliminar(g)"
|
||||
: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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Delete: 1er clic muestra confirmación, 2do borra -->
|
||||
<template v-if="confirmDeleteId !== g.id">
|
||||
<button
|
||||
@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 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"
|
||||
>Sí</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') {
|
||||
@ -435,11 +525,12 @@ async function confirmarEliminar(g) {
|
||||
} else {
|
||||
await api.generados.eliminar(g.id)
|
||||
}
|
||||
guiones.value = guiones.value.filter(x => x.id !== g.id)
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user