Initial commit — Sistema Generador de Guiones V4.0
Pipeline completo: URL → Whisper → GPT-4o → pgvector → Supabase Frontend Vue 3 + Tailwind, Backend Express + Vercel serverless functions
This commit is contained in:
31
frontend/src/App.vue
Normal file
31
frontend/src/App.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="bg-surface min-h-screen text-on-surface selection:bg-primary/30">
|
||||
<SideNavBar />
|
||||
<TopAppBar />
|
||||
|
||||
<!-- Main Content Canvas -->
|
||||
<main class="ml-64 pt-24 pb-12 px-8 min-h-screen relative">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SideNavBar from './components/SideNavBar.vue'
|
||||
import TopAppBar from './components/TopAppBar.vue'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
15
frontend/src/components/CialdiniItem.vue
Normal file
15
frontend/src/components/CialdiniItem.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 p-2.5 rounded-xl border transition-colors" :class="active ? 'bg-indigo-500/10 border-indigo-500/30' : 'bg-surface-container-lowest border-white/5'">
|
||||
<div class="w-4 h-4 rounded-full flex items-center justify-center shrink-0" :class="active ? 'bg-indigo-500/20 text-indigo-400' : 'bg-white/5 text-transparent'">
|
||||
<span v-if="active" class="material-symbols-outlined text-[12px] font-bold">check</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold tracking-wide" :class="active ? 'text-white' : 'text-outline'">{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: String,
|
||||
active: Boolean
|
||||
})
|
||||
</script>
|
||||
30
frontend/src/components/DataRow.vue
Normal file
30
frontend/src/components/DataRow.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between border-b border-white/5 pb-2 last:border-0 last:pb-0">
|
||||
<span class="text-[10px] font-bold text-outline uppercase tracking-wider">{{ label }}</span>
|
||||
|
||||
<template v-if="type === 'boolean'">
|
||||
<span :class="value ? 'text-primary' : 'text-outline/40'" class="text-xs font-bold uppercase tracking-widest">
|
||||
{{ value ? 'YES' : 'NO' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span v-if="value" class="text-xs font-medium text-right max-w-[150px] truncate" :class="highlight ? 'text-white' : 'text-on-surface-variant'">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-else class="text-gray-600 text-xs">—</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
label: String,
|
||||
value: [String, Number, Boolean],
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text' // or 'boolean'
|
||||
},
|
||||
highlight: Boolean
|
||||
})
|
||||
</script>
|
||||
45
frontend/src/components/SideNavBar.vue
Normal file
45
frontend/src/components/SideNavBar.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<aside class="fixed left-0 top-0 h-full z-40 flex flex-col p-4 w-64 border-r border-white/5 bg-[#13131a] font-['Manrope'] antialiased shadow-2xl shadow-indigo-500/10">
|
||||
<!-- Branding -->
|
||||
<div class="flex items-center gap-3 mb-10 px-2">
|
||||
<div class="w-8 h-8 rounded bg-gradient-to-br from-primary-container to-primary flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-on-primary-container text-lg" style="font-variation-settings: 'FILL' 1;">psychology</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold tracking-tight text-white">Guiones IA</h1>
|
||||
<p class="text-[10px] uppercase tracking-widest text-primary/60 font-bold">Marketing Pro</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 space-y-1">
|
||||
<router-link to="/" class="flex items-center gap-3 px-3 py-2.5 text-[#c7c4d7] hover:text-white hover:bg-white/5 transition-all duration-200 group relative" active-class="bg-white/10 !text-white rounded-lg font-semibold">
|
||||
<span class="material-symbols-outlined text-[20px]">dashboard</span>
|
||||
<span class="text-sm font-medium">Dashboard</span>
|
||||
</router-link>
|
||||
<router-link to="/analysis" class="flex items-center gap-3 px-3 py-2.5 text-[#c7c4d7] hover:text-white hover:bg-white/5 transition-all duration-200 group relative" active-class="bg-white/10 !text-white rounded-lg font-semibold">
|
||||
<span class="material-symbols-outlined text-[20px]">analytics</span>
|
||||
<span class="text-sm font-medium">Analysis</span>
|
||||
</router-link>
|
||||
<router-link to="/scripts" class="flex items-center gap-3 px-3 py-2.5 text-[#c7c4d7] hover:text-white hover:bg-white/5 transition-all duration-200 group relative" active-class="bg-white/10 !text-white rounded-lg font-semibold">
|
||||
<span class="material-symbols-outlined text-[20px]">description</span>
|
||||
<span class="text-sm font-medium">Scripts</span>
|
||||
</router-link>
|
||||
<router-link to="/settings" class="flex items-center gap-3 px-3 py-2.5 text-[#c7c4d7] hover:text-white hover:bg-white/5 transition-all duration-200 group relative" active-class="bg-white/10 !text-white rounded-lg font-semibold">
|
||||
<span class="material-symbols-outlined text-[20px]">settings</span>
|
||||
<span class="text-sm font-medium">Settings</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- Footer Action -->
|
||||
<div class="mt-auto pt-6 border-t border-white/5">
|
||||
<router-link to="/new-analysis" class="w-full flex items-center justify-center gap-2 py-3 px-4 bg-primary-container text-on-primary-container rounded-lg font-bold text-sm shadow-lg shadow-primary/20 scale-95 active:scale-90 transition-transform hover:brightness-110">
|
||||
<span class="material-symbols-outlined text-sm">add</span>
|
||||
New Analysis
|
||||
</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
26
frontend/src/components/TopAppBar.vue
Normal file
26
frontend/src/components/TopAppBar.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<header class="fixed top-0 right-0 left-64 h-16 flex items-center justify-between px-8 z-30 bg-[#13131a]/80 backdrop-blur-xl font-['Manrope'] text-sm">
|
||||
<div class="relative w-96 group">
|
||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-outline text-lg group-focus-within:text-primary transition-colors">search</span>
|
||||
<input class="w-full bg-surface-container-lowest border-none rounded-full pl-10 pr-4 py-2 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface placeholder:text-outline/50 transition-all font-medium" placeholder="Search analyzed scripts..." type="text"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-6">
|
||||
<button class="relative text-[#c7c4d7] hover:text-white transition-opacity">
|
||||
<span class="material-symbols-outlined">notifications</span>
|
||||
<span class="absolute top-0 right-0 w-2 h-2 bg-secondary rounded-full border-2 border-[#13131a]"></span>
|
||||
</button>
|
||||
<div class="flex items-center gap-3 pl-6 border-l border-white/5">
|
||||
<div class="text-right">
|
||||
<p class="text-xs font-bold text-white leading-none">Alex Rivera</p>
|
||||
<p class="text-[10px] text-outline font-medium">Growth Lead</p>
|
||||
</div>
|
||||
<div class="w-8 h-8 rounded-full overflow-hidden bg-surface-container-highest flex items-center justify-center">
|
||||
<span class="material-symbols-outlined text-outline">account_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
22
frontend/src/lib/api.js
Normal file
22
frontend/src/lib/api.js
Normal file
@ -0,0 +1,22 @@
|
||||
const BASE = '/api'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || `Error ${res.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
export const api = {
|
||||
guiones: {
|
||||
listar: (params = {}) => request('/guiones?' + new URLSearchParams(params)),
|
||||
obtener: (id) => request(`/guiones/${id}`),
|
||||
},
|
||||
analizar: (body) => request('/analizar', { method: 'POST', body: JSON.stringify(body) }),
|
||||
nichos: () => request('/nichos'),
|
||||
clientes: () => request('/clientes'),
|
||||
stats: () => request('/stats'),
|
||||
}
|
||||
7
frontend/src/main.js
Normal file
7
frontend/src/main.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router/index.js'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).use(createPinia()).use(router).mount('#app')
|
||||
43
frontend/src/router/index.js
Normal file
43
frontend/src/router/index.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import DashboardView from '../views/DashboardView.vue'
|
||||
import AnalysisCreateView from '../views/AnalysisCreateView.vue'
|
||||
import AnalysisDetailView from '../views/AnalysisDetailView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: DashboardView
|
||||
},
|
||||
{
|
||||
path: '/new-analysis',
|
||||
name: 'AnalysisCreate',
|
||||
component: AnalysisCreateView
|
||||
},
|
||||
{
|
||||
path: '/analysis/:id',
|
||||
name: 'AnalysisDetail',
|
||||
component: AnalysisDetailView
|
||||
},
|
||||
// Placeholders for sidebar consistency
|
||||
{
|
||||
path: '/analysis',
|
||||
name: 'AnalysisList',
|
||||
redirect: '/'
|
||||
},
|
||||
{
|
||||
path: '/scripts',
|
||||
name: 'Scripts',
|
||||
component: () => import('../views/DashboardView.vue') // Placeholder
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/DashboardView.vue') // Placeholder
|
||||
}
|
||||
]
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
35
frontend/src/style.css
Normal file
35
frontend/src/style.css
Normal file
@ -0,0 +1,35 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { @apply bg-surface; }
|
||||
::-webkit-scrollbar-thumb { @apply bg-surface-container-highest rounded-full; }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
.glass-panel {
|
||||
background: rgba(31, 31, 38, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
.neon-glow {
|
||||
text-shadow: 0 0 10px rgba(78, 222, 163, 0.4);
|
||||
}
|
||||
.radial-gradient-score {
|
||||
background: conic-gradient(from 0deg, #4edea3 88%, #1f1f26 0%);
|
||||
}
|
||||
.step-pulse {
|
||||
box-shadow: 0 0 0 0 rgba(192, 193, 255, 0.4);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(192, 193, 255, 0.4); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(192, 193, 255, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(192, 193, 255, 0); }
|
||||
}
|
||||
238
frontend/src/views/AnalysisCreateView.vue
Normal file
238
frontend/src/views/AnalysisCreateView.vue
Normal file
@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto flex flex-col gap-10">
|
||||
<!-- Header -->
|
||||
<header class="flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-white/5 pb-8">
|
||||
<div>
|
||||
<h1 class="text-5xl font-extrabold font-headline tracking-tighter text-white mb-2 leading-tight">New Video Analysis</h1>
|
||||
<p class="text-primary text-sm font-bold flex items-center gap-2 tracking-widest uppercase">
|
||||
<span class="material-symbols-outlined text-sm">rocket_launch</span>
|
||||
GPT-4o + Whisper Pipeline Engine
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="px-6 py-3 bg-surface-container border border-white/5 text-outline font-bold rounded-xl text-sm hover:text-white transition-colors">Save as Draft</button>
|
||||
<button class="px-8 py-3 bg-gradient-to-br from-primary-container to-primary text-on-primary-container font-headline font-bold rounded-xl shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm h-12 flex items-center gap-2" :disabled="analizando" @click="iniciarAnalisis">
|
||||
<span class="material-symbols-outlined text-sm">{{ analizando ? 'hourglass_top' : 'auto_fix_high' }}</span>
|
||||
{{ analizando ? 'Analyzing...' : 'Start Pipeline' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-12">
|
||||
<!-- Form Column -->
|
||||
<div class="xl:col-span-7 flex flex-col gap-10">
|
||||
<!-- Step 1: Source -->
|
||||
<section class="space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center font-black text-sm border border-primary/20">01</span>
|
||||
<h2 class="text-xl font-headline font-extrabold text-white tracking-tight">Source Identification</h2>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel p-8 rounded-3xl border border-white/5 shadow-2xl space-y-8">
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Video Destination URL</label>
|
||||
<div class="relative group">
|
||||
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-primary text-lg group-focus-within:animate-pulse">link</span>
|
||||
<input v-model="form.url" type="url" placeholder="https://www.tiktok.com/@user/video/..." class="w-full bg-surface-container-low border border-white/10 rounded-2xl pl-12 pr-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 focus:border-primary/40 text-white placeholder:text-outline/40 transition-all font-medium font-serif" :disabled="analizando"/>
|
||||
</div>
|
||||
<p class="text-[10px] text-outline/60 italic px-1">Supports TikTok, Instagram Reels and YouTube Shorts.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Organization / Client</label>
|
||||
<select v-model="form.cliente_id" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface font-medium appearance-none transition-all shadow-inner" :disabled="analizando">
|
||||
<option :value="null">In-house / Internal</option>
|
||||
<option v-for="c in clientes" :key="c.id" :value="c.id">{{ c.nombre }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Project Name (Optional)</label>
|
||||
<input v-model="form.proyecto_nombre" type="text" placeholder="e.g. Q1 Campaign" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface font-medium transition-all shadow-inner" :disabled="analizando"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Step 2: Contextual Metrics -->
|
||||
<section class="space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-8 h-8 rounded-lg bg-secondary/10 text-secondary flex items-center justify-center font-black text-sm border border-secondary/20">02</span>
|
||||
<h2 class="text-xl font-headline font-extrabold text-white tracking-tight">Market Intelligence</h2>
|
||||
</div>
|
||||
|
||||
<div class="glass-panel p-8 rounded-3xl border border-white/5 shadow-2xl space-y-8">
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Primary Niche</label>
|
||||
<div class="relative">
|
||||
<input v-model="form.niche" list="nichos-list" placeholder="Select or type..." class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface font-black uppercase tracking-widest shadow-inner" :disabled="analizando"/>
|
||||
<datalist id="nichos-list">
|
||||
<option v-for="n in nichos" :key="n" :value="n">{{ n }}</option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Target Audience</label>
|
||||
<input v-model="form.mercado_objetivo" type="text" placeholder="e.g. Female Entrepreneurs 25-35" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface font-medium shadow-inner" :disabled="analizando"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-6">
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Views</label>
|
||||
<input v-model.number="form.vistas" type="number" placeholder="0" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface font-bold text-center" :disabled="analizando"/>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Likes</label>
|
||||
<input v-model.number="form.likes" type="number" placeholder="0" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface font-bold text-center" :disabled="analizando"/>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-outline ml-1">Shares / Saves</label>
|
||||
<input v-model.number="form.compartidos" type="number" placeholder="0" class="w-full bg-surface-container-low border border-white/10 rounded-2xl px-4 py-4 text-sm focus:ring-2 focus:ring-primary/40 text-on-surface font-bold text-center" :disabled="analizando"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 p-4 rounded-2xl bg-surface-container-lowest border border-white/5">
|
||||
<input v-model="form.competidor_referente" type="checkbox" id="check-comp" class="w-5 h-5 rounded border-white/10 bg-surface text-primary focus:ring-primary focus:ring-offset-surface-container ring-offset-2 transition-all cursor-pointer" :disabled="analizando"/>
|
||||
<label for="check-comp" class="text-xs font-bold text-on-surface cursor-pointer select-none">Mark as Strategic Competitor Reference</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Status Column -->
|
||||
<div class="xl:col-span-5 flex flex-col gap-6">
|
||||
<div class="sticky top-24">
|
||||
<div class="bg-surface-container p-8 rounded-[40px] border border-outline-variant/10 shadow-3xl relative overflow-hidden flex flex-col gap-8">
|
||||
<div class="absolute -top-12 -right-12 w-32 h-32 bg-secondary/10 blur-3xl rounded-full"></div>
|
||||
|
||||
<div class="flex items-center justify-between border-b border-white/5 pb-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-headline font-black text-white uppercase tracking-widest mb-1">Pipeline Engine</h3>
|
||||
<p class="text-[10px] text-outline uppercase font-bold tracking-tight">Real-time Analysis Status</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1 bg-surface-container-low rounded-full border border-white/5">
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="analizando ? 'bg-secondary animate-pulse' : 'bg-outline/30'"></span>
|
||||
<span class="text-[10px] font-black tracking-widest uppercase" :class="analizando ? 'text-secondary' : 'text-outline'">{{ analizando ? 'Active' : 'Idle' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8 relative">
|
||||
<div class="absolute left-4 top-2 bottom-2 w-px bg-white/5 z-0"></div>
|
||||
|
||||
<div v-for="(s, idx) in pasisVisibles" :key="s.id" class="flex gap-4 relative z-10" :class="idx > currentStepIdx ? 'opacity-30' : 'opacity-100'">
|
||||
<div class="w-8 h-8 rounded-full flex items-center justify-center shrink-0 transition-all duration-300" :class="idx === currentStepIdx ? 'bg-secondary text-on-secondary shadow-lg shadow-secondary/40 scale-110' : (idx < currentStepIdx ? 'bg-primary/20 text-primary' : 'bg-surface-container-highest text-outline')">
|
||||
<span class="material-symbols-outlined text-sm font-black">{{ idx < currentStepIdx ? 'check' : s.icon }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-black uppercase tracking-widest mb-1" :class="idx === currentStepIdx ? 'text-white' : 'text-outline'">{{ s.label }}</p>
|
||||
<p class="text-[10px] font-bold tracking-tight leading-none" :class="idx === currentStepIdx ? 'text-secondary' : 'text-outline/40'">{{ s.desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-4 rounded-xl bg-red-500/10 border border-red-500/30 text-red-500 text-[11px] font-bold leading-relaxed shadow-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="material-symbols-outlined text-sm">report</span>
|
||||
<span class="uppercase tracking-widest">Pipeline Error</span>
|
||||
</div>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="analizando" class="mt-4 pt-4 border-t border-white/5 flex flex-col gap-3">
|
||||
<div class="w-full bg-surface-container-low h-1 rounded-full overflow-hidden">
|
||||
<div class="bg-secondary h-full transition-all duration-500" :style="{ width: ((currentStepIdx / 4) * 100) + '%' }"></div>
|
||||
</div>
|
||||
<p class="text-[10px] text-outline italic text-center animate-pulse">Processing vector embeddings and OpenAI request. Estimated time remaining: 15s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
|
||||
const router = useRouter()
|
||||
const analizando = ref(false)
|
||||
const error = ref(null)
|
||||
const paso = ref('inicio')
|
||||
|
||||
const nichos = ref([])
|
||||
const clientes = ref([])
|
||||
|
||||
const form = ref({
|
||||
url: '',
|
||||
niche: '',
|
||||
sub_niche: '',
|
||||
mercado_objetivo: '',
|
||||
vistas: null,
|
||||
likes: null,
|
||||
compartidos: null,
|
||||
cliente_id: null,
|
||||
proyecto_nombre: '',
|
||||
competidor_referente: false
|
||||
})
|
||||
|
||||
const pasisVisibles = [
|
||||
{ id: 'extraccion', label: 'Audio Extraction', icon: 'downloading', desc: 'Fetching source video and demuxing audio stream.' },
|
||||
{ id: 'transcripcion', label: 'Whisper Context', icon: 'mic', desc: 'Generating millisecond-perfect SRD transcription.' },
|
||||
{ id: 'analisis', label: 'GPT-4o Reasoning', icon: 'psychology', desc: 'Analyzing semantic hooks and neuro-marketing patterns.' },
|
||||
{ id: 'embedding', label: 'Vector Encoding', icon: 'hub', desc: 'Finalizing database injection and embedding generation.' },
|
||||
]
|
||||
|
||||
const currentStepIdx = computed(() => {
|
||||
if (!analizando.value) return -1
|
||||
return pasisVisibles.findIndex(p => p.id === paso.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [n, c] = await Promise.all([api.nichos(), api.clientes()])
|
||||
nichos.value = n
|
||||
clientes.value = c
|
||||
} catch (e) { console.error(e) }
|
||||
})
|
||||
|
||||
async function iniciarAnalisis() {
|
||||
if (!form.value.url || !form.value.niche) {
|
||||
error.value = "Missing URL or Niche. These parameters are mandatory for the pipeline."
|
||||
return
|
||||
}
|
||||
|
||||
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 not supported. Only TikTok, Instagram Reels and YouTube Shorts are accepted."
|
||||
return
|
||||
}
|
||||
|
||||
analizando.value = true
|
||||
error.value = null
|
||||
paso.value = 'extraccion'
|
||||
|
||||
const fakeInterval = setInterval(() => {
|
||||
if (paso.value === 'extraccion') paso.value = 'transcripcion';
|
||||
else if (paso.value === 'transcripcion') paso.value = 'analisis';
|
||||
else if (paso.value === 'analisis') paso.value = 'embedding';
|
||||
}, 4000)
|
||||
|
||||
try {
|
||||
const res = await api.analizar(form.value)
|
||||
clearInterval(fakeInterval)
|
||||
paso.value = 'embedding'
|
||||
setTimeout(() => {
|
||||
router.push({ name: 'AnalysisDetail', params: { id: res.guion_id } })
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
clearInterval(fakeInterval)
|
||||
error.value = err.message
|
||||
} finally {
|
||||
analizando.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
231
frontend/src/views/AnalysisDetailView.vue
Normal file
231
frontend/src/views/AnalysisDetailView.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div v-if="cargando" class="flex flex-col items-center justify-center py-24 gap-4 min-h-[50vh]">
|
||||
<div class="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="text-outline text-sm animate-pulse">Loading Neuro-Semantic Data...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="guion" class="max-w-6xl mx-auto relative flex flex-col gap-8 pb-12">
|
||||
<!-- Header Section -->
|
||||
<header class="relative z-10 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<router-link to="/" class="flex items-center gap-2 text-outline hover:text-white transition-colors text-sm font-bold uppercase tracking-widest mb-6 w-fit">
|
||||
<span class="material-symbols-outlined text-lg">west</span> Back to Hub
|
||||
</router-link>
|
||||
<div class="flex items-center gap-3 mb-3 flex-wrap">
|
||||
<span class="px-3 py-1 bg-surface-container-highest text-on-surface-variant text-[11px] font-black rounded uppercase tracking-widest">{{ guion.niche }}</span>
|
||||
<span v-if="guion.sub_niche" class="px-3 py-1 bg-surface-container-low border border-outline-variant/20 text-outline text-[11px] font-bold rounded shadow-sm">{{ guion.sub_niche }}</span>
|
||||
<span :class="plataformaBadge(guion.plataforma)" class="px-3 py-1 text-[11px] font-black rounded uppercase tracking-widest">{{ guion.plataforma }}</span>
|
||||
</div>
|
||||
<h1 class="text-4xl md:text-5xl font-extrabold font-headline tracking-tighter text-white mb-2 leading-tight max-w-3xl">
|
||||
{{ guion.tema_principal || 'Untitled Analysis' }}
|
||||
</h1>
|
||||
<p class="text-primary text-sm font-bold flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm">visibility</span>
|
||||
{{ guion.angulo_unico || 'Unique Angle Not Specified' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 shrink-0">
|
||||
<button v-if="guion.url_origen" class="h-12 w-12 rounded-xl bg-surface-container-low border border-outline-variant/20 flex items-center justify-center text-on-surface hover:bg-surface-container transition-colors shadow-lg" title="Watch Original Web" @click="openUrl(guion.url_origen)">
|
||||
<span class="material-symbols-outlined">link</span>
|
||||
</button>
|
||||
<div class="h-12 w-12 rounded-xl bg-surface-container-low border border-outline-variant/20 flex items-center justify-center text-on-surface hover:bg-surface-container transition-colors shadow-lg cursor-pointer">
|
||||
<span class="material-symbols-outlined">bookmark</span>
|
||||
</div>
|
||||
<button class="px-6 py-3 bg-gradient-to-br from-primary-container to-primary text-on-primary-container font-headline font-bold rounded-xl shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm h-12 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm">download</span> Vector Package
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Grid -->
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8 relative z-10">
|
||||
<!-- Left Column: Core Analytics -->
|
||||
<div class="xl:col-span-4 flex flex-col gap-6">
|
||||
<!-- Score Card -->
|
||||
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-2xl relative overflow-hidden group">
|
||||
<div class="absolute -top-24 -right-24 w-48 h-48 bg-primary/20 blur-3xl rounded-full group-hover:bg-primary/30 transition-colors pointer-events-none"></div>
|
||||
<h3 class="text-sm font-headline font-bold text-white mb-8 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-primary">analytics</span> Neuro-Engagement Score
|
||||
</h3>
|
||||
<div class="flex justify-center mb-6 relative">
|
||||
<svg class="w-48 h-48 transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="45" fill="none" stroke="rgba(255,255,255,0.05)" stroke-width="8" />
|
||||
<circle cx="50" cy="50" r="45" fill="none" class="stroke-primary drop-shadow-[0_0_8px_rgba(78,222,163,0.5)] transition-all duration-1000 ease-out" stroke-width="8" :stroke-dasharray="`${(guion.score_virabilidad || 0)/100 * 283} 283`" stroke-linecap="round" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span class="text-6xl font-black font-headline text-white tracking-tighter neon-glow">{{ guion.score_virabilidad || 0 }}</span>
|
||||
<span class="text-xs font-bold text-primary uppercase tracking-widest mt-1">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 pt-6 border-t border-white/5">
|
||||
<div>
|
||||
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-1">Cialdini Index</p>
|
||||
<p class="text-xl font-bold text-white">{{ guion.score_cialdini ?? 0 }}<span class="text-sm text-outline">/7</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-1">Real Eng.</p>
|
||||
<p class="text-xl font-bold text-emerald-400">{{ guion.score_engagement ? (guion.score_engagement*1).toFixed(2) + '%' : 'N/A' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
|
||||
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-secondary">psychology_alt</span> Semantic Hooks
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Narrative Framework</p>
|
||||
<div class="glass-panel p-3 rounded-lg border border-white/5 inline-block w-full">
|
||||
<span class="text-sm font-bold text-on-surface">{{ guion.estructura_narrativa || 'Not Detected' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2 flex justify-between">
|
||||
<span>Core Hook</span>
|
||||
<span class="text-secondary">{{ guion.gancho_duracion_seg ? guion.gancho_duracion_seg + 's delay' : '' }}</span>
|
||||
</p>
|
||||
<div class="glass-panel p-4 rounded-xl border border-secondary/20 relative">
|
||||
<div class="absolute top-0 right-0 p-2"><span class="w-1.5 h-1.5 rounded-full bg-secondary block"></span></div>
|
||||
<p class="text-xs text-secondary font-bold uppercase tracking-wider mb-2">{{ guion.gancho_tipo || 'Standard Hook' }}</p>
|
||||
<p class="text-sm text-white font-medium leading-relaxed italic">"{{ guion.gancho_texto || '—' }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center & Right Columns -->
|
||||
<div class="xl:col-span-8 flex flex-col gap-6">
|
||||
<div class="bg-surface-container p-8 rounded-3xl border border-primary/20 shadow-2xl relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent pointer-events-none"></div>
|
||||
<p class="text-xs text-primary font-bold uppercase tracking-widest mb-4">Winning Pattern Synthesis</p>
|
||||
<p class="text-lg md:text-xl text-white font-medium leading-relaxed max-w-3xl relative z-10">{{ guion.resumen_patron }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
|
||||
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-orange-400">local_fire_department</span> Emotional Resonance
|
||||
</h3>
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between text-xs font-bold uppercase tracking-widest mb-2">
|
||||
<span class="text-outline">Intensity</span>
|
||||
<span class="text-orange-400">{{ guion.intensidad_emocional || 0 }}/10</span>
|
||||
</div>
|
||||
<div class="w-full bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-orange-500/50 to-orange-400 h-full" :style="{ width: ((guion.intensidad_emocional||0)*10) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<DataRow label="Primary Trigger" :value="guion.trigger_emocional" highlight />
|
||||
<DataRow label="Cognitive Bias" :value="guion.sesgo_cognitivo" />
|
||||
<DataRow label="Pain / Pleasure" :value="guion.dolor_placer" highlight />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl flex flex-col">
|
||||
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-indigo-400">group_work</span> Cialdini Framework
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-3 flex-1">
|
||||
<CialdiniItem label="Reciprocity" :active="!!guion.cialdini_reciprocidad" />
|
||||
<CialdiniItem label="Scarcity" :active="!!guion.cialdini_escasez" />
|
||||
<CialdiniItem label="Authority" :active="!!guion.cialdini_autoridad" />
|
||||
<CialdiniItem label="Consistency" :active="!!guion.cialdini_consistencia" />
|
||||
<CialdiniItem label="Social Proof" :active="!!guion.cialdini_prueba_social" />
|
||||
<CialdiniItem label="Liking" :active="!!guion.cialdini_simpatia" />
|
||||
<CialdiniItem label="Unity" :active="!!guion.cialdini_unidad" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
|
||||
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-fuchsia-400">biotech</span> Neuromarketing
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<DataRow label="Visual Attention" :value="guion.atencion_visual" highlight />
|
||||
<DataRow label="Cognitive Load" :value="guion.carga_cognitiva" highlight />
|
||||
<DataRow label="Pacing" :value="guion.pacing_ritmo" highlight />
|
||||
<DataRow label="Sensory Language" :value="guion.lenguaje_sensorial" type="boolean" />
|
||||
<DataRow label="Contrast" :value="guion.contraste_narrativo" type="boolean" />
|
||||
<DataRow label="Novelty Effect" :value="guion.efecto_novedad" type="boolean" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl flex flex-col">
|
||||
<h3 class="text-sm font-headline font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-cyan-400">record_voice_over</span> Delivery & Reach
|
||||
</h3>
|
||||
<div class="space-y-4 mb-6">
|
||||
<DataRow label="Tone" :value="guion.tono" highlight/>
|
||||
<DataRow label="Perspective" :value="guion.persona_narradora" highlight/>
|
||||
<DataRow label="Specificity" :value="guion.nivel_especificidad" highlight/>
|
||||
<div>
|
||||
<p class="text-[10px] text-outline uppercase tracking-widest font-bold mb-2">Keywords Extracted</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="kw in guion.palabras_clave" :key="kw" class="px-2 py-1 bg-surface-container-lowest border border-white/5 rounded text-[10px] font-bold text-on-surface-variant">{{ kw }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transcript Viewer -->
|
||||
<div class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-headline font-bold text-white flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-outline">notes</span> Full Transcription
|
||||
</h3>
|
||||
<button @click="showTranscript = !showTranscript" class="text-xs font-bold uppercase tracking-widest text-primary hover:text-white transition-colors">
|
||||
{{ showTranscript ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</div>
|
||||
<div :class="showTranscript ? 'max-h-[800px]' : 'max-h-24'" class="overflow-hidden relative transition-all duration-500 ease-in-out">
|
||||
<div v-if="!showTranscript" class="absolute inset-0 bg-gradient-to-t from-surface-container to-transparent z-10"></div>
|
||||
<p class="text-sm text-on-surface-variant leading-relaxed whitespace-pre-wrap font-serif pt-4">
|
||||
{{ guion.transcript || 'Video without available transcription.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
import CialdiniItem from '../components/CialdiniItem.vue'
|
||||
import DataRow from '../components/DataRow.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const guion = ref(null)
|
||||
const cargando = ref(true)
|
||||
const showTranscript = ref(false)
|
||||
|
||||
function openUrl(url) {
|
||||
if(url) window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function plataformaBadge(p) {
|
||||
return {
|
||||
tiktok: 'bg-red-500/20 text-red-400',
|
||||
reels: 'bg-fuchsia-500/20 text-fuchsia-400',
|
||||
shorts: 'bg-red-600/20 text-red-500'
|
||||
}[p] ?? 'bg-white/5 text-on-surface-variant'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
guion.value = await api.guiones.obtener(route.params.id)
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
222
frontend/src/views/DashboardView.vue
Normal file
222
frontend/src/views/DashboardView.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto flex flex-col gap-10">
|
||||
<!-- Header -->
|
||||
<header class="flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-white/5 pb-8">
|
||||
<div>
|
||||
<h1 class="text-5xl font-extrabold font-headline tracking-tighter text-white mb-2 leading-tight">Engineering Hub</h1>
|
||||
<p class="text-primary text-sm font-bold flex items-center gap-2 tracking-widest uppercase">
|
||||
<span class="material-symbols-outlined text-sm">terminal</span>
|
||||
System Overview & Performance Analytics
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="px-6 py-3 bg-surface-container border border-white/5 text-outline font-bold rounded-xl text-sm hover:text-white transition-colors" @click="cargarDatos">Refresh Hub</button>
|
||||
<router-link to="/new-analysis" class="px-8 py-3 bg-gradient-to-br from-primary-container to-primary text-on-primary-container font-headline font-bold rounded-xl shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm h-12 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm">add</span> New Analysis
|
||||
</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- KPI Bento Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div v-for="stat in stats" :key="stat.niche" class="bg-surface-container p-6 rounded-3xl border border-outline-variant/10 shadow-xl relative overflow-hidden group">
|
||||
<div class="absolute -bottom-6 -right-6 w-24 h-24 bg-primary/5 blur-2xl rounded-full group-hover:bg-primary/10 transition-colors"></div>
|
||||
<p class="text-[10px] text-outline uppercase tracking-widest font-black mb-1">{{ stat.niche }}</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h2 class="text-3xl font-black font-headline text-white tracking-tighter">{{ stat.total_guiones }}</h2>
|
||||
<div class="text-right">
|
||||
<p class="text-[10px] text-primary font-bold uppercase tracking-tighter">Avg Score</p>
|
||||
<p class="text-lg font-black text-primary leading-none">{{ (stat.avg_score || 0).toFixed(1) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 w-full bg-surface-container-highest h-1 rounded-full overflow-hidden">
|
||||
<div class="bg-primary h-full transition-all duration-1000" :style="{ width: (stat.avg_score || 0) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder if no stats -->
|
||||
<div v-if="stats.length === 0" class="lg:col-span-4 bg-surface-container/50 border border-dashed border-white/5 p-8 rounded-3xl flex items-center justify-center italic text-outline text-sm">
|
||||
Connect database to view niche performance.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
<!-- Detailed Table -->
|
||||
<div class="lg:col-span-12 xl:col-span-8 bg-surface-container rounded-3xl border border-outline-variant/10 shadow-2xl overflow-hidden flex flex-col">
|
||||
<div class="px-8 py-6 border-b border-white/5 bg-surface-container-high/50 flex items-center justify-between">
|
||||
<h3 class="text-sm font-headline font-black text-white uppercase tracking-widest flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-outline text-lg">database</span> Analyzed Scripts
|
||||
</h3>
|
||||
<div class="flex gap-2">
|
||||
<button class="px-4 py-2 bg-surface-container-low text-[10px] font-black uppercase text-outline rounded hover:text-white transition-colors" @click="cambiarPagina(filtros.page - 1)" :disabled="filtros.page <= 1">Prev</button>
|
||||
<button class="px-4 py-2 bg-surface-container-low text-[10px] font-black uppercase text-outline rounded hover:text-white transition-colors" @click="cambiarPagina(filtros.page + 1)" :disabled="guiones.length < filtros.limit">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto flex-1 max-h-[600px] scrollbar-custom">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="sticky top-0 bg-surface-container z-10 border-b border-white/5">
|
||||
<tr class="text-[10px] font-black text-outline uppercase tracking-widest bg-surface-container-high/30">
|
||||
<th class="px-8 py-4">Context</th>
|
||||
<th class="px-6 py-4">Viral Score</th>
|
||||
<th class="px-6 py-4">Neurometric Pattern</th>
|
||||
<th class="px-8 py-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/5">
|
||||
<tr v-for="g in guiones" :key="g.id" class="group hover:bg-white/[0.02] transition-colors cursor-pointer" @click="verDetalle(g.id)">
|
||||
<td class="px-8 py-6">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="plataformaBadge(g.plataforma)" class="text-[8px] font-black px-1.5 py-0.5 rounded uppercase tracking-widest">{{ g.plataforma }}</span>
|
||||
<span class="text-[10px] text-outline font-bold uppercase tracking-wider">{{ g.niche }}</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-white leading-tight group-hover:text-primary transition-colors line-clamp-2 max-w-sm">{{ g.tema_principal || 'No semantic title detected' }}</p>
|
||||
<p class="text-[10px] text-outline/60 italic font-medium truncate max-w-xs">{{ g.url_origen }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6 font-headline">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between text-[11px] font-black tracking-tighter">
|
||||
<span class="text-white">{{ g.score_virabilidad || 0 }}/100</span>
|
||||
<span class="text-primary">{{ (g.score_engagement || 0).toFixed(1) }}% ENG</span>
|
||||
</div>
|
||||
<div class="w-32 bg-surface-container-highest h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-primary/50 to-primary h-full" :style="{ width: (g.score_virabilidad || 0) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-6">
|
||||
<div class="max-w-xs">
|
||||
<p class="text-[11px] text-outline font-bold uppercase tracking-widest mb-1">{{ g.gancho_tipo || 'Hook' }}</p>
|
||||
<p class="text-xs text-on-surface-variant line-clamp-2 leading-relaxed italic">"{{ g.gancho_texto }}"</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-6 text-right">
|
||||
<button class="p-2.5 rounded-xl bg-surface-container-low border border-white/5 text-outline hover:text-white hover:border-primary/20 transition-all opacity-0 group-hover:opacity-100 scale-90 group-hover:scale-100">
|
||||
<span class="material-symbols-outlined text-xl">open_in_new</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-if="guiones.length === 0 && !cargando">
|
||||
<td colspan="4" class="py-24 text-center">
|
||||
<div class="flex flex-col items-center gap-2 opacity-30">
|
||||
<span class="material-symbols-outlined text-5xl">inventory_2</span>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">Repository Empty</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Algorithm Pick -->
|
||||
<div class="lg:col-span-12 xl:col-span-4 flex flex-col gap-8">
|
||||
<div class="bg-surface-container p-8 rounded-[40px] border border-secondary/20 shadow-3xl relative overflow-hidden flex-1 flex flex-col gap-8 group">
|
||||
<div class="absolute -top-12 -right-12 w-48 h-48 bg-secondary/5 blur-3xl rounded-full group-hover:bg-secondary/10 transition-colors"></div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 text-secondary font-black text-[10px] uppercase tracking-[0.2em] mb-3">
|
||||
<span class="material-symbols-outlined text-sm animate-pulse">new_releases</span> Editors Pick
|
||||
</div>
|
||||
<h3 class="text-3xl font-headline font-black text-white tracking-widest leading-none mb-2">MASTER SCRIPT</h3>
|
||||
<p class="text-xs text-outline font-bold">Top Performing Neuromarketing Pattern</p>
|
||||
</div>
|
||||
|
||||
<div v-if="guionTop" class="space-y-6 flex-1 flex flex-col">
|
||||
<div class="p-4 rounded-2xl bg-surface-container-low border border-white/5 flex flex-col gap-2">
|
||||
<p class="text-[10px] text-secondary font-bold uppercase tracking-widest">{{ guionTop.niche }} • {{ guionTop.sub_niche }}</p>
|
||||
<p class="text-lg font-black text-white leading-tight font-headline">{{ guionTop.tema_principal }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-4">
|
||||
<div>
|
||||
<p class="text-[10px] text-outline font-black uppercase tracking-widest mb-2">Narrative Core</p>
|
||||
<p class="text-sm text-on-surface-variant leading-relaxed line-clamp-4">{{ guionTop.resumen_patron }}</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 rounded-xl bg-surface-container-low border border-white/5">
|
||||
<p class="text-[10px] text-outline font-bold uppercase mb-1">Cialdini</p>
|
||||
<p class="text-xl font-black text-white">{{ guionTop.score_cialdini }}/7</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-surface-container-low border border-white/5">
|
||||
<p class="text-[10px] text-outline font-bold uppercase mb-1">Intensity</p>
|
||||
<p class="text-xl font-black text-white">{{ guionTop.score_virabilidad }}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="verDetalle(guionTop.id)" class="w-full py-4 bg-white text-surface rounded-2xl font-black text-sm uppercase tracking-widest hover:bg-secondary hover:text-white transition-all transform active:scale-95 shadow-xl shadow-white/5">Analyze Vector</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center flex-1 italic text-outline text-xs opacity-50">
|
||||
No top patterns analyzed yet.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api.js'
|
||||
|
||||
const router = useRouter()
|
||||
const guiones = ref([])
|
||||
const stats = ref([])
|
||||
const cargando = ref(true)
|
||||
|
||||
const filtros = ref({
|
||||
page: 1,
|
||||
limit: 20
|
||||
})
|
||||
|
||||
const guionTop = computed(() => {
|
||||
if (guiones.value.length === 0) return null
|
||||
return [...guiones.value].sort((a,b) => (b.score_virabilidad || 0) - (a.score_virabilidad || 0))[0]
|
||||
})
|
||||
|
||||
async function cargarDatos() {
|
||||
cargando.value = true
|
||||
try {
|
||||
const [dg, ds] = await Promise.all([
|
||||
api.guiones.listar(filtros.value),
|
||||
api.stats()
|
||||
])
|
||||
guiones.value = dg.guiones
|
||||
stats.value = ds
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cambiarPagina(p) {
|
||||
if (p < 1) return
|
||||
filtros.value.page = p
|
||||
cargarDatos()
|
||||
}
|
||||
|
||||
function verDetalle(id) {
|
||||
router.push({ name: 'AnalysisDetail', params: { id } })
|
||||
}
|
||||
|
||||
function plataformaBadge(p) {
|
||||
const map = {
|
||||
tiktok: 'bg-red-500/10 text-red-500 border border-red-500/20',
|
||||
reels: 'bg-fuchsia-500/10 text-fuchsia-500 border border-fuchsia-500/20',
|
||||
shorts: 'bg-red-600/10 text-red-600 border border-red-600/20'
|
||||
}
|
||||
return map[p] || 'bg-white/5 text-outline'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
cargarDatos()
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user