
@@ -879,24 +881,43 @@ function openAniListModal(userId) {
- ` : `
-
-
Connect with AniList
-
- Sync your anime list by logging in with AniList.
-
-
-
+
+ `;
+ } else {
+ // VISTA: NO CONECTADO (Formulario Manual)
+ content.innerHTML = `
+
+
+
How to connect:
+
+
-
- You will be redirected and then returned here.
-
+
+
+
+ 2. Authorize the app, then copy the Access Token provided and paste it here:
+
+
+
+
+
+
+
- `}
-
- `;
+
+ `;
+ }
}).catch(err => {
console.error(err);
const content = document.getElementById('aniListContent');
@@ -904,6 +925,49 @@ function openAniListModal(userId) {
});
}
+// Nueva función para manejar el token pegado manualmente
+async function handleManualAniListToken() {
+ const tokenInput = document.getElementById('manualAniListToken');
+ const token = tokenInput.value.trim();
+
+ if (!token) {
+ showUserToast('Please paste the AniList token first', 'error');
+ return;
+ }
+
+ const submitBtn = document.querySelector('.btn-connect');
+ const originalText = submitBtn.textContent;
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Verifying...';
+
+ try {
+ const res = await fetch(`${API_BASE}/anilist/store`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: currentUserId,
+ accessToken: token,
+ })
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Failed to verify token');
+ }
+
+ showUserToast('AniList connected successfully!', 'success');
+ // Recargar el modal para mostrar el estado "Conectado"
+ openAniListModal(currentUserId);
+
+ } catch (err) {
+ console.error(err);
+ showUserToast(err.message || 'Invalid Token', 'error');
+ submitBtn.disabled = false;
+ submitBtn.textContent = originalText;
+ }
+}
+
async function redirectToAniListLogin() {
try {
const res = await fetch(`/api/login`, {
@@ -918,15 +982,10 @@ async function redirectToAniListLogin() {
localStorage.setItem('token', data.token);
const clientId = 32898;
- const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
- const state = encodeURIComponent(currentUserId);
-
- window.location.href =
- `https://anilist.co/api/v2/oauth/authorize` +
- `?client_id=${clientId}` +
- `&response_type=code` +
- `&redirect_uri=${redirectUri}` +
- `&state=${state}`;
+ window.open(
+ `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token`,
+ '_blank'
+ );
} catch (err) {
console.error(err);
diff --git a/desktop/views/css/users.css b/desktop/views/css/users.css
index ed90504..afdefdc 100644
--- a/desktop/views/css/users.css
+++ b/desktop/views/css/users.css
@@ -10,15 +10,29 @@
.background-gradient {
position: fixed;
inset: 0;
- background: radial-gradient(ellipse at top, rgba(139, 92, 246, 0.15) 0%, transparent 60%),
- radial-gradient(ellipse at bottom right, rgba(59, 130, 246, 0.1) 0%, transparent 50%);
+ background:
+ radial-gradient(
+ ellipse at top,
+ rgba(139, 92, 246, 0.15) 0%,
+ transparent 60%
+ ),
+ radial-gradient(
+ ellipse at bottom right,
+ rgba(59, 130, 246, 0.1) 0%,
+ transparent 50%
+ );
z-index: 0;
animation: gradientShift 10s ease infinite;
}
@keyframes gradientShift {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.7; }
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.7;
+ }
}
.content-container {
@@ -80,9 +94,8 @@
box-shadow: 0 20px 40px rgba(139, 92, 246, 0.3);
}
-/* Badge de contraseña protegida */
.user-card.has-password::after {
- content: '🔒';
+ content: "🔒";
position: absolute;
top: 10px;
left: 10px;
@@ -116,7 +129,11 @@
display: flex;
align-items: center;
justify-content: center;
- background: linear-gradient(135deg, rgba(139, 92, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%);
+ background: linear-gradient(
+ 135deg,
+ rgba(139, 92, 246, 0.1) 0%,
+ rgba(59, 130, 246, 0.05) 100%
+ );
}
.user-avatar-placeholder svg {
@@ -131,7 +148,12 @@
left: 0;
right: 0;
padding: 1rem 1.5rem;
- background: linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 50%, transparent 100%);
+ background: linear-gradient(
+ to top,
+ rgba(0, 0, 0, 0.95) 0%,
+ rgba(0, 0, 0, 0.7) 50%,
+ transparent 100%
+ );
transform: translateY(100%);
transition: transform 0.3s ease;
}
@@ -168,7 +190,9 @@
justify-content: center;
cursor: pointer;
opacity: 0;
- transition: opacity 0.3s, background 0.2s;
+ transition:
+ opacity 0.3s,
+ background 0.2s;
z-index: 20;
}
@@ -205,7 +229,6 @@
box-shadow: 0 10px 30px var(--color-primary-glow);
}
-/* Modal Styles */
.modal {
display: none;
position: fixed;
@@ -438,7 +461,6 @@ input[type="file"] {
color: white;
}
-/* Estilos para modal de password */
.password-modal-content {
padding: 1.5rem;
}
@@ -461,7 +483,6 @@ input[type="file"] {
margin-top: 0.1rem;
}
-/* ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */
.manage-actions-modal {
display: flex;
flex-direction: column;
@@ -673,10 +694,13 @@ input[type="file"] {
border: 1px solid rgba(59, 130, 246, 0.4);
}
-/* Animations */
@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
}
@keyframes fadeInDown {
@@ -720,22 +744,140 @@ input[type="file"] {
}
@keyframes shimmer {
- 0% { background-position: 200% 0; }
- 100% { background-position: -200% 0; }
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
}
-/* Responsive */
@media (max-width: 768px) {
+ .page-wrapper {
+ padding: 1rem;
+
+ align-items: flex-start;
+ padding-top: 2rem;
+ }
+
+ .header-section {
+ margin-bottom: 2rem;
+ }
+
.page-title {
- font-size: 2.5rem;
+ font-size: 2.2rem;
+ line-height: 1.1;
}
.users-grid {
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- gap: 1.5rem;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 0.75rem;
+ max-width: 100%;
+ }
+
+ .user-name {
+ font-size: 1.1rem;
+ font-weight: 800;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
+ }
+
+ .user-config-btn {
+ width: 44px;
+ height: 44px;
+ top: 5px;
+ right: 5px;
+ background: rgba(0, 0, 0, 0.6);
+ opacity: 1 !important;
+ }
+
+ .user-config-btn svg {
+ width: 20px;
+ height: 20px;
+ }
+
+ .btn-add-user {
+ width: 100%;
+ padding: 1.25rem;
+ font-size: 1.1rem;
+ margin-bottom: 3rem;
+ }
+}
+
+@media (max-width: 380px) {
+ .users-grid {
+ grid-template-columns: 1fr;
+ gap: 1rem;
+ }
+
+ .user-card {
+ aspect-ratio: 16/9;
+ }
+}
+
+@media (hover: none) {
+ .user-info {
+ transform: translateY(0);
+ background: linear-gradient(
+ to top,
+ rgba(0, 0, 0, 0.95) 0%,
+ rgba(0, 0, 0, 0.6) 50%,
+ transparent 100%
+ );
+ padding: 1rem;
+ }
+
+ .user-card:hover {
+ transform: none;
+ border-color: rgba(255, 255, 255, 0.1);
+ }
+
+ .user-card:active {
+ transform: scale(0.97);
+ border-color: var(--color-primary);
+ }
+}
+
+@media (max-width: 768px) {
+ .modal {
+ align-items: flex-end;
}
.modal-content {
+ width: 100%;
+ max-width: 100%;
+ border-radius: 20px 20px 0 0;
+ max-height: 85vh;
padding: 1.5rem;
+ overflow-y: auto;
}
+
+ .form-group input[type="text"],
+ .form-group input[type="password"] {
+ padding: 1.2rem;
+ font-size: 16px;
+ }
+
+ .btn-primary,
+ .btn-secondary {
+ padding: 1.2rem;
+ }
+}
+
+.anilist-status a.btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ transition: all 0.2s;
+}
+
+.anilist-status a.btn-secondary:hover {
+ border-color: var(--color-primary);
+ color: white;
+ background: rgba(139, 92, 246, 0.1);
+}
+
+#manualAniListToken {
+ font-family: monospace;
+ font-size: 0.9rem;
}
\ No newline at end of file
diff --git a/desktop/views/profile.html b/desktop/views/profile.html
index 21b327e..909af9e 100644
--- a/desktop/views/profile.html
+++ b/desktop/views/profile.html
@@ -242,7 +242,17 @@
Update available: v1.x
Download
+
diff --git a/docker/src/api/anilist/anilist.ts b/docker/src/api/anilist/anilist.ts
index a245daf..c9991d3 100644
--- a/docker/src/api/anilist/anilist.ts
+++ b/docker/src/api/anilist/anilist.ts
@@ -2,58 +2,49 @@ import { FastifyInstance } from "fastify";
import { run } from "../../shared/database";
async function anilist(fastify: FastifyInstance) {
- fastify.get("/anilist", async (request, reply) => {
+ fastify.post("/anilist/store", async (request, reply) => {
try {
- const { code, state } = request.query as { code?: string; state?: string };
+ const {
+ userId,
+ accessToken,
+ tokenType = "Bearer",
+ expiresIn
+ } = request.body as {
+ userId: number;
+ accessToken: string;
+ tokenType?: string;
+ expiresIn?: number;
+ };
- if (!code) return reply.status(400).send("No code");
- if (!state) return reply.status(400).send("No user state");
-
- const userId = Number(state);
- if (!userId || isNaN(userId)) {
- return reply.status(400).send("Invalid user id");
- }
-
- const tokenRes = await fetch("https://anilist.co/api/v2/oauth/token", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- grant_type: "authorization_code",
- client_id: process.env.ANILIST_CLIENT_ID,
- client_secret: process.env.ANILIST_CLIENT_SECRET,
- redirect_uri: "http://localhost:54322/api/anilist",
- code
- })
- });
-
- const tokenData = await tokenRes.json();
-
- if (!tokenData.access_token) {
- console.error("AniList token error:", tokenData);
- return reply.status(500).send("Failed to get AniList token");
+ if (!userId || !accessToken) {
+ return reply.status(400).send({ error: "Faltan datos (User ID o Token)" });
}
+ // 1. Verificar que el token es válido consultando a AniList
const userRes = await fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
- Authorization: `${tokenData.token_type} ${tokenData.access_token}`
+ Authorization: `${tokenType} ${accessToken}`
},
body: JSON.stringify({
query: `query { Viewer { id } }`
})
});
+ if (!userRes.ok) {
+ return reply.status(401).send({ error: "Token de AniList inválido o expirado" });
+ }
+
const userData = await userRes.json();
const anilistUserId = userData?.data?.Viewer?.id;
if (!anilistUserId) {
- console.error("AniList Viewer error:", userData);
- return reply.status(500).send("Failed to fetch AniList user");
+ return reply.status(500).send({ error: "No se pudo obtener el ID de usuario de AniList" });
}
const expiresAt = new Date(
- Date.now() + tokenData.expires_in * 1000
+ Date.now() + 365 * 24 * 60 * 60 * 1000
).toISOString();
await run(
@@ -71,19 +62,19 @@ async function anilist(fastify: FastifyInstance) {
[
userId,
"AniList",
- tokenData.access_token,
- tokenData.refresh_token,
- tokenData.token_type,
+ accessToken,
+ "", // <- aquí
+ tokenType,
anilistUserId,
expiresAt
],
"userdata"
);
- return reply.redirect("http://localhost:54322/?anilist=success");
+ return reply.send({ ok: true, anilistUserId });
} catch (e) {
console.error("AniList error:", e);
- return reply.redirect("http://localhost:54322/?anilist=error");
+ return reply.status(500).send({ error: "Error interno del servidor al guardar AniList" });
}
});
}
diff --git a/docker/src/scripts/profile.js b/docker/src/scripts/profile.js
index 6a2e3a7..273d6d0 100644
--- a/docker/src/scripts/profile.js
+++ b/docker/src/scripts/profile.js
@@ -122,13 +122,16 @@ const DashboardApp = {
headerBadge.title = `Connected as ${data.anilistUserId}`;
}
if (statusEl) {
- statusEl.textContent = `Connected as ID: ${data.anilistUserId}`;
- statusEl.style.color = 'var(--color-success)';
+ // CAMBIO: Mostrar fecha de expiración si existe
+ const expiresDate = data.expiresAt ? new Date(data.expiresAt).toLocaleDateString() : 'Unknown';
+ statusEl.innerHTML = `
+
Connected as: ${data.anilistUserId}
+
Expires: ${expiresDate}
+ `;
}
if (btn) {
btn.textContent = 'Disconnect';
btn.className = 'btn-stream-outline link-danger';
-
btn.onclick = () => this.disconnectAniList(userId);
}
} else {
@@ -140,7 +143,7 @@ const DashboardApp = {
if (btn) {
btn.textContent = 'Connect';
btn.className = 'btn-stream-outline';
- btn.onclick = () => this.redirectToAniListLogin();
+ btn.onclick = () => this.openAniListModal();
}
}
},
@@ -154,6 +157,83 @@ const DashboardApp = {
window.location.href = `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${redirectUri}&state=${state}`;
} catch (err) { console.error(err); alert('Error starting AniList login'); }
},
+ openAniListModal: function() {
+ const modal = document.getElementById('anilist-connect-modal');
+ const body = document.getElementById('anilist-modal-body');
+ const clientId = 32898; // Tu Client ID
+
+ // Generamos el HTML del modal dinámicamente
+ body.innerHTML = `
+
Connect your AniList account to sync your progress automatically.
+
+
+
+
+
+
+
+
+
+ `;
+
+ modal.classList.remove('hidden');
+ },
+
+ closeAniListModal: function() {
+ document.getElementById('anilist-connect-modal').classList.add('hidden');
+ },
+
+ submitAniListToken: async function() {
+ const tokenInput = document.getElementById('manual-anilist-token');
+ const token = tokenInput.value.trim();
+ const userId = DashboardApp.State.currentUserId;
+
+ if (!token) {
+ alert('Please paste the AniList token first');
+ return;
+ }
+
+ const confirmBtn = document.querySelector('#anilist-connect-modal .btn-primary');
+ const originalText = confirmBtn.textContent;
+ confirmBtn.textContent = "Verifying...";
+ confirmBtn.disabled = true;
+
+ try {
+ const res = await fetch(`${API_BASE}/anilist/store`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: userId,
+ accessToken: token
+ })
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) throw new Error(data.error || 'Failed to verify token');
+
+ this.closeAniListModal();
+ await this.checkIntegrations(userId);
+ alert('AniList connected successfully!');
+
+ } catch (err) {
+ console.error(err);
+ alert(err.message || 'Invalid Token');
+ } finally {
+ confirmBtn.textContent = originalText;
+ confirmBtn.disabled = false;
+ }
+ },
disconnectAniList: async function(userId) {
if(!confirm("Disconnect AniList?")) return;
diff --git a/docker/src/scripts/users.js b/docker/src/scripts/users.js
index 2d236a2..581ca99 100644
--- a/docker/src/scripts/users.js
+++ b/docker/src/scripts/users.js
@@ -838,8 +838,7 @@ function openAniListModal(userId) {
modalUserActions.classList.remove('active');
modalEditUser.classList.remove('active');
- aniListContent.innerHTML = `
Loading integration status...
`;
-
+ // Estado de carga inicial
modalAniList.innerHTML = `
@@ -860,12 +859,15 @@ function openAniListModal(userId) {
modalAniList.classList.add('active');
+ // Verificar si ya está conectado
getIntegrationStatus(userId).then(integration => {
const content = document.getElementById('aniListContent');
+ const clientId = 32898; // Tu Client ID de AniList
- content.innerHTML = `
-
- ${integration.connected ? `
+ if (integration.connected) {
+ // VISTA: YA CONECTADO
+ content.innerHTML = `
+

@@ -879,24 +881,43 @@ function openAniListModal(userId) {
- ` : `
-
-
Connect with AniList
-
- Sync your anime list by logging in with AniList.
-
-
-
+
+ `;
+ } else {
+ // VISTA: NO CONECTADO (Formulario Manual)
+ content.innerHTML = `
+
+
+
How to connect:
+
+
-
- You will be redirected and then returned here.
-
+
+
+
+ 2. Authorize the app, then copy the Access Token provided and paste it here:
+
+
+
+
+
+
+
- `}
-
- `;
+
+ `;
+ }
}).catch(err => {
console.error(err);
const content = document.getElementById('aniListContent');
@@ -904,6 +925,49 @@ function openAniListModal(userId) {
});
}
+// Nueva función para manejar el token pegado manualmente
+async function handleManualAniListToken() {
+ const tokenInput = document.getElementById('manualAniListToken');
+ const token = tokenInput.value.trim();
+
+ if (!token) {
+ showUserToast('Please paste the AniList token first', 'error');
+ return;
+ }
+
+ const submitBtn = document.querySelector('.btn-connect');
+ const originalText = submitBtn.textContent;
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Verifying...';
+
+ try {
+ const res = await fetch(`${API_BASE}/anilist/store`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: currentUserId,
+ accessToken: token,
+ })
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Failed to verify token');
+ }
+
+ showUserToast('AniList connected successfully!', 'success');
+ // Recargar el modal para mostrar el estado "Conectado"
+ openAniListModal(currentUserId);
+
+ } catch (err) {
+ console.error(err);
+ showUserToast(err.message || 'Invalid Token', 'error');
+ submitBtn.disabled = false;
+ submitBtn.textContent = originalText;
+ }
+}
+
async function redirectToAniListLogin() {
try {
const res = await fetch(`/api/login`, {
@@ -918,15 +982,10 @@ async function redirectToAniListLogin() {
localStorage.setItem('token', data.token);
const clientId = 32898;
- const redirectUri = encodeURIComponent(window.location.origin + '/api/anilist');
- const state = encodeURIComponent(currentUserId);
-
- window.location.href =
- `https://anilist.co/api/v2/oauth/authorize` +
- `?client_id=${clientId}` +
- `&response_type=code` +
- `&redirect_uri=${redirectUri}` +
- `&state=${state}`;
+ window.open(
+ `https://anilist.co/api/v2/oauth/authorize?client_id=${clientId}&response_type=token`,
+ '_blank'
+ );
} catch (err) {
console.error(err);
diff --git a/docker/views/css/users.css b/docker/views/css/users.css
index 94f2f0c..afdefdc 100644
--- a/docker/views/css/users.css
+++ b/docker/views/css/users.css
@@ -862,3 +862,22 @@ input[type="file"] {
padding: 1.2rem;
}
}
+
+.anilist-status a.btn-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ transition: all 0.2s;
+}
+
+.anilist-status a.btn-secondary:hover {
+ border-color: var(--color-primary);
+ color: white;
+ background: rgba(139, 92, 246, 0.1);
+}
+
+#manualAniListToken {
+ font-family: monospace;
+ font-size: 0.9rem;
+}
\ No newline at end of file
diff --git a/docker/views/profile.html b/docker/views/profile.html
index 2c2053a..cad45be 100644
--- a/docker/views/profile.html
+++ b/docker/views/profile.html
@@ -229,7 +229,17 @@
Update available: v1.x
Download
+