iagofp commited on
Commit
ff7e387
·
1 Parent(s): 14f098f

Añadido Login/Register, Preferencias

Browse files
.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-chip size="small" class="user-chip">
16
- <v-icon start size="13">mdi-account-circle-outline</v-icon>
17
- {{ userIdLabel }}
18
- </v-chip>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- userIdLabel: { type: String, default: "anon" },
44
  });
45
 
46
- const router = useRouter();
47
- const theme = useTheme();
48
- const isDark = computed(() => theme.global.current.value.dark);
 
 
 
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
- export default createRouter({
6
  history: createWebHashHistory(),
7
  routes: [
8
- { path: "/", component: LandingPage },
9
- { path: "/chat", component: ChatView },
 
 
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 :user-id-label="userIdLabel" />
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
- const existing = localStorage.getItem("vs_user_id");
178
- if (existing) { userId.value = existing; return; }
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>