Spaces:
Running
Running
| <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 ; } | |
| /* ββ 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 ; } | |
| /* ββ 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> | |