ValorSentimental / chatbot /src /components /MessageFeed.vue
iagofp's picture
Fachada DAO, Preparacion Render
f2ee01f
<template>
<v-col cols="12">
<v-card class="chat-card" rounded="xl" elevation="0">
<v-card-text ref="feedEl" class="feed-scroll">
<!-- Empty state -->
<div v-if="messages.length === 0 && !loading" class="empty-state">
<div class="empty-icon-wrap">
<v-icon size="38" color="primary">mdi-movie-open-star-outline</v-icon>
</div>
<h3 class="empty-title">Tu mood, tu pelΓ­cula</h3>
<p class="empty-subtitle">CuΓ©ntame cΓ³mo te sientes y encontrarΓ© la pelΓ­cula perfecta para ti.</p>
<div class="example-chips">
<v-chip
v-for="ex in examples"
:key="ex"
variant="tonal"
color="primary"
size="small"
class="example-chip"
@click="$emit('quick-fill', ex)"
>
{{ ex }}
</v-chip>
</div>
</div>
<!-- Messages -->
<template v-for="(msg, i) in messages" :key="i">
<!-- User bubble -->
<div v-if="msg.type === 'user'" class="user-row">
<div class="user-bubble">{{ msg.text }}</div>
</div>
<!-- Result card -->
<div
v-else-if="msg.type === 'result'"
class="result-card mb-4"
:style="{ '--emotion-color': dominantEmotionInfo(msg.emotions).color }"
>
<!-- Bot bubble -->
<div v-if="msg.chatbotText" class="bot-message">
<div class="bot-avatar">
<v-icon size="15" color="primary">mdi-robot-outline</v-icon>
</div>
<div class="bot-bubble">{{ msg.chatbotText }}</div>
</div>
<!-- Emotion analysis -->
<div class="emotion-section">
<div class="emotion-header">
<div class="emotion-badge">
<span class="emotion-emoji">{{ dominantEmotionInfo(msg.emotions).emoji }}</span>
<div class="emotion-info">
<div class="emotion-name">{{ dominantEmotionInfo(msg.emotions).label }}</div>
<div class="emotion-score">
{{ (dominantEmotionInfo(msg.emotions).score * 100).toFixed(0) }}% Β·
valencia {{ msg.dominantValence }}
</div>
</div>
</div>
<v-chip size="x-small" variant="tonal" color="primary" class="strategy-chip">
{{ strategyLabel(msg.estrategia) }}
</v-chip>
</div>
<div class="bars">
<div
v-for="e in msg.emotions.filter(em => em.score > 0.02)"
:key="e.label"
class="bar-row"
>
<span class="bar-label">{{ emotionInfo(e.label).emoji }} {{ emotionInfo(e.label).label }}</span>
<v-progress-linear
:model-value="e.score * 100"
:color="emotionInfo(e.label).color"
:bg-color="barBgColor"
rounded
height="7"
class="bar-progress"
/>
<span class="bar-pct">{{ (e.score * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
<!-- Transition -->
<div v-if="msg.previousEmotion" class="info-row transition-row">
<v-icon size="13" color="cyan-darken-1" class="mr-1">mdi-transit-connection-horizontal</v-icon>
<span>
TransiciΓ³n detectada:
<strong>{{ msg.previousEmotion }}</strong> β†’
<strong>{{ msg.dominantEmotion }}</strong>
<template v-if="msg.transitionMovie?.title">
Β· asociada a: {{ msg.transitionMovie.title }}
</template>
</span>
</div>
<!-- Recommendations -->
<div class="reco-section">
<div class="reco-header">
<v-icon size="14" color="amber-darken-1" class="mr-1">mdi-filmstrip</v-icon>
<span class="reco-title">
Recomendaciones para <strong>{{ msg.dominantEmotion }}</strong>
</span>
<v-chip
size="x-small"
variant="tonal"
:color="msg.recommendationMode === 'diferente' ? 'teal-darken-1' : 'indigo'"
class="ml-auto"
>
{{ msg.recommendationMode === 'diferente' ? 'Novedad' : 'Afinidad' }}
</v-chip>
</div>
<v-alert v-if="msg.recommendations.length === 0" type="warning" variant="tonal" density="comfortable" class="mt-2" rounded="lg">
No hay recomendaciones disponibles.
</v-alert>
<div v-else class="poster-grid">
<div
v-for="movie in msg.recommendations.slice(0, 6)"
:key="movie.movieId"
class="poster-card"
:class="{ 'poster-card--viewed': viewedMovieIds.has(String(movie.movieId)) }"
>
<!-- Poster thumbnail -->
<div class="poster-thumb" :style="posters[String(movie.imdb_id)] ? {} : posterStyle(movie.title)">
<img
v-if="posters[String(movie.imdb_id)]"
:src="posters[String(movie.imdb_id)]"
:alt="movie.title"
class="poster-real-img"
loading="lazy"
/>
<template v-else>
<v-icon size="32" color="white" style="opacity: 0.55;">mdi-filmstrip</v-icon>
<div class="poster-initials">{{ movieInitials(movie.title) }}</div>
</template>
<div v-if="viewedMovieIds.has(String(movie.movieId))" class="poster-seen-badge">
<v-icon size="14" color="white">mdi-check</v-icon>
</div>
</div>
<!-- Poster info -->
<div class="poster-body">
<div class="poster-title" :title="movie.title">{{ movie.title }}</div>
<div class="poster-genres">{{ firstGenre(movie.genres) }}</div>
<v-btn
size="x-small"
:variant="viewedMovieIds.has(String(movie.movieId)) ? 'tonal' : 'flat'"
:color="viewedMovieIds.has(String(movie.movieId)) ? 'success' : 'amber-darken-1'"
:disabled="viewedMovieIds.has(String(movie.movieId))"
rounded="lg"
class="poster-btn mt-1"
block
@click="$emit('mark-viewed', movie, msg.dominantEmotion, msg.text, msg.recommendationCycleId)"
>
<v-icon v-if="viewedMovieIds.has(String(movie.movieId))" size="11" class="mr-1">mdi-check</v-icon>
{{ viewedMovieIds.has(String(movie.movieId)) ? 'Vista' : 'Marcar vista' }}
</v-btn>
</div>
</div>
</div>
</div>
<!-- Followup -->
<div
v-if="msg.recommendationCycleId && followupByCycle[String(msg.recommendationCycleId)]"
class="info-row followup-row"
>
<v-icon size="13" color="success" class="mr-1">mdi-check-circle-outline</v-icon>
<span>
Seguimiento:
<strong>{{ followupByCycle[String(msg.recommendationCycleId)].pre_emotion }}</strong>
({{ followupByCycle[String(msg.recommendationCycleId)].pre_valence }})
β†’
<strong>{{ followupByCycle[String(msg.recommendationCycleId)].post_emotion }}</strong>
({{ followupByCycle[String(msg.recommendationCycleId)].post_valence }})
Β·
{{ followupByCycle[String(msg.recommendationCycleId)].cambio_emocional ? 'βœ“ cambio emocional' : 'sin cambio' }}
</span>
</div>
</div>
<!-- Error -->
<v-alert v-else-if="msg.type === 'error'" type="error" variant="tonal" class="mb-3" rounded="lg">
{{ msg.text }}
</v-alert>
</template>
<!-- Loading -->
<div v-if="loading" class="loading-row">
<v-progress-circular indeterminate size="16" width="2" color="primary" />
<span>Analizando emociones...</span>
</div>
</v-card-text>
</v-card>
<MessageComposer
:input="input"
:loading="loading"
:estrategia="estrategia"
@update:input="$emit('update:input', $event)"
@update:estrategia="$emit('update:estrategia', $event)"
@analyze="$emit('analyze')"
/>
</v-col>
</template>
<script setup>
import { computed, nextTick, reactive, ref, watch } from "vue";
import { useTheme } from "vuetify";
import { dominantEmotionInfo, emotionInfo } from "../constants/emotions";
import MessageComposer from "./MessageComposer.vue";
import API_BASE_URL from "../config.js";
const props = defineProps({
messages: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
input: { type: String, default: "" },
estrategia: { type: String, default: "v1" },
viewedMovieIds: { type: Object, required: true },
followupByCycle: { type: Object, required: true },
});
defineEmits(["mark-viewed", "update:input", "update:estrategia", "analyze", "quick-fill"]);
const theme = useTheme();
const feedEl = ref(null);
const posters = reactive({}); // { [tmdb_id]: url | null }
async function loadPoster(imdbId) {
const key = String(imdbId);
if (!imdbId || key in posters) return;
posters[key] = null;
try {
const res = await fetch(`${API_BASE_URL}/poster/${key}`);
const data = await res.json();
posters[key] = data.poster_url || null;
} catch { /* keep null */ }
}
watch(
() => props.messages,
(msgs) => {
for (const msg of msgs) {
if (msg.type === "result") {
for (const movie of msg.recommendations ?? []) {
if (movie.imdb_id) loadPoster(movie.imdb_id);
}
}
}
},
{ immediate: true, deep: true },
);
const barBgColor = computed(() =>
theme.global.current.value.dark ? "rgba(255,255,255,0.07)" : "rgba(0,0,0,0.06)"
);
const examples = [
"Hoy me siento ansioso y con ganas de desconectar",
"Estoy muy feliz y quiero descubrir algo nuevo",
"Me siento triste y necesito algo reconfortante",
];
const strategyMap = { v1: "BΓ‘sica", v2: "Avanzada", v3: "Experimental" };
function strategyLabel(key) { return strategyMap[key] || key; }
function firstGenre(genres) {
if (!genres) return "";
return genres.split("|")[0];
}
function movieInitials(title) {
if (!title) return "?";
return title
.replace(/\s*\(\d{4}\)\s*$/, "")
.split(/\s+/)
.filter(w => w.length > 2)
.slice(0, 2)
.map(w => w[0].toUpperCase())
.join("") || title[0].toUpperCase();
}
const POSTER_GRADIENTS = [
["#667eea", "#764ba2"],
["#f59e0b", "#ef4444"],
["#10b981", "#0d9488"],
["#6366f1", "#8b5cf6"],
["#ec4899", "#a855f7"],
["#14b8a6", "#3b82f6"],
["#f97316", "#dc2626"],
["#3b82f6", "#6366f1"],
["#84cc16", "#059669"],
["#e11d48", "#9333ea"],
];
function posterStyle(title) {
let hash = 0;
for (let i = 0; i < (title?.length ?? 0); i++) {
hash = (hash * 31 + title.charCodeAt(i)) | 0;
}
const [c1, c2] = POSTER_GRADIENTS[Math.abs(hash) % POSTER_GRADIENTS.length];
return {
background: `linear-gradient(145deg, ${c1}, ${c2})`,
};
}
async function scrollToBottom() {
await nextTick();
const el = feedEl.value?.$el;
if (el) el.scrollTop = el.scrollHeight;
}
watch(() => props.messages.length, scrollToBottom);
watch(() => props.loading, val => { if (val) scrollToBottom(); });
</script>
<style scoped>
/* ── Card ─────────────────────────────────────── */
.chat-card {
border: 1.5px solid var(--vs-border);
background: var(--vs-panel);
backdrop-filter: blur(var(--vs-glass-blur)) saturate(120%);
-webkit-backdrop-filter: blur(var(--vs-glass-blur)) saturate(120%);
box-shadow: var(--vs-card-shadow);
}
.feed-scroll {
height: calc(100vh - 360px);
min-height: 320px;
overflow-y: auto;
padding: 20px;
scroll-behavior: smooth;
}
.feed-scroll::-webkit-scrollbar { width: 6px; }
.feed-scroll::-webkit-scrollbar-track { background: transparent; }
.feed-scroll::-webkit-scrollbar-thumb {
background: var(--vs-scrollbar);
border-radius: 999px;
}
.feed-scroll::-webkit-scrollbar-thumb:hover { background: var(--vs-scrollbar-hover); }
/* ── Empty state ─────────────────────────────── */
.empty-state {
min-height: 260px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
padding: 20px;
}
.empty-icon-wrap {
width: 72px;
height: 72px;
border-radius: 20px;
background: var(--vs-empty-icon-bg);
border: 1px solid var(--vs-empty-icon-border);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
}
.empty-title {
margin: 0;
font-family: "Fraunces", serif;
font-size: 1.3rem;
color: var(--vs-text);
font-weight: 600;
}
.empty-subtitle {
margin: 0;
color: var(--vs-muted);
font-size: 0.88rem;
max-width: 38ch;
line-height: 1.5;
}
.example-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 4px;
}
.example-chip { cursor: pointer; font-size: 0.78rem !important; }
/* ── User bubble ─────────────────────────────── */
.user-row { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.user-bubble {
max-width: 78%;
padding: 11px 16px;
background: linear-gradient(135deg, #f59e0b, #f97316);
color: #1a0a00;
font-weight: 600;
font-size: 0.92rem;
line-height: 1.5;
border-radius: 18px 4px 18px 18px;
box-shadow: 0 4px 14px rgba(245,158,11,0.3);
}
/* ── Result card ─────────────────────────────── */
.result-card {
border: 1px solid var(--vs-result-border);
border-left: 3px solid var(--emotion-color, #667eea);
background: var(--vs-result-bg);
border-radius: 14px;
padding: 16px;
margin-bottom: 16px;
backdrop-filter: blur(12px);
}
/* ── Bot bubble ──────────────────────────────── */
.bot-message {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.bot-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--vs-bot-bg);
border: 1px solid var(--vs-bot-border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.bot-bubble {
background: var(--vs-bot-bg);
border: 1px solid var(--vs-bot-border);
border-radius: 4px 16px 16px 16px;
padding: 11px 15px;
color: var(--vs-text);
line-height: 1.6;
font-size: 0.92rem;
max-width: 90%;
}
/* ── Emotion section ─────────────────────────── */
.emotion-section {
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--vs-border);
}
.emotion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 8px;
}
.emotion-badge {
display: flex;
align-items: center;
gap: 10px;
}
.emotion-emoji { font-size: 1.8rem; line-height: 1; }
.emotion-name {
font-weight: 700;
font-size: 1rem;
color: var(--vs-text);
}
.emotion-score {
font-size: 0.78rem;
color: var(--vs-muted);
margin-top: 1px;
}
/* ── Bars ────────────────────────────────────── */
.bars { display: flex; flex-direction: column; gap: 8px; }
.bar-row {
display: grid;
grid-template-columns: 110px 1fr 38px;
align-items: center;
gap: 10px;
}
.bar-label {
color: var(--vs-muted);
font-size: 0.78rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-pct {
text-align: right;
color: var(--vs-muted);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
}
/* ── Info rows (transition / followup) ────────── */
.info-row {
display: flex;
align-items: flex-start;
gap: 4px;
font-size: 0.78rem;
color: var(--vs-muted);
padding: 7px 10px;
border-radius: 8px;
margin-bottom: 12px;
}
.info-row strong { color: var(--vs-text); }
.transition-row {
background: var(--vs-transition-bg);
border: 1px solid var(--vs-transition-border);
}
.followup-row {
background: var(--vs-followup-bg);
border: 1px solid var(--vs-followup-border);
margin-bottom: 0;
margin-top: 12px;
}
/* ── Recommendations ─────────────────────────── */
.reco-section { margin-top: 4px; }
.reco-header {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 12px;
}
.reco-title {
font-size: 0.82rem;
font-weight: 600;
color: var(--vs-muted);
}
.reco-title strong { color: var(--vs-text); }
/* ── Poster grid ─────────────────────────────── */
.poster-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
}
.poster-card {
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--vs-border);
background: var(--vs-movie-bg);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.poster-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.poster-card--viewed {
opacity: 0.65;
}
.poster-thumb {
position: relative;
width: 100%;
aspect-ratio: 2 / 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
overflow: hidden;
}
.poster-real-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 0;
}
.poster-initials {
color: rgba(255,255,255,0.9);
font-weight: 800;
font-size: 1.4rem;
letter-spacing: 0.04em;
text-shadow: 0 1px 4px rgba(0,0,0,0.4);
font-family: "Fraunces", serif;
}
.poster-seen-badge {
position: absolute;
top: 6px;
right: 6px;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(34,197,94,0.85);
display: flex;
align-items: center;
justify-content: center;
}
.poster-body {
padding: 8px 8px 10px;
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.poster-title {
font-weight: 700;
font-size: 0.78rem;
color: var(--vs-text);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.poster-genres {
font-size: 0.68rem;
color: var(--vs-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.poster-btn { font-size: 0.68rem !important; }
/* ── Loading ─────────────────────────────────── */
.loading-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 4px;
color: var(--vs-muted);
font-size: 0.86rem;
}
@media (max-width: 960px) {
.feed-scroll { height: auto; min-height: 320px; }
.bar-row { grid-template-columns: 90px 1fr 36px; }
.poster-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); }
}
@media (max-width: 600px) {
.bar-row { grid-template-columns: 80px 1fr 32px; }
.poster-grid { grid-template-columns: repeat(3, 1fr); gap: 8px; }
}
</style>