diff --git a/server.js b/server.js index 8f04804..a0f92ab 100644 --- a/server.js +++ b/server.js @@ -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) => { diff --git a/src/api/anilist/anilist.service.ts b/src/api/anilist/anilist.service.ts new file mode 100644 index 0000000..48940aa --- /dev/null +++ b/src/api/anilist/anilist.service.ts @@ -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; +} \ No newline at end of file diff --git a/src/api/anilist.ts b/src/api/anilist/anilist.ts similarity index 98% rename from src/api/anilist.ts rename to src/api/anilist/anilist.ts index f9cea42..a245daf 100644 --- a/src/api/anilist.ts +++ b/src/api/anilist/anilist.ts @@ -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) => { diff --git a/src/api/list/list.controller.ts b/src/api/list/list.controller.ts index 9dc1a42..794bc87 100644 --- a/src/api/list/list.controller.ts +++ b/src/api/list/list.controller.ts @@ -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." }; diff --git a/src/api/list/list.routes.ts b/src/api/list/list.routes.ts index 7d2342e..ca16fe7 100644 --- a/src/api/list/list.routes.ts +++ b/src/api/list/list.routes.ts @@ -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); } diff --git a/src/api/list/list.service.ts b/src/api/list/list.service.ts index 5e317fe..e649e40 100644 --- a/src/api/list/list.service.ts +++ b/src/api/list/list.service.ts @@ -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 { const sql = ` SELECT * FROM ListEntry @@ -77,11 +94,25 @@ export async function getUserList(userId: number): Promise { `; 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 { 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 { }; } 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 { 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 { } } -/** - * 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 { + + 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 { + 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 { + const token = await getActiveAccessToken(userId); + return !!token; } \ No newline at end of file diff --git a/src/scripts/anime/anime.js b/src/scripts/anime/anime.js index 8f63544..d44a86d 100644 --- a/src/scripts/anime/anime.js +++ b/src/scripts/anime/anime.js @@ -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 { diff --git a/src/scripts/books/book.js b/src/scripts/books/book.js index 206d8d3..1b55391 100644 --- a/src/scripts/books/book.js +++ b/src/scripts/books/book.js @@ -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 { diff --git a/src/scripts/list.js b/src/scripts/list.js index 8d4b1ac..964598a 100644 --- a/src/scripts/list.js +++ b/src/scripts/list.js @@ -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 = ` - `; 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 = ` - ${item.title || 'Entry'} + + ${item.title || 'Entry'} +
-

${item.title || 'Unknown Title'}

-
- ${statusLabels[item.status] || item.status} - ${entryType} - ${item.source.toUpperCase()} +
+ +

${item.title || 'Unknown Title'}

+
+
+ ${statusLabels[item.status] || item.status} + ${entryType} + ${item.source.toUpperCase()} +
-
-
-
-
- ${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel} ${score ? `⭐ ${score}` : ''} + +
+
+
+
+
+ ${progress}${totalUnits > 0 ? ` / ${totalUnits}` : ''} ${unitLabel} ${score ? `⭐ ${score}` : ''} +
- `; + + + `; 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(); diff --git a/src/scripts/updateNotifier.js b/src/scripts/updateNotifier.js index f292182..0ef6787 100644 --- a/src/scripts/updateNotifier.js +++ b/src/scripts/updateNotifier.js @@ -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; diff --git a/views/books/book.html b/views/books/book.html index 8237f80..1e2938b 100644 --- a/views/books/book.html +++ b/views/books/book.html @@ -119,6 +119,7 @@
+
diff --git a/views/css/list.css b/views/css/list.css index dd8e57e..06f95b0 100644 --- a/views/css/list.css +++ b/views/css/list.css @@ -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; } \ No newline at end of file diff --git a/views/list.html b/views/list.html index 694cbe2..08a7ef5 100644 --- a/views/list.html +++ b/views/list.html @@ -148,7 +148,7 @@
-