support for multiple users
This commit is contained in:
121
package-lock.json
generated
121
package-lock.json
generated
@@ -14,11 +14,13 @@
|
|||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"node-addon-api": "^8.5.0",
|
"node-addon-api": "^8.5.0",
|
||||||
"playwright-chromium": "1.57.0",
|
"playwright-chromium": "1.57.0",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"electron": "^39.2.5",
|
"electron": "^39.2.5",
|
||||||
"node-gyp": "^12.1.0",
|
"node-gyp": "^12.1.0",
|
||||||
@@ -485,6 +487,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/keyv": {
|
"node_modules/@types/keyv": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
||||||
@@ -495,6 +508,13 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.1",
|
"version": "24.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz",
|
||||||
@@ -819,6 +839,12 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/cacache": {
|
"node_modules/cacache": {
|
||||||
"version": "20.0.3",
|
"version": "20.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz",
|
||||||
@@ -1192,6 +1218,15 @@
|
|||||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron": {
|
"node_modules/electron": {
|
||||||
"version": "39.2.5",
|
"version": "39.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/electron/-/electron-39.2.5.tgz",
|
||||||
@@ -2084,6 +2119,49 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^4.0.1",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -2131,6 +2209,48 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lowercase-keys": {
|
"node_modules/lowercase-keys": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||||
@@ -2420,7 +2540,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
"bindings": "^1.5.0",
|
"bindings": "^1.5.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"fastify": "^5.6.2",
|
"fastify": "^5.6.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"node-addon-api": "^8.5.0",
|
"node-addon-api": "^8.5.0",
|
||||||
"playwright-chromium": "1.57.0",
|
"playwright-chromium": "1.57.0",
|
||||||
"sqlite3": "^5.1.7"
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"electron": "^39.2.5",
|
"electron": "^39.2.5",
|
||||||
"node-gyp": "^12.1.0",
|
"node-gyp": "^12.1.0",
|
||||||
|
|||||||
18
server.js
18
server.js
@@ -2,6 +2,7 @@ const fastify = require('fastify')({ logger: true });
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
const { initHeadless } = require("./dist/shared/headless");
|
const { initHeadless } = require("./dist/shared/headless");
|
||||||
const { initDatabase } = require('./dist/shared/database');
|
const { initDatabase } = require('./dist/shared/database');
|
||||||
const { loadExtensions } = require('./dist/shared/extensions');
|
const { loadExtensions } = require('./dist/shared/extensions');
|
||||||
@@ -16,6 +17,20 @@ const proxyRoutes = require('./dist/api/proxy/proxy.routes');
|
|||||||
const extensionsRoutes = require('./dist/api/extensions/extensions.routes');
|
const extensionsRoutes = require('./dist/api/extensions/extensions.routes');
|
||||||
const galleryRoutes = require('./dist/api/gallery/gallery.routes');
|
const galleryRoutes = require('./dist/api/gallery/gallery.routes');
|
||||||
const rpcRoutes = require('./dist/api/rpc/rpc.routes');
|
const rpcRoutes = require('./dist/api/rpc/rpc.routes');
|
||||||
|
const userRoutes = require('./dist/api/user/user.routes');
|
||||||
|
const anilistRoute = require('./dist/api/anilist');
|
||||||
|
|
||||||
|
|
||||||
|
fastify.addHook("preHandler", async (request) => {
|
||||||
|
const auth = request.headers.authorization;
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = auth.replace("Bearer ", "");
|
||||||
|
request.user = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fastify.register(require('@fastify/static'), {
|
fastify.register(require('@fastify/static'), {
|
||||||
root: path.join(__dirname, 'public'),
|
root: path.join(__dirname, 'public'),
|
||||||
@@ -42,6 +57,8 @@ fastify.register(proxyRoutes, { prefix: '/api' });
|
|||||||
fastify.register(extensionsRoutes, { prefix: '/api' });
|
fastify.register(extensionsRoutes, { prefix: '/api' });
|
||||||
fastify.register(galleryRoutes, { prefix: '/api' });
|
fastify.register(galleryRoutes, { prefix: '/api' });
|
||||||
fastify.register(rpcRoutes, { prefix: '/api' });
|
fastify.register(rpcRoutes, { prefix: '/api' });
|
||||||
|
fastify.register(userRoutes, { prefix: '/api' });
|
||||||
|
fastify.register(anilistRoute, { prefix: '/api' });
|
||||||
|
|
||||||
function startCppScraper() {
|
function startCppScraper() {
|
||||||
const exePath = path.join(
|
const exePath = path.join(
|
||||||
@@ -83,6 +100,7 @@ const start = async () => {
|
|||||||
initDatabase("anilist");
|
initDatabase("anilist");
|
||||||
initDatabase("favorites");
|
initDatabase("favorites");
|
||||||
initDatabase("cache");
|
initDatabase("cache");
|
||||||
|
initDatabase("userdata");
|
||||||
init()
|
init()
|
||||||
|
|
||||||
await loadExtensions();
|
await loadExtensions();
|
||||||
|
|||||||
91
src/api/anilist.ts
Normal file
91
src/api/anilist.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { run } from "../shared/database";
|
||||||
|
|
||||||
|
async function anilist(fastify: FastifyInstance) {
|
||||||
|
fastify.get("/anilist", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { code, state } = request.query as { code?: string; state?: string };
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRes = await fetch("https://graphql.anilist.co", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `${tokenData.token_type} ${tokenData.access_token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `query { Viewer { id } }`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAt = new Date(
|
||||||
|
Date.now() + tokenData.expires_in * 1000
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
await run(
|
||||||
|
`
|
||||||
|
INSERT INTO UserIntegration
|
||||||
|
(user_id, platform, access_token, refresh_token, token_type, anilist_user_id, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
access_token = excluded.access_token,
|
||||||
|
refresh_token = excluded.refresh_token,
|
||||||
|
token_type = excluded.token_type,
|
||||||
|
anilist_user_id = excluded.anilist_user_id,
|
||||||
|
expires_at = excluded.expires_at
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
userId,
|
||||||
|
"AniList",
|
||||||
|
tokenData.access_token,
|
||||||
|
tokenData.refresh_token,
|
||||||
|
tokenData.token_type,
|
||||||
|
anilistUserId,
|
||||||
|
expiresAt
|
||||||
|
],
|
||||||
|
"userdata"
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.redirect("http://localhost:54322/?anilist=success");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AniList error:", e);
|
||||||
|
return reply.redirect("http://localhost:54322/?anilist=error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default anilist;
|
||||||
174
src/api/user/user.controller.ts
Normal file
174
src/api/user/user.controller.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import * as userService from './user.service';
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
interface UserIdBody { userId: number; }
|
||||||
|
interface UserIdParams { id: string; }
|
||||||
|
interface CreateUserBody { username: string; profilePictureUrl?: string; }
|
||||||
|
interface UpdateUserBody { username?: string; profilePictureUrl?: string | null; password?: string; }
|
||||||
|
interface ConnectBody { accessToken: string; anilistUserId: number; expiresIn: number; }
|
||||||
|
|
||||||
|
interface DBRunResult { changes: number; lastID: number; }
|
||||||
|
|
||||||
|
export async function login(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const { userId } = req.body as { userId: number };
|
||||||
|
|
||||||
|
if (!userId || typeof userId !== "number" || userId <= 0) {
|
||||||
|
return reply.code(400).send({ error: "Invalid userId provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await userService.userExists(userId)) {
|
||||||
|
return reply.code(404).send({ error: "User not found in local database" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: userId },
|
||||||
|
process.env.JWT_SECRET!,
|
||||||
|
{ expiresIn: "7d" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: true,
|
||||||
|
token
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUsers(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const users: any = await userService.getAllUsers();
|
||||||
|
return { users };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Get All Users Error:", (err as Error).message);
|
||||||
|
return reply.code(500).send({ error: "Failed to retrieve user list" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { username, profilePictureUrl } = req.body as CreateUserBody;
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return reply.code(400).send({ error: "Missing required field: username" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = await userService.createUser(username, profilePictureUrl);
|
||||||
|
|
||||||
|
return reply.code(201).send({ success: true, userId: result.lastID, username });
|
||||||
|
} catch (err) {
|
||||||
|
|
||||||
|
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
|
||||||
|
return reply.code(409).send({ error: "Username already exists." });
|
||||||
|
}
|
||||||
|
console.error("Create User Error:", (err as Error).message);
|
||||||
|
return reply.code(500).send({ error: "Failed to create user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as UserIdParams;
|
||||||
|
const userId = parseInt(id, 10);
|
||||||
|
|
||||||
|
const user: any = await userService.getUserById(userId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Get User Error:", (err as Error).message);
|
||||||
|
return reply.code(500).send({ error: "Failed to retrieve user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as UserIdParams;
|
||||||
|
const userId = parseInt(id, 10);
|
||||||
|
const updates = req.body as UpdateUserBody;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
return reply.code(400).send({ error: "No update fields provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: DBRunResult = await userService.updateUser(userId, updates);
|
||||||
|
|
||||||
|
if (result && result.changes > 0) {
|
||||||
|
return { success: true, message: "User updated successfully" };
|
||||||
|
} else {
|
||||||
|
|
||||||
|
return reply.code(404).send({ error: "User not found or nothing to update" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).message.includes('SQLITE_CONSTRAINT')) {
|
||||||
|
return reply.code(409).send({ error: "Username already exists or is invalid." });
|
||||||
|
}
|
||||||
|
console.error("Update User Error:", (err as Error).message);
|
||||||
|
return reply.code(500).send({ error: "Failed to update user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const userId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (!userId || isNaN(userId)) {
|
||||||
|
return reply.code(400).send({ error: "Invalid user id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await userService.deleteUser(userId);
|
||||||
|
|
||||||
|
if (result && result.changes > 0) {
|
||||||
|
return { success: true, message: "User deleted successfully" };
|
||||||
|
} else {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete User Error:", (err as Error).message);
|
||||||
|
return reply.code(500).send({ error: "Failed to delete user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntegrationStatus(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const userId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (!userId || isNaN(userId)) {
|
||||||
|
return reply.code(400).send({ error: "Invalid user id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const integration = await userService.getAniListIntegration(userId);
|
||||||
|
|
||||||
|
return reply.code(200).send(integration);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Get Integration Status Error:", (err as Error).message);
|
||||||
|
return reply.code(500).send({ error: "Failed to check integration status" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectAniList(req: FastifyRequest, reply: FastifyReply) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const userId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (!userId || isNaN(userId)) {
|
||||||
|
return reply.code(400).send({ error: "Invalid user id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await userService.removeAniListIntegration(userId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return reply.code(404).send({ error: "AniList integration not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Disconnect AniList Error:", err);
|
||||||
|
return reply.code(500).send({ error: "Failed to disconnect AniList" });
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/api/user/user.routes.ts
Normal file
15
src/api/user/user.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import * as controller from './user.controller';
|
||||||
|
|
||||||
|
async function userRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.post("/login", controller.login);
|
||||||
|
fastify.get('/users', controller.getAllUsers);
|
||||||
|
fastify.post('/users', controller.createUser);
|
||||||
|
fastify.get('/users/:id', controller.getUser);
|
||||||
|
fastify.put('/users/:id', controller.updateUser);
|
||||||
|
fastify.delete('/users/:id', controller.deleteUser);
|
||||||
|
fastify.get('/users/:id/integration', controller.getIntegrationStatus);
|
||||||
|
fastify.delete('/users/:id/integration', controller.disconnectAniList);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default userRoutes;
|
||||||
133
src/api/user/user.service.ts
Normal file
133
src/api/user/user.service.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {queryAll, queryOne, run} from '../../shared/database';
|
||||||
|
|
||||||
|
const USER_DB_NAME = 'userdata';
|
||||||
|
|
||||||
|
interface User { id: number; username: string; profile_picture_url: string | null; }
|
||||||
|
|
||||||
|
export async function userExists(id: number): Promise<boolean> {
|
||||||
|
const sql = 'SELECT 1 FROM User WHERE id = ?';
|
||||||
|
const row = queryOne(sql, [id], USER_DB_NAME);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(username: string, profilePictureUrl?: string): Promise<{ lastID: number }> {
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO User (username, profile_picture_url)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`;
|
||||||
|
const params = [username, profilePictureUrl || null];
|
||||||
|
|
||||||
|
const result = await run(sql, params, USER_DB_NAME);
|
||||||
|
|
||||||
|
return { lastID: result.lastID };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(userId: number, updates: any): Promise<any> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: (string | number | null)[] = [];
|
||||||
|
|
||||||
|
if (updates.username !== undefined) {
|
||||||
|
fields.push('username = ?');
|
||||||
|
values.push(updates.username);
|
||||||
|
}
|
||||||
|
if (updates.profilePictureUrl !== undefined) {
|
||||||
|
|
||||||
|
fields.push('profile_picture_url = ?');
|
||||||
|
values.push(updates.profilePictureUrl);
|
||||||
|
}
|
||||||
|
if (updates.password !== undefined) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return { changes: 0, lastID: userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = fields.join(', ');
|
||||||
|
const sql = `UPDATE User SET ${setClause} WHERE id = ?`;
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
return await run(sql, values, USER_DB_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userId: number): Promise<any> {
|
||||||
|
|
||||||
|
await run(
|
||||||
|
`DELETE FROM ListEntry WHERE user_id = ?`,
|
||||||
|
[userId],
|
||||||
|
USER_DB_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
await run(
|
||||||
|
`DELETE FROM UserIntegration WHERE user_id = ?`,
|
||||||
|
[userId],
|
||||||
|
USER_DB_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await run(
|
||||||
|
`DELETE FROM User WHERE id = ?`,
|
||||||
|
[userId],
|
||||||
|
USER_DB_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUsers(): Promise<User[]> {
|
||||||
|
const sql = 'SELECT id, username, profile_picture_url FROM User ORDER BY id';
|
||||||
|
|
||||||
|
const users = await queryAll(sql, [], USER_DB_NAME);
|
||||||
|
|
||||||
|
return users.map((user: any) => ({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
profile_picture_url: user.profile_picture_url || null
|
||||||
|
})) as User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: number): Promise<User | null> {
|
||||||
|
const sql = `
|
||||||
|
SELECT id, username, profile_picture_url, email
|
||||||
|
FROM User
|
||||||
|
WHERE id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const user = await queryOne(sql, [id], USER_DB_NAME);
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
profile_picture_url: user.profile_picture_url || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAniListIntegration(userId: number) {
|
||||||
|
const sql = `
|
||||||
|
SELECT anilist_user_id, expires_at
|
||||||
|
FROM UserIntegration
|
||||||
|
WHERE user_id = ? AND platform = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const row = await queryOne(sql, [userId, "AniList"], USER_DB_NAME);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
anilistUserId: row.anilist_user_id,
|
||||||
|
expiresAt: row.expires_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAniListIntegration(userId: number) {
|
||||||
|
const sql = `
|
||||||
|
DELETE FROM UserIntegration
|
||||||
|
WHERE user_id = ? AND platform = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
return run(sql, [userId, "AniList"], USER_DB_NAME);
|
||||||
|
}
|
||||||
688
src/scripts/users.js
Normal file
688
src/scripts/users.js
Normal file
@@ -0,0 +1,688 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
let users = [];
|
||||||
|
let selectedFile = null;
|
||||||
|
let currentUserId = null;
|
||||||
|
|
||||||
|
const usersGrid = document.getElementById('usersGrid');
|
||||||
|
const btnAddUser = document.getElementById('btnAddUser');
|
||||||
|
|
||||||
|
const modalCreateUser = document.getElementById('modalCreateUser');
|
||||||
|
const closeCreateModal = document.getElementById('closeCreateModal');
|
||||||
|
const cancelCreate = document.getElementById('cancelCreate');
|
||||||
|
const createUserForm = document.getElementById('createUserForm');
|
||||||
|
const usernameInput = document.getElementById('username');
|
||||||
|
const avatarInput = document.getElementById('avatarInput');
|
||||||
|
const avatarPreview = document.getElementById('avatarPreview');
|
||||||
|
const avatarUploadArea = document.getElementById('avatarUploadArea');
|
||||||
|
|
||||||
|
const modalUserActions = document.getElementById('modalUserActions');
|
||||||
|
const closeActionsModal = document.getElementById('closeActionsModal');
|
||||||
|
const actionsModalTitle = document.getElementById('actionsModalTitle');
|
||||||
|
|
||||||
|
const modalEditUser = document.getElementById('modalEditUser');
|
||||||
|
const closeEditModal = document.getElementById('closeEditModal');
|
||||||
|
const cancelEdit = document.getElementById('cancelEdit');
|
||||||
|
const editUserForm = document.getElementById('editUserForm');
|
||||||
|
const editUsernameInput = document.getElementById('editUsername');
|
||||||
|
const editAvatarPreview = document.getElementById('editAvatarPreview');
|
||||||
|
const editAvatarUploadArea = document.getElementById('editAvatarUploadArea');
|
||||||
|
const editAvatarInput = document.getElementById('editAvatarInput');
|
||||||
|
|
||||||
|
const modalAniList = document.getElementById('modalAniList');
|
||||||
|
const closeAniListModal = document.getElementById('closeAniListModal');
|
||||||
|
const aniListContent = document.getElementById('aniListContent');
|
||||||
|
|
||||||
|
const toastContainer = document.getElementById('userToastContainer');
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const anilistStatus = params.get("anilist");
|
||||||
|
|
||||||
|
if (anilistStatus === "success") {
|
||||||
|
showUserToast("✅ AniList connected successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anilistStatus === "error") {
|
||||||
|
showUserToast("❌ Failed to connect AniList");
|
||||||
|
}
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initAvatarUpload('avatarUploadArea', 'avatarInput', 'avatarPreview');
|
||||||
|
initAvatarUpload('editAvatarUploadArea', 'editAvatarInput', 'editAvatarPreview');
|
||||||
|
loadUsers();
|
||||||
|
attachEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
function authFetch(url, options = {}) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers = {
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
return fetch(url, { ...options, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUserToast(message, type = 'info') {
|
||||||
|
if (!toastContainer) return;
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `wb-toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
|
||||||
|
toastContainer.prepend(toast);
|
||||||
|
|
||||||
|
setTimeout(() => toast.classList.add('show'), 10);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
toast.addEventListener('transitionend', () => toast.remove());
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachEventListeners() {
|
||||||
|
if (btnAddUser) btnAddUser.addEventListener('click', openCreateModal);
|
||||||
|
|
||||||
|
if (closeCreateModal) closeCreateModal.addEventListener('click', closeModal);
|
||||||
|
if (cancelCreate) cancelCreate.addEventListener('click', closeModal);
|
||||||
|
if (closeAniListModal) closeAniListModal.addEventListener('click', closeModal);
|
||||||
|
if (closeActionsModal) closeActionsModal.addEventListener('click', closeModal);
|
||||||
|
if (closeEditModal) closeEditModal.addEventListener('click', closeModal);
|
||||||
|
if (cancelEdit) cancelEdit.addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
if (createUserForm) createUserForm.addEventListener('submit', handleCreateUser);
|
||||||
|
if (editUserForm) editUserForm.addEventListener('submit', handleEditUser);
|
||||||
|
|
||||||
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('modal-overlay')) closeModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAvatarUpload(uploadAreaId, fileInputId, previewId) {
|
||||||
|
const uploadArea = document.getElementById(uploadAreaId);
|
||||||
|
const fileInput = document.getElementById(fileInputId);
|
||||||
|
const preview = document.getElementById(previewId);
|
||||||
|
|
||||||
|
if (!uploadArea || !fileInput) return;
|
||||||
|
|
||||||
|
uploadArea.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) handleFileSelect(file, previewId);
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', () => {
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
handleFileSelect(file, previewId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(file, previewId) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showUserToast('Please select an image file', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showUserToast('Image size must be less than 5MB', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedFile = file;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const preview = document.getElementById(previewId);
|
||||||
|
if (preview) preview.innerHTML = `<img src="${e.target.result}" alt="Avatar preview">`;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = (err) => reject(err);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch users');
|
||||||
|
const data = await res.json();
|
||||||
|
users = data.users || [];
|
||||||
|
renderUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading users:', err);
|
||||||
|
showEmptyState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(username, profilePictureUrl) {
|
||||||
|
try {
|
||||||
|
const body = { username };
|
||||||
|
if (profilePictureUrl) body.profilePictureUrl = profilePictureUrl;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/users`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Error creating user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser(userId, username, profilePictureUrl) {
|
||||||
|
try {
|
||||||
|
const updates = { username };
|
||||||
|
if (profilePictureUrl !== undefined) updates.profilePictureUrl = profilePictureUrl;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Error updating user');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(userId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users/${userId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Error deleting user');
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
if (!usersGrid) return;
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
showEmptyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usersGrid.innerHTML = '';
|
||||||
|
users.forEach(user => {
|
||||||
|
const userCard = createUserCard(user);
|
||||||
|
usersGrid.appendChild(userCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUserCard(user) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'user-card';
|
||||||
|
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
|
||||||
|
if (!e.target.closest('.user-config-btn')) {
|
||||||
|
loginUser(user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatarContent = user.profile_picture_url
|
||||||
|
? `<img src="${user.profile_picture_url}" alt="${user.username}">`
|
||||||
|
: `<div class="user-avatar-placeholder">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="user-avatar">${avatarContent}</div>
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-name">${user.username}</div>
|
||||||
|
<div class="user-status">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span>Available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="user-config-btn" title="Manage User" data-user-id="${user.id}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0-.33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1.51-1V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const configBtn = card.querySelector('.user-config-btn');
|
||||||
|
configBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openUserActionsModal(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyState() {
|
||||||
|
if (!usersGrid) return;
|
||||||
|
usersGrid.innerHTML = `
|
||||||
|
<div class="empty-state" style="grid-column: 1/-1;">
|
||||||
|
<svg class="empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
|
</svg>
|
||||||
|
<h2 class="empty-title">No Users Yet</h2>
|
||||||
|
<p class="empty-text">Create your first profile to get started</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateModal() {
|
||||||
|
modalCreateUser.classList.add('active');
|
||||||
|
if (usernameInput) usernameInput.focus();
|
||||||
|
selectedFile = null;
|
||||||
|
if (avatarPreview) {
|
||||||
|
avatarPreview.innerHTML = `
|
||||||
|
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalCreateUser.classList.remove('active');
|
||||||
|
modalAniList.classList.remove('active');
|
||||||
|
modalUserActions.classList.remove('active');
|
||||||
|
modalEditUser.classList.remove('active');
|
||||||
|
if (createUserForm) createUserForm.reset();
|
||||||
|
if (editUserForm) editUserForm.reset();
|
||||||
|
selectedFile = null;
|
||||||
|
const modalHeader = modalAniList.querySelector('.modal-header h2');
|
||||||
|
if (modalHeader) modalHeader.textContent = 'AniList Integration';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
if (!username) {
|
||||||
|
showUserToast('Please enter a username', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = createUserForm.querySelector('button[type="submit"]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Creating...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let profilePictureUrl = null;
|
||||||
|
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
|
||||||
|
|
||||||
|
await createUser(username, profilePictureUrl);
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
await loadUsers();
|
||||||
|
showUserToast(`User ${username} created successfully!`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showUserToast(err.message || 'Error creating user', 'error');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Create User';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserActionsModal(userId) {
|
||||||
|
currentUserId = userId;
|
||||||
|
const user = users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
modalAniList.classList.remove('active');
|
||||||
|
modalEditUser.classList.remove('active');
|
||||||
|
|
||||||
|
actionsModalTitle.textContent = `Manage ${user.username}`;
|
||||||
|
|
||||||
|
const content = document.getElementById('actionsModalContent');
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="manage-actions-modal">
|
||||||
|
<button class="btn-action edit" onclick="openEditModal(${user.id})">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path></svg>
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
<button class="btn-action anilist" onclick="openAniListModal(${user.id})">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||||||
|
AniList Integration
|
||||||
|
</button>
|
||||||
|
<button class="btn-action delete" onclick="handleDeleteConfirmation(${user.id})">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 4H8l-7 8 7 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"></path><line x1="18" y1="9" x2="12" y2="15"></line><line x1="12" y1="9" x2="18" y2="15"></line></svg>
|
||||||
|
Delete Profile
|
||||||
|
</button>
|
||||||
|
<button class="btn-action cancel" onclick="closeModal()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modalUserActions.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openEditModal = function(userId) {
|
||||||
|
currentUserId = userId;
|
||||||
|
modalUserActions.classList.remove('active');
|
||||||
|
const user = users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
editUsernameInput.value = user.username || '';
|
||||||
|
selectedFile = null;
|
||||||
|
|
||||||
|
const avatarPlaceholder = `
|
||||||
|
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (user.profile_picture_url) {
|
||||||
|
editAvatarPreview.innerHTML = `<img src="${user.profile_picture_url}" alt="Avatar preview">`;
|
||||||
|
} else {
|
||||||
|
editAvatarPreview.innerHTML = avatarPlaceholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editAvatarInput) editAvatarInput.value = '';
|
||||||
|
modalEditUser.classList.add('active');
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleEditUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const user = users.find(u => u.id === currentUserId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const username = editUsernameInput.value.trim();
|
||||||
|
if (!username) {
|
||||||
|
showUserToast('Please enter a username', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = editUserForm.querySelector('.btn-primary');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let profilePictureUrl;
|
||||||
|
if (selectedFile) profilePictureUrl = await fileToBase64(selectedFile);
|
||||||
|
|
||||||
|
await updateUser(currentUserId, username, profilePictureUrl);
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
await loadUsers();
|
||||||
|
showUserToast('Profile updated successfully!', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showUserToast(err.message || 'Error updating user', 'error');
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Save Changes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.handleDeleteConfirmation = function(userId) {
|
||||||
|
const user = users.find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
showConfirmationModal(
|
||||||
|
'Confirm Deletion',
|
||||||
|
`Are you absolutely sure you want to delete profile ${user.username}? This action cannot be undone.`,
|
||||||
|
`handleConfirmedDeleteUser(${userId})`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.handleConfirmedDeleteUser = async function(userId) {
|
||||||
|
closeModal();
|
||||||
|
showUserToast('Deleting user...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteUser(userId);
|
||||||
|
await loadUsers();
|
||||||
|
showUserToast('User deleted successfully!', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showUserToast('Error deleting user', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function showConfirmationModal(title, message, confirmAction) {
|
||||||
|
closeModal();
|
||||||
|
|
||||||
|
const modalHeader = modalAniList.querySelector('.modal-header h2');
|
||||||
|
if (modalHeader) modalHeader.textContent = title;
|
||||||
|
|
||||||
|
aniListContent.innerHTML = `
|
||||||
|
<div style="text-align: center; padding: 1rem;">
|
||||||
|
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" style="opacity: 0.8; margin: 0 auto 1rem; display: block;">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||||
|
</svg>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 2rem; font-size: 1rem;">
|
||||||
|
${message}
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<button class="btn-secondary" style="flex: 1;" onclick="closeModal()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="btn-disconnect" style="flex: 1; background: #ef4444; color: white;" onclick="window.${confirmAction}">
|
||||||
|
Confirm Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modalAniList.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAniListModal(userId) {
|
||||||
|
currentUserId = userId;
|
||||||
|
|
||||||
|
aniListContent.innerHTML = `<div style="text-align: center; padding: 2rem;">Loading integration status...</div>`;
|
||||||
|
modalUserActions.classList.remove('active');
|
||||||
|
modalEditUser.classList.remove('active');
|
||||||
|
|
||||||
|
getIntegrationStatus(userId).then(integration => {
|
||||||
|
aniListContent.innerHTML = `
|
||||||
|
<div class="anilist-status">
|
||||||
|
${integration.connected ? `
|
||||||
|
<div class="anilist-connected">
|
||||||
|
<div class="anilist-icon">
|
||||||
|
<img
|
||||||
|
src="https://anilist.co/img/icons/icon.svg"
|
||||||
|
alt="AniList"
|
||||||
|
style="width:40px; height:40px;"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="anilist-info">
|
||||||
|
<h3>Connected to AniList</h3>
|
||||||
|
<p>User ID: ${integration.anilistUserId}</p>
|
||||||
|
<p style="font-size: 0.75rem;">Expires: ${new Date(integration.expiresAt).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-disconnect" onclick="handleDisconnectAniList()">
|
||||||
|
Disconnect AniList
|
||||||
|
</button>
|
||||||
|
` : `
|
||||||
|
<div style="text-align: center; padding: 1rem;">
|
||||||
|
<h3 style="margin-bottom: 0.5rem;">Connect with AniList</h3>
|
||||||
|
<p style="color: var(--text-secondary); margin-bottom: 1.5rem;">
|
||||||
|
Sync your anime list by logging in with AniList.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:center;">
|
||||||
|
<button class="btn-connect" onclick="redirectToAniListLogin()">
|
||||||
|
Login with AniList
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:0.85rem; margin-top:1rem; color:var(--text-secondary);">You will be redirected and then returned here.</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modalAniList.classList.add('active');
|
||||||
|
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
aniListContent.innerHTML = `<div style="text-align:center;padding:1rem;color:var(--text-secondary)">Error loading integration status.</div>`;
|
||||||
|
modalAniList.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redirectToAniListLogin() {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await fetch(`/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: currentUserId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Login failed before AniList redirect');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
token = data.token;
|
||||||
|
|
||||||
|
localStorage.setItem('anilist_link_user', currentUserId);
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showUserToast('Error starting AniList login', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getIntegrationStatus(userId) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/users/${userId}/integration`);
|
||||||
|
if (!res.ok) {
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('getIntegrationStatus error', err);
|
||||||
|
return { connected: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnectAniList(userId) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const res = await authFetch(`${API_BASE}/users/${userId}/integration`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || 'Error disconnecting AniList');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await res.json();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.handleDisconnectAniList = async function() {
|
||||||
|
if (!confirm('Are you sure you want to disconnect AniList?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await disconnectAniList(currentUserId);
|
||||||
|
showUserToast('AniList disconnected successfully', 'success');
|
||||||
|
await openAniListModal(currentUserId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showUserToast('Error disconnecting AniList', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loginUser(userId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
|
||||||
|
window.location.href = '/anime';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Login error', err);
|
||||||
|
showUserToast('Login failed', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openAniListModal = openAniListModal;
|
||||||
|
window.openEditModal = window.openEditModal;
|
||||||
|
|
||||||
|
window.handleDeleteConfirmation = window.handleDeleteConfirmation;
|
||||||
|
window.handleConfirmedDeleteUser = window.handleConfirmedDeleteUser;
|
||||||
|
window.redirectToAniListLogin = redirectToAniListLogin;
|
||||||
@@ -8,9 +8,70 @@ const databases = new Map();
|
|||||||
const DEFAULT_PATHS = {
|
const DEFAULT_PATHS = {
|
||||||
anilist: path.join(process.cwd(), 'src', 'metadata', 'anilist_anime.db'),
|
anilist: path.join(process.cwd(), 'src', 'metadata', 'anilist_anime.db'),
|
||||||
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
|
favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db"),
|
||||||
cache: path.join(os.homedir(), "WaifuBoards", "cache.db")
|
cache: path.join(os.homedir(), "WaifuBoards", "cache.db"),
|
||||||
|
userdata: path.join(os.homedir(), "WaifuBoards", "user_data.db")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function ensureUserDataDB(dbPath) {
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(
|
||||||
|
dbPath,
|
||||||
|
sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const schema = `
|
||||||
|
-- Tabla 1: User
|
||||||
|
CREATE TABLE IF NOT EXISTS User (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
profile_picture_url TEXT,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
password_hash TEXT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tabla 2: UserIntegration (✅ ACTUALIZADA)
|
||||||
|
CREATE TABLE IF NOT EXISTS UserIntegration (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL UNIQUE,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'AniList',
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT NOT NULL,
|
||||||
|
token_type TEXT NOT NULL,
|
||||||
|
anilist_user_id INTEGER NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tabla 3: ListEntry
|
||||||
|
CREATE TABLE IF NOT EXISTS ListEntry (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
anime_id INTEGER NOT NULL,
|
||||||
|
external_id INTEGER,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0,
|
||||||
|
score INTEGER,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, anime_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
db.exec(schema, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureExtensionsTable(db) {
|
async function ensureExtensionsTable(db) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
@@ -128,6 +189,11 @@ function initDatabase(name = 'anilist', dbPath = null, readOnly = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (name === "userdata") {
|
||||||
|
ensureUserDataDB(finalPath)
|
||||||
|
.catch(err => console.error("Error creando userdata:", err));
|
||||||
|
}
|
||||||
|
|
||||||
const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
|
const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE);
|
||||||
|
|
||||||
const db = new sqlite3.Database(finalPath, mode, (err) => {
|
const db = new sqlite3.Database(finalPath, mode, (err) => {
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ const BLOCK_LIST = [
|
|||||||
"map", "cdn.ampproject.org", "googletagmanager"
|
"map", "cdn.ampproject.org", "googletagmanager"
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_SCRIPTS = [];
|
|
||||||
|
|
||||||
async function initHeadless() {
|
async function initHeadless() {
|
||||||
if (browser && browser.isConnected()) return;
|
if (browser && browser.isConnected()) return;
|
||||||
|
|
||||||
@@ -30,7 +28,6 @@ async function initHeadless() {
|
|||||||
"--mute-audio",
|
"--mute-audio",
|
||||||
"--no-first-run",
|
"--no-first-run",
|
||||||
"--no-zygote",
|
"--no-zygote",
|
||||||
"--single-process",
|
|
||||||
"--disable-software-rasterizer",
|
"--disable-software-rasterizer",
|
||||||
"--disable-client-side-phishing-detection",
|
"--disable-client-side-phishing-detection",
|
||||||
"--no-default-browser-check",
|
"--no-default-browser-check",
|
||||||
@@ -43,42 +40,103 @@ async function initHeadless() {
|
|||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36"
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122.0.0.0 Safari/537.36"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error al inicializar browser:", error);
|
console.error("Error initializing browser:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function safeEvaluate(page, fn, ...args) {
|
||||||
|
const maxAttempts = 3;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
// Checkeo de estado de página antes de intentar evaluar
|
||||||
|
if (page.isClosed()) {
|
||||||
|
throw new Error("Page is closed before evaluation");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Promise.race([
|
||||||
|
page.evaluate(fn, ...args),
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
// Timeout más corto podría ser más seguro, e.g., 20000ms
|
||||||
|
setTimeout(() => reject(new Error("Evaluate timeout")), 30000)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = (error.message || "").toLowerCase();
|
||||||
|
const isLastAttempt = i === maxAttempts - 1;
|
||||||
|
|
||||||
|
// Priorizar errores irrecuperables de contexto/página cerrada
|
||||||
|
if (
|
||||||
|
page.isClosed() ||
|
||||||
|
errorMsg.includes("closed") ||
|
||||||
|
errorMsg.includes("target closed") ||
|
||||||
|
errorMsg.includes("session closed")
|
||||||
|
) {
|
||||||
|
console.error("Page context lost or closed, throwing fatal error.");
|
||||||
|
throw error; // Lanzar inmediatamente, no tiene sentido reintentar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reintentar solo por errores transitorios de ejecución
|
||||||
|
if (!isLastAttempt && (
|
||||||
|
errorMsg.includes("execution context was destroyed") ||
|
||||||
|
errorMsg.includes("cannot find context") ||
|
||||||
|
errorMsg.includes("timeout")
|
||||||
|
)) {
|
||||||
|
console.warn(`Evaluate attempt ${i + 1} failed, retrying...`, error.message);
|
||||||
|
await new Promise(r => setTimeout(r, 500 * (i + 1)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function turboScroll(page) {
|
async function turboScroll(page) {
|
||||||
try {
|
try {
|
||||||
await page.evaluate(() => {
|
|
||||||
|
if (page.isClosed()) {
|
||||||
|
console.warn("Page closed, skipping scroll");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await safeEvaluate(page, () => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let last = 0;
|
let lastHeight = 0;
|
||||||
let same = 0;
|
let sameCount = 0;
|
||||||
const timer = setInterval(() => {
|
|
||||||
const h = document.body.scrollHeight;
|
const scrollInterval = setInterval(() => {
|
||||||
window.scrollTo(0, h);
|
try {
|
||||||
if (h === last) {
|
const currentHeight = document.body.scrollHeight;
|
||||||
same++;
|
window.scrollTo(0, currentHeight);
|
||||||
if (same >= 5) {
|
|
||||||
clearInterval(timer);
|
if (currentHeight === lastHeight) {
|
||||||
resolve();
|
sameCount++;
|
||||||
|
if (sameCount >= 5) {
|
||||||
|
clearInterval(scrollInterval);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sameCount = 0;
|
||||||
|
lastHeight = currentHeight;
|
||||||
}
|
}
|
||||||
} else {
|
} catch (err) {
|
||||||
same = 0;
|
clearInterval(scrollInterval);
|
||||||
last = h;
|
resolve();
|
||||||
}
|
}
|
||||||
}, 20);
|
}, 20);
|
||||||
|
|
||||||
// Safety timeout
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(timer);
|
clearInterval(scrollInterval);
|
||||||
resolve();
|
resolve();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en turboScroll:", error.message);
|
console.error("Error in turboScroll:", error.message);
|
||||||
// No lanzamos el error, continuamos
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +148,6 @@ async function scrape(url, handler, options = {}) {
|
|||||||
scrollToBottom = false,
|
scrollToBottom = false,
|
||||||
renderWaitTime = 0,
|
renderWaitTime = 0,
|
||||||
loadImages = true,
|
loadImages = true,
|
||||||
blockScripts = true,
|
|
||||||
retries = 3,
|
retries = 3,
|
||||||
retryDelay = 1000
|
retryDelay = 1000
|
||||||
} = options;
|
} = options;
|
||||||
@@ -101,7 +158,7 @@ async function scrape(url, handler, options = {}) {
|
|||||||
let page = null;
|
let page = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verificar que el browser esté activo
|
|
||||||
if (!browser || !browser.isConnected()) {
|
if (!browser || !browser.isConnected()) {
|
||||||
await initHeadless();
|
await initHeadless();
|
||||||
}
|
}
|
||||||
@@ -109,7 +166,10 @@ async function scrape(url, handler, options = {}) {
|
|||||||
page = await context.newPage();
|
page = await context.newPage();
|
||||||
const requests = [];
|
const requests = [];
|
||||||
|
|
||||||
// Listener para requests
|
page.on("close", () => {
|
||||||
|
console.warn("Page closed unexpectedly");
|
||||||
|
});
|
||||||
|
|
||||||
page.on("request", req => {
|
page.on("request", req => {
|
||||||
requests.push({
|
requests.push({
|
||||||
url: req.url(),
|
url: req.url(),
|
||||||
@@ -118,7 +178,6 @@ async function scrape(url, handler, options = {}) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route para bloquear recursos
|
|
||||||
await page.route("**/*", (route) => {
|
await page.route("**/*", (route) => {
|
||||||
const req = route.request();
|
const req = route.request();
|
||||||
const resUrl = req.url().toLowerCase();
|
const resUrl = req.url().toLowerCase();
|
||||||
@@ -128,9 +187,7 @@ async function scrape(url, handler, options = {}) {
|
|||||||
type === "font" ||
|
type === "font" ||
|
||||||
type === "stylesheet" ||
|
type === "stylesheet" ||
|
||||||
type === "media" ||
|
type === "media" ||
|
||||||
type === "manifest" ||
|
type === "other"
|
||||||
type === "other" ||
|
|
||||||
(blockScripts && type === "script" && !ALLOWED_SCRIPTS.some(k => resUrl.includes(k)))
|
|
||||||
) {
|
) {
|
||||||
return route.abort("blockedbyclient");
|
return route.abort("blockedbyclient");
|
||||||
}
|
}
|
||||||
@@ -148,66 +205,71 @@ async function scrape(url, handler, options = {}) {
|
|||||||
route.continue();
|
route.continue();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Navegar a la URL
|
|
||||||
await page.goto(url, { waitUntil, timeout });
|
await page.goto(url, { waitUntil, timeout });
|
||||||
|
|
||||||
// Esperar selector si se especifica
|
if (!page.isClosed()) {
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
if (waitSelector) {
|
if (waitSelector) {
|
||||||
try {
|
try {
|
||||||
await page.waitForSelector(waitSelector, { timeout: Math.min(timeout, 5000) });
|
await page.waitForSelector(waitSelector, {
|
||||||
|
timeout: Math.min(timeout, 5000)
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Selector '${waitSelector}' no encontrado, continuando...`);
|
console.warn(`Selector '${waitSelector}' not found, continuing...`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll si es necesario
|
|
||||||
if (scrollToBottom) {
|
if (scrollToBottom) {
|
||||||
await turboScroll(page);
|
await turboScroll(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tiempo de espera adicional para renderizado
|
|
||||||
if (renderWaitTime > 0) {
|
if (renderWaitTime > 0) {
|
||||||
await page.waitForTimeout(renderWaitTime);
|
await page.waitForTimeout(renderWaitTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ejecutar el handler personalizado
|
if (page.isClosed()) {
|
||||||
const result = await handler(page);
|
throw new Error("Page closed before handler execution");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await handler(page, safeEvaluate);
|
||||||
|
|
||||||
// Cerrar la página antes de retornar
|
|
||||||
await page.close();
|
await page.close();
|
||||||
|
|
||||||
return { result, requests };
|
return { result, requests };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
console.error(`[Intento ${attempt}/${retries}] Error durante el scraping de ${url}:`, error.message);
|
console.error(`[Attempt ${attempt}/${retries}] Error scraping ${url}:`, error.message);
|
||||||
|
|
||||||
// Cerrar página si está abierta
|
|
||||||
if (page && !page.isClosed()) {
|
if (page && !page.isClosed()) {
|
||||||
try {
|
try {
|
||||||
await page.close();
|
await page.close();
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
console.error("Error al cerrar página:", closeError.message);
|
console.error("Error closing page:", closeError.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si el browser está cerrado, limpiar referencias
|
if (
|
||||||
if (error.message.includes("closed") || error.message.includes("Target closed")) {
|
error.message.includes("closed") ||
|
||||||
console.log("Browser cerrado detectado, reiniciando...");
|
error.message.includes("Target closed") ||
|
||||||
|
error.message.includes("Session closed")
|
||||||
|
) {
|
||||||
|
console.log("Browser closure detected, reinitializing...");
|
||||||
await closeScraper();
|
await closeScraper();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no es el último intento, esperar antes de reintentar
|
|
||||||
if (attempt < retries) {
|
if (attempt < retries) {
|
||||||
const delay = retryDelay * attempt; // Backoff exponencial
|
const delay = retryDelay * attempt;
|
||||||
console.log(`Reintentando en ${delay}ms...`);
|
|
||||||
|
console.log(`Retrying in ${delay}ms...`);
|
||||||
await new Promise(r => setTimeout(r, delay));
|
await new Promise(r => setTimeout(r, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si llegamos aquí, todos los intentos fallaron
|
console.error(`All attempts failed for ${url}`);
|
||||||
console.error(`Todos los intentos fallaron para ${url}`);
|
|
||||||
throw lastError || new Error("Scraping failed after all retries");
|
throw lastError || new Error("Scraping failed after all retries");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +280,7 @@ async function closeScraper() {
|
|||||||
context = null;
|
context = null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error cerrando context:", error.message);
|
console.error("Error closing context:", error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -227,12 +289,13 @@ async function closeScraper() {
|
|||||||
browser = null;
|
browser = null;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error cerrando browser:", error.message);
|
console.error("Error closing browser:", error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
initHeadless,
|
initHeadless,
|
||||||
scrape,
|
scrape,
|
||||||
closeScraper
|
closeScraper,
|
||||||
|
safeEvaluate
|
||||||
};
|
};
|
||||||
@@ -3,9 +3,13 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
async function viewsRoutes(fastify: FastifyInstance) {
|
async function viewsRoutes(fastify: FastifyInstance) {
|
||||||
|
|
||||||
fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => {
|
fastify.get('/', (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'index.html'));
|
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'users.html'));
|
||||||
|
reply.type('text/html').send(stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/anime', (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime', 'animes.html'));
|
||||||
reply.type('text/html').send(stream);
|
reply.type('text/html').send(stream);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<button class="nav-button active" onclick="window.location.href='/'">Anime</button>
|
<button class="nav-button active">Anime</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<button class="nav-button" onclick="window.location.href='/'">Anime</button>
|
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
|
||||||
<button class="nav-button active">Books</button>
|
<button class="nav-button active">Books</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
||||||
|
|||||||
@@ -60,9 +60,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#downloadButton {
|
#downloadButton {
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
703
views/css/users.css
Normal file
703
views/css/users.css
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
:root {
|
||||||
|
--bg-base: #09090b;
|
||||||
|
--bg-surface: #121215;
|
||||||
|
--bg-surface-hover: #1e1e22;
|
||||||
|
--accent: #8b5cf6;
|
||||||
|
--accent-glow: rgba(139, 92, 246, 0.4);
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a1a1aa;
|
||||||
|
--radius-md: 12px;
|
||||||
|
--radius-lg: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-wrapper {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%);
|
||||||
|
z-index: 0;
|
||||||
|
animation: gradientShift 10s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradientShift {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
/* Añadido: Centra el contenido si la cuadrícula no llena el ancho */
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
animation: fadeInDown 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 900;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #a78bfa 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
display: grid;
|
||||||
|
/* Usar auto-fit con un ancho fijo para centrar elementos sueltos */
|
||||||
|
grid-template-columns: repeat(auto-fit, 260px);
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
animation: fadeInUp 0.8s ease;
|
||||||
|
|
||||||
|
/* Centrado de la cuadrícula en su contenedor */
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card:hover {
|
||||||
|
transform: translateY(-12px) scale(1.02);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 20px 40px rgba(139, 92, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #1e1e22 0%, #2a2a2f 100%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-placeholder svg {
|
||||||
|
width: 50%;
|
||||||
|
height: 50%;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 1rem 1.5rem; /* Reducido el padding */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card:hover .user-info {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* OCULTAR: El estado "Available" completo */
|
||||||
|
.user-status {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* BOTÓN DE CONFIGURACIÓN EN LA TARJETA (NUEVO) */
|
||||||
|
.user-config-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s, background 0.2s;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card:hover .user-config-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-config-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
padding: 1.2rem 2rem;
|
||||||
|
background: rgba(139, 92, 246, 0.1);
|
||||||
|
border: 2px dashed var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-user:hover {
|
||||||
|
background: rgba(139, 92, 246, 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 10px 30px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-manage {
|
||||||
|
display: none; /* OCULTADO */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
animation: scaleIn 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: white;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"]:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 15px var(--accent-glow);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-area {
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-area:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(139, 92, 246, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-area.dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview-placeholder {
|
||||||
|
width: 60%;
|
||||||
|
height: 60%;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-upload-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #7c3aed;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */
|
||||||
|
.manage-actions-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-actions-modal .btn-action {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.edit {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border-color: rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.edit:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.delete {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.anilist {
|
||||||
|
background: rgba(2, 169, 255, 0.1);
|
||||||
|
color: #02a9ff;
|
||||||
|
border-color: rgba(2, 169, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.anilist:hover {
|
||||||
|
background: rgba(2, 169, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.cancel {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action.cancel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
/* FIN ESTILOS PARA MODAL DE ACCIONES INDIVIDUALES */
|
||||||
|
|
||||||
|
.anilist-status {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anilist-connected {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anilist-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: linear-gradient(135deg, #02a9ff 0%, #0170d9 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 900;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anilist-info h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anilist-info p {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disconnect {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
color: #ef4444;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-disconnect:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
background: linear-gradient(135deg, #02a9ff 0%, #0170d9 100%);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 169, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
animation: fadeInUp 0.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ESTILOS DEL TOAST DE NOTIFICACIÓN (NUEVO) */
|
||||||
|
#userToastContainer { /* <-- ASEGÚRATE DE USAR ESTE ID */
|
||||||
|
position: fixed; /* <-- CRUCIAL: Mantiene el contenedor en su sitio al hacer scroll */
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-toast {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: all 0.5s ease-out;
|
||||||
|
pointer-events: all;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-toast.success {
|
||||||
|
background: #22c55e;
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-toast.error {
|
||||||
|
background: #ef4444;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wb-toast.info {
|
||||||
|
background: #3b82f6;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
/* FIN ESTILOS TOAST */
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading State */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #18181b 25%, #27272a 50%, #18181b 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-grid {
|
||||||
|
/* Para móvil, volvemos a usar minmax para ocupar el espacio */
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<button class="nav-button" onclick="window.location.href='/'">Anime</button>
|
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
||||||
<button class="nav-button active" onclick="window.location.href='/gallery'">Gallery</button>
|
<button class="nav-button active" onclick="window.location.href='/gallery'">Gallery</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<button class="nav-button" onclick="window.location.href='/'">Anime</button>
|
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
||||||
<button class="nav-button active" onclick="window.location.href='/gallery'">Gallery</button>
|
<button class="nav-button active" onclick="window.location.href='/gallery'">Gallery</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
||||||
|
|||||||
@@ -32,12 +32,12 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<button class="nav-button" onclick="window.location.href='/'">Anime</button>
|
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
<button class="nav-button" onclick="window.location.href='/schedule'">Schedule</button>
|
||||||
<button class="nav-button">My List</button>
|
<button class="nav-button">My List</button>
|
||||||
<button class="nav-button active" onclick="window.location.href='/marketplace'">Marketplace</button>
|
<button class="nav-button active">Marketplace</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-wrapper" style="visibility: hidden;">
|
<div class="search-wrapper" style="visibility: hidden;">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="nav-center">
|
<div class="nav-center">
|
||||||
<button class="nav-button" onclick="window.location.href='/'">Anime</button>
|
<button class="nav-button" onclick="window.location.href='/anime'">Anime</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
<button class="nav-button" onclick="window.location.href='/books'">Books</button>
|
||||||
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
<button class="nav-button" onclick="window.location.href='/gallery'">Gallery</button>
|
||||||
<button class="nav-button active">Schedule</button>
|
<button class="nav-button active">Schedule</button>
|
||||||
|
|||||||
174
views/users.html
Normal file
174
views/users.html
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>WaifuBoard - Users</title>
|
||||||
|
<link rel="stylesheet" href="/views/css/anime/home.css">
|
||||||
|
<link rel="stylesheet" href="/views/css/updateNotifier.css">
|
||||||
|
<link rel="stylesheet" href="/views/css/titlebar.css">
|
||||||
|
<link rel="stylesheet" href="/views/css/users.css">
|
||||||
|
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
|
||||||
|
<script src="/src/scripts/titlebar.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="titlebar"> <div class="title-left">
|
||||||
|
<img class="app-icon" src="/public/assets/waifuboards.ico" alt=""/>
|
||||||
|
<span class="app-title">WaifuBoard</span>
|
||||||
|
</div>
|
||||||
|
<div class="title-right">
|
||||||
|
<button class="min">—</button>
|
||||||
|
<button class="max">🗖</button>
|
||||||
|
<button class="close">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="background-gradient"></div>
|
||||||
|
|
||||||
|
<div class="content-container">
|
||||||
|
<div class="header-section">
|
||||||
|
<h1 class="page-title">Who's exploring?</h1>
|
||||||
|
<p class="page-subtitle">Select your profile to continue</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="users-grid" id="usersGrid">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-add-user" id="btnAddUser">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modalCreateUser">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Create New User</h2>
|
||||||
|
<button class="modal-close" id="closeCreateModal">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="createUserForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required maxlength="20" placeholder="Enter your name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Picture</label>
|
||||||
|
<div class="avatar-upload-area" id="avatarUploadArea">
|
||||||
|
<div class="avatar-preview" id="avatarPreview">
|
||||||
|
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="avatar-upload-text">Click to upload or drag and drop</p>
|
||||||
|
<p class="avatar-upload-hint">PNG, JPG up to 5MB</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="avatarInput" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="cancelCreate">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary">Create User</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modalUserActions">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content" style="max-width: 400px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="actionsModalTitle">Manage Profile</h2>
|
||||||
|
<button class="modal-close" id="closeActionsModal">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="actionsModalContent">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modalEditUser">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Edit Profile</h2>
|
||||||
|
<button class="modal-close" id="closeEditModal">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="editUserForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editUsername">Username</label>
|
||||||
|
<input type="text" id="editUsername" name="username" required maxlength="20" placeholder="Enter your new name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Profile Picture</label>
|
||||||
|
<div class="avatar-upload-area" id="editAvatarUploadArea">
|
||||||
|
<div class="avatar-preview" id="editAvatarPreview">
|
||||||
|
<svg class="avatar-preview-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="avatar-upload-text">Click to upload or drag and drop</p>
|
||||||
|
<p class="avatar-upload-hint">PNG, JPG up to 5MB</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="editAvatarInput" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="cancelEdit">Cancel</button>
|
||||||
|
<button type="submit" class="btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modalAniList">
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>AniList Integration</h2>
|
||||||
|
<button class="modal-close" id="closeAniListModal">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="aniListContent">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="userToastContainer"></div>
|
||||||
|
<div id="updateToast" class="hidden">
|
||||||
|
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
|
||||||
|
<a
|
||||||
|
id="downloadButton"
|
||||||
|
href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Click To Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/src/scripts/updateNotifier.js"></script>
|
||||||
|
<script src="/src/scripts/users.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user