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">
|
<div class="bg-canvas min-h-screen text-ink selection:bg-accent/20 selection:text-accent">
|
||||||
|
|
||||||
<template v-if="isAuthenticated">
|
<template v-if="isAuthenticated">
|
||||||
<SideNavBar />
|
<SideNavBar :open="sidebarOpen" @close="sidebarOpen = false" />
|
||||||
<TopAppBar />
|
<!-- Mobile backdrop -->
|
||||||
<main class="ml-60 pt-16 pb-12 px-8 min-h-screen">
|
<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">
|
<div class="max-w-7xl mx-auto pt-8">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
@ -19,23 +25,27 @@
|
|||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useAuthStore } from './stores/auth.js'
|
import { useAuthStore } from './stores/auth.js'
|
||||||
import SideNavBar from './components/SideNavBar.vue'
|
import SideNavBar from './components/SideNavBar.vue'
|
||||||
import TopAppBar from './components/TopAppBar.vue'
|
import TopAppBar from './components/TopAppBar.vue'
|
||||||
|
import ToastContainer from './components/ToastContainer.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const { isAuthenticated } = storeToRefs(auth)
|
const { isAuthenticated } = storeToRefs(auth)
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-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-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
<template>
|
<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 -->
|
<!-- Logo -->
|
||||||
<div class="px-5 py-5 border-b border-border">
|
<div class="px-5 py-5 border-b border-border">
|
||||||
<div class="flex items-center gap-2.5">
|
<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>
|
<span class="material-symbols-outlined text-white text-[18px]" style="font-variation-settings:'FILL' 1;">psychology</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -15,40 +19,49 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navegación -->
|
<!-- 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
|
<router-link
|
||||||
to="/"
|
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"
|
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"
|
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>
|
<span>Dashboard</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/analysis"
|
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"
|
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"
|
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>
|
<span>Análisis</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/scripts"
|
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"
|
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"
|
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>
|
<span>Guiones</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/generate"
|
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"
|
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"
|
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>
|
<span>Generar</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
@ -57,7 +70,10 @@
|
|||||||
<div class="px-3 pb-5">
|
<div class="px-3 pb-5">
|
||||||
<router-link
|
<router-link
|
||||||
to="/new-analysis"
|
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>
|
<span class="material-symbols-outlined text-[18px]">add</span>
|
||||||
Nuevo Análisis
|
Nuevo Análisis
|
||||||
@ -67,4 +83,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
defineProps({ open: Boolean })
|
||||||
|
defineEmits(['close'])
|
||||||
</script>
|
</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>
|
<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 -->
|
<!-- 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>
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-ink-3 text-[18px]">search</span>
|
||||||
<input
|
<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..."
|
placeholder="Buscar guiones..."
|
||||||
type="text"
|
type="text"
|
||||||
|
@keydown.enter="irABusqueda"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Acciones -->
|
<!-- Acciones -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4 ml-auto">
|
||||||
<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-3 pl-4 border-l border-border">
|
<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">
|
<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>
|
<span class="material-symbols-outlined text-accent text-[18px]" style="font-variation-settings:'FILL' 1;">account_circle</span>
|
||||||
</div>
|
</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-xs font-semibold text-ink leading-none">Marketing Pro</p>
|
||||||
<p class="text-[10px] text-ink-3 mt-0.5">Administrador</p>
|
<p class="text-[10px] text-ink-3 mt-0.5">Administrador</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
title="Cerrar sesión"
|
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>
|
<span class="material-symbols-outlined text-[20px]">logout</span>
|
||||||
</button>
|
</button>
|
||||||
@ -39,14 +49,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth.js'
|
import { useAuthStore } from '../stores/auth.js'
|
||||||
|
|
||||||
|
defineEmits(['toggle-sidebar'])
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const query = ref('')
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
auth.logout()
|
auth.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function irABusqueda() {
|
||||||
|
if (!query.value.trim()) return
|
||||||
|
router.push({ path: '/scripts', query: { q: query.value.trim() } })
|
||||||
|
query.value = ''
|
||||||
|
}
|
||||||
</script>
|
</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>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
:disabled="analizando"
|
||||||
@click="iniciarAnalisis"
|
@click="iniciarAnalisis"
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined text-[18px]" :class="analizando ? 'animate-spin' : ''">
|
<span class="material-symbols-outlined text-[18px]" :class="analizando ? 'animate-spin' : ''">
|
||||||
{{ analizando ? 'hourglass_top' : 'play_arrow' }}
|
{{ analizando ? 'progress_activity' : 'play_arrow' }}
|
||||||
</span>
|
</span>
|
||||||
{{ analizando ? 'Analizando…' : 'Iniciar Análisis' }}
|
{{ analizando ? 'Analizando…' : 'Iniciar Análisis' }}
|
||||||
</button>
|
</button>
|
||||||
@ -23,7 +25,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
<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">
|
<div class="xl:col-span-7 flex flex-col gap-6">
|
||||||
|
|
||||||
<!-- Paso 1: Fuente del Video -->
|
<!-- Paso 1: Fuente del Video -->
|
||||||
@ -34,18 +36,40 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-5">
|
<div class="p-6 space-y-5">
|
||||||
<div class="space-y-1.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">
|
<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
|
<input
|
||||||
v-model="form.url"
|
v-model="form.url"
|
||||||
|
@input="validarUrl"
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://www.tiktok.com/@usuario/video/..."
|
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"
|
: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>
|
</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<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>
|
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Cliente</label>
|
||||||
<select
|
<select
|
||||||
v-model="form.cliente_id"
|
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"
|
:disabled="analizando"
|
||||||
>
|
>
|
||||||
<option :value="null">Interno / Sin cliente</option>
|
<option :value="null">Interno / Sin cliente</option>
|
||||||
@ -61,12 +86,13 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<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
|
<input
|
||||||
v-model="form.proyecto_nombre"
|
v-model="form.proyecto_nombre"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ej. Campaña Q1"
|
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"
|
:disabled="analizando"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -83,12 +109,16 @@
|
|||||||
<div class="p-6 space-y-5">
|
<div class="p-6 space-y-5">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<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
|
<input
|
||||||
v-model="form.niche"
|
v-model="form.niche"
|
||||||
list="nichos-list"
|
list="nichos-list"
|
||||||
placeholder="Seleccionar o escribir…"
|
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"
|
:disabled="analizando"
|
||||||
/>
|
/>
|
||||||
<datalist id="nichos-list">
|
<datalist id="nichos-list">
|
||||||
@ -101,7 +131,8 @@
|
|||||||
v-model="form.mercado_objetivo"
|
v-model="form.mercado_objetivo"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ej. Emprendedoras 25-35 años"
|
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"
|
:disabled="analizando"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -109,16 +140,29 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div class="space-y-1.5">
|
<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>
|
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
|
||||||
<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"/>
|
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>
|
||||||
<div class="space-y-1.5">
|
<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>
|
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide flex items-center gap-1">
|
||||||
<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"/>
|
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>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Compartidos</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -126,7 +170,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="form.competidor_referente"
|
v-model="form.competidor_referente"
|
||||||
type="checkbox"
|
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"
|
:disabled="analizando"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-ink-2 group-hover:text-ink transition-colors select-none">
|
<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>
|
<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">
|
<h2 class="text-sm font-semibold text-ink">
|
||||||
Contexto del Video
|
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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-xs text-ink-3 leading-relaxed mb-3">
|
<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>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.contexto_video"
|
v-model="form.contexto_video"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Ej. Este video responde a una tendencia viral usando humor sarcástico dirigido a emprendedores…"
|
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"
|
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"
|
:disabled="analizando"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
@ -178,23 +224,22 @@
|
|||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="space-y-5 relative">
|
<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 class="absolute left-[14px] top-4 bottom-4 w-px bg-border z-0"></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="(s, idx) in pasosVisibles"
|
v-for="(s, idx) in pasosVisibles"
|
||||||
:key="s.id"
|
: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'"
|
:class="idx > currentStepIdx && analizando ? 'opacity-40' : 'opacity-100'"
|
||||||
>
|
>
|
||||||
<div
|
<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="
|
:class="
|
||||||
idx < currentStepIdx
|
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
|
: idx === currentStepIdx && analizando
|
||||||
? 'bg-accent border-2 border-accent text-white step-pulse'
|
? '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>
|
<span class="material-symbols-outlined text-[14px]">{{ idx < currentStepIdx ? 'check' : s.icon }}</span>
|
||||||
@ -206,27 +251,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progreso -->
|
<!-- Progreso con tiempo transcurrido -->
|
||||||
<div v-if="analizando" class="mt-6 pt-5 border-t border-border space-y-2">
|
<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="w-full bg-surface-subtle h-1.5 rounded-full overflow-hidden">
|
||||||
<div
|
<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) + '%' }"
|
:style="{ width: ((currentStepIdx / 3) * 100) + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error con retry -->
|
||||||
<div v-if="error" class="mt-5 p-4 rounded-lg bg-error-subtle border border-error-border text-error text-sm">
|
<div v-if="error" class="mt-5 p-4 rounded-lg bg-error-subtle border border-error-border">
|
||||||
<p class="font-semibold mb-1">Error en el pipeline</p>
|
<div class="flex items-start gap-3 mb-3">
|
||||||
<p class="text-[12px] leading-relaxed">{{ error }}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Estado inicial -->
|
<!-- Estado inicial -->
|
||||||
<div v-if="!analizando && !error" class="mt-6 pt-5 border-t border-border">
|
<div v-if="!analizando && !error" class="mt-6 pt-5 border-t border-border">
|
||||||
<p class="text-[11px] text-ink-3 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -237,7 +299,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
|
|
||||||
@ -245,6 +307,11 @@ const router = useRouter()
|
|||||||
const analizando = ref(false)
|
const analizando = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const paso = ref('inicio')
|
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 nichos = ref([])
|
||||||
const clientes = 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: '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: '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: '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(() => {
|
const currentStepIdx = computed(() => {
|
||||||
@ -275,6 +342,11 @@ const currentStepIdx = computed(() => {
|
|||||||
return pasosVisibles.findIndex(p => p.id === paso.value)
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const [n, c] = await Promise.all([api.nichos(), api.clientes()])
|
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."
|
error.value = "Vistas y Likes son obligatorios. Cópialos directamente del video antes de analizar."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!URL_REGEX.test(form.value.url)) {
|
||||||
const URL_SOPORTADAS = /^https?:\/\/(www\.)?(tiktok\.com|vm\.tiktok\.com|instagram\.com|youtube\.com|youtu\.be)/
|
|
||||||
if (!URL_SOPORTADAS.test(form.value.url)) {
|
|
||||||
error.value = "URL no soportada. Solo se aceptan TikTok, Instagram Reels y YouTube Shorts."
|
error.value = "URL no soportada. Solo se aceptan TikTok, Instagram Reels y YouTube Shorts."
|
||||||
|
urlState.value = 'invalid'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
analizando.value = true
|
analizando.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
paso.value = 'extraccion'
|
paso.value = 'extraccion'
|
||||||
|
elapsed.value = 0
|
||||||
|
|
||||||
|
elapsedTimer = setInterval(() => elapsed.value++, 1000)
|
||||||
|
|
||||||
const fakeInterval = setInterval(() => {
|
const fakeInterval = setInterval(() => {
|
||||||
if (paso.value === 'extraccion') paso.value = 'transcripcion';
|
if (paso.value === 'extraccion') paso.value = 'transcripcion'
|
||||||
else if (paso.value === 'transcripcion') paso.value = 'analisis';
|
else if (paso.value === 'transcripcion') paso.value = 'analisis'
|
||||||
else if (paso.value === 'analisis') paso.value = 'embedding';
|
else if (paso.value === 'analisis') paso.value = 'embedding'
|
||||||
}, 4000)
|
}, 4000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.analizar(form.value)
|
const res = await api.analizar(form.value)
|
||||||
clearInterval(fakeInterval)
|
clearInterval(fakeInterval)
|
||||||
|
clearInterval(elapsedTimer)
|
||||||
paso.value = 'embedding'
|
paso.value = 'embedding'
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push({ name: 'AnalysisDetail', params: { id: res.guion_id } })
|
router.push({ name: 'AnalysisDetail', params: { id: res.guion_id } })
|
||||||
}, 1000)
|
}, 800)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearInterval(fakeInterval)
|
clearInterval(fakeInterval)
|
||||||
|
clearInterval(elapsedTimer)
|
||||||
error.value = err.message
|
error.value = err.message
|
||||||
} finally {
|
} finally {
|
||||||
analizando.value = false
|
analizando.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
<p class="text-sm text-ink-3">Cargando análisis…</p>
|
||||||
</div>
|
</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 -->
|
<!-- Encabezado -->
|
||||||
<header class="flex flex-col md:flex-row md:items-start justify-between gap-6 pb-6 border-b border-border">
|
<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">
|
<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">
|
<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 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>
|
<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 }}
|
Replicabilidad {{ guion.replicabilidad }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="text-3xl font-bold font-headline text-ink mb-2 leading-tight max-w-2xl">
|
<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' }}
|
{{ guion.tema_principal || 'Análisis sin título' }}
|
||||||
</h1>
|
</h1>
|
||||||
@ -31,23 +35,43 @@
|
|||||||
{{ guion.angulo_unico || 'Ángulo único no especificado' }}
|
{{ guion.angulo_unico || 'Ángulo único no especificado' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
<button
|
<button
|
||||||
v-if="guion.url_origen"
|
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"
|
title="Ver video original"
|
||||||
|
aria-label="Ver video original"
|
||||||
@click="openUrl(guion.url_origen)"
|
@click="openUrl(guion.url_origen)"
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
|
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
|
||||||
</button>
|
</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>
|
<span class="material-symbols-outlined text-[16px]">auto_fix_high</span>
|
||||||
Generar Guion
|
Generar Guion
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 -->
|
<!-- Cuadrícula principal -->
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6">
|
<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">
|
<div class="xl:col-span-4 flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- Métricas Sociales -->
|
<!-- 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">
|
<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>
|
<span class="material-symbols-outlined text-ink-3 text-[16px]">bar_chart</span>
|
||||||
Métricas del Video
|
Métricas del Video
|
||||||
@ -64,61 +88,66 @@
|
|||||||
<div class="text-center p-3 rounded-lg bg-surface-muted border border-border">
|
<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>
|
<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-[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>
|
||||||
<div class="text-center p-3 rounded-lg bg-surface-muted border border-border">
|
<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>
|
<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-[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>
|
||||||
<div class="text-center p-3 rounded-lg bg-surface-muted border border-border">
|
<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>
|
<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-[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>
|
</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">
|
<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-[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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Puntaje Circular -->
|
<!-- 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">
|
<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>
|
<span class="material-symbols-outlined text-accent text-[16px]">analytics</span>
|
||||||
Puntaje de Viralidad
|
Puntaje de Viralidad
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex justify-center mb-5 relative">
|
<div class="flex justify-center mb-5 relative">
|
||||||
<svg class="w-40 h-40 transform -rotate-90" viewBox="0 0 100 100">
|
<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
|
<circle
|
||||||
cx="50" cy="50" r="42" fill="none"
|
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-width="7"
|
||||||
:stroke-dasharray="`${(guion.score_virabilidad || 0)/100 * 264} 264`"
|
:stroke-dasharray="`${(guion.score_virabilidad || 0)/100 * 264} 264`"
|
||||||
stroke-linecap="round"
|
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>
|
</svg>
|
||||||
<div class="absolute flex flex-col items-center justify-center" style="margin-top: 30px;">
|
<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>
|
<span class="text-xs text-ink-3 font-medium">/ 100</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-3 pt-4 border-t border-border">
|
<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">
|
<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-[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>
|
||||||
<div class="text-center p-3 rounded-lg bg-surface-muted">
|
<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-[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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ganchos Semánticos -->
|
<!-- 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">
|
<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>
|
<span class="material-symbols-outlined text-success text-[16px]">psychology_alt</span>
|
||||||
Ganchos Semánticos
|
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">
|
<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="material-symbols-outlined text-accent text-[16px]">repeat</span>
|
||||||
<span class="text-sm font-medium text-accent">{{ guion.tecnica_retencion || '—' }}</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Avatar y Consciencia -->
|
<!-- 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">
|
<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>
|
<span class="material-symbols-outlined text-warn text-[16px]">person_search</span>
|
||||||
Avatar & Copywriting
|
Avatar & Copywriting
|
||||||
@ -205,7 +234,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Patrón Ganador -->
|
<!-- 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-[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>
|
<p class="text-base text-ink leading-relaxed max-w-2xl">{{ guion.resumen_patron }}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -227,7 +256,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ingredientes Clave -->
|
<!-- 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">
|
<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>
|
<span class="material-symbols-outlined text-warn text-[16px]">key</span>
|
||||||
Ingredientes Clave para Replicar
|
Ingredientes Clave para Replicar
|
||||||
@ -241,7 +270,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Emocional + Cialdini -->
|
<!-- 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">
|
<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">
|
<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>
|
<span class="material-symbols-outlined text-orange-500 text-[16px]">local_fire_department</span>
|
||||||
@ -250,10 +279,10 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex justify-between text-xs font-medium mb-1.5">
|
<div class="flex justify-between text-xs font-medium mb-1.5">
|
||||||
<span class="text-ink-3">Intensidad</span>
|
<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>
|
||||||
<div class="w-full bg-surface-subtle h-1.5 rounded-full overflow-hidden">
|
<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>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@ -264,7 +293,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<span class="material-symbols-outlined text-indigo-500 text-[16px]">group_work</span>
|
||||||
Principios de Cialdini
|
Principios de Cialdini
|
||||||
@ -282,7 +311,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Neuromarketing + Entrega -->
|
<!-- 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">
|
<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">
|
<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>
|
<span class="material-symbols-outlined text-fuchsia-500 text-[16px]">biotech</span>
|
||||||
@ -338,7 +367,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fortalezas / Debilidades -->
|
<!-- 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">
|
<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">
|
<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
|
<span class="material-symbols-outlined text-[16px]" style="font-variation-settings:'FILL' 1;">thumb_up</span> Fortalezas
|
||||||
@ -373,7 +402,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hashtags -->
|
<!-- 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">
|
<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
|
<span class="material-symbols-outlined text-ink-3 text-[16px]">tag</span> Hashtags Sugeridos
|
||||||
</h3>
|
</h3>
|
||||||
@ -381,32 +410,35 @@
|
|||||||
<span
|
<span
|
||||||
v-for="tag in guion.hashtags_sugeridos"
|
v-for="tag in guion.hashtags_sugeridos"
|
||||||
:key="tag"
|
: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)"
|
@click="copiarTag(tag)"
|
||||||
>#{{ tag.replace(/^#/, '') }}</span>
|
>
|
||||||
|
{{ tagCopiado === tag ? '✓' : '#' }}{{ tag.replace(/^#/, '') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[10px] text-ink-3 mt-2">Haz clic en un hashtag para copiarlo</p>
|
<p class="text-[10px] text-ink-3 mt-2">Haz clic en un hashtag para copiarlo</p>
|
||||||
</div>
|
</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 -->
|
<!-- 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">
|
<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">
|
<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
|
<span class="material-symbols-outlined text-ink-3 text-[16px]">notes</span> Transcripción Completa
|
||||||
</h3>
|
</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' }}
|
{{ showTranscript ? 'Colapsar' : 'Expandir' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<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">
|
<p class="text-sm text-ink-2 leading-relaxed whitespace-pre-wrap">
|
||||||
{{ guion.transcript || 'Video sin transcripción disponible.' }}
|
{{ guion.transcript || 'Video sin transcripción disponible.' }}
|
||||||
@ -420,16 +452,31 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
|
import { useToast } from '../composables/useToast.js'
|
||||||
import CialdiniItem from '../components/CialdiniItem.vue'
|
import CialdiniItem from '../components/CialdiniItem.vue'
|
||||||
import DataRow from '../components/DataRow.vue'
|
import DataRow from '../components/DataRow.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
const guion = ref(null)
|
const guion = ref(null)
|
||||||
const cargando = ref(true)
|
const cargando = ref(true)
|
||||||
const showTranscript = ref(false)
|
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 = [
|
const nivelesConciencia = [
|
||||||
{ key: 'inconsciente' },
|
{ key: 'inconsciente' },
|
||||||
@ -454,6 +501,22 @@ const ratioIcon = computed(() => {
|
|||||||
return map[guion.value?.ratio_emocion_logica] || 'help'
|
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) {
|
function openUrl(url) {
|
||||||
if (url) window.open(url, '_blank')
|
if (url) window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
@ -466,7 +529,19 @@ function formatNum(n) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copiarTag(tag) {
|
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) {
|
function plataformaBadge(p) {
|
||||||
@ -485,11 +560,36 @@ function replicabilidadBadge(r) {
|
|||||||
}[r] ?? 'bg-surface-muted text-ink-3 border border-border'
|
}[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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
guion.value = await api.guiones.obtener(route.params.id)
|
guion.value = await api.guiones.obtener(route.params.id)
|
||||||
} finally {
|
} finally {
|
||||||
cargando.value = false
|
cargando.value = false
|
||||||
}
|
}
|
||||||
|
setTimeout(setupObserver, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => observer?.disconnect())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
|
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
</style>
|
||||||
|
|||||||
@ -13,14 +13,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
@click="cargarDatos"
|
||||||
>
|
>
|
||||||
|
<span class="material-symbols-outlined text-[16px]" :class="cargando ? 'animate-spin' : ''">refresh</span>
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
<router-link
|
<router-link
|
||||||
to="/new-analysis"
|
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>
|
<span class="material-symbols-outlined text-[18px]">add</span>
|
||||||
Nuevo Análisis
|
Nuevo Análisis
|
||||||
@ -35,7 +39,7 @@
|
|||||||
v-for="f in filtrosEstado"
|
v-for="f in filtrosEstado"
|
||||||
:key="f.valor"
|
:key="f.valor"
|
||||||
@click="filtroActivo = f.valor; filtros.page = 1; cargarDatos()"
|
@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
|
:class="filtroActivo === f.valor
|
||||||
? 'bg-accent text-white shadow-sm'
|
? 'bg-accent text-white shadow-sm'
|
||||||
: 'text-ink-2 hover:bg-surface-muted'"
|
: 'text-ink-2 hover:bg-surface-muted'"
|
||||||
@ -48,7 +52,8 @@
|
|||||||
<select
|
<select
|
||||||
v-model="filtros.niche"
|
v-model="filtros.niche"
|
||||||
@change="filtros.page = 1; cargarDatos()"
|
@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 value="">Todos los nichos</option>
|
||||||
<option v-for="n in nichos" :key="n" :value="n">{{ n }}</option>
|
<option v-for="n in nichos" :key="n" :value="n">{{ n }}</option>
|
||||||
@ -58,51 +63,106 @@
|
|||||||
|
|
||||||
<!-- Tabla -->
|
<!-- Tabla -->
|
||||||
<div class="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
|
<div class="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
|
<!-- Header tabla -->
|
||||||
<h2 class="text-sm font-semibold text-ink">Registro completo</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">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">
|
<div class="flex items-center gap-2">
|
||||||
<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
|
||||||
:disabled="filtros.page <= 1"
|
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
|
||||||
|
:disabled="filtros.page <= 1 || cargando"
|
||||||
@click="cambiarPagina(filtros.page - 1)"
|
@click="cambiarPagina(filtros.page - 1)"
|
||||||
>Anterior</button>
|
>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
|
<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
|
||||||
:disabled="guiones.length < filtros.limit"
|
hover:bg-surface-muted transition-all disabled:opacity-40 active:scale-[0.97]"
|
||||||
|
:disabled="guiones.length < filtros.limit || cargando"
|
||||||
@click="cambiarPagina(filtros.page + 1)"
|
@click="cambiarPagina(filtros.page + 1)"
|
||||||
>Siguiente</button>
|
>Siguiente</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading skeleton -->
|
||||||
<div v-if="cargando" class="py-16 flex items-center justify-center gap-3">
|
<div v-if="cargando" class="divide-y divide-border">
|
||||||
<div class="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin"></div>
|
<div v-for="i in 8" :key="i" class="px-6 py-4 animate-pulse flex items-center gap-6">
|
||||||
<span class="text-sm text-ink-3">Cargando historial…</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Vacío -->
|
<!-- Vacío -->
|
||||||
<div v-else-if="guiones.length === 0" class="py-20 flex flex-col items-center gap-3 text-ink-3">
|
<div v-else-if="guiones.length === 0" class="py-24 flex flex-col items-center gap-4 text-center">
|
||||||
<span class="material-symbols-outlined text-4xl">manage_search</span>
|
<div class="w-14 h-14 rounded-2xl bg-surface-muted border border-border flex items-center justify-center">
|
||||||
<p class="text-sm font-medium">Sin registros para este filtro</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Tabla con datos -->
|
<!-- Tabla con datos -->
|
||||||
<div v-else class="overflow-x-auto">
|
<div v-else class="overflow-x-auto">
|
||||||
<table class="w-full text-left border-collapse">
|
<table class="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead class="sticky top-0 bg-surface/90 backdrop-blur-sm border-b border-border z-10">
|
||||||
<tr class="border-b border-border bg-surface-muted/60">
|
<tr>
|
||||||
<th class="px-6 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Estado</th>
|
<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">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">Niche</th>
|
||||||
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Puntajes</th>
|
<!-- Columna Viralidad: ordenable -->
|
||||||
<th class="px-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Fecha</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">
|
||||||
|
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>
|
<th class="px-6 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border">
|
<tbody class="divide-y divide-border">
|
||||||
<tr
|
<tr
|
||||||
v-for="g in guiones"
|
v-for="g in guionesOrdenados"
|
||||||
:key="g.id"
|
:key="g.id"
|
||||||
class="group hover:bg-surface-muted/60 transition-colors"
|
class="group hover:bg-surface-muted/60 transition-colors"
|
||||||
:class="g.procesado_ok ? 'cursor-pointer' : 'opacity-60'"
|
:class="g.procesado_ok ? 'cursor-pointer' : 'opacity-60'"
|
||||||
@ -116,7 +176,11 @@
|
|||||||
{{ g.procesado_ok ? 'Completado' : 'Fallido' }}
|
{{ g.procesado_ok ? 'Completado' : 'Fallido' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</td>
|
||||||
|
|
||||||
<!-- Fuente -->
|
<!-- Fuente -->
|
||||||
@ -124,7 +188,7 @@
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span :class="plataformaBadge(g.plataforma)" class="platform-badge w-fit">{{ g.plataforma || '—' }}</span>
|
<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-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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@ -139,15 +203,15 @@
|
|||||||
<div v-if="g.procesado_ok" class="flex items-center gap-4">
|
<div v-if="g.procesado_ok" class="flex items-center gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[10px] text-ink-3 font-medium">Viral</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[10px] text-ink-3 font-medium">Cialdini</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-[10px] text-ink-3 font-medium">Eng.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="text-[11px] text-ink-3">—</span>
|
<span v-else class="text-[11px] text-ink-3">—</span>
|
||||||
@ -159,14 +223,29 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Acción -->
|
<!-- Acción -->
|
||||||
<td class="px-6 py-4 text-right">
|
<td class="px-6 py-4 text-right" @click.stop>
|
||||||
<button
|
<div class="relative" v-if="g.procesado_ok">
|
||||||
v-if="g.procesado_ok"
|
<button
|
||||||
@click.stop="verDetalle(g.id)"
|
@click="menuAbierto = menuAbierto === g.id ? null : 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"
|
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"
|
||||||
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
|
aria-label="Acciones"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -177,7 +256,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
|
|
||||||
@ -186,6 +265,10 @@ const guiones = ref([])
|
|||||||
const nichos = ref([])
|
const nichos = ref([])
|
||||||
const cargando = ref(true)
|
const cargando = ref(true)
|
||||||
const filtroActivo = ref('todos')
|
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: '' })
|
const filtros = ref({ page: 1, limit: 20, niche: '' })
|
||||||
|
|
||||||
@ -198,11 +281,36 @@ const filtrosEstado = [
|
|||||||
const totalOk = ref(0)
|
const totalOk = ref(0)
|
||||||
const totalFallidos = 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() {
|
async function cargarDatos() {
|
||||||
cargando.value = true
|
cargando.value = true
|
||||||
try {
|
try {
|
||||||
const params = { page: filtros.value.page, limit: filtros.value.limit }
|
const params = { page: filtros.value.page, limit: filtros.value.limit }
|
||||||
if (filtros.value.niche) params.niche = filtros.value.niche
|
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([
|
const [dg, dn, okReq, allReq] = await Promise.all([
|
||||||
api.guiones.listarTodos(params),
|
api.guiones.listarTodos(params),
|
||||||
@ -211,15 +319,16 @@ async function cargarDatos() {
|
|||||||
api.guiones.listarTodos({ limit: 1, ...(params.niche ? { niche: params.niche } : {}) }),
|
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)
|
totalFallidos.value = (allReq.total || 0) - (okReq.total || 0)
|
||||||
|
|
||||||
let lista = dg.guiones
|
let lista = dg.guiones
|
||||||
if (filtroActivo.value === 'exitosos') lista = lista.filter(g => g.procesado_ok)
|
if (filtroActivo.value === 'exitosos') lista = lista.filter(g => g.procesado_ok)
|
||||||
if (filtroActivo.value === 'fallidos') lista = lista.filter(g => !g.procesado_ok)
|
if (filtroActivo.value === 'fallidos') lista = lista.filter(g => !g.procesado_ok)
|
||||||
|
|
||||||
guiones.value = lista
|
guiones.value = lista
|
||||||
nichos.value = dn
|
totalGuiones.value = dg.total || lista.length
|
||||||
|
nichos.value = dn
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
@ -251,5 +360,21 @@ function plataformaBadge(p) {
|
|||||||
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
|
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>
|
</script>
|
||||||
|
|||||||
@ -9,14 +9,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
@click="cargarDatos"
|
||||||
>
|
>
|
||||||
|
<span class="material-symbols-outlined text-[16px]" :class="cargando ? 'animate-spin' : ''">refresh</span>
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
<router-link
|
<router-link
|
||||||
to="/new-analysis"
|
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>
|
<span class="material-symbols-outlined text-[18px]">add</span>
|
||||||
Nuevo Análisis
|
Nuevo Análisis
|
||||||
@ -29,26 +33,27 @@
|
|||||||
<div
|
<div
|
||||||
v-for="stat in stats"
|
v-for="stat in stats"
|
||||||
:key="stat.niche"
|
: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>
|
<p class="text-[11px] font-semibold text-ink-3 uppercase tracking-wider mb-3">{{ stat.niche }}</p>
|
||||||
<div class="flex items-end justify-between">
|
<div class="flex items-end justify-between">
|
||||||
<span class="text-3xl font-bold font-headline text-ink">{{ stat.total_guiones }}</span>
|
<span class="text-3xl font-bold font-headline text-ink">{{ stat.total_guiones }}</span>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-[10px] text-ink-3 mb-0.5">Puntaje prom.</p>
|
<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>
|
</div>
|
||||||
<div class="mt-3 w-full bg-surface-subtle h-1 rounded-full overflow-hidden">
|
<div class="mt-3 w-full bg-surface-subtle h-1 rounded-full overflow-hidden">
|
||||||
<div
|
<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) + '%' }"
|
:style="{ width: (stat.avg_score || 0) + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
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.
|
Conecta la base de datos para ver el rendimiento por niche.
|
||||||
@ -60,35 +65,70 @@
|
|||||||
|
|
||||||
<!-- Tabla de guiones -->
|
<!-- 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="xl:col-span-8 bg-surface rounded-xl border border-border shadow-sm overflow-hidden flex flex-col">
|
||||||
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-border flex items-center justify-between bg-surface/90 backdrop-blur-sm">
|
||||||
<h2 class="text-sm font-semibold text-ink">Guiones Analizados</h2>
|
<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">
|
<div class="flex gap-2">
|
||||||
<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)"
|
@click="cambiarPagina(filtros.page - 1)"
|
||||||
:disabled="filtros.page <= 1"
|
:disabled="filtros.page <= 1 || cargando"
|
||||||
>Anterior</button>
|
>Anterior</button>
|
||||||
<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)"
|
@click="cambiarPagina(filtros.page + 1)"
|
||||||
:disabled="guiones.length < filtros.limit"
|
:disabled="guiones.length < filtros.limit || cargando"
|
||||||
>Siguiente</button>
|
>Siguiente</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<table class="w-full text-left border-collapse">
|
||||||
<thead class="sticky top-0 bg-surface-muted z-10">
|
<thead class="sticky top-0 bg-surface/90 backdrop-blur-sm z-10 border-b border-border">
|
||||||
<tr class="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-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-4 py-3 text-[11px] font-semibold text-ink-3 uppercase tracking-wider">Gancho</th>
|
||||||
<th class="px-6 py-3"></th>
|
<th class="px-6 py-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border">
|
<tbody class="divide-y divide-border">
|
||||||
<tr
|
<tr
|
||||||
v-for="g in guiones"
|
v-for="g in guionesOrdenados"
|
||||||
:key="g.id"
|
:key="g.id"
|
||||||
class="group hover:bg-surface-muted/60 transition-colors cursor-pointer"
|
class="group hover:bg-surface-muted/60 transition-colors cursor-pointer"
|
||||||
@click="verDetalle(g.id)"
|
@click="verDetalle(g.id)"
|
||||||
@ -109,12 +149,13 @@
|
|||||||
<td class="px-4 py-4">
|
<td class="px-4 py-4">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<div class="flex items-center gap-2 text-[11px]">
|
<div class="flex items-center gap-2 text-[11px]">
|
||||||
<span class="font-bold text-ink">{{ g.score_virabilidad || 0 }}/100</span>
|
<span class="font-bold tabular-nums" :class="scoreColor(g.score_virabilidad)">{{ g.score_virabilidad || 0 }}</span>
|
||||||
<span class="text-success text-[10px]">{{ (g.score_engagement || 0).toFixed(1) }}%</span>
|
<span class="text-success text-[10px] tabular-nums">{{ (g.score_engagement || 0).toFixed(1) }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24 bg-surface-subtle h-1 rounded-full overflow-hidden">
|
<div class="w-24 bg-surface-subtle h-1 rounded-full overflow-hidden">
|
||||||
<div
|
<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) + '%' }"
|
:style="{ width: (g.score_virabilidad || 0) + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@ -129,17 +170,30 @@
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td class="px-6 py-4 text-right">
|
<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>
|
<span class="material-symbols-outlined text-[18px]">open_in_new</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr v-if="guiones.length === 0 && !cargando">
|
<tr v-if="guiones.length === 0">
|
||||||
<td colspan="4" class="py-20 text-center">
|
<td colspan="4" class="py-20 text-center">
|
||||||
<div class="flex flex-col items-center gap-2 text-ink-3">
|
<div class="flex flex-col items-center gap-3 text-ink-3">
|
||||||
<span class="material-symbols-outlined text-4xl">inbox</span>
|
<div class="w-14 h-14 rounded-2xl bg-surface-muted border border-border flex items-center justify-center">
|
||||||
<p class="text-sm font-medium">Sin guiones analizados</p>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 class="xl:col-span-4 bg-surface rounded-xl border border-border shadow-sm p-6 flex flex-col gap-5">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-1.5 text-success text-[11px] font-semibold uppercase tracking-wider mb-2">
|
<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
|
Mejor Rendimiento
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-lg font-bold font-headline text-ink">Guión Destacado</h2>
|
<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>
|
<p class="text-xs text-ink-3 mt-0.5">Mayor puntaje de viralidad en tu biblioteca</p>
|
||||||
</div>
|
</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">
|
<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">
|
<p class="text-[10px] font-semibold text-success uppercase tracking-wider mb-1">
|
||||||
{{ guionTop.niche }}{{ guionTop.sub_niche ? ` · ${guionTop.sub_niche}` : '' }}
|
{{ guionTop.niche }}{{ guionTop.sub_niche ? ` · ${guionTop.sub_niche}` : '' }}
|
||||||
@ -175,24 +242,34 @@
|
|||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="p-3 rounded-lg bg-surface-muted border border-border text-center">
|
<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-[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>
|
||||||
<div class="p-3 rounded-lg bg-accent-subtle border border-accent-border text-center">
|
<div class="p-3 rounded-lg border text-center"
|
||||||
<p class="text-[10px] text-accent font-medium mb-1">Viralidad</p>
|
:class="guionTop.score_virabilidad >= 80 ? 'bg-success-subtle border-success-border' :
|
||||||
<p class="text-xl font-bold text-accent">{{ guionTop.score_virabilidad }}<span class="text-xs">%</span></p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="verDetalle(guionTop.id)"
|
@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
|
Ver análisis completo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col items-center justify-center flex-1 py-8 text-ink-3 text-sm italic">
|
<div v-else class="flex flex-col items-center justify-center flex-1 py-8 gap-3 text-center">
|
||||||
Aún no hay guiones analizados.
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,7 +277,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
|
|
||||||
@ -208,17 +285,52 @@ const router = useRouter()
|
|||||||
const guiones = ref([])
|
const guiones = ref([])
|
||||||
const stats = ref([])
|
const stats = ref([])
|
||||||
const cargando = ref(true)
|
const cargando = ref(true)
|
||||||
|
const totalGuiones = ref(0)
|
||||||
|
const sortField = ref('score_virabilidad')
|
||||||
|
const sortDir = ref('desc')
|
||||||
|
|
||||||
const filtros = ref({
|
const filtros = ref({ page: 1, limit: 20 })
|
||||||
page: 1,
|
|
||||||
limit: 20
|
|
||||||
})
|
|
||||||
|
|
||||||
const guionTop = computed(() => {
|
const guionTop = computed(() => {
|
||||||
if (guiones.value.length === 0) return null
|
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() {
|
async function cargarDatos() {
|
||||||
cargando.value = true
|
cargando.value = true
|
||||||
try {
|
try {
|
||||||
@ -226,8 +338,9 @@ async function cargarDatos() {
|
|||||||
api.guiones.listar(filtros.value),
|
api.guiones.listar(filtros.value),
|
||||||
api.stats()
|
api.stats()
|
||||||
])
|
])
|
||||||
guiones.value = dg.guiones
|
guiones.value = dg.guiones
|
||||||
stats.value = ds
|
totalGuiones.value = dg.total || dg.guiones.length
|
||||||
|
stats.value = ds
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
@ -254,7 +367,5 @@ function plataformaBadge(p) {
|
|||||||
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
|
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(cargarDatos)
|
||||||
cargarDatos()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
<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">
|
<div class="xl:col-span-7 flex flex-col gap-6">
|
||||||
|
|
||||||
<!-- Paso 1: Contexto -->
|
<!-- Paso 1: Contexto -->
|
||||||
@ -21,14 +21,23 @@
|
|||||||
<div class="p-6 space-y-5">
|
<div class="p-6 space-y-5">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<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
|
<input
|
||||||
v-model="form.niche"
|
v-model="form.niche"
|
||||||
list="nichos-gen"
|
list="nichos-gen"
|
||||||
placeholder="ej. fitness, finanzas…"
|
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"
|
: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">
|
<datalist id="nichos-gen">
|
||||||
<option v-for="n in nichos" :key="n" :value="n" />
|
<option v-for="n in nichos" :key="n" :value="n" />
|
||||||
</datalist>
|
</datalist>
|
||||||
@ -37,7 +46,8 @@
|
|||||||
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Plataforma</label>
|
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Plataforma</label>
|
||||||
<select
|
<select
|
||||||
v-model="form.plataforma"
|
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"
|
:disabled="generando"
|
||||||
>
|
>
|
||||||
<option value="tiktok">TikTok</option>
|
<option value="tiktok">TikTok</option>
|
||||||
@ -48,25 +58,43 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<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
|
<input
|
||||||
v-model="form.tema"
|
v-model="form.tema"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ej. Cómo perder 5kg en 30 días sin pasar hambre"
|
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"
|
: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>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<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
|
<input
|
||||||
v-model="form.audiencia"
|
v-model="form.audiencia"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ej. Mujeres de 25-40 años con poco tiempo para el gimnasio"
|
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"
|
: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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -81,7 +109,10 @@
|
|||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Estructura Narrativa</label>
|
<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="AIDA">AIDA — Atención · Interés · Deseo · Acción</option>
|
||||||
<option value="PAS">PAS — Problema · Agitación · Solución</option>
|
<option value="PAS">PAS — Problema · Agitación · Solución</option>
|
||||||
<option value="hero_journey">Hero's Journey</option>
|
<option value="hero_journey">Hero's Journey</option>
|
||||||
@ -91,7 +122,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Objetivo</label>
|
<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="engagement">Engagement (likes, comentarios)</option>
|
||||||
<option value="awareness">Awareness (alcance)</option>
|
<option value="awareness">Awareness (alcance)</option>
|
||||||
<option value="conversion">Conversión (ventas, leads)</option>
|
<option value="conversion">Conversión (ventas, leads)</option>
|
||||||
@ -104,7 +138,10 @@
|
|||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label class="text-xs font-semibold text-ink-2 uppercase tracking-wide">Tono</label>
|
<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="educativo">Educativo</option>
|
||||||
<option value="entretenimiento">Entretenimiento</option>
|
<option value="entretenimiento">Entretenimiento</option>
|
||||||
<option value="inspiracional">Inspiracional</option>
|
<option value="inspiracional">Inspiracional</option>
|
||||||
@ -118,43 +155,84 @@
|
|||||||
<input
|
<input
|
||||||
v-model.number="form.duracion_objetivo"
|
v-model.number="form.duracion_objetivo"
|
||||||
type="number" min="15" max="180"
|
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"
|
:disabled="generando"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<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
|
<textarea
|
||||||
v-model="form.instrucciones_extra"
|
v-model="form.instrucciones_extra"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="ej. Incluir una estadística de estudio, no mencionar competidores, usar lenguaje informal…"
|
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"
|
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"
|
:disabled="generando"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Botón Generar -->
|
<!-- Botón Generar con tooltip de campos faltantes -->
|
||||||
<button
|
<div class="relative group/btn">
|
||||||
@click="generar"
|
<button
|
||||||
:disabled="generando || !form.niche || !form.tema || !form.audiencia"
|
@click="generar"
|
||||||
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"
|
: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
|
||||||
<span class="material-symbols-outlined text-[18px]" :class="generando ? 'animate-spin' : ''">
|
flex items-center justify-center gap-2 transition-all shadow-sm
|
||||||
{{ generando ? 'hourglass_top' : 'auto_fix_high' }}
|
active:scale-[0.98] hover:shadow-md hover:shadow-accent/20
|
||||||
</span>
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:shadow-none disabled:active:scale-100"
|
||||||
{{ generando ? 'Generando con GPT-4o…' : 'Generar Guion' }}
|
>
|
||||||
</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Panel de resultados (derecha) -->
|
<!-- Panel de resultados -->
|
||||||
<div class="xl:col-span-5 flex flex-col gap-5">
|
<div class="xl:col-span-5 flex flex-col gap-5">
|
||||||
|
|
||||||
<!-- Resultado -->
|
<!-- Resultado -->
|
||||||
@ -164,29 +242,38 @@
|
|||||||
<div class="bg-surface rounded-xl border border-success-border shadow-sm p-5">
|
<div class="bg-surface rounded-xl border border-success-border shadow-sm p-5">
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<h3 class="text-sm font-semibold text-ink leading-snug">{{ resultado.guion.titulo_sugerido }}</h3>
|
<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="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>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1.5 mb-3">
|
<div class="flex flex-wrap gap-1.5 mb-3">
|
||||||
<span
|
<span
|
||||||
v-for="t in resultado.guion.tecnicas_aplicadas"
|
v-for="t in resultado.guion.tecnicas_aplicadas"
|
||||||
:key="t"
|
: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>
|
>{{ t }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<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-[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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Guion completo -->
|
<!-- Guion completo -->
|
||||||
<div class="bg-surface rounded-xl border border-border shadow-sm overflow-hidden">
|
<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>
|
<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>
|
<span class="material-symbols-outlined text-[14px]">{{ copiado ? 'check' : 'content_copy' }}</span>
|
||||||
{{ copiado ? 'Copiado' : 'Copiar' }}
|
{{ copiado ? 'Copiado' : 'Copiar' }}
|
||||||
</button>
|
</button>
|
||||||
@ -213,7 +300,11 @@
|
|||||||
<span class="material-symbols-outlined text-ink-3 text-[16px]">shuffle</span> Variantes del Gancho
|
<span class="material-symbols-outlined text-ink-3 text-[16px]">shuffle</span> Variantes del Gancho
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-2">
|
<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-[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>
|
<span class="text-sm text-ink-2 italic leading-relaxed">"{{ v }}"</span>
|
||||||
</div>
|
</div>
|
||||||
@ -228,10 +319,11 @@
|
|||||||
<p class="text-sm text-ink-2 leading-relaxed">{{ resultado.guion.notas_produccion }}</p>
|
<p class="text-sm text-ink-2 leading-relaxed">{{ resultado.guion.notas_produccion }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nuevo guion -->
|
<!-- Generar otro -->
|
||||||
<button
|
<button
|
||||||
@click="resultado = null; form.tema = ''; form.instrucciones_extra = ''"
|
@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-colors"
|
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
|
Generar otro guion
|
||||||
</button>
|
</button>
|
||||||
@ -239,12 +331,20 @@
|
|||||||
|
|
||||||
<!-- Estado vacío / Info -->
|
<!-- Estado vacío / Info -->
|
||||||
<div v-else class="bg-surface rounded-xl border border-border shadow-sm p-6 sticky top-24">
|
<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">
|
<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.
|
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>
|
</p>
|
||||||
<div class="space-y-4">
|
<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">
|
<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>
|
<span class="material-symbols-outlined text-accent text-[14px]">{{ paso.icon }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -254,8 +354,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 pt-4 border-t border-border">
|
<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–10 segundos · Modelo: GPT-4o</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -264,27 +390,46 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { api } from '../lib/api.js'
|
import { api } from '../lib/api.js'
|
||||||
|
import { useToast } from '../composables/useToast.js'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
const generando = ref(false)
|
const generando = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const resultado = ref(null)
|
const resultado = ref(null)
|
||||||
const copiado = ref(false)
|
const copiado = ref(false)
|
||||||
const nichos = ref([])
|
const nichos = ref([])
|
||||||
|
|
||||||
|
const campoTocado = ref({ niche: false, tema: false, audiencia: false })
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
niche: '',
|
niche: '',
|
||||||
tema: '',
|
tema: '',
|
||||||
audiencia: '',
|
audiencia: '',
|
||||||
plataforma: 'tiktok',
|
plataforma: 'tiktok',
|
||||||
estructura: 'AIDA',
|
estructura: 'AIDA',
|
||||||
objetivo: 'engagement',
|
objetivo: 'engagement',
|
||||||
tono: 'educativo',
|
tono: 'educativo',
|
||||||
duracion_objetivo: 60,
|
duracion_objetivo: 60,
|
||||||
instrucciones_extra: '',
|
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 = [
|
const pasoInfo = [
|
||||||
{ icon: 'search', label: 'Busca patrones', desc: 'Selecciona los mejores guiones del niche en tu biblioteca' },
|
{ 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' },
|
{ icon: 'psychology', label: 'Extrae técnicas', desc: 'Identifica estructura, triggers y principios Cialdini activos' },
|
||||||
@ -293,15 +438,19 @@ const pasoInfo = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
async function generar() {
|
async function generar() {
|
||||||
|
campoTocado.value = { niche: true, tema: true, audiencia: true }
|
||||||
if (!form.value.niche || !form.value.tema || !form.value.audiencia) return
|
if (!form.value.niche || !form.value.tema || !form.value.audiencia) return
|
||||||
|
|
||||||
generando.value = true
|
generando.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
resultado.value = null
|
resultado.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
resultado.value = await api.generar(form.value)
|
resultado.value = await api.generar(form.value)
|
||||||
|
toast.success('Guion generado correctamente')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
|
toast.error('Error al generar el guion')
|
||||||
} finally {
|
} finally {
|
||||||
generando.value = false
|
generando.value = false
|
||||||
}
|
}
|
||||||
@ -311,7 +460,15 @@ async function copiarGuion() {
|
|||||||
if (!resultado.value?.guion?.guion_completo) return
|
if (!resultado.value?.guion?.guion_completo) return
|
||||||
await navigator.clipboard.writeText(resultado.value.guion.guion_completo)
|
await navigator.clipboard.writeText(resultado.value.guion.guion_completo)
|
||||||
copiado.value = true
|
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 () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold font-headline text-ink mb-1">Biblioteca de Guiones</h1>
|
<h1 class="text-3xl font-bold font-headline text-ink mb-1">Biblioteca de Guiones</h1>
|
||||||
<p class="text-sm text-ink-3">
|
<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'">
|
<span class="font-medium" :class="filtros.tipo === 'analizados' ? 'text-accent' : 'text-success'">
|
||||||
{{ filtros.tipo }}
|
{{ filtros.tipo }}
|
||||||
</span>
|
</span>
|
||||||
@ -15,14 +15,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
@click="cargarDatos"
|
||||||
>
|
>
|
||||||
|
<span class="material-symbols-outlined text-[16px]" :class="cargando ? 'animate-spin' : ''">refresh</span>
|
||||||
Actualizar
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
<router-link
|
<router-link
|
||||||
to="/new-analysis"
|
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>
|
<span class="material-symbols-outlined text-[18px]">add</span>
|
||||||
Nuevo Análisis
|
Nuevo Análisis
|
||||||
@ -32,13 +36,12 @@
|
|||||||
|
|
||||||
<!-- Filtros -->
|
<!-- Filtros -->
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<!-- Toggle -->
|
||||||
<!-- Toggle Analizados / Generados -->
|
|
||||||
<div class="flex items-center bg-surface border border-border rounded-lg p-1 gap-1">
|
<div class="flex items-center bg-surface border border-border rounded-lg p-1 gap-1">
|
||||||
<button
|
<button
|
||||||
@click="cambiarTipo('analizados')"
|
@click="cambiarTipo('analizados')"
|
||||||
:class="filtros.tipo === 'analizados' ? 'bg-accent text-white shadow-sm' : 'text-ink-2 hover:bg-surface-muted'"
|
: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>
|
<span class="material-symbols-outlined text-[14px]">analytics</span>
|
||||||
Analizados
|
Analizados
|
||||||
@ -46,7 +49,7 @@
|
|||||||
<button
|
<button
|
||||||
@click="cambiarTipo('generados')"
|
@click="cambiarTipo('generados')"
|
||||||
:class="filtros.tipo === 'generados' ? 'bg-success text-white shadow-sm' : 'text-ink-2 hover:bg-surface-muted'"
|
: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>
|
<span class="material-symbols-outlined text-[14px]">auto_fix_high</span>
|
||||||
Generados
|
Generados
|
||||||
@ -60,7 +63,9 @@
|
|||||||
v-model="filtros.busqueda"
|
v-model="filtros.busqueda"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar por tema, gancho…"
|
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>
|
</div>
|
||||||
|
|
||||||
@ -88,15 +93,60 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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-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>
|
</div>
|
||||||
|
|
||||||
<!-- Vacío -->
|
<!-- Vacío con CTA -->
|
||||||
<div v-else-if="guionesFiltrados.length === 0" class="flex flex-col items-center justify-center py-24 gap-3 text-ink-3">
|
<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">
|
||||||
<span class="material-symbols-outlined text-4xl">inventory_2</span>
|
<div class="w-14 h-14 rounded-2xl bg-surface-muted border border-border flex items-center justify-center mb-2">
|
||||||
<p class="text-sm font-medium">No hay guiones que coincidan con los filtros</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Grid de Guiones -->
|
<!-- Grid de Guiones -->
|
||||||
@ -105,7 +155,8 @@
|
|||||||
v-for="g in guionesFiltrados"
|
v-for="g in guionesFiltrados"
|
||||||
:key="g.id"
|
:key="g.id"
|
||||||
@click="filtros.tipo === 'analizados' ? verDetalle(g.id) : verGenerado(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'"
|
:class="filtros.tipo === 'analizados' ? 'hover:border-accent/30' : 'hover:border-success/30'"
|
||||||
>
|
>
|
||||||
<!-- Badges + Score -->
|
<!-- Badges + Score -->
|
||||||
@ -116,12 +167,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-bold border"
|
class="flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-bold border"
|
||||||
:class="filtros.tipo === 'analizados'
|
:class="scoreScoreBadge(filtros.tipo === 'analizados' ? g.score_virabilidad : g.score_estimado)"
|
||||||
? 'bg-accent-subtle border-accent-border text-accent'
|
|
||||||
: 'bg-success-subtle border-success-border text-success'"
|
|
||||||
>
|
>
|
||||||
<span class="material-symbols-outlined text-[12px]" style="font-variation-settings:'FILL' 1;">bolt</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -133,7 +182,7 @@
|
|||||||
>
|
>
|
||||||
{{ filtros.tipo === 'analizados' ? (g.tema_principal || 'Sin título detectado') : (g.titulo_sugerido || g.tema || 'Sin título') }}
|
{{ filtros.tipo === 'analizados' ? (g.tema_principal || 'Sin título detectado') : (g.titulo_sugerido || g.tema || 'Sin título') }}
|
||||||
</p>
|
</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>
|
<p v-else class="text-[10px] text-ink-3 truncate">{{ g.tono }} · {{ g.objetivo }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -150,27 +199,31 @@
|
|||||||
<template v-if="filtros.tipo === 'analizados'">
|
<template v-if="filtros.tipo === 'analizados'">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Engagement</p>
|
<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>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Cialdini</p>
|
<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>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-[9px] text-ink-3 font-medium mb-1">Viralidad</p>
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Score</p>
|
<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>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Duración</p>
|
<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>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Estructura</p>
|
<p class="text-[9px] text-ink-3 font-medium mb-0.5">Estructura</p>
|
||||||
@ -178,18 +231,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
|
<!-- Acciones hover: confirmación inline de borrado -->
|
||||||
<button class="p-1.5 rounded-lg text-ink-3 hover:text-accent hover:bg-accent-subtle transition-all">
|
<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>
|
<span class="material-symbols-outlined text-[16px]">open_in_new</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
@click.stop="confirmarEliminar(g)"
|
<!-- Delete: 1er clic muestra confirmación, 2do borra -->
|
||||||
:disabled="eliminandoId === g.id"
|
<template v-if="confirmDeleteId !== 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"
|
<button
|
||||||
>
|
@click.stop="confirmDeleteId = g.id"
|
||||||
<span v-if="eliminandoId === g.id" class="material-symbols-outlined text-[16px] animate-spin">progress_activity</span>
|
:disabled="eliminandoId === g.id"
|
||||||
<span v-else class="material-symbols-outlined text-[16px]">delete</span>
|
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"
|
||||||
</button>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -202,24 +277,28 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||||
@click.self="modalGenerado = null"
|
@click.self="modalGenerado = null"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/30 backdrop-blur-sm"></div>
|
<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-xl flex flex-col">
|
<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 -->
|
<!-- Header sticky 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">
|
<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-1">
|
||||||
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<span :class="plataformaBadge(modalGenerado.plataforma)" class="platform-badge">{{ modalGenerado.plataforma }}</span>
|
<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>
|
<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">
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-base font-bold font-headline text-ink leading-tight">{{ modalGenerado.titulo_sugerido }}</h2>
|
<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>
|
<p class="text-[11px] text-ink-3 mt-0.5">{{ modalGenerado.tono }} · {{ modalGenerado.objetivo }} · {{ modalGenerado.duracion_estimada_seg }}s estimados</p>
|
||||||
</div>
|
</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>
|
<span class="material-symbols-outlined text-[20px]">close</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -266,15 +345,20 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
@click="copiarGuionModal"
|
@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>
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -283,13 +367,15 @@
|
|||||||
<!-- Paginación -->
|
<!-- Paginación -->
|
||||||
<div v-if="!cargando && guionesFiltrados.length > 0" class="flex items-center justify-center gap-3 pt-2">
|
<div v-if="!cargando && guionesFiltrados.length > 0" class="flex items-center justify-center gap-3 pt-2">
|
||||||
<button
|
<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"
|
:disabled="filtros.page <= 1"
|
||||||
@click="cambiarPagina(filtros.page - 1)"
|
@click="cambiarPagina(filtros.page - 1)"
|
||||||
>Anterior</button>
|
>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
|
<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"
|
:disabled="guiones.length < filtros.limit"
|
||||||
@click="cambiarPagina(filtros.page + 1)"
|
@click="cambiarPagina(filtros.page + 1)"
|
||||||
>Siguiente</button>
|
>Siguiente</button>
|
||||||
@ -300,10 +386,14 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
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 { api } from '../lib/api.js'
|
||||||
|
import { useToast } from '../composables/useToast.js'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
const guiones = ref([])
|
const guiones = ref([])
|
||||||
const nichos = ref([])
|
const nichos = ref([])
|
||||||
const cargando = ref(true)
|
const cargando = ref(true)
|
||||||
@ -312,6 +402,7 @@ const modalGenerado = ref(null)
|
|||||||
const cargandoModal = ref(false)
|
const cargandoModal = ref(false)
|
||||||
const copiadoModal = ref(false)
|
const copiadoModal = ref(false)
|
||||||
const eliminandoId = ref(null)
|
const eliminandoId = ref(null)
|
||||||
|
const confirmDeleteId = ref(null)
|
||||||
|
|
||||||
const filtros = ref({
|
const filtros = ref({
|
||||||
tipo: 'analizados',
|
tipo: 'analizados',
|
||||||
@ -319,13 +410,12 @@ const filtros = ref({
|
|||||||
limit: 18,
|
limit: 18,
|
||||||
niche: '',
|
niche: '',
|
||||||
plataforma: '',
|
plataforma: '',
|
||||||
busqueda: '',
|
busqueda: route.query.q || '',
|
||||||
orden: 'fecha_desc',
|
orden: 'fecha_desc',
|
||||||
})
|
})
|
||||||
|
|
||||||
const guionesFiltrados = computed(() => {
|
const guionesFiltrados = computed(() => {
|
||||||
let lista = [...guiones.value]
|
let lista = [...guiones.value]
|
||||||
|
|
||||||
if (filtros.value.busqueda.trim()) {
|
if (filtros.value.busqueda.trim()) {
|
||||||
const q = filtros.value.busqueda.toLowerCase()
|
const q = filtros.value.busqueda.toLowerCase()
|
||||||
if (filtros.value.tipo === 'analizados') {
|
if (filtros.value.tipo === 'analizados') {
|
||||||
@ -342,19 +432,18 @@ const guionesFiltrados = computed(() => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (filtros.value.orden) {
|
switch (filtros.value.orden) {
|
||||||
case 'viralidad_desc': lista.sort((a, b) => (b.score_virabilidad || 0) - (a.score_virabilidad || 0)); break
|
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 '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 '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
|
case 'score_desc': lista.sort((a, b) => (b.score_estimado || 0) - (a.score_estimado || 0)); break
|
||||||
}
|
}
|
||||||
|
|
||||||
return lista
|
return lista
|
||||||
})
|
})
|
||||||
|
|
||||||
async function cargarDatos() {
|
async function cargarDatos() {
|
||||||
cargando.value = true
|
cargando.value = true
|
||||||
|
confirmDeleteId.value = null
|
||||||
try {
|
try {
|
||||||
const params = { page: filtros.value.page, limit: filtros.value.limit }
|
const params = { page: filtros.value.page, limit: filtros.value.limit }
|
||||||
if (filtros.value.niche) params.niche = filtros.value.niche
|
if (filtros.value.niche) params.niche = filtros.value.niche
|
||||||
@ -373,6 +462,7 @@ async function cargarDatos() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
toast.error('Error al cargar los guiones')
|
||||||
} finally {
|
} finally {
|
||||||
cargando.value = false
|
cargando.value = false
|
||||||
}
|
}
|
||||||
@ -408,6 +498,7 @@ async function verGenerado(id) {
|
|||||||
modalGenerado.value = generado
|
modalGenerado.value = generado
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
toast.error('No se pudo cargar el guion')
|
||||||
} finally {
|
} finally {
|
||||||
cargandoModal.value = false
|
cargandoModal.value = false
|
||||||
}
|
}
|
||||||
@ -418,16 +509,15 @@ async function copiarGuionModal() {
|
|||||||
if (!texto) return
|
if (!texto) return
|
||||||
await navigator.clipboard.writeText(texto)
|
await navigator.clipboard.writeText(texto)
|
||||||
copiadoModal.value = true
|
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'
|
const titulo = filtros.value.tipo === 'analizados'
|
||||||
? (g.tema_principal || 'este guion')
|
? (g.tema_principal || 'este guion')
|
||||||
: (g.titulo_sugerido || 'este guion')
|
: (g.titulo_sugerido || 'este guion')
|
||||||
|
|
||||||
if (!confirm(`¿Eliminar "${titulo}"? Esta acción no se puede deshacer.`)) return
|
|
||||||
|
|
||||||
eliminandoId.value = g.id
|
eliminandoId.value = g.id
|
||||||
try {
|
try {
|
||||||
if (filtros.value.tipo === 'analizados') {
|
if (filtros.value.tipo === 'analizados') {
|
||||||
@ -435,11 +525,12 @@ async function confirmarEliminar(g) {
|
|||||||
} else {
|
} else {
|
||||||
await api.generados.eliminar(g.id)
|
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)
|
totalGuiones.value = Math.max(0, totalGuiones.value - 1)
|
||||||
|
toast.success(`"${titulo.substring(0, 40)}${titulo.length > 40 ? '…' : ''}" eliminado`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
alert('No se pudo eliminar: ' + e.message)
|
toast.error('No se pudo eliminar: ' + e.message)
|
||||||
} finally {
|
} finally {
|
||||||
eliminandoId.value = null
|
eliminandoId.value = null
|
||||||
}
|
}
|
||||||
@ -454,5 +545,21 @@ function plataformaBadge(p) {
|
|||||||
return map[p] || 'bg-surface-muted text-ink-3 border border-border'
|
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)
|
onMounted(cargarDatos)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user