anilist integrated to my list

This commit is contained in:
2025-12-06 17:18:03 +01:00
parent e5ec8aa7e5
commit 822a9f83cf
13 changed files with 774 additions and 257 deletions

View File

@@ -19,7 +19,7 @@ const galleryRoutes = require('./dist/api/gallery/gallery.routes');
const rpcRoutes = require('./dist/api/rpc/rpc.routes');
const userRoutes = require('./dist/api/user/user.routes');
const listRoutes = require('./dist/api/list/list.routes');
const anilistRoute = require('./dist/api/anilist');
const anilistRoute = require('./dist/api/anilist/anilist');
fastify.addHook("preHandler", async (request) => {

View File

@@ -0,0 +1,214 @@
import { queryOne } from '../../shared/database';
const USER_DB = 'userdata';
export async function getUserAniList(appUserId: number) {
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [appUserId], USER_DB) as any;
if (!integration) return [];
const { access_token, anilist_user_id } = integration;
const query = `
query ($userId: Int) {
anime: MediaListCollection(userId: $userId, type: ANIME) {
lists {
entries {
mediaId
status
progress
score
}
}
}
manga: MediaListCollection(userId: $userId, type: MANGA) {
lists {
entries {
mediaId
status
progress
score
}
}
}
}
`;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables: { userId: anilist_user_id }
}),
});
const json = await res.json();
const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => {
const result: any[] = [];
for (const list of lists || []) {
for (const entry of list.entries || []) {
result.push({
user_id: appUserId,
entry_id: entry.mediaId,
source: 'anilist',
entry_type: type,
status: entry.status,
progress: entry.progress || 0,
score: entry.score || null,
});
}
}
return result;
};
return [
...normalize(json?.data?.anime?.lists, 'ANIME'),
...normalize(json?.data?.manga?.lists, 'MANGA')
];
}
export async function updateAniListEntry(token: string, params: {
mediaId: number | string;
status?: string | null;
progress?: number | null;
score?: number | null;
}) {
const mutation = `
mutation ($mediaId: Int, $status: MediaListStatus, $progress: Int, $score: Float) {
SaveMediaListEntry (
mediaId: $mediaId,
status: $status,
progress: $progress,
score: $score
) {
id
status
progress
score
}
}
`;
const variables: any = {
mediaId: Number(params.mediaId),
};
if (params.status != null) variables.status = params.status;
if (params.progress != null) variables.progress = params.progress;
if (params.score != null) variables.score = params.score;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: mutation, variables }),
});
const json = await res.json();
if (!res.ok || json?.errors?.length) {
throw new Error("AniList update failed");
}
return json.data?.SaveMediaListEntry || null;
}
export async function deleteAniListEntry(token: string, mediaId: number) {
const query = `
query ($mediaId: Int) {
MediaList(mediaId: $mediaId) {
id
}
}
`;
const qRes = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables: { mediaId } }),
});
const qJson = await qRes.json();
const listEntryId = qJson?.data?.MediaList?.id;
if (!listEntryId) {
throw new Error("Entry not found or unauthorized to delete.");
}
const mutation = `
mutation ($id: Int) {
DeleteMediaListEntry(id: $id) {
deleted
}
}
`;
const mRes = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: mutation,
variables: { id: listEntryId }
}),
});
const mJson = await mRes.json();
if (mJson?.errors?.length) {
throw new Error("Error eliminando entrada en AniList");
}
return true;
}
export async function getSingleAniListEntry(
token: string,
mediaId: number,
type: 'ANIME' | 'MANGA'
) {
const query = `
query ($mediaId: Int, $type: MediaType) {
MediaList(mediaId: $mediaId, type: $type) {
status
progress
score
}
}
`;
const res = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
variables: { mediaId, type }
})
});
const json = await res.json();
return json?.data?.MediaList || null;
}

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify";
import { run } from "../shared/database";
import { run } from "../../shared/database";
async function anilist(fastify: FastifyInstance) {
fastify.get("/anilist", async (request, reply) => {

View File

@@ -1,10 +1,6 @@
// list.controller.ts
import { FastifyReply, FastifyRequest } from 'fastify';
import * as listService from './list.service';
// Tipos de solicitud asumidos:
// - UserRequest: Request con el objeto 'user' adjunto por el hook de autenticación.
interface UserRequest extends FastifyRequest {
user?: { id: number };
}
@@ -19,14 +15,16 @@ interface UpsertEntryBody {
score: number;
}
interface DeleteEntryParams {
interface EntryParams {
entryId: string;
}
interface SingleEntryQuery {
source: string;
entry_type: string;
}
/**
* GET /list
* Obtiene toda la lista del usuario autenticado.
*/
export async function getList(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
if (!userId) {
@@ -42,10 +40,42 @@ export async function getList(req: UserRequest, reply: FastifyReply) {
}
}
/**
* POST /list/entry
* Crea o actualiza una entrada de lista (upsert).
*/
export async function getSingleEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
const { entryId } = req.params as EntryParams;
const { source, entry_type } = req.query as SingleEntryQuery;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
if (!entryId || !source || !entry_type) {
return reply.code(400).send({ error: "Missing required identifier: entryId, source, or entry_type." });
}
const entryIdentifier = entryId;
try {
const entry = await listService.getSingleListEntry(
userId,
entryIdentifier,
source,
entry_type
);
if (!entry) {
return reply.code(404).send({ found: false, message: "Entry not found in user list." });
}
return { found: true, entry: entry };
} catch (err) {
console.error(err);
return reply.code(500).send({ error: "Failed to retrieve list entry" });
}
}
export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
const body = req.body as UpsertEntryBody;
@@ -54,7 +84,6 @@ export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
return reply.code(401).send({ error: "Unauthorized" });
}
// <--- NUEVO: Validación de entry_type
if (!body.entry_id || !body.source || !body.status || !body.entry_type) {
return reply.code(400).send({ error: "Missing required fields (entry_id, source, status, entry_type)." });
}
@@ -65,7 +94,7 @@ export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
entry_id: body.entry_id,
external_id: body.external_id,
source: body.source,
entry_type: body.entry_type, // <--- NUEVO: Pasar entry_type
entry_type: body.entry_type,
status: body.status,
progress: body.progress || 0,
score: body.score || null
@@ -80,25 +109,22 @@ export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
}
}
/**
* DELETE /list/entry/:entryId
* Elimina una entrada de lista.
*/
export async function deleteEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id;
const { entryId } = req.params as DeleteEntryParams;
const { entryId } = req.params as EntryParams;
if (!userId) {
return reply.code(401).send({ error: "Unauthorized" });
}
const numericEntryId = parseInt(entryId, 10);
if (isNaN(numericEntryId)) {
const entryIdentifier = entryId;
if (!entryIdentifier) {
return reply.code(400).send({ error: "Invalid entry ID." });
}
try {
const result = await listService.deleteListEntry(userId, numericEntryId);
const result = await listService.deleteListEntry(userId, entryIdentifier);
if (result.success) {
return { success: true, message: "Entry deleted successfully." };

View File

@@ -3,6 +3,7 @@ import * as controller from './list.controller';
async function listRoutes(fastify: FastifyInstance) {
fastify.get('/list', controller.getList);
fastify.get('/list/entry/:entryId', controller.getSingleEntry);
fastify.post('/list/entry', controller.upsertEntry);
fastify.delete('/list/entry/:entryId', controller.deleteEntry);
}

View File

@@ -1,27 +1,24 @@
// list.service.ts (Actualizado)
import {queryAll, run} from '../../shared/database';
import {queryAll, run, queryOne} from '../../shared/database';
import {getExtension} from '../../shared/extensions';
import * as animeService from '../anime/anime.service';
import * as booksService from '../books/books.service';
import * as aniListService from '../anilist/anilist.service';
// Define la interfaz de entrada de lista (sin external_id)
interface ListEntryData {
entry_type: any;
user_id: number;
entry_id: number; // ID de contenido de la fuente (AniList, MAL, o local)
source: string; // 'anilist', 'local', etc.
status: string; // 'COMPLETED', 'WATCHING', etc.
entry_id: number;
source: string;
status: string;
progress: number;
score: number | null;
}
const USER_DB = 'userdata';
/**
* Inserta o actualiza una entrada de lista.
* Utiliza ON CONFLICT(user_id, entry_id) para el upsert.
*/
export async function upsertListEntry(entry: any) {
const {
user_id,
@@ -33,6 +30,29 @@ export async function upsertListEntry(entry: any) {
score
} = entry;
if (source === 'anilist') {
const token = await getActiveAccessToken(user_id);
if (token) {
try {
const result = await aniListService.updateAniListEntry(token, {
mediaId: entry_id,
status,
progress,
score
});
return { changes: 0, external: true, anilistResult: result };
} catch (err) {
console.error("Error actualizando AniList:", err);
}
}
}
const sql = `
INSERT INTO ListEntry
(user_id, entry_id, source, entry_type, status, progress, score, updated_at)
@@ -59,16 +79,13 @@ export async function upsertListEntry(entry: any) {
try {
const result = await run(sql, params, USER_DB);
return { changes: result.changes, lastID: result.lastID };
return { changes: result.changes, lastID: result.lastID, external: false };
} catch (error) {
console.error("Error al guardar la entrada de lista:", error);
throw new Error("Error en la base de datos al guardar la entrada.");
}
}
/**
* Recupera la lista completa de un usuario.
*/
export async function getUserList(userId: number): Promise<any> {
const sql = `
SELECT * FROM ListEntry
@@ -77,11 +94,25 @@ export async function getUserList(userId: number): Promise<any> {
`;
try {
// 1. Obtener la lista base de la DB
const dbList = await queryAll(sql, [userId], USER_DB) as ListEntryData[];
// 2. Crear un array de promesas para obtener los detalles de cada entrada concurrentemente
const enrichedListPromises = dbList.map(async (entry) => {
const connected = await isConnected(userId);
let finalList: ListEntryData[] = [...dbList];
if (connected) {
const token = await getActiveAccessToken(userId);
const anilistEntries = await aniListService.getUserAniList(userId);
const localWithoutAnilist = dbList.filter(
entry => entry.source !== 'anilist'
);
finalList = [...anilistEntries, ...localWithoutAnilist];
}
const enrichedListPromises = finalList.map(async (entry) => {
let contentDetails: any | null = null;
const id = entry.entry_id;
const source = entry.source;
@@ -89,13 +120,12 @@ export async function getUserList(userId: number): Promise<any> {
try {
if (type === 'ANIME') {
// Lógica para ANIME
let anime: any;
if (source === 'anilist') {
anime = await animeService.getAnimeById(id);
} else {
const ext = getExtension(source);
// Asegurar que id sea una cadena para getAnimeInfoExtension si el id es un número
anime = await animeService.getAnimeInfoExtension(ext, id.toString());
}
@@ -106,13 +136,12 @@ export async function getUserList(userId: number): Promise<any> {
};
} else if (type === 'MANGA' || type === 'NOVEL') {
// Lógica para MANGA, NOVEL y otros "books"
let book: any;
if (source === 'anilist') {
book = await booksService.getBookById(id);
} else {
const ext = getExtension(source);
// Asegurar que id sea una cadena
const result = await booksService.getBookInfoExtension(ext, id.toString());
book = result || null;
}
@@ -120,42 +149,33 @@ export async function getUserList(userId: number): Promise<any> {
contentDetails = {
title: book?.title || 'Unknown Book Title',
poster: book?.coverImage?.extraLarge || book?.image || '',
// Priorizar chapters, luego volumes * 10, sino 0
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
} catch (contentError) {
console.error(`Error fetching details for entry ${id} (${source}):`, contentError);
} catch {
contentDetails = {
title: 'Error Loading Details',
poster: '/public/assets/placeholder.png',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
// 3. Estandarizar y Combinar los datos.
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || '/public/assets/placeholder.png';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
// Aplanamiento del título (Necesario para Anilist que devuelve un objeto)
if (typeof finalTitle === 'object' && finalTitle !== null) {
// Priorizar userPreferred, luego english, luego romaji, sino 'Unknown Title'
finalTitle = finalTitle.userPreferred || finalTitle.english || finalTitle.romaji || 'Unknown Title';
}
// Retornar el objeto combinado y estandarizado
return {
...entry,
// Datos estandarizados para el frontend:
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes, // Será undefined si es Manga/Novel
total_chapters: contentDetails?.total_chapters, // Será undefined si es Anime
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
});
// 4. Ejecutar todas las promesas y esperar el resultado
return await Promise.all(enrichedListPromises);
} catch (error) {
@@ -164,10 +184,39 @@ export async function getUserList(userId: number): Promise<any> {
}
}
/**
* Elimina una entrada de lista por user_id y entry_id.
*/
export async function deleteListEntry(userId: number, entryId: number) {
export async function deleteListEntry(userId: number, entryId: string | number) {
const checkSql = `
SELECT source
FROM ListEntry
WHERE user_id = ? AND entry_id = ?;
`;
const existing = await queryOne(checkSql, [userId, entryId], USER_DB) as any;
if (existing?.source === 'anilist') {
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [userId], USER_DB) as any;
if (integration?.access_token) {
try {
await aniListService.deleteAniListEntry(
integration.access_token,
Number(entryId)
);
return { success: true, external: true };
} catch (err) {
console.error("Error borrando en AniList:", err);
}
}
}
const sql = `
DELETE FROM ListEntry
WHERE user_id = ? AND entry_id = ?;
@@ -175,9 +224,189 @@ export async function deleteListEntry(userId: number, entryId: number) {
try {
const result = await run(sql, [userId, entryId], USER_DB);
return { success: result.changes > 0, changes: result.changes };
return { success: result.changes > 0, changes: result.changes, external: false };
} catch (error) {
console.error("Error al eliminar la entrada de lista:", error);
throw new Error("Error en la base de datos al eliminar la entrada.");
}
}
export async function getSingleListEntry(
userId: number,
entryId: string | number,
source: string,
entryType: string
): Promise<any> {
const connected = await isConnected(userId);
if (source === 'anilist' && connected) {
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [userId], USER_DB) as any;
if (!integration) return null;
const aniEntry = await aniListService.getSingleAniListEntry(
integration.access_token,
Number(entryId),
entryType as any
);
if (!aniEntry) return null;
let contentDetails: any = null;
if (entryType === 'ANIME') {
const anime:any = await animeService.getAnimeById(entryId);
contentDetails = {
title: anime?.title,
poster: anime?.coverImage?.extraLarge || anime?.image || '',
total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0,
};
} else {
const book: any = await booksService.getBookById(entryId);
contentDetails = {
title: book?.title,
poster: book?.coverImage?.extraLarge || book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle = finalTitle.userPreferred || finalTitle.english || finalTitle.romaji || 'Unknown Title';
}
return {
user_id: userId,
entry_id: Number(entryId),
source: 'anilist',
entry_type: entryType,
status: aniEntry.status,
progress: aniEntry.progress,
score: aniEntry.score,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
}
const sql = `
SELECT * FROM ListEntry
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
`;
const dbEntry = await queryAll(sql, [userId, entryId, source, entryType], USER_DB) as ListEntryData[];
if (!dbEntry || dbEntry.length === 0) return null;
const entry = dbEntry[0];
let contentDetails: any | null = null;
try {
if (entryType === 'ANIME') {
let anime: any;
if (source === 'anilist') {
anime = await animeService.getAnimeById(entryId);
} else {
const ext = getExtension(source);
anime = await animeService.getAnimeInfoExtension(ext, entryId.toString());
}
contentDetails = {
title: anime?.title,
poster: anime?.coverImage?.extraLarge || anime?.image || '',
total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0,
};
} else {
let book: any;
if (source === 'anilist') {
book = await booksService.getBookById(entryId);
} else {
const ext = getExtension(source);
book = await booksService.getBookInfoExtension(ext, entryId.toString());
}
contentDetails = {
title: book?.title,
poster: book?.coverImage?.extraLarge || book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
} catch {
contentDetails = {
title: 'Unknown',
poster: 'https://placehold.co/400x600?text=No+Cover',
};
}
let finalTitle = contentDetails?.title || 'Unknown Title';
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object' && finalTitle !== null) {
finalTitle = finalTitle.userPreferred || finalTitle.english || finalTitle.romaji || 'Unknown Title';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.total_episodes,
total_chapters: contentDetails?.total_chapters,
};
}
export async function getActiveAccessToken(userId: number): Promise<string | null> {
const sql = `
SELECT access_token, expires_at
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
try {
const integration = await queryOne(sql, [userId], USER_DB) as any | null;
if (!integration) {
return null;
}
const expiryDate = new Date(integration.expires_at);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;
if (expiryDate.getTime() < (now.getTime() + fiveMinutes)) {
console.log(`AniList token for user ${userId} expired or near expiry.`);
return null;
}
return integration.access_token;
} catch (error) {
console.error("Error al verificar la integración de AniList:", error);
return null;
}
}
export async function isConnected(userId: number): Promise<boolean> {
const token = await getActiveAccessToken(userId);
return !!token;
}

View File

@@ -15,7 +15,6 @@ tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
// Auth helpers
function getAuthToken() {
return localStorage.getItem('token');
}
@@ -28,32 +27,46 @@ function getAuthHeaders() {
};
}
// Check if anime is in list
function getSimpleAuthHeaders() {
const token = getAuthToken();
return {
'Authorization': `Bearer ${token}`
};
}
async function checkIfInList() {
const entryId = window.location.pathname.split('/').pop();
const source = extensionName || 'anilist';
const entryType = 'ANIME';
const fetchUrl = `${API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`;
try {
const response = await fetch(`${API_BASE}/list`, {
headers: getAuthHeaders()
const response = await fetch(fetchUrl, {
headers: getSimpleAuthHeaders()
});
if (response.ok) {
const data = await response.json();
const entry = data.results?.find(item =>
item.entry_id === parseInt(animeId) &&
item.source === (extensionName || 'anilist')
);
if (entry) {
if (data.found && data.entry) {
isInList = true;
currentListEntry = entry;
updateAddToListButton();
currentListEntry = data.entry;
} else {
isInList = false;
currentListEntry = null;
}
updateAddToListButton();
}
} catch (error) {
console.error('Error checking list:', error);
console.error('Error checking single list entry:', error);
}
}
// Update button state
function updateAddToListButton() {
const btn = document.getElementById('add-to-list-btn');
if (isInList) {
@@ -68,13 +81,15 @@ function updateAddToListButton() {
btn.style.borderColor = 'rgba(34, 197, 94, 0.3)';
} else {
btn.innerHTML = '+ Add to List';
btn.style.background = null;
btn.style.color = null;
btn.style.borderColor = null;
}
}
// Open add to list modal
function openAddToListModal() {
if (isInList) {
// If already in list, open edit modal with current data
document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING';
document.getElementById('modal-progress').value = currentListEntry.progress || 0;
document.getElementById('modal-score').value = currentListEntry.score || '';
@@ -82,7 +97,7 @@ function openAddToListModal() {
document.getElementById('modal-title').textContent = 'Edit List Entry';
document.getElementById('modal-delete-btn').style.display = 'block';
} else {
// New entry defaults
document.getElementById('modal-status').value = 'PLANNING';
document.getElementById('modal-progress').value = 0;
document.getElementById('modal-score').value = '';
@@ -94,12 +109,10 @@ function openAddToListModal() {
document.getElementById('modal-progress').max = totalEpisodes || 999;
document.getElementById('add-list-modal').classList.add('active');}
// Close modal
function closeAddToListModal() {
document.getElementById('add-list-modal').classList.remove('active');
}
// Save to list
async function saveToList() {
const status = document.getElementById('modal-status').value;
const progress = parseInt(document.getElementById('modal-progress').value) || 0;
@@ -123,12 +136,12 @@ async function saveToList() {
throw new Error('Failed to save entry');
}
// Si la operación fue exitosa, actualizamos currentListEntry con el nuevo campo type
isInList = true;
currentListEntry = {
entry_id: parseInt(animeId),
source: extensionName || 'anilist',
entry_type: 'ANIME', // <--- También se actualiza aquí
entry_type: 'ANIME',
status,
progress,
score
@@ -142,14 +155,14 @@ async function saveToList() {
}
}
// Delete from list
async function deleteFromList() {
if (!confirm('Remove this anime from your list?')) return;
try {
const response = await fetch(`${API_BASE}/list/entry/${animeId}`, {
method: 'DELETE',
headers: getAuthHeaders()
headers: getSimpleAuthHeaders()
});
if (!response.ok) {
@@ -167,7 +180,6 @@ async function deleteFromList() {
}
}
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
@@ -208,7 +220,7 @@ async function loadAnime() {
const fetchUrl = extensionName
? `/api/anime/${animeId}?source=${extensionName}`
: `/api/anime/${animeId}?source=anilist`;
const res = await fetch(fetchUrl);
const res = await fetch(fetchUrl, { headers: getSimpleAuthHeaders() });
const data = await res.json();
if (data.error) {
@@ -362,7 +374,6 @@ async function loadAnime() {
renderEpisodes();
// Check if in list after loading anime data
await checkIfInList();
} catch (err) {
@@ -458,7 +469,6 @@ searchInput.addEventListener('input', (e) => {
}
});
// Close modal on outside click
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('add-list-modal');
if (modal) {
@@ -470,7 +480,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Add animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {

View File

@@ -6,7 +6,6 @@ const itemsPerPage = 12;
let extensionName = null;
let bookSlug = null;
// NUEVAS VARIABLES GLOBALES PARA LISTA
let currentBookData = null;
let isInList = false;
let currentListEntry = null;
@@ -21,11 +20,6 @@ function getChaptersUrl(id, source = 'anilist') {
return `/api/book/${id}/chapters?source=${source}`;
}
// ==========================================================
// 1. FUNCIONES DE AUTENTICACIÓN Y LISTA
// ==========================================================
// Auth helpers
function getAuthToken() {
return localStorage.getItem('token');
}
@@ -38,34 +32,53 @@ function getAuthHeaders() {
};
}
// Check if book is in list
function getSimpleAuthHeaders() {
const token = getAuthToken();
return {
'Authorization': `Bearer ${token}`
};
}
function getBookEntryType(bookData) {
if (!bookData) return 'MANGA';
const format = bookData.format?.toUpperCase() || 'MANGA';
return (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') ? 'MANGA' : 'NOVEL';
}
async function checkIfInList() {
if (!currentBookData) return;
const entryId = extensionName ? bookSlug : bookId;
const source = extensionName || 'anilist';
const entryType = getBookEntryType(currentBookData);
const fetchUrl = `${API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`;
try {
const response = await fetch(`${API_BASE}/list`, {
headers: getAuthHeaders()
const response = await fetch(fetchUrl, {
headers: getSimpleAuthHeaders()
});
if (response.ok) {
const data = await response.json();
const idToSearch = extensionName ? bookSlug : bookId;
const entry = data.results?.find(item =>
item.entry_id === idToSearch &&
item.source === (extensionName || 'anilist')
);
if (data.found && data.entry) {
if (entry) {
isInList = true;
currentListEntry = entry;
updateAddToListButton();
currentListEntry = data.entry;
} else {
isInList = false;
currentListEntry = null;
}
updateAddToListButton();
}
} catch (error) {
console.error('Error checking list:', error);
console.error('Error checking single list entry:', error);
}
}
// Update button state
function updateAddToListButton() {
const btn = document.getElementById('add-to-list-btn');
if (!btn) return;
@@ -83,22 +96,20 @@ function updateAddToListButton() {
btn.onclick = openAddToListModal;
} else {
btn.innerHTML = '+ Add to Library';
btn.style.background = null; // Restablecer estilos si no está en lista
btn.style.background = null;
btn.style.color = null;
btn.style.borderColor = null;
btn.onclick = openAddToListModal;
}
}
// Open add to list modal
function openAddToListModal() {
if (!currentBookData) return;
// Obtener el total de capítulos/volúmenes
const totalUnits = currentBookData.chapters || currentBookData.volumes || 999;
if (isInList) {
// If already in list, open edit modal with current data
document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING';
document.getElementById('modal-progress').value = currentListEntry.progress || 0;
document.getElementById('modal-score').value = currentListEntry.score || '';
@@ -106,7 +117,7 @@ function openAddToListModal() {
document.getElementById('modal-title').textContent = 'Edit Library Entry';
document.getElementById('modal-delete-btn').style.display = 'block';
} else {
// New entry defaults
document.getElementById('modal-status').value = 'PLANNING';
document.getElementById('modal-progress').value = 0;
document.getElementById('modal-score').value = '';
@@ -115,7 +126,6 @@ function openAddToListModal() {
document.getElementById('modal-delete-btn').style.display = 'none';
}
// Ajustar etiqueta de progreso según el formato
const progressLabel = document.getElementById('modal-progress-label');
if (progressLabel) {
const format = currentBookData.format?.toUpperCase() || 'MANGA';
@@ -130,12 +140,10 @@ function openAddToListModal() {
document.getElementById('add-list-modal').classList.add('active');
}
// Close modal
function closeAddToListModal() {
document.getElementById('add-list-modal').classList.remove('active');
}
// Save to list
async function saveToList() {
const status = document.getElementById('modal-status').value;
const progress = parseInt(document.getElementById('modal-progress').value) || 0;
@@ -146,9 +154,7 @@ async function saveToList() {
return;
}
// Determinar el tipo de entrada (MANGA o NOVEL basado en el formato)
const format = currentBookData.format?.toUpperCase() || 'MANGA';
const entryType = (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') ? 'MANGA' : 'NOVEL';
const entryType = getBookEntryType(currentBookData);
const idToSave = extensionName ? bookSlug : bookId;
try {
@@ -180,7 +186,6 @@ async function saveToList() {
}
}
// Delete from list
async function deleteFromList() {
if (!confirm('Remove this book from your library?')) return;
@@ -189,7 +194,8 @@ async function deleteFromList() {
try {
const response = await fetch(`${API_BASE}/list/entry/${idToDelete}`, {
method: 'DELETE',
headers: getAuthHeaders()
headers: getSimpleAuthHeaders()
});
if (!response.ok) {
@@ -207,7 +213,6 @@ async function deleteFromList() {
}
}
// Show notification
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
@@ -232,11 +237,6 @@ function showNotification(message, type = 'info') {
}, 3000);
}
// ==========================================================
// 2. FUNCIÓN PRINCIPAL DE CARGA (init)
// ==========================================================
async function init() {
try {
const path = window.location.pathname;
@@ -260,7 +260,7 @@ async function init() {
extensionName || 'anilist'
);
const res = await fetch(fetchUrl);
const res = await fetch(fetchUrl, { headers: getSimpleAuthHeaders() });
const data = await res.json();
if (data.error || !data) {
@@ -269,7 +269,7 @@ async function init() {
return;
}
currentBookData = data; // <--- GUARDAR DATOS GLOBALES
currentBookData = data;
let title, description, score, year, status, format, chapters, poster, banner, genres;
@@ -350,18 +350,13 @@ async function init() {
loadChapters(idForFetch);
await checkIfInList(); // <--- COMPROBAR ESTADO DE LA LISTA
await checkIfInList();
} catch (err) {
console.error("Metadata Error:", err);
}
}
// ==========================================================
// 3. FUNCIONES DE CARGA Y RENDERIZADO DE CAPÍTULOS
// (Sin cambios, pero se incluyen para completar el script)
// ==========================================================
async function loadChapters(idForFetch) {
const tbody = document.getElementById('chapters-body');
if (!tbody) return;
@@ -375,7 +370,7 @@ async function loadChapters(idForFetch) {
extensionName || 'anilist'
);
const res = await fetch(fetchUrl);
const res = await fetch(fetchUrl, { headers: getSimpleAuthHeaders() });
const data = await res.json();
allChapters = data.chapters || [];
@@ -523,11 +518,6 @@ function openReader(bookId, chapterId, provider) {
window.location.href = `/read/${p}/${c}/${bookId}${extension}`;
}
// ==========================================================
// 4. LISTENERS Y ARRANQUE
// ==========================================================
// Close modal on outside click
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('add-list-modal');
if (modal) {
@@ -539,7 +529,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Add animations (Copied from anime.js)
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {

View File

@@ -1,15 +1,12 @@
// API Configuration
const API_BASE = '/api';
let currentList = [];
let filteredList = [];
let currentEditingEntry = null;
// Get token from localStorage
function getAuthToken() {
return localStorage.getItem('token');
}
// Create headers with auth token
function getAuthHeaders() {
const token = getAuthToken();
return {
@@ -18,24 +15,39 @@ function getAuthHeaders() {
};
}
// Initialize on page load
function getSimpleAuthHeaders() {
const token = getAuthToken();
return {
'Authorization': `Bearer ${token}`
};
}
document.addEventListener('DOMContentLoaded', async () => {
await loadList();
setupEventListeners();
});
// ==========================================================
// FUNCIÓN: Poblar Filtro de Fuente (NUEVA LÓGICA)
// ==========================================================
function getEntryLink(item) {
const isAnime = item.entry_type?.toUpperCase() === 'ANIME';
const baseRoute = isAnime ? '/anime' : '/book';
const source = item.source || 'anilist';
if (source === 'anilist') {
return `${baseRoute}/${item.entry_id}`;
} else {
return `${baseRoute}/${source}/${item.entry_id}`;
}
}
async function populateSourceFilter() {
const select = document.getElementById('source-filter');
if (!select) return;
// Opciones base
select.innerHTML = `
<option value="all">All Sources</option>
<option value="anilist">AniList</option>
<option value="local">Local</option>
`;
try {
@@ -44,13 +56,10 @@ async function populateSourceFilter() {
const data = await response.json();
const extensions = data.extensions || [];
// Añadir cada nombre de extensión como una opción
extensions.forEach(extName => {
// Evitar duplicar 'anilist' o 'local'
if (extName.toLowerCase() !== 'anilist' && extName.toLowerCase() !== 'local') {
const option = document.createElement('option');
option.value = extName;
// Capitalizar el nombre
option.textContent = extName.charAt(0).toUpperCase() + extName.slice(1);
select.appendChild(option);
}
@@ -61,10 +70,8 @@ async function populateSourceFilter() {
}
}
// Setup all event listeners
function setupEventListeners() {
// View toggle
document.querySelectorAll('.view-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
@@ -79,13 +86,11 @@ function setupEventListeners() {
});
});
// Filters
document.getElementById('status-filter').addEventListener('change', applyFilters);
document.getElementById('source-filter').addEventListener('change', applyFilters);
document.getElementById('type-filter').addEventListener('change', applyFilters);
document.getElementById('sort-filter').addEventListener('change', applyFilters);
// Search
document.querySelector('.search-input').addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
if (query) {
@@ -99,13 +104,11 @@ function setupEventListeners() {
});
}
// Load list from API (MODIFICADO para incluir populateSourceFilter)
async function loadList() {
const loadingState = document.getElementById('loading-state');
const emptyState = document.getElementById('empty-state');
const container = document.getElementById('list-container');
// Ejecutar la carga de extensiones antes de la lista principal
await populateSourceFilter();
try {
@@ -114,7 +117,7 @@ async function loadList() {
container.innerHTML = '';
const response = await fetch(`${API_BASE}/list`, {
headers: getAuthHeaders()
headers: getSimpleAuthHeaders()
});
if (!response.ok) {
@@ -140,7 +143,6 @@ async function loadList() {
}
}
// Update statistics
function updateStats() {
const total = currentList.length;
const watching = currentList.filter(item => item.status === 'WATCHING').length;
@@ -153,14 +155,12 @@ function updateStats() {
document.getElementById('planned-count').textContent = planning;
}
// Apply filters and sorting
function applyFilters() {
const statusFilter = document.getElementById('status-filter').value;
const sourceFilter = document.getElementById('source-filter').value;
const typeFilter = document.getElementById('type-filter').value;
const sortFilter = document.getElementById('sort-filter').value;
// Filter
let filtered = [...filteredList];
if (statusFilter !== 'all') {
@@ -171,12 +171,10 @@ function applyFilters() {
filtered = filtered.filter(item => item.source === sourceFilter);
}
// Filtrado por tipo
if (typeFilter !== 'all') {
filtered = filtered.filter(item => (item.entry_type || 'ANIME') === typeFilter);
}
// Sort
switch (sortFilter) {
case 'title':
filtered.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
@@ -196,7 +194,6 @@ function applyFilters() {
renderList(filtered);
}
// Render list items
function renderList(items) {
const container = document.getElementById('list-container');
container.innerHTML = '';
@@ -212,16 +209,15 @@ function renderList(items) {
});
}
// Create individual list item (ACTUALIZADO para MANGA/NOVEL)
function createListItem(item) {
const div = document.createElement('div');
div.className = 'list-item';
div.onclick = () => openEditModal(item);
const itemLink = getEntryLink(item);
const posterUrl = item.poster || '/public/assets/placeholder.png';
const progress = item.progress || 0;
// Determinar total de unidades basado en el tipo
const totalUnits = item.entry_type === 'ANIME' ?
item.total_episodes || 0 :
item.total_chapters || 0;
@@ -229,7 +225,6 @@ function createListItem(item) {
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
const score = item.score ? item.score.toFixed(1) : null;
// Determinar la etiqueta de unidad (Episodes, Chapters, Volumes, etc.)
const entryType = (item.entry_type || 'ANIME').toUpperCase();
let unitLabel = 'units';
if (entryType === 'ANIME') {
@@ -240,7 +235,6 @@ function createListItem(item) {
unitLabel = 'chapters/volumes';
}
// Ajustar etiquetas de estado según el tipo (Watching/Reading)
const statusLabels = {
'WATCHING': entryType === 'ANIME' ? 'Watching' : 'Reading',
'COMPLETED': 'Completed',
@@ -250,27 +244,41 @@ function createListItem(item) {
};
div.innerHTML = `
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'">
<a href="${itemLink}" class="item-poster-link">
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'">
</a>
<div class="item-content">
<h3 class="item-title">${item.title || 'Unknown Title'}</h3>
<div class="item-meta">
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
<div>
<a href="${itemLink}" style="text-decoration:none; color:inherit;">
<h3 class="item-title">${item.title || 'Unknown Title'}</h3>
</a>
<div class="item-meta">
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${progressPercent}%"></div>
</div>
<div class="progress-text">
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel}</span> ${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
<div>
<div class="progress-bar-container">
<div class="progress-bar" style="width: ${progressPercent}%"></div>
</div>
<div class="progress-text">
<span>${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel}</span> ${score ? `<span class="score-badge">⭐ ${score}</span>` : ''}
</div>
</div>
</div>
`;
<button class="edit-icon-btn" onclick="openEditModal(${JSON.stringify(item).replace(/"/g, '&quot;')})">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path d="M15.232 5.232l3.536 3.536m-2.036-5.808a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.536L15.232 5.232z"/>
</svg>
</button>
`;
return div;
}
// Open edit modal (ACTUALIZADO para MANGA/NOVEL)
function openEditModal(item) {
currentEditingEntry = item;
@@ -278,7 +286,6 @@ function openEditModal(item) {
document.getElementById('edit-progress').value = item.progress || 0;
document.getElementById('edit-score').value = item.score || '';
// Ajusta el texto del campo de progreso en el modal
const entryType = (item.entry_type || 'ANIME').toUpperCase();
const progressLabel = document.querySelector('label[for="edit-progress"]');
if (progressLabel) {
@@ -291,23 +298,20 @@ function openEditModal(item) {
}
}
// Establecer el max del progreso
const totalUnits = item.entry_type === 'ANIME' ?
item.total_episodes || 999 :
item.total_chapters || 999;
document.getElementById('edit-progress').max = totalUnits;
document.getElementById('edit-modal').style.display = 'flex';
document.getElementById('edit-modal').classList.add('active');
}
// Close edit modal
function closeEditModal() {
currentEditingEntry = null;
document.getElementById('edit-modal').style.display = 'none';
document.getElementById('edit-modal').classList.remove('active');
}
// Save entry changes
async function saveEntry() {
if (!currentEditingEntry) return;
@@ -342,7 +346,6 @@ async function saveEntry() {
}
}
// Delete entry
async function deleteEntry() {
if (!currentEditingEntry) return;
@@ -353,7 +356,7 @@ async function deleteEntry() {
try {
const response = await fetch(`${API_BASE}/list/entry/${currentEditingEntry.entry_id}`, {
method: 'DELETE',
headers: getAuthHeaders()
headers: getSimpleAuthHeaders()
});
if (!response.ok) {
@@ -369,7 +372,6 @@ async function deleteEntry() {
}
}
// Show notification (unchanged)
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
@@ -394,7 +396,6 @@ function showNotification(message, type = 'info') {
}, 3000);
}
// Add keyframe animations (unchanged)
const style = document.createElement('style');
style.textContent = `
@keyframes slideInRight {
@@ -420,7 +421,6 @@ style.textContent = `
`;
document.head.appendChild(style);
// Close modal on outside click (unchanged)
document.getElementById('edit-modal').addEventListener('click', (e) => {
if (e.target.id === 'edit-modal') {
closeEditModal();

View File

@@ -1,6 +1,6 @@
const Gitea_OWNER = 'ItsSkaiya';
const Gitea_REPO = 'WaifuBoard';
const CURRENT_VERSION = 'v1.6.3';
const CURRENT_VERSION = 'v1.6.4';
const UPDATE_CHECK_INTERVAL = 5 * 60 * 1000;
let currentVersionDisplay;

View File

@@ -119,6 +119,7 @@
</button>
<button class="btn-secondary" id="add-to-list-btn" onclick="openAddToListModal()">+ Add to Library</button>
</div>
</div>
<div class="chapters-section">
<div class="section-title">

View File

@@ -23,7 +23,6 @@ body {
padding-top: var(--nav-height);
}
/* Navbar Styles */
.navbar {
width: 100%;
height: var(--nav-height);
@@ -127,14 +126,12 @@ body {
color: var(--text-secondary);
}
/* Container */
.container {
max-width: 1600px;
margin: 0 auto;
padding: 3rem;
}
/* Header Section */
.header-section {
margin-bottom: 3rem;
}
@@ -157,18 +154,19 @@ body {
.stat-card {
background: var(--bg-surface);
border: 1px solid rgba(255,255,255,0.05);
border-radius: var(--radius-md);
border: 1px solid rgba(255,255,255,0.1);
border-radius: var(--radius-lg);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: transform 0.3s, border-color 0.3s;
transition: transform 0.3s, box-shadow 0.3s;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.stat-card:hover {
transform: translateY(-4px);
border-color: var(--accent);
transform: translateY(-5px);
box-shadow: 0 10px 40px var(--accent-glow);
}
.stat-value {
@@ -183,7 +181,6 @@ body {
font-weight: 600;
}
/* Filters Section */
.filters-section {
display: flex;
gap: 1rem;
@@ -261,7 +258,6 @@ body {
color: white;
}
/* Loading State */
.loading-state {
display: flex;
flex-direction: column;
@@ -284,7 +280,6 @@ body {
to { transform: rotate(360deg); }
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
@@ -304,10 +299,10 @@ body {
color: var(--text-primary);
}
/* List Grid View */
.list-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
@@ -317,25 +312,33 @@ body {
.list-item {
background: var(--bg-surface);
border: 1px solid rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
transition: transform 0.3s, border-color 0.3s;
transition: transform 0.3s, border-color 0.3s, box-shadow 0.3s;
display: flex;
flex-direction: column;
position: relative;
}
.list-item:hover {
transform: translateY(-8px);
transform: translateY(-5px);
border-color: var(--accent);
box-shadow: 0 10px 20px rgba(0,0,0,0.5);
}
.list-grid.list-view .list-item {
display: flex;
flex-direction: row;
}
.list-grid.list-view .list-item:hover {
transform: translateX(8px);
transform: translateX(5px);
}
.item-poster-link {
display: block;
cursor: pointer;
}
.item-poster {
@@ -353,12 +356,12 @@ body {
.item-content {
padding: 1rem;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.list-grid.list-view .item-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
@@ -389,24 +392,31 @@ body {
border-radius: 6px;
font-weight: 600;
white-space: nowrap;
text-transform: uppercase;
}
.status-pill {
background: rgba(34, 197, 94, 0.2);
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.3);
}
.type-pill {
background: rgba(139, 92, 246, 0.15);
color: var(--accent);
border: 1px solid rgba(139, 92, 246, 0.3);
}
.source-pill {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
border: 1px solid rgba(168, 85, 247, 0.3);
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.progress-bar-container {
background: rgba(255,255,255,0.05);
border-radius: 999px;
height: 6px;
height: 8px;
overflow: hidden;
margin-bottom: 0.5rem;
}
@@ -419,10 +429,11 @@ body {
}
.progress-text {
font-size: 0.8rem;
font-size: 0.9rem;
color: var(--text-secondary);
display: flex;
justify-content: space-between;
align-items: center;
}
.score-badge {
@@ -433,7 +444,22 @@ body {
color: var(--success);
}
/* Modal */
.edit-btn-card {
background: var(--accent);
color: white;
padding: 0.5rem 1rem;
border-radius: 999px;
font-weight: 700;
border: none;
cursor: pointer;
transition: transform 0.2s, background 0.2s;
margin-top: 1rem;
}
.edit-btn-card:hover {
background: #7c3aed;
transform: scale(1.03);
}
.modal-overlay {
position: fixed;
inset: 0;
@@ -577,7 +603,16 @@ body {
opacity: 0.9;
}
/* Responsive */
.modal-overlay {
display: none;
opacity: 0;
}
.modal-overlay.active {
display: flex;
opacity: 1;
}
@media (max-width: 768px) {
.navbar {
padding: 0 1.5rem;
@@ -600,36 +635,49 @@ body {
}
.list-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
}
}
.meta-pill {
padding: 0.3rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
.edit-icon-btn {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 50;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s, background 0.2s;
}
/* Estilo para la píldora de tipo (opcional, para diferenciar) */
.type-pill {
background: rgba(255, 165, 0, 0.2); /* Naranja suave */
color: #ffb74d;
border: 1px solid rgba(255, 165, 0, 0.3);
.list-item:hover .edit-icon-btn {
opacity: 1;
}
/* Si usas la convención de colores de tu proyecto (e.g., violeta para extensión), puedes usar eso: */
.source-pill {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
border: 1px solid rgba(139, 92, 246, 0.3);
.edit-icon-btn:hover {
background: var(--accent);
border-color: var(--accent);
}
/* Ejemplo de color para la píldora de estado si no lo tienes */
.status-pill {
background: rgba(34, 197, 94, 0.2); /* Verde de ejemplo */
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
.edit-btn-card {
display: none;
}
.item-poster-link {
z-index: 1;
}

View File

@@ -148,7 +148,7 @@
<div id="list-container" class="list-grid"></div>
</div>
<div class="modal-overlay" id="edit-modal" style="display: none;">
<div class="modal-overlay" id="edit-modal">
<div class="modal-content">
<button class="modal-close" onclick="closeEditModal()"></button>
<h2 class="modal-title">Edit Entry</h2>