Spaces:
Sleeping
Sleeping
Añadido Login/Register, Preferencias
Browse files- .claude/settings.local.json +13 -0
- backend/app_factory.py +68 -0
- backend/db.py +12 -0
- backend/repositories/auth_repository.py +91 -0
- backend/services/recommender_service.py +9 -0
- chatbot/src/components/AppHero.vue +80 -9
- chatbot/src/router/index.js +15 -3
- chatbot/src/views/AuthView.vue +374 -0
- chatbot/src/views/ChatView.vue +4 -9
- chatbot/src/views/PreferencesView.vue +184 -0
.claude/settings.local.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"permissions": {
|
| 3 |
+
"allow": [
|
| 4 |
+
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\usuario\\\\Desktop\\\\ValorSentimental\" -Recurse -Directory)",
|
| 5 |
+
"Bash(Select-Object -ExpandProperty FullName)",
|
| 6 |
+
"Bash(Sort-Object)",
|
| 7 |
+
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\usuario\\\\Desktop\\\\ValorSentimental\" -Recurse -Force)",
|
| 8 |
+
"Bash(Select-Object -Property FullName, Name)",
|
| 9 |
+
"Bash(ConvertTo-Json)",
|
| 10 |
+
"Bash(Out-String)"
|
| 11 |
+
]
|
| 12 |
+
}
|
| 13 |
+
}
|
backend/app_factory.py
CHANGED
|
@@ -10,6 +10,13 @@ from flask import Flask, jsonify, request
|
|
| 10 |
from flask_cors import CORS
|
| 11 |
|
| 12 |
from db import iniciar_historial_usuario
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from repositories.history_repository import (
|
| 14 |
añadir_evento_emocional,
|
| 15 |
borrar_historial_usuario,
|
|
@@ -39,6 +46,67 @@ def create_app() -> Flask:
|
|
| 39 |
|
| 40 |
analysis_service = AnalysisService(modelo, movies_df, media_rating_global)
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
@app.route("/analizar", methods=["POST"])
|
| 43 |
def analizar():
|
| 44 |
payload = request.json or {}
|
|
|
|
| 10 |
from flask_cors import CORS
|
| 11 |
|
| 12 |
from db import iniciar_historial_usuario
|
| 13 |
+
from repositories.auth_repository import (
|
| 14 |
+
cambiar_contraseña,
|
| 15 |
+
cerrar_sesion,
|
| 16 |
+
eliminar_cuenta,
|
| 17 |
+
iniciar_sesion,
|
| 18 |
+
registrar_usuario,
|
| 19 |
+
)
|
| 20 |
from repositories.history_repository import (
|
| 21 |
añadir_evento_emocional,
|
| 22 |
borrar_historial_usuario,
|
|
|
|
| 46 |
|
| 47 |
analysis_service = AnalysisService(modelo, movies_df, media_rating_global)
|
| 48 |
|
| 49 |
+
@app.route("/auth/register", methods=["POST"])
|
| 50 |
+
def register():
|
| 51 |
+
payload = request.json or {}
|
| 52 |
+
username = str(payload.get("username", "")).strip()
|
| 53 |
+
password = str(payload.get("password", "")).strip()
|
| 54 |
+
email = str(payload.get("email", "")).strip()
|
| 55 |
+
if not username or not password:
|
| 56 |
+
return jsonify({"error": "username y password son obligatorios"}), 400
|
| 57 |
+
if len(username) < 3:
|
| 58 |
+
return jsonify({"error": "El usuario debe tener al menos 3 caracteres"}), 400
|
| 59 |
+
if len(password) < 6:
|
| 60 |
+
return jsonify({"error": "La contraseña debe tener al menos 6 caracteres"}), 400
|
| 61 |
+
result = registrar_usuario(username, password, email)
|
| 62 |
+
if not result:
|
| 63 |
+
return jsonify({"error": "El nombre de usuario ya existe"}), 409
|
| 64 |
+
return jsonify(result), 201
|
| 65 |
+
|
| 66 |
+
@app.route("/auth/login", methods=["POST"])
|
| 67 |
+
def login():
|
| 68 |
+
payload = request.json or {}
|
| 69 |
+
username = str(payload.get("username", "")).strip()
|
| 70 |
+
password = str(payload.get("password", "")).strip()
|
| 71 |
+
if not username or not password:
|
| 72 |
+
return jsonify({"error": "username y password son obligatorios"}), 400
|
| 73 |
+
result = iniciar_sesion(username, password)
|
| 74 |
+
if not result:
|
| 75 |
+
return jsonify({"error": "Credenciales incorrectas"}), 401
|
| 76 |
+
return jsonify(result)
|
| 77 |
+
|
| 78 |
+
@app.route("/auth/logout", methods=["POST"])
|
| 79 |
+
def logout():
|
| 80 |
+
payload = request.json or {}
|
| 81 |
+
token = str(payload.get("token", "")).strip()
|
| 82 |
+
cerrar_sesion(token)
|
| 83 |
+
return jsonify({"ok": True})
|
| 84 |
+
|
| 85 |
+
@app.route("/auth/password", methods=["POST"])
|
| 86 |
+
def change_password():
|
| 87 |
+
payload = request.json or {}
|
| 88 |
+
token = str(payload.get("token", "")).strip()
|
| 89 |
+
old_password = str(payload.get("old_password", "")).strip()
|
| 90 |
+
new_password = str(payload.get("new_password", "")).strip()
|
| 91 |
+
if not token or not old_password or not new_password:
|
| 92 |
+
return jsonify({"error": "token, old_password y new_password son obligatorios"}), 400
|
| 93 |
+
if len(new_password) < 6:
|
| 94 |
+
return jsonify({"error": "La nueva contraseña debe tener al menos 6 caracteres"}), 400
|
| 95 |
+
if not cambiar_contraseña(token, old_password, new_password):
|
| 96 |
+
return jsonify({"error": "Contraseña actual incorrecta o sesión inválida"}), 401
|
| 97 |
+
return jsonify({"ok": True})
|
| 98 |
+
|
| 99 |
+
@app.route("/auth/account", methods=["DELETE"])
|
| 100 |
+
def delete_account():
|
| 101 |
+
payload = request.json or {}
|
| 102 |
+
token = str(payload.get("token", "")).strip()
|
| 103 |
+
if not token:
|
| 104 |
+
return jsonify({"error": "token es obligatorio"}), 400
|
| 105 |
+
result = eliminar_cuenta(token)
|
| 106 |
+
if not result:
|
| 107 |
+
return jsonify({"error": "Token inválido o cuenta no encontrada"}), 401
|
| 108 |
+
return jsonify({"ok": True, "deleted_user": result["username"]})
|
| 109 |
+
|
| 110 |
@app.route("/analizar", methods=["POST"])
|
| 111 |
def analizar():
|
| 112 |
payload = request.json or {}
|
backend/db.py
CHANGED
|
@@ -97,4 +97,16 @@ def iniciar_historial_usuario() -> None:
|
|
| 97 |
ON ciclos_recomendaciones (user_id, created_at DESC)
|
| 98 |
"""
|
| 99 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
conn.commit()
|
|
|
|
| 97 |
ON ciclos_recomendaciones (user_id, created_at DESC)
|
| 98 |
"""
|
| 99 |
)
|
| 100 |
+
conn.execute(
|
| 101 |
+
"""
|
| 102 |
+
CREATE TABLE IF NOT EXISTS usuarios (
|
| 103 |
+
id TEXT PRIMARY KEY,
|
| 104 |
+
username TEXT UNIQUE NOT NULL,
|
| 105 |
+
email TEXT,
|
| 106 |
+
password_hash TEXT NOT NULL,
|
| 107 |
+
session_token TEXT,
|
| 108 |
+
created_at TEXT NOT NULL
|
| 109 |
+
)
|
| 110 |
+
"""
|
| 111 |
+
)
|
| 112 |
conn.commit()
|
backend/repositories/auth_repository.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
|
| 4 |
+
from werkzeug.security import check_password_hash, generate_password_hash
|
| 5 |
+
|
| 6 |
+
from db import obtener_conexion_bd
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def registrar_usuario(username: str, password: str, email: str = "") -> dict | None:
|
| 10 |
+
user_id = str(uuid.uuid4())
|
| 11 |
+
created_at = datetime.now(timezone.utc).isoformat()
|
| 12 |
+
password_hash = generate_password_hash(password)
|
| 13 |
+
token = str(uuid.uuid4())
|
| 14 |
+
try:
|
| 15 |
+
with obtener_conexion_bd() as conn:
|
| 16 |
+
conn.execute(
|
| 17 |
+
"""INSERT INTO usuarios (id, username, email, password_hash, session_token, created_at)
|
| 18 |
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
| 19 |
+
(user_id, username, email, password_hash, token, created_at),
|
| 20 |
+
)
|
| 21 |
+
conn.commit()
|
| 22 |
+
return {"user_id": user_id, "username": username, "token": token}
|
| 23 |
+
except Exception:
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def iniciar_sesion(username: str, password: str) -> dict | None:
|
| 28 |
+
with obtener_conexion_bd() as conn:
|
| 29 |
+
row = conn.execute(
|
| 30 |
+
"SELECT id, username, password_hash FROM usuarios WHERE username = ?",
|
| 31 |
+
(username,),
|
| 32 |
+
).fetchone()
|
| 33 |
+
if not row or not check_password_hash(row["password_hash"], password):
|
| 34 |
+
return None
|
| 35 |
+
token = str(uuid.uuid4())
|
| 36 |
+
with obtener_conexion_bd() as conn:
|
| 37 |
+
conn.execute("UPDATE usuarios SET session_token = ? WHERE id = ?", (token, row["id"]))
|
| 38 |
+
conn.commit()
|
| 39 |
+
return {"user_id": row["id"], "username": row["username"], "token": token}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def obtener_usuario_por_token(token: str) -> dict | None:
|
| 43 |
+
if not token:
|
| 44 |
+
return None
|
| 45 |
+
with obtener_conexion_bd() as conn:
|
| 46 |
+
row = conn.execute(
|
| 47 |
+
"SELECT id, username FROM usuarios WHERE session_token = ?",
|
| 48 |
+
(token,),
|
| 49 |
+
).fetchone()
|
| 50 |
+
return dict(row) if row else None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def cerrar_sesion(token: str) -> None:
|
| 54 |
+
if not token:
|
| 55 |
+
return
|
| 56 |
+
with obtener_conexion_bd() as conn:
|
| 57 |
+
conn.execute("UPDATE usuarios SET session_token = NULL WHERE session_token = ?", (token,))
|
| 58 |
+
conn.commit()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def cambiar_contraseña(token: str, old_password: str, new_password: str) -> bool:
|
| 62 |
+
user = obtener_usuario_por_token(token)
|
| 63 |
+
if not user:
|
| 64 |
+
return False
|
| 65 |
+
with obtener_conexion_bd() as conn:
|
| 66 |
+
row = conn.execute(
|
| 67 |
+
"SELECT password_hash FROM usuarios WHERE id = ?", (user["id"],)
|
| 68 |
+
).fetchone()
|
| 69 |
+
if not row or not check_password_hash(row["password_hash"], old_password):
|
| 70 |
+
return False
|
| 71 |
+
with obtener_conexion_bd() as conn:
|
| 72 |
+
conn.execute(
|
| 73 |
+
"UPDATE usuarios SET password_hash = ? WHERE id = ?",
|
| 74 |
+
(generate_password_hash(new_password), user["id"]),
|
| 75 |
+
)
|
| 76 |
+
conn.commit()
|
| 77 |
+
return True
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def eliminar_cuenta(token: str) -> dict | None:
|
| 81 |
+
user = obtener_usuario_por_token(token)
|
| 82 |
+
if not user:
|
| 83 |
+
return None
|
| 84 |
+
user_id = user["id"]
|
| 85 |
+
with obtener_conexion_bd() as conn:
|
| 86 |
+
conn.execute("DELETE FROM historial_peliculas WHERE user_id = ?", (user_id,))
|
| 87 |
+
conn.execute("DELETE FROM eventos_emociones WHERE user_id = ?", (user_id,))
|
| 88 |
+
conn.execute("DELETE FROM ciclos_recomendaciones WHERE user_id = ?", (user_id,))
|
| 89 |
+
conn.execute("DELETE FROM usuarios WHERE id = ?", (user_id,))
|
| 90 |
+
conn.commit()
|
| 91 |
+
return user
|
backend/services/recommender_service.py
CHANGED
|
@@ -68,6 +68,15 @@ def cargar_dataset_movies() -> tuple[list[dict], float]:
|
|
| 68 |
|
| 69 |
return rows, global_mean
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return [], global_mean
|
| 72 |
|
| 73 |
|
|
|
|
| 68 |
|
| 69 |
return rows, global_mean
|
| 70 |
|
| 71 |
+
fallback_path = ROOT_DIR / "data" / "procesado" / "peliculas_100_emociones.csv"
|
| 72 |
+
if fallback_path.exists():
|
| 73 |
+
with open(fallback_path, "r", encoding="utf-8", newline="") as f:
|
| 74 |
+
rows = list(csv.DictReader(f))
|
| 75 |
+
total_w = sum(float(r.get("rating_mean", 0) or 0) * int(r.get("rating_count", 0) or 0) for r in rows)
|
| 76 |
+
total_n = sum(int(r.get("rating_count", 0) or 0) for r in rows)
|
| 77 |
+
fallback_mean = (total_w / total_n) if total_n else 3.5
|
| 78 |
+
return rows, fallback_mean
|
| 79 |
+
|
| 80 |
return [], global_mean
|
| 81 |
|
| 82 |
|
chatbot/src/components/AppHero.vue
CHANGED
|
@@ -12,10 +12,29 @@
|
|
| 12 |
</div>
|
| 13 |
|
| 14 |
<div class="hero-controls">
|
| 15 |
-
<v-
|
| 16 |
-
<
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
<v-btn
|
| 20 |
icon
|
| 21 |
size="small"
|
|
@@ -28,6 +47,26 @@
|
|
| 28 |
</div>
|
| 29 |
</div>
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
<p class="hero-subtitle">
|
| 32 |
Cuéntame cómo te sientes y te recomendaré la película perfecta para tu estado de ánimo.
|
| 33 |
</p>
|
|
@@ -35,20 +74,52 @@
|
|
| 35 |
</template>
|
| 36 |
|
| 37 |
<script setup>
|
| 38 |
-
import { computed } from "vue";
|
| 39 |
import { useRouter } from "vue-router";
|
| 40 |
import { useTheme } from "vuetify";
|
| 41 |
|
| 42 |
defineProps({
|
| 43 |
-
|
| 44 |
});
|
| 45 |
|
| 46 |
-
const router
|
| 47 |
-
const theme
|
| 48 |
-
const isDark
|
|
|
|
|
|
|
|
|
|
| 49 |
function toggleTheme() {
|
| 50 |
theme.global.name.value = isDark.value ? "vsLight" : "vsDark";
|
| 51 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
</script>
|
| 53 |
|
| 54 |
<style scoped>
|
|
|
|
| 12 |
</div>
|
| 13 |
|
| 14 |
<div class="hero-controls">
|
| 15 |
+
<v-menu :close-on-content-click="true">
|
| 16 |
+
<template #activator="{ props: menuProps }">
|
| 17 |
+
<v-chip v-bind="menuProps" size="small" class="user-chip" style="cursor:pointer">
|
| 18 |
+
<v-icon start size="13">mdi-account-circle-outline</v-icon>
|
| 19 |
+
{{ username || 'Usuario' }}
|
| 20 |
+
<v-icon end size="13">mdi-chevron-down</v-icon>
|
| 21 |
+
</v-chip>
|
| 22 |
+
</template>
|
| 23 |
+
<v-list density="compact" rounded="lg" min-width="180">
|
| 24 |
+
<v-list-item prepend-icon="mdi-cog-outline" @click="router.push('/preferences')">
|
| 25 |
+
<v-list-item-title>Preferencias</v-list-item-title>
|
| 26 |
+
</v-list-item>
|
| 27 |
+
<v-divider class="my-1" />
|
| 28 |
+
<v-list-item prepend-icon="mdi-logout" @click="logout">
|
| 29 |
+
<v-list-item-title>Cerrar sesión</v-list-item-title>
|
| 30 |
+
</v-list-item>
|
| 31 |
+
<v-divider class="my-1" />
|
| 32 |
+
<v-list-item prepend-icon="mdi-account-remove-outline" @click="deleteDialog = true">
|
| 33 |
+
<v-list-item-title class="text-error">Eliminar cuenta</v-list-item-title>
|
| 34 |
+
</v-list-item>
|
| 35 |
+
</v-list>
|
| 36 |
+
</v-menu>
|
| 37 |
+
|
| 38 |
<v-btn
|
| 39 |
icon
|
| 40 |
size="small"
|
|
|
|
| 47 |
</div>
|
| 48 |
</div>
|
| 49 |
|
| 50 |
+
<!-- Delete account confirmation dialog -->
|
| 51 |
+
<v-dialog v-model="deleteDialog" max-width="380">
|
| 52 |
+
<v-card rounded="xl" elevation="24" style="background: var(--vs-panel); border: 1.5px solid var(--vs-border);">
|
| 53 |
+
<v-card-title class="pa-5 pb-3" style="font-family:'Fraunces',serif; font-size:1.1rem;">
|
| 54 |
+
<v-icon color="error" size="20" class="mr-2">mdi-account-remove-outline</v-icon>
|
| 55 |
+
Eliminar cuenta
|
| 56 |
+
</v-card-title>
|
| 57 |
+
<v-card-text class="px-5" style="color: var(--vs-muted); font-size:0.9rem; line-height:1.6;">
|
| 58 |
+
Se eliminarán tu cuenta y todos tus datos permanentemente. Esta acción no se puede deshacer.
|
| 59 |
+
</v-card-text>
|
| 60 |
+
<v-card-actions class="px-5 pb-4 pt-2">
|
| 61 |
+
<v-spacer />
|
| 62 |
+
<v-btn variant="text" color="blue-grey" @click="deleteDialog = false">Cancelar</v-btn>
|
| 63 |
+
<v-btn color="error" variant="flat" rounded="lg" :loading="deleteLoading" @click="confirmDelete">
|
| 64 |
+
Eliminar todo
|
| 65 |
+
</v-btn>
|
| 66 |
+
</v-card-actions>
|
| 67 |
+
</v-card>
|
| 68 |
+
</v-dialog>
|
| 69 |
+
|
| 70 |
<p class="hero-subtitle">
|
| 71 |
Cuéntame cómo te sientes y te recomendaré la película perfecta para tu estado de ánimo.
|
| 72 |
</p>
|
|
|
|
| 74 |
</template>
|
| 75 |
|
| 76 |
<script setup>
|
| 77 |
+
import { computed, ref } from "vue";
|
| 78 |
import { useRouter } from "vue-router";
|
| 79 |
import { useTheme } from "vuetify";
|
| 80 |
|
| 81 |
defineProps({
|
| 82 |
+
username: { type: String, default: "" },
|
| 83 |
});
|
| 84 |
|
| 85 |
+
const router = useRouter();
|
| 86 |
+
const theme = useTheme();
|
| 87 |
+
const isDark = computed(() => theme.global.current.value.dark);
|
| 88 |
+
const deleteDialog = ref(false);
|
| 89 |
+
const deleteLoading = ref(false);
|
| 90 |
+
|
| 91 |
function toggleTheme() {
|
| 92 |
theme.global.name.value = isDark.value ? "vsLight" : "vsDark";
|
| 93 |
}
|
| 94 |
+
|
| 95 |
+
async function logout() {
|
| 96 |
+
const token = localStorage.getItem("vs_token") || "";
|
| 97 |
+
try {
|
| 98 |
+
await fetch("http://localhost:5000/auth/logout", {
|
| 99 |
+
method: "POST",
|
| 100 |
+
headers: { "Content-Type": "application/json" },
|
| 101 |
+
body: JSON.stringify({ token }),
|
| 102 |
+
});
|
| 103 |
+
} catch { /* ignore */ }
|
| 104 |
+
["vs_token", "vs_user_id", "vs_username"].forEach(k => localStorage.removeItem(k));
|
| 105 |
+
router.push("/login");
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async function confirmDelete() {
|
| 109 |
+
deleteLoading.value = true;
|
| 110 |
+
const token = localStorage.getItem("vs_token") || "";
|
| 111 |
+
try {
|
| 112 |
+
await fetch("http://localhost:5000/auth/account", {
|
| 113 |
+
method: "DELETE",
|
| 114 |
+
headers: { "Content-Type": "application/json" },
|
| 115 |
+
body: JSON.stringify({ token }),
|
| 116 |
+
});
|
| 117 |
+
} catch { /* ignore */ }
|
| 118 |
+
["vs_token", "vs_user_id", "vs_username"].forEach(k => localStorage.removeItem(k));
|
| 119 |
+
deleteDialog.value = false;
|
| 120 |
+
deleteLoading.value = false;
|
| 121 |
+
router.push("/login");
|
| 122 |
+
}
|
| 123 |
</script>
|
| 124 |
|
| 125 |
<style scoped>
|
chatbot/src/router/index.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
| 1 |
import { createRouter, createWebHashHistory } from "vue-router";
|
| 2 |
import LandingPage from "../views/LandingPage.vue";
|
| 3 |
import ChatView from "../views/ChatView.vue";
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
history: createWebHashHistory(),
|
| 7 |
routes: [
|
| 8 |
-
{ path: "/",
|
| 9 |
-
{ path: "/
|
|
|
|
|
|
|
| 10 |
],
|
| 11 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { createRouter, createWebHashHistory } from "vue-router";
|
| 2 |
import LandingPage from "../views/LandingPage.vue";
|
| 3 |
import ChatView from "../views/ChatView.vue";
|
| 4 |
+
import AuthView from "../views/AuthView.vue";
|
| 5 |
+
import PreferencesView from "../views/PreferencesView.vue";
|
| 6 |
|
| 7 |
+
const router = createRouter({
|
| 8 |
history: createWebHashHistory(),
|
| 9 |
routes: [
|
| 10 |
+
{ path: "/", component: LandingPage },
|
| 11 |
+
{ path: "/login", component: AuthView },
|
| 12 |
+
{ path: "/chat", component: ChatView, meta: { requiresAuth: true } },
|
| 13 |
+
{ path: "/preferences", component: PreferencesView, meta: { requiresAuth: true } },
|
| 14 |
],
|
| 15 |
});
|
| 16 |
+
|
| 17 |
+
router.beforeEach((to) => {
|
| 18 |
+
const loggedIn = !!localStorage.getItem("vs_token");
|
| 19 |
+
if (to.meta.requiresAuth && !loggedIn) return "/login";
|
| 20 |
+
if (to.path === "/login" && loggedIn) return "/chat";
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
export default router;
|
chatbot/src/views/AuthView.vue
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<v-main class="auth-main">
|
| 3 |
+
<div class="auth-blobs">
|
| 4 |
+
<div class="blob-1" />
|
| 5 |
+
<div class="blob-2" />
|
| 6 |
+
</div>
|
| 7 |
+
|
| 8 |
+
<div class="auth-wrapper">
|
| 9 |
+
<!-- Brand -->
|
| 10 |
+
<div class="auth-brand" role="button" @click="router.push('/')">
|
| 11 |
+
<div class="brand-icon">
|
| 12 |
+
<v-icon color="amber-darken-1" size="22">mdi-movie-open-play-outline</v-icon>
|
| 13 |
+
</div>
|
| 14 |
+
<div>
|
| 15 |
+
<p class="brand-kicker">Recomendador emocional de cine</p>
|
| 16 |
+
<h1 class="brand-title">Valor Sentimental</h1>
|
| 17 |
+
</div>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
<!-- Card -->
|
| 21 |
+
<div class="auth-card-wrap">
|
| 22 |
+
<v-card class="auth-card" rounded="xl" elevation="0">
|
| 23 |
+
<div class="auth-card-header">
|
| 24 |
+
<h2 class="card-title">
|
| 25 |
+
{{ activeTab === 'login' ? 'Bienvenido de vuelta' : 'Crear cuenta' }}
|
| 26 |
+
</h2>
|
| 27 |
+
<p class="card-subtitle">
|
| 28 |
+
{{ activeTab === 'login'
|
| 29 |
+
? 'Inicia sesión para acceder al chat'
|
| 30 |
+
: 'Regístrate para guardar tu historial' }}
|
| 31 |
+
</p>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<v-tabs v-model="activeTab" color="primary" density="compact" class="auth-tabs">
|
| 35 |
+
<v-tab value="login">Iniciar sesión</v-tab>
|
| 36 |
+
<v-tab value="register">Registrarse</v-tab>
|
| 37 |
+
</v-tabs>
|
| 38 |
+
|
| 39 |
+
<v-divider />
|
| 40 |
+
|
| 41 |
+
<div class="form-wrap">
|
| 42 |
+
<!-- Login -->
|
| 43 |
+
<form v-if="activeTab === 'login'" class="auth-form" @submit.prevent="submitLogin">
|
| 44 |
+
<v-text-field
|
| 45 |
+
v-model="loginForm.username"
|
| 46 |
+
label="Nombre de usuario"
|
| 47 |
+
variant="outlined"
|
| 48 |
+
density="comfortable"
|
| 49 |
+
prepend-inner-icon="mdi-account-outline"
|
| 50 |
+
autocomplete="username"
|
| 51 |
+
hide-details="auto"
|
| 52 |
+
class="mb-4"
|
| 53 |
+
/>
|
| 54 |
+
<v-text-field
|
| 55 |
+
v-model="loginForm.password"
|
| 56 |
+
label="Contraseña"
|
| 57 |
+
:type="showLoginPwd ? 'text' : 'password'"
|
| 58 |
+
variant="outlined"
|
| 59 |
+
density="comfortable"
|
| 60 |
+
prepend-inner-icon="mdi-lock-outline"
|
| 61 |
+
:append-inner-icon="showLoginPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
| 62 |
+
autocomplete="current-password"
|
| 63 |
+
hide-details="auto"
|
| 64 |
+
class="mb-4"
|
| 65 |
+
@click:append-inner="showLoginPwd = !showLoginPwd"
|
| 66 |
+
/>
|
| 67 |
+
<v-alert
|
| 68 |
+
v-if="loginError"
|
| 69 |
+
type="error"
|
| 70 |
+
variant="tonal"
|
| 71 |
+
rounded="lg"
|
| 72 |
+
density="compact"
|
| 73 |
+
class="mb-4"
|
| 74 |
+
>
|
| 75 |
+
{{ loginError }}
|
| 76 |
+
</v-alert>
|
| 77 |
+
<v-btn
|
| 78 |
+
type="submit"
|
| 79 |
+
color="primary"
|
| 80 |
+
variant="flat"
|
| 81 |
+
block
|
| 82 |
+
rounded="lg"
|
| 83 |
+
size="large"
|
| 84 |
+
:loading="loginLoading"
|
| 85 |
+
>
|
| 86 |
+
Iniciar sesión
|
| 87 |
+
</v-btn>
|
| 88 |
+
</form>
|
| 89 |
+
|
| 90 |
+
<!-- Register -->
|
| 91 |
+
<form v-else class="auth-form" @submit.prevent="submitRegister">
|
| 92 |
+
<v-text-field
|
| 93 |
+
v-model="registerForm.username"
|
| 94 |
+
label="Nombre de usuario"
|
| 95 |
+
variant="outlined"
|
| 96 |
+
density="comfortable"
|
| 97 |
+
prepend-inner-icon="mdi-account-outline"
|
| 98 |
+
autocomplete="username"
|
| 99 |
+
hide-details="auto"
|
| 100 |
+
class="mb-4"
|
| 101 |
+
/>
|
| 102 |
+
<v-text-field
|
| 103 |
+
v-model="registerForm.email"
|
| 104 |
+
label="Email (opcional)"
|
| 105 |
+
type="email"
|
| 106 |
+
variant="outlined"
|
| 107 |
+
density="comfortable"
|
| 108 |
+
prepend-inner-icon="mdi-email-outline"
|
| 109 |
+
autocomplete="email"
|
| 110 |
+
hide-details="auto"
|
| 111 |
+
class="mb-4"
|
| 112 |
+
/>
|
| 113 |
+
<v-text-field
|
| 114 |
+
v-model="registerForm.password"
|
| 115 |
+
label="Contraseña"
|
| 116 |
+
:type="showRegPwd ? 'text' : 'password'"
|
| 117 |
+
variant="outlined"
|
| 118 |
+
density="comfortable"
|
| 119 |
+
prepend-inner-icon="mdi-lock-outline"
|
| 120 |
+
:append-inner-icon="showRegPwd ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
| 121 |
+
autocomplete="new-password"
|
| 122 |
+
hide-details="auto"
|
| 123 |
+
class="mb-4"
|
| 124 |
+
@click:append-inner="showRegPwd = !showRegPwd"
|
| 125 |
+
/>
|
| 126 |
+
<v-alert
|
| 127 |
+
v-if="registerError"
|
| 128 |
+
type="error"
|
| 129 |
+
variant="tonal"
|
| 130 |
+
rounded="lg"
|
| 131 |
+
density="compact"
|
| 132 |
+
class="mb-4"
|
| 133 |
+
>
|
| 134 |
+
{{ registerError }}
|
| 135 |
+
</v-alert>
|
| 136 |
+
<v-btn
|
| 137 |
+
type="submit"
|
| 138 |
+
color="primary"
|
| 139 |
+
variant="flat"
|
| 140 |
+
block
|
| 141 |
+
rounded="lg"
|
| 142 |
+
size="large"
|
| 143 |
+
:loading="registerLoading"
|
| 144 |
+
>
|
| 145 |
+
Crear cuenta
|
| 146 |
+
</v-btn>
|
| 147 |
+
</form>
|
| 148 |
+
</div>
|
| 149 |
+
</v-card>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</v-main>
|
| 153 |
+
</template>
|
| 154 |
+
|
| 155 |
+
<script setup>
|
| 156 |
+
import { ref } from "vue";
|
| 157 |
+
import { useRouter } from "vue-router";
|
| 158 |
+
|
| 159 |
+
const router = useRouter();
|
| 160 |
+
const activeTab = ref("login");
|
| 161 |
+
|
| 162 |
+
const loginForm = ref({ username: "", password: "" });
|
| 163 |
+
const loginError = ref("");
|
| 164 |
+
const loginLoading = ref(false);
|
| 165 |
+
const showLoginPwd = ref(false);
|
| 166 |
+
|
| 167 |
+
const registerForm = ref({ username: "", email: "", password: "" });
|
| 168 |
+
const registerError = ref("");
|
| 169 |
+
const registerLoading = ref(false);
|
| 170 |
+
const showRegPwd = ref(false);
|
| 171 |
+
|
| 172 |
+
async function submitLogin() {
|
| 173 |
+
loginError.value = "";
|
| 174 |
+
if (!loginForm.value.username || !loginForm.value.password) {
|
| 175 |
+
loginError.value = "Completa todos los campos.";
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
loginLoading.value = true;
|
| 179 |
+
try {
|
| 180 |
+
const res = await fetch("http://localhost:5000/auth/login", {
|
| 181 |
+
method: "POST",
|
| 182 |
+
headers: { "Content-Type": "application/json" },
|
| 183 |
+
body: JSON.stringify({ username: loginForm.value.username, password: loginForm.value.password }),
|
| 184 |
+
});
|
| 185 |
+
const data = await res.json();
|
| 186 |
+
if (!res.ok) { loginError.value = data.error || "Credenciales incorrectas."; return; }
|
| 187 |
+
localStorage.setItem("vs_token", data.token);
|
| 188 |
+
localStorage.setItem("vs_user_id", data.user_id);
|
| 189 |
+
localStorage.setItem("vs_username", data.username);
|
| 190 |
+
router.push("/chat");
|
| 191 |
+
} catch {
|
| 192 |
+
loginError.value = "Error de conexión con el servidor.";
|
| 193 |
+
} finally {
|
| 194 |
+
loginLoading.value = false;
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
async function submitRegister() {
|
| 199 |
+
registerError.value = "";
|
| 200 |
+
if (!registerForm.value.username || !registerForm.value.password) {
|
| 201 |
+
registerError.value = "Usuario y contraseña son obligatorios.";
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
if (registerForm.value.username.length < 3) {
|
| 205 |
+
registerError.value = "El usuario debe tener al menos 3 caracteres.";
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
if (registerForm.value.password.length < 6) {
|
| 209 |
+
registerError.value = "La contraseña debe tener al menos 6 caracteres.";
|
| 210 |
+
return;
|
| 211 |
+
}
|
| 212 |
+
registerLoading.value = true;
|
| 213 |
+
try {
|
| 214 |
+
const res = await fetch("http://localhost:5000/auth/register", {
|
| 215 |
+
method: "POST",
|
| 216 |
+
headers: { "Content-Type": "application/json" },
|
| 217 |
+
body: JSON.stringify({
|
| 218 |
+
username: registerForm.value.username,
|
| 219 |
+
email: registerForm.value.email,
|
| 220 |
+
password: registerForm.value.password,
|
| 221 |
+
}),
|
| 222 |
+
});
|
| 223 |
+
const data = await res.json();
|
| 224 |
+
if (!res.ok) { registerError.value = data.error || "Error al crear la cuenta."; return; }
|
| 225 |
+
localStorage.setItem("vs_token", data.token);
|
| 226 |
+
localStorage.setItem("vs_user_id", data.user_id);
|
| 227 |
+
localStorage.setItem("vs_username", data.username);
|
| 228 |
+
router.push("/chat");
|
| 229 |
+
} catch {
|
| 230 |
+
registerError.value = "Error de conexión con el servidor.";
|
| 231 |
+
} finally {
|
| 232 |
+
registerLoading.value = false;
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
</script>
|
| 236 |
+
|
| 237 |
+
<style scoped>
|
| 238 |
+
.auth-main {
|
| 239 |
+
min-height: 100vh;
|
| 240 |
+
display: flex;
|
| 241 |
+
align-items: center;
|
| 242 |
+
justify-content: center;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.auth-blobs {
|
| 246 |
+
position: fixed;
|
| 247 |
+
inset: 0;
|
| 248 |
+
pointer-events: none;
|
| 249 |
+
overflow: hidden;
|
| 250 |
+
z-index: 0;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.blob-1 {
|
| 254 |
+
position: absolute;
|
| 255 |
+
top: -15%;
|
| 256 |
+
right: -8%;
|
| 257 |
+
width: 520px;
|
| 258 |
+
height: 520px;
|
| 259 |
+
border-radius: 50%;
|
| 260 |
+
background: radial-gradient(circle, rgba(102,126,234,0.11) 0%, transparent 70%);
|
| 261 |
+
animation: liquidFloat 12s ease-in-out infinite;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.blob-2 {
|
| 265 |
+
position: absolute;
|
| 266 |
+
bottom: -18%;
|
| 267 |
+
left: -6%;
|
| 268 |
+
width: 440px;
|
| 269 |
+
height: 440px;
|
| 270 |
+
border-radius: 50%;
|
| 271 |
+
background: radial-gradient(circle, rgba(118,75,162,0.08) 0%, transparent 70%);
|
| 272 |
+
animation: liquidFloat 15s ease-in-out infinite reverse;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
.auth-wrapper {
|
| 276 |
+
position: relative;
|
| 277 |
+
z-index: 1;
|
| 278 |
+
width: 100%;
|
| 279 |
+
max-width: 440px;
|
| 280 |
+
padding: 24px 16px;
|
| 281 |
+
margin: 0 auto;
|
| 282 |
+
display: flex;
|
| 283 |
+
flex-direction: column;
|
| 284 |
+
align-items: center;
|
| 285 |
+
gap: 28px;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
.auth-brand {
|
| 289 |
+
display: flex;
|
| 290 |
+
align-items: center;
|
| 291 |
+
gap: 14px;
|
| 292 |
+
cursor: pointer;
|
| 293 |
+
transition: opacity 0.2s ease;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.auth-brand:hover { opacity: 0.8; }
|
| 297 |
+
|
| 298 |
+
.brand-icon {
|
| 299 |
+
width: 44px;
|
| 300 |
+
height: 44px;
|
| 301 |
+
border-radius: 12px;
|
| 302 |
+
background: rgba(212,175,55,0.1);
|
| 303 |
+
border: 1px solid rgba(212,175,55,0.25);
|
| 304 |
+
display: flex;
|
| 305 |
+
align-items: center;
|
| 306 |
+
justify-content: center;
|
| 307 |
+
flex-shrink: 0;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.brand-kicker {
|
| 311 |
+
margin: 0;
|
| 312 |
+
font-size: 0.65rem;
|
| 313 |
+
font-weight: 700;
|
| 314 |
+
text-transform: uppercase;
|
| 315 |
+
letter-spacing: 0.09em;
|
| 316 |
+
color: var(--vs-gold, #d4af37);
|
| 317 |
+
opacity: 0.9;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.brand-title {
|
| 321 |
+
margin: 3px 0 0;
|
| 322 |
+
font-family: "Fraunces", serif;
|
| 323 |
+
font-size: 1.35rem;
|
| 324 |
+
font-weight: 700;
|
| 325 |
+
line-height: 1.2;
|
| 326 |
+
color: var(--vs-text, #1a1a2e);
|
| 327 |
+
letter-spacing: -0.01em;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.auth-card-wrap { width: 100%; }
|
| 331 |
+
|
| 332 |
+
.auth-card {
|
| 333 |
+
border: 1.5px solid var(--vs-border, rgba(102,126,234,0.15)) !important;
|
| 334 |
+
background: var(--vs-panel, rgba(255,255,255,0.85)) !important;
|
| 335 |
+
backdrop-filter: blur(16px) saturate(150%);
|
| 336 |
+
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
| 337 |
+
box-shadow: 0 8px 40px rgba(102,126,234,0.12), 0 2px 8px rgba(0,0,0,0.06) !important;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.auth-card-header {
|
| 341 |
+
padding: 28px 28px 16px;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.card-title {
|
| 345 |
+
font-family: "Fraunces", serif;
|
| 346 |
+
font-size: 1.35rem;
|
| 347 |
+
font-weight: 700;
|
| 348 |
+
color: var(--vs-text, #1a1a2e);
|
| 349 |
+
margin: 0 0 6px;
|
| 350 |
+
letter-spacing: -0.01em;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.card-subtitle {
|
| 354 |
+
font-size: 0.875rem;
|
| 355 |
+
color: var(--vs-muted, #6b7280);
|
| 356 |
+
margin: 0;
|
| 357 |
+
line-height: 1.5;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.auth-tabs {
|
| 361 |
+
padding: 0 16px;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.form-wrap {
|
| 365 |
+
padding: 24px 28px 28px;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.auth-form { display: flex; flex-direction: column; }
|
| 369 |
+
|
| 370 |
+
@media (max-width: 480px) {
|
| 371 |
+
.auth-card-header { padding: 20px 20px 12px; }
|
| 372 |
+
.form-wrap { padding: 20px 20px 24px; }
|
| 373 |
+
}
|
| 374 |
+
</style>
|
chatbot/src/views/ChatView.vue
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
<template>
|
| 2 |
<v-main class="chat-main">
|
| 3 |
<v-container fluid class="chat-container">
|
| 4 |
-
<AppHero :
|
| 5 |
|
| 6 |
<v-row align="start" class="mt-0">
|
| 7 |
<SidePanel
|
|
@@ -155,6 +155,7 @@ const messages = ref([]);
|
|
| 155 |
const input = ref("");
|
| 156 |
const loading = ref(false);
|
| 157 |
const userId = ref("");
|
|
|
|
| 158 |
const history = ref([]);
|
| 159 |
const estrategia = ref(localStorage.getItem("vs_estrategia") || "v1");
|
| 160 |
const followupByCycle = ref({});
|
|
@@ -167,20 +168,14 @@ const confirmClearDialog = ref(false);
|
|
| 167 |
const snackbar = ref({ show: false, text: "", color: "success" });
|
| 168 |
|
| 169 |
const viewedMovieIds = computed(() => new Set(history.value.map(i => String(i.movie_id))));
|
| 170 |
-
const userIdLabel = computed(() => userId.value ? userId.value.slice(0, 8) : "anon");
|
| 171 |
|
| 172 |
function showSnack(text, color = "success") {
|
| 173 |
snackbar.value = { show: true, text, color };
|
| 174 |
}
|
| 175 |
|
| 176 |
onMounted(() => {
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
const generated = typeof crypto !== "undefined" && crypto.randomUUID
|
| 180 |
-
? crypto.randomUUID()
|
| 181 |
-
: `anon_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
| 182 |
-
localStorage.setItem("vs_user_id", generated);
|
| 183 |
-
userId.value = generated;
|
| 184 |
});
|
| 185 |
|
| 186 |
watch(estrategia, v => localStorage.setItem("vs_estrategia", v));
|
|
|
|
| 1 |
<template>
|
| 2 |
<v-main class="chat-main">
|
| 3 |
<v-container fluid class="chat-container">
|
| 4 |
+
<AppHero :username="username" />
|
| 5 |
|
| 6 |
<v-row align="start" class="mt-0">
|
| 7 |
<SidePanel
|
|
|
|
| 155 |
const input = ref("");
|
| 156 |
const loading = ref(false);
|
| 157 |
const userId = ref("");
|
| 158 |
+
const username = ref("");
|
| 159 |
const history = ref([]);
|
| 160 |
const estrategia = ref(localStorage.getItem("vs_estrategia") || "v1");
|
| 161 |
const followupByCycle = ref({});
|
|
|
|
| 168 |
const snackbar = ref({ show: false, text: "", color: "success" });
|
| 169 |
|
| 170 |
const viewedMovieIds = computed(() => new Set(history.value.map(i => String(i.movie_id))));
|
|
|
|
| 171 |
|
| 172 |
function showSnack(text, color = "success") {
|
| 173 |
snackbar.value = { show: true, text, color };
|
| 174 |
}
|
| 175 |
|
| 176 |
onMounted(() => {
|
| 177 |
+
userId.value = localStorage.getItem("vs_user_id") || "";
|
| 178 |
+
username.value = localStorage.getItem("vs_username") || "";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
});
|
| 180 |
|
| 181 |
watch(estrategia, v => localStorage.setItem("vs_estrategia", v));
|
chatbot/src/views/PreferencesView.vue
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<template>
|
| 2 |
+
<v-main class="pref-main">
|
| 3 |
+
<v-container class="pref-container" style="max-width:680px">
|
| 4 |
+
|
| 5 |
+
<!-- Topbar nav -->
|
| 6 |
+
<div class="pref-topbar">
|
| 7 |
+
<v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" @click="router.back()">Volver</v-btn>
|
| 8 |
+
<div class="pref-brand">
|
| 9 |
+
<v-icon color="amber-darken-1" size="16" class="mr-1">mdi-cog-outline</v-icon>
|
| 10 |
+
<span class="pref-brand-text">Preferencias</span>
|
| 11 |
+
</div>
|
| 12 |
+
<v-btn variant="text" size="small" append-icon="mdi-chat-outline" to="/chat">Chat</v-btn>
|
| 13 |
+
</div>
|
| 14 |
+
|
| 15 |
+
<!-- Apariencia -->
|
| 16 |
+
<v-card class="pref-card mb-3" rounded="xl" elevation="0">
|
| 17 |
+
<v-card-item>
|
| 18 |
+
<template #prepend><v-icon color="primary" size="20">mdi-palette-outline</v-icon></template>
|
| 19 |
+
<v-card-title class="pref-stitle">Apariencia</v-card-title>
|
| 20 |
+
</v-card-item>
|
| 21 |
+
<v-card-text>
|
| 22 |
+
<v-btn-toggle v-model="themeName" mandatory color="primary" rounded="lg" density="comfortable">
|
| 23 |
+
<v-btn value="vsLight" prepend-icon="mdi-weather-sunny" size="small">Claro</v-btn>
|
| 24 |
+
<v-btn value="vsDark" prepend-icon="mdi-weather-night" size="small">Oscuro</v-btn>
|
| 25 |
+
</v-btn-toggle>
|
| 26 |
+
</v-card-text>
|
| 27 |
+
</v-card>
|
| 28 |
+
|
| 29 |
+
<!-- Idioma -->
|
| 30 |
+
<v-card class="pref-card mb-3" rounded="xl" elevation="0">
|
| 31 |
+
<v-card-item>
|
| 32 |
+
<template #prepend><v-icon color="primary" size="20">mdi-translate</v-icon></template>
|
| 33 |
+
<v-card-title class="pref-stitle">Idioma</v-card-title>
|
| 34 |
+
</v-card-item>
|
| 35 |
+
<v-card-text>
|
| 36 |
+
<v-select
|
| 37 |
+
v-model="language"
|
| 38 |
+
:items="LANGS"
|
| 39 |
+
item-title="label"
|
| 40 |
+
item-value="value"
|
| 41 |
+
variant="outlined"
|
| 42 |
+
density="comfortable"
|
| 43 |
+
hide-details
|
| 44 |
+
style="max-width:220px"
|
| 45 |
+
@update:model-value="v => localStorage.setItem('vs_lang', v)"
|
| 46 |
+
/>
|
| 47 |
+
<p class="hint mt-2">Los cambios de idioma se aplicarán al recargar.</p>
|
| 48 |
+
</v-card-text>
|
| 49 |
+
</v-card>
|
| 50 |
+
|
| 51 |
+
<!-- Contraseña -->
|
| 52 |
+
<v-card class="pref-card" rounded="xl" elevation="0">
|
| 53 |
+
<v-card-item>
|
| 54 |
+
<template #prepend><v-icon color="primary" size="20">mdi-lock-outline</v-icon></template>
|
| 55 |
+
<v-card-title class="pref-stitle">Contraseña</v-card-title>
|
| 56 |
+
</v-card-item>
|
| 57 |
+
<v-card-text>
|
| 58 |
+
<div class="pwd-grid">
|
| 59 |
+
<v-text-field
|
| 60 |
+
v-model="oldPwd"
|
| 61 |
+
label="Contraseña actual"
|
| 62 |
+
:type="showOld ? 'text' : 'password'"
|
| 63 |
+
variant="outlined"
|
| 64 |
+
density="comfortable"
|
| 65 |
+
:append-inner-icon="showOld ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
| 66 |
+
hide-details
|
| 67 |
+
@click:append-inner="showOld = !showOld"
|
| 68 |
+
/>
|
| 69 |
+
<v-text-field
|
| 70 |
+
v-model="newPwd"
|
| 71 |
+
label="Nueva contraseña"
|
| 72 |
+
:type="showNew ? 'text' : 'password'"
|
| 73 |
+
variant="outlined"
|
| 74 |
+
density="comfortable"
|
| 75 |
+
:append-inner-icon="showNew ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
| 76 |
+
hide-details
|
| 77 |
+
@click:append-inner="showNew = !showNew"
|
| 78 |
+
/>
|
| 79 |
+
</div>
|
| 80 |
+
<v-alert v-if="pwdMsg.text" :type="pwdMsg.type" variant="tonal" density="compact" rounded="lg" class="mt-3">
|
| 81 |
+
{{ pwdMsg.text }}
|
| 82 |
+
</v-alert>
|
| 83 |
+
<v-btn
|
| 84 |
+
color="primary"
|
| 85 |
+
variant="flat"
|
| 86 |
+
rounded="lg"
|
| 87 |
+
class="mt-3"
|
| 88 |
+
:loading="pwdLoading"
|
| 89 |
+
:disabled="!oldPwd || !newPwd"
|
| 90 |
+
@click="changePassword"
|
| 91 |
+
>
|
| 92 |
+
Actualizar contraseña
|
| 93 |
+
</v-btn>
|
| 94 |
+
</v-card-text>
|
| 95 |
+
</v-card>
|
| 96 |
+
|
| 97 |
+
</v-container>
|
| 98 |
+
</v-main>
|
| 99 |
+
</template>
|
| 100 |
+
|
| 101 |
+
<script setup>
|
| 102 |
+
import { computed, ref } from "vue";
|
| 103 |
+
import { useRouter } from "vue-router";
|
| 104 |
+
import { useTheme } from "vuetify";
|
| 105 |
+
|
| 106 |
+
const router = useRouter();
|
| 107 |
+
const theme = useTheme();
|
| 108 |
+
|
| 109 |
+
const LANGS = [
|
| 110 |
+
{ label: "Castellano", value: "es" },
|
| 111 |
+
{ label: "English", value: "en" },
|
| 112 |
+
{ label: "Galego", value: "gl" },
|
| 113 |
+
];
|
| 114 |
+
|
| 115 |
+
const themeName = computed({
|
| 116 |
+
get: () => theme.global.name.value,
|
| 117 |
+
set: v => { theme.global.name.value = v; },
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
const language = ref(localStorage.getItem("vs_lang") || "es");
|
| 121 |
+
|
| 122 |
+
const oldPwd = ref("");
|
| 123 |
+
const newPwd = ref("");
|
| 124 |
+
const showOld = ref(false);
|
| 125 |
+
const showNew = ref(false);
|
| 126 |
+
const pwdLoading = ref(false);
|
| 127 |
+
const pwdMsg = ref({ text: "", type: "success" });
|
| 128 |
+
|
| 129 |
+
async function changePassword() {
|
| 130 |
+
pwdMsg.value = { text: "", type: "success" };
|
| 131 |
+
if (newPwd.value.length < 6) {
|
| 132 |
+
pwdMsg.value = { text: "La nueva contraseña debe tener al menos 6 caracteres.", type: "error" };
|
| 133 |
+
return;
|
| 134 |
+
}
|
| 135 |
+
pwdLoading.value = true;
|
| 136 |
+
try {
|
| 137 |
+
const res = await fetch("http://localhost:5000/auth/password", {
|
| 138 |
+
method: "POST",
|
| 139 |
+
headers: { "Content-Type": "application/json" },
|
| 140 |
+
body: JSON.stringify({
|
| 141 |
+
token: localStorage.getItem("vs_token") || "",
|
| 142 |
+
old_password: oldPwd.value,
|
| 143 |
+
new_password: newPwd.value,
|
| 144 |
+
}),
|
| 145 |
+
});
|
| 146 |
+
const data = await res.json();
|
| 147 |
+
if (!res.ok) { pwdMsg.value = { text: data.error || "Error al actualizar.", type: "error" }; return; }
|
| 148 |
+
pwdMsg.value = { text: "Contraseña actualizada correctamente.", type: "success" };
|
| 149 |
+
oldPwd.value = newPwd.value = "";
|
| 150 |
+
} catch {
|
| 151 |
+
pwdMsg.value = { text: "Error de conexión.", type: "error" };
|
| 152 |
+
} finally {
|
| 153 |
+
pwdLoading.value = false;
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
</script>
|
| 157 |
+
|
| 158 |
+
<style scoped>
|
| 159 |
+
.pref-main { min-height: 100vh; }
|
| 160 |
+
.pref-container { padding: 24px 20px 48px; }
|
| 161 |
+
|
| 162 |
+
.pref-topbar {
|
| 163 |
+
display: flex;
|
| 164 |
+
align-items: center;
|
| 165 |
+
justify-content: space-between;
|
| 166 |
+
margin-bottom: 28px;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
.pref-brand { display: flex; align-items: center; }
|
| 170 |
+
.pref-brand-text { font-family: "Fraunces", serif; font-size: 1rem; font-weight: 700; color: var(--vs-text); }
|
| 171 |
+
|
| 172 |
+
.pref-card {
|
| 173 |
+
border: 1.5px solid var(--vs-border) !important;
|
| 174 |
+
background: var(--vs-panel) !important;
|
| 175 |
+
backdrop-filter: blur(12px);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.pref-stitle { font-size: 0.9rem !important; font-weight: 700 !important; color: var(--vs-text) !important; }
|
| 179 |
+
|
| 180 |
+
.pwd-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
| 181 |
+
.hint { font-size: 0.78rem; color: var(--vs-muted); margin: 0; }
|
| 182 |
+
|
| 183 |
+
@media (max-width: 540px) { .pwd-grid { grid-template-columns: 1fr; } }
|
| 184 |
+
</style>
|