diff --git a/desktop/src/api/books/books.controller.ts b/desktop/src/api/books/books.controller.ts index 04be2f4..414465f 100644 --- a/desktop/src/api/books/books.controller.ts +++ b/desktop/src/api/books/books.controller.ts @@ -88,9 +88,10 @@ export async function getChapters(req: any, reply: FastifyReply) { const { id } = req.params; const source = req.query.source || 'anilist'; const provider = req.query.provider; + const extensionBookId = req.query.extensionBookId; const isExternal = source !== 'anilist'; - return await booksService.getChaptersForBook(id, isExternal, provider); + return await booksService.getChaptersForBook(id, isExternal, provider, extensionBookId); } catch (err) { console.error(err); return { chapters: [] }; diff --git a/desktop/src/api/books/books.service.ts b/desktop/src/api/books/books.service.ts index 255b9aa..0a87060 100644 --- a/desktop/src/api/books/books.service.ts +++ b/desktop/src/api/books/books.service.ts @@ -326,7 +326,8 @@ export async function searchBooksInExtension(ext: Extension | null, name: string averageScore: m.rating || m.score || null, format: m.format, seasonYear: null, - isExtensionResult: true + isExtensionResult: true, + url: m.url, })); } @@ -361,47 +362,60 @@ async function fetchBookMetadata(id: string): Promise { } } -async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise { - const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`; - const cached = await getCache(cacheKey); +async function searchChaptersInExtension(ext: Extension, name: string, lookupId: string, cacheId: string, search: boolean, origin: string, disableCache = false): Promise { + const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${cacheId}`; + if (!disableCache) { + const cached = await getCache(cacheKey); - if (cached) { - const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; + if (cached) { + const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; - if (!isExpired) { - console.log(`[${name}] Chapters cache hit for: ${searchTitle}`); - try { - return JSON.parse(cached.result) as ChapterWithProvider[]; - } catch (e) { - console.error(`[${name}] Error parsing cached chapters:`, e); + if (!isExpired) { + console.log(`[${name}] Chapters cache hit for: ${lookupId}`); + try { + return JSON.parse(cached.result) as ChapterWithProvider[]; + } catch (e) { + console.error(`[${name}] Error parsing cached chapters:`, e); + } } - } else { - console.log(`[${name}] Chapters cache expired for: ${searchTitle}`); } } try { - console.log(`[${name}] Searching chapters for: ${searchTitle}`); + console.log(`[${name}] Searching chapters for: ${lookupId}`); let mediaId: string; if (search) { const matches = await ext.search!({ - query: searchTitle, + query: lookupId, media: { - romajiTitle: searchTitle, - englishTitle: searchTitle, + romajiTitle: lookupId, + englishTitle: lookupId, startDate: { year: 0, month: 0, day: 0 } } }); - const best = matches?.[0]; + if (!matches?.length) return []; - if (!best) { return [] } + const nq = normalize(lookupId); - mediaId = best.id; + const scored = matches.map(m => { + const nt = normalize(m.title); + let score = similarity(nq, nt); + + if (nt === nq || nt.includes(nq)) score += 0.5; + + return { m, score }; + }); + + scored.sort((a, b) => b.score - a.score); + + if (scored[0].score < 0.4) return []; + + mediaId = scored[0].m.id; } else { - const match = await ext.getMetadata(searchTitle); + const match = await ext.getMetadata(lookupId); mediaId = match.id; } @@ -432,7 +446,7 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit } } -export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> { +export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string, extensionBookId?: string): Promise<{ chapters: ChapterWithProvider[] }> { let bookData: Book | null = null; let searchTitle: string = ""; @@ -462,11 +476,30 @@ export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider? for (const [name, ext] of bookExtensions) { if (onlyProvider && name !== onlyProvider) continue; - if (name == extension) { - const chapters = await searchChaptersInExtension(ext, name, id, false, exts); + if (extensionBookId && name === onlyProvider) { + const targetId = extensionBookId ?? id; + + const chapters = await searchChaptersInExtension( + ext, + name, + targetId, // lookup + id, // cache siempre con el id normal + false, + exts, + Boolean(extensionBookId) + ); + allChapters.push(...chapters); } else { - const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts); + const chapters = await searchChaptersInExtension( + ext, + name, + searchTitle, + id, // cache con id normal + true, + exts + ); + allChapters.push(...chapters); } } @@ -548,4 +581,47 @@ export async function getChapterContent(bookId: string, chapterId: string, provi console.error(`[Chapter] Error loading from ${providerName}:`, error.message); throw err; } +} + +function similarity(s1: string, s2: string): number { + const str1 = normalize(s1); + const str2 = normalize(s2); + + const longer = str1.length > str2.length ? str1 : str2; + const shorter = str1.length > str2.length ? str2 : str1; + + if (longer.length === 0) return 1.0; + + const editDistance = levenshteinDistance(longer, shorter); + return (longer.length - editDistance) / longer.length; +} + +function levenshteinDistance(s1: string, s2: string): number { + const costs: number[] = []; + for (let i = 0; i <= s1.length; i++) { + let lastValue = i; + for (let j = 0; j <= s2.length; j++) { + if (i === 0) { + costs[j] = j; + } else if (j > 0) { + let newValue = costs[j - 1]; + if (s1.charAt(i - 1) !== s2.charAt(j - 1)) { + newValue = Math.min(Math.min(newValue, lastValue), costs[j]) + 1; + } + costs[j - 1] = lastValue; + lastValue = newValue; + } + } + if (i > 0) costs[s2.length] = lastValue; + } + return costs[s2.length]; +} + +function normalize(str: string): string { + return str + .toLowerCase() + .replace(/'/g, "'") // decodificar entidades HTML + .replace(/[^\w\s]/g, ' ') // convertir puntuación a espacios + .replace(/\s+/g, ' ') // normalizar espacios + .trim(); } \ No newline at end of file diff --git a/desktop/src/scripts/anime/player.js b/desktop/src/scripts/anime/player.js index b4ee5bb..afd2376 100644 --- a/desktop/src/scripts/anime/player.js +++ b/desktop/src/scripts/anime/player.js @@ -23,7 +23,6 @@ const AnimePlayer = (function() { let hlsInstance = null; let _manualExtensionId = null; - let _searchTimeout = null; const els = { wrapper: null, @@ -46,10 +45,6 @@ const AnimePlayer = (function() { dlConfirmBtn: null, dlCancelBtn: null, manualMatchBtn: null, - matchModal: null, - matchInput: null, - matchList: null, - closeMatchModalBtn: null }; function init(animeId, initialSource, isLocal, animeData) { @@ -83,31 +78,6 @@ const AnimePlayer = (function() { els.dlConfirmBtn = document.getElementById('confirm-dl-btn'); els.dlCancelBtn = document.getElementById('cancel-dl-btn'); els.manualMatchBtn = document.getElementById('manual-match-btn'); - els.matchModal = document.getElementById('match-modal'); - els.matchInput = document.getElementById('match-search-input'); - els.matchList = document.getElementById('match-results-list'); - els.closeMatchModalBtn = document.getElementById('close-match-modal'); - - // Event Listeners para Manual Match - if (els.manualMatchBtn) els.manualMatchBtn.addEventListener('click', openMatchModal); - if (els.closeMatchModalBtn) els.closeMatchModalBtn.addEventListener('click', closeMatchModal); - - // Cerrar modal al hacer click fuera - if (els.matchModal) { - els.matchModal.addEventListener('click', (e) => { - if (e.target === els.matchModal) closeMatchModal(); - }); - } - - // Input de búsqueda con Debounce - if (els.matchInput) { - els.matchInput.addEventListener('input', (e) => { - clearTimeout(_searchTimeout); - _searchTimeout = setTimeout(() => { - executeMatchSearch(e.target.value); - }, 500); // Esperar 500ms tras dejar de escribir - }); - } const closeDlModalBtn = document.getElementById('close-download-modal'); @@ -168,112 +138,33 @@ const AnimePlayer = (function() { if(els.subDubToggle) els.subDubToggle.addEventListener('click', toggleAudioMode); if(els.serverSelect) els.serverSelect.addEventListener('change', () => loadStream()); if(els.extSelect) els.extSelect.addEventListener('change', () => handleExtensionChange(true)); + if (els.manualMatchBtn) { + els.manualMatchBtn.addEventListener('click', openMatchModal); + } loadExtensionsList(); } function openMatchModal() { - if (!els.matchModal) return; + const currentExt = els.extSelect.value; + if (!currentExt || currentExt === 'local') return; - // Limpiar contenido previo - els.matchInput.value = ''; - els.matchList.innerHTML = `
Type to search in ${els.extSelect.value}...
`; - - // 1. Mostrar el contenedor (para que el navegador calcule el layout) - els.matchModal.style.display = 'flex'; - - // 2. Pequeño delay o forzar reflow para que la transición de opacidad funcione - requestAnimationFrame(() => { - els.matchModal.classList.add('show'); - }); - - setTimeout(() => els.matchInput.focus(), 100); - } - - function closeMatchModal() { - if (!els.matchModal) return; - els.matchModal.classList.remove('show'); - setTimeout(() => { - if (!els.matchModal.classList.contains('show')) { - els.matchModal.style.display = 'none'; + MatchModal.open({ + provider: currentExt, + initialQuery: _animeTitle, // Variable existente en player.js + onSearch: async (query, prov) => { + const res = await fetch(`/api/search/${prov}?q=${encodeURIComponent(query)}`); + const data = await res.json(); + return data.results || []; + }, + onSelect: (item) => { + console.log("Selected Anime ID:", item.id); + _manualExtensionId = item.id; + loadStream(); } - }, 300); - } - - async function executeMatchSearch(query) { - if (!query || query.trim().length < 2) return; - - const ext = els.extSelect.value; - if (!ext || ext === 'local') return; - - els.matchList.innerHTML = '
'; - - try { - const res = await fetch(`/api/search/${ext}?q=${encodeURIComponent(query)}`); - const data = await res.json(); - - renderMatchResults(data.results || []); - } catch (e) { - console.error("Match Search Error:", e); - els.matchList.innerHTML = '

Error searching extension.

'; - } - } - - function renderMatchResults(results) { - els.matchList.innerHTML = ''; - - if (results.length === 0) { - els.matchList.innerHTML = '

No results found.

'; - return; - } - - results.forEach(item => { - const div = document.createElement('div'); - div.className = 'match-item dl-item'; - - const img = (item.coverImage && item.coverImage.large) ? item.coverImage.large : "/public/assets/placeholder.svg"; - const title = item.title.english || item.title.romaji || item.title || 'Unknown'; - const externalUrl = item.url || '#'; // El parámetro URL del JSON - - div.innerHTML = ` - cover -
- ${title} - ${item.releaseDate || item.year || ''} -
- ${item.url ? ` - - - - - - - - ` : ''} - `; - - div.onclick = (e) => { - if (e.target.closest('.btn-view-source')) return; - selectManualMatch(item); - }; - - els.matchList.appendChild(div); }); } - function selectManualMatch(item) { - // 1. Guardar el ID de la extensión - _manualExtensionId = item.id; - - console.log("Manual Match Selected:", _manualExtensionId, "for extension:", els.extSelect.value); - - // 2. Cerrar modal - closeMatchModal(); - - // 3. Recargar el stream con el nuevo ID - loadStream(); - } - async function openInMPV() { if (!_rawVideoData) { alert("No video loaded yet."); diff --git a/desktop/src/scripts/books/book.js b/desktop/src/scripts/books/book.js index 4b07b18..89d70bf 100644 --- a/desktop/src/scripts/books/book.js +++ b/desktop/src/scripts/books/book.js @@ -11,6 +11,7 @@ let isLocal = false; let currentLanguage = null; let uniqueLanguages = []; let isSortAscending = true; +let manualExtensionBookId = null; const chapterPagination = Object.create(PaginationManager); chapterPagination.init(6, () => renderChapterList()); @@ -36,6 +37,37 @@ async function init() { await loadChapters(); await setupAddToListButton(); + document.getElementById('manual-match-btn')?.addEventListener('click', () => { + const select = document.getElementById('provider-filter'); + const provider = select.value; + + // Obtener título para prellenar + const currentTitle = bookData?.title?.romaji || bookData?.title?.english || ''; + + MatchModal.open({ + provider: provider, + initialQuery: currentTitle, + // Define CÓMO buscar + onSearch: async (query, prov) => { + const res = await fetch(`/api/search/books/${prov}?q=${encodeURIComponent(query)}`); + const data = await res.json(); + return data.results || []; + }, + // Define QUÉ hacer al seleccionar + onSelect: (item) => { + console.log("Selected Book ID:", item.id); + manualExtensionBookId = item.id; + + // Lógica existente de tu book.js para recargar caps + loadChapters(provider); + + // Feedback visual en el botón + const btn = document.getElementById('manual-match-btn'); + if(btn) btn.style.color = '#22c55e'; + } + }); + }); + } catch (err) { console.error("Init Error:", err); showError("Error loading book"); @@ -311,6 +343,9 @@ async function loadChapters(targetProvider = null) { const source = extensionName || 'anilist'; fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`; + if (manualExtensionBookId && targetProvider !== 'all') { + fetchUrl += `&extensionBookId=${manualExtensionBookId}`; + } } const res = await fetch(fetchUrl); @@ -515,6 +550,8 @@ async function loadAvailableExtensions() { function setupProviderFilter() { const select = document.getElementById('provider-filter'); + const manualBtn = document.getElementById('manual-match-btn'); // NUEVO + if (!select) return; select.style.display = 'inline-block'; select.innerHTML = ''; @@ -538,11 +575,32 @@ function setupProviderFilter() { select.appendChild(opt); }); + // Lógica de selección inicial if (isLocal) select.value = 'local'; else if (extensionName && availableExtensions.includes(extensionName)) select.value = extensionName; else if (availableExtensions.length > 0) select.value = availableExtensions[0]; - select.onchange = () => loadChapters(select.value); + // Visibilidad inicial del botón manual + updateManualButtonVisibility(select.value); + + select.onchange = () => { + // Al cambiar de proveedor, reseteamos la selección manual para evitar conflictos + manualExtensionBookId = null; + updateManualButtonVisibility(select.value); + loadChapters(select.value); + }; +} + +function updateManualButtonVisibility(provider) { + const btn = document.getElementById('manual-match-btn'); + if (!btn) return; + + // Solo mostrar si es un proveedor específico (no 'all' ni 'local') + if (provider !== 'all' && provider !== 'local') { + btn.style.display = 'flex'; + } else { + btn.style.display = 'none'; + } } function updateExtensionPill() { @@ -569,9 +627,9 @@ function updateCustomAddButton() { } function setupModalClickOutside() { - const modal = document.getElementById('add-list-modal'); - if (modal) { - modal.addEventListener('click', (e) => { + const addListModal = document.getElementById('add-list-modal'); + if (addListModal) { + addListModal.addEventListener('click', (e) => { if (e.target.id === 'add-list-modal') ListModalManager.close(); }); } diff --git a/desktop/src/scripts/utils/match-modal.js b/desktop/src/scripts/utils/match-modal.js new file mode 100644 index 0000000..6308e60 --- /dev/null +++ b/desktop/src/scripts/utils/match-modal.js @@ -0,0 +1,171 @@ +const MatchModal = (function() { + let _config = { + onSearch: async (query, provider) => [], // Debe devolver Array de objetos + onSelect: (item, provider) => {}, + provider: 'generic' + }; + + let elements = {}; + let searchTimeout = null; + + function init() { + if (document.getElementById('waifu-match-modal')) return; + + // Inyectar HTML + const modalHTML = ` +
+
+
+

Manual Match

+ +
+ +
+ + +
+ +
+
Type to start searching...
+
+
+
+ `; + + document.body.insertAdjacentHTML('beforeend', modalHTML); + + // Cachear elementos + elements = { + overlay: document.getElementById('waifu-match-modal'), + input: document.getElementById('match-input'), + results: document.getElementById('match-results-container'), + badge: document.getElementById('match-provider-badge'), + closeBtn: document.getElementById('match-close-btn'), + searchBtn: document.getElementById('match-btn-action') + }; + + // Event Listeners + elements.closeBtn.onclick = close; + elements.overlay.onclick = (e) => { if(e.target === elements.overlay) close(); }; + + // Búsqueda al hacer clic + elements.searchBtn.onclick = () => performSearch(elements.input.value); + + // Búsqueda al escribir (Debounce) + elements.input.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + if(e.target.value.trim().length === 0) return; + searchTimeout = setTimeout(() => performSearch(e.target.value), 600); + }); + + // Enter key + elements.input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') performSearch(elements.input.value); + }); + } + + function open(options) { + init(); // Asegurar que el DOM existe + + _config = { ..._config, ...options }; + + // Resetear UI + elements.input.value = options.initialQuery || ''; + elements.results.innerHTML = '
Search above to find matches...
'; + elements.badge.innerText = options.provider ? `(${options.provider})` : ''; + + // Mostrar Modal + elements.overlay.classList.add('active'); + + // Auto-search si hay query inicial + if (options.initialQuery) { + performSearch(options.initialQuery); + } + + setTimeout(() => elements.input.focus(), 100); + } + + function close() { + if(elements.overlay) elements.overlay.classList.remove('active'); + } + + async function performSearch(query) { + if (!query || query.trim().length < 2) return; + + elements.results.innerHTML = '
'; + + try { + // Ejecutar la función de búsqueda pasada en la config + const results = await _config.onSearch(query, _config.provider); + renderResults(results); + } catch (err) { + console.error(err); + elements.results.innerHTML = '
Error searching provider.
'; + } + } + + function renderResults(results) { + elements.results.innerHTML = ''; + + if (!results || results.length === 0) { + elements.results.innerHTML = '
No matches found.
'; + return; + } + + const grid = document.createElement('div'); + grid.className = 'match-list-grid'; + + results.forEach(item => { + const el = document.createElement('div'); + el.className = 'match-item'; + + // Normalización de datos para asegurar compatibilidad con Anime/Libros + const img = item.coverImage?.large || item.coverImage || item.image || '/public/assets/no-image.png'; + const title = item.title?.english || item.title?.romaji || item.title || 'Unknown Title'; + const meta = item.releaseDate || item.year || item.startDate?.year || ''; + const url = item.url || item.externalUrl || null; + + el.innerHTML = ` + +
+
${title}
+
${meta}
+
+ `; + + // Botón de enlace externo (si existe URL) + if (url) { + const linkBtn = document.createElement('a'); + linkBtn.href = url; + linkBtn.target = "_blank"; + linkBtn.className = "match-link-btn"; + linkBtn.title = "View Source"; + linkBtn.innerHTML = ` + + + + + + `; + // Evitar que el click en el enlace dispare el select + linkBtn.onclick = (e) => e.stopPropagation(); + el.appendChild(linkBtn); + } + + // Click en la tarjeta selecciona + el.onclick = () => { + _config.onSelect(item); + close(); + }; + + grid.appendChild(el); + }); + + elements.results.appendChild(grid); + } + + return { + open, + close + }; +})(); \ No newline at end of file diff --git a/desktop/views/anime/anime.html b/desktop/views/anime/anime.html index 1fb941e..f41562f 100644 --- a/desktop/views/anime/anime.html +++ b/desktop/views/anime/anime.html @@ -16,6 +16,7 @@ +
@@ -103,20 +104,6 @@
-
@@ -234,9 +221,9 @@ + - \ No newline at end of file diff --git a/desktop/views/books/book.html b/desktop/views/books/book.html index 4a2d33c..10d95a5 100644 --- a/desktop/views/books/book.html +++ b/desktop/views/books/book.html @@ -11,6 +11,7 @@ + @@ -91,6 +92,12 @@

Chapters

+ @@ -150,6 +157,7 @@ + diff --git a/desktop/views/css/anime/player.css b/desktop/views/css/anime/player.css index b599430..1d1414d 100644 --- a/desktop/views/css/anime/player.css +++ b/desktop/views/css/anime/player.css @@ -935,78 +935,4 @@ body.stop-scrolling { background: rgba(255, 255, 255, 0.15); border-color: var(--brand-color); box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2); -} - -/* Reutilización para los resultados de búsqueda */ -.match-item { - display: flex; - align-items: center; - gap: 12px; - background: rgba(255, 255, 255, 0.05); - padding: 10px; - border-radius: 6px; - cursor: pointer; - transition: background 0.2s; -} - -.match-item:hover { - background: rgba(255, 255, 255, 0.15); -} - -.match-item img { - width: 40px; - height: 56px; - object-fit: cover; - border-radius: 4px; - background: #222; -} - -.match-title { - font-weight: 600; - font-size: 0.95rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: #fff; -} - -.match-meta { - font-size: 0.8rem; - color: #aaa; -} - -/* Estado activo (seleccionado actualmente si quisieras marcarlo) */ -.match-item.active { - border: 1px solid var(--brand-color); - background: rgba(139, 92, 246, 0.1); -} - -.btn-view-source { - display: flex; - align-items: center; - justify-content: center; - padding: 8px; - background: rgba(255, 255, 255, 0.1); - border-radius: 6px; - color: rgba(255, 255, 255, 0.7); - transition: all 0.2s ease; - border: 1px solid rgba(255, 255, 255, 0.1); - text-decoration: none; - margin-left: auto; /* Empuja el botón a la derecha */ -} - -.btn-view-source:hover { - background: var(--brand-color); - color: white; - border-color: var(--brand-color-light); - transform: scale(1.1); -} - -/* Ajuste para que el texto no choque con el botón */ -.match-info { - flex: 1; - margin-right: 10px; - display: flex; - flex-direction: column; - overflow: hidden; } \ No newline at end of file diff --git a/desktop/views/css/components/match-modal.css b/desktop/views/css/components/match-modal.css new file mode 100644 index 0000000..99442ee --- /dev/null +++ b/desktop/views/css/components/match-modal.css @@ -0,0 +1,282 @@ +/* match-modal.css */ +:root { + --match-primary: #8b5cf6; + --match-bg: rgba(15, 15, 15, 0.95); + --match-border: rgba(255, 255, 255, 0.1); + --match-input-bg: rgba(0, 0, 0, 0.4); + --match-text-muted: #a1a1aa; +} + +/* Overlay Global con efecto Glass */ +.match-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.match-modal-overlay.active { + opacity: 1; + visibility: visible; +} + +/* Contenido del Modal */ +.match-modal-content { + background: var(--match-bg); + border: 1px solid var(--match-border); + border-radius: 16px; + width: 100%; + max-width: 700px; /* Un poco más ancho para mejor lectura */ + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: + 0 25px 80px rgba(0, 0, 0, 0.9), + 0 0 0 1px rgba(255, 255, 255, 0.05); + transform: scale(0.95) translateY(10px); + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; +} + +.match-modal-overlay.active .match-modal-content { + transform: scale(1) translateY(0); +} + +/* Header Estilizado */ +.match-header { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--match-border); + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(to bottom, rgba(255,255,255,0.02), transparent); +} + +.match-title { + font-size: 1.5rem; + font-weight: 800; + margin: 0; + letter-spacing: -0.02em; + /* Efecto gradiente en el texto como en anilist-modal */ + background: linear-gradient(to right, #fff, #aaa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +#match-provider-badge { + opacity: 0.6; + font-size: 0.8em; + margin-left: 8px; + font-weight: normal; + -webkit-text-fill-color: #888; /* Resetear el gradiente para el badge */ +} + +.match-close-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--match-border); + color: #ccc; + width: 32px; + height: 32px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.match-close-btn:hover { + background: #ef4444; + border-color: #ef4444; + color: white; + transform: rotate(90deg); +} + +/* Buscador y Controles */ +.match-search-container { + padding: 1.5rem 2rem 1rem 2rem; + display: flex; + gap: 1rem; + background: rgba(0,0,0,0.2); +} + +.match-input { + flex: 1; + background: var(--match-input-bg); + border: 1px solid var(--match-border); + color: white; + padding: 1rem 1.2rem; + border-radius: 10px; + font-size: 1rem; + font-family: inherit; + font-weight: 500; + transition: all 0.2s ease; + outline: none; +} + +.match-input:focus { + background: rgba(0,0,0,0.6); + border-color: var(--match-primary); + box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.15); +} + +.match-search-btn { + padding: 0 1.8rem; + background: var(--match-primary); + color: white; + border: none; + border-radius: 10px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3); +} + +.match-search-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4); + filter: brightness(1.1); +} + +/* Lista de Resultados */ +.match-results-body { + flex: 1; + overflow-y: auto; + padding: 0.5rem 2rem 2rem 2rem; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.2) transparent; +} + +.match-results-body::-webkit-scrollbar { width: 6px; } +.match-results-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; } + +.match-list-grid { + display: flex; + flex-direction: column; + gap: 0.8rem; + margin-top: 1rem; +} + +/* Tarjeta de Resultado Mejorada */ +.match-item { + display: flex; + align-items: center; + gap: 1.2rem; + background: rgba(255, 255, 255, 0.02); + padding: 0.8rem; + border-radius: 12px; + border: 1px solid transparent; + transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); + cursor: pointer; + position: relative; +} + +.match-item:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.1); + transform: translateX(4px); + box-shadow: 0 4px 20px rgba(0,0,0,0.2); +} + +.match-poster { + width: 50px; + height: 72px; + object-fit: cover; + border-radius: 8px; + background: #222; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.match-info { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.3rem; + min-width: 0; +} + +.match-item-title { + font-size: 1rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.match-item-meta { + font-size: 0.85rem; + color: var(--match-text-muted); + font-weight: 500; +} + +/* Botón de enlace externo refinado */ +.match-link-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + color: rgba(255, 255, 255, 0.4); + transition: all 0.2s; + border: 1px solid rgba(255, 255, 255, 0.05); + margin-left: 0.5rem; +} + +.match-link-btn:hover { + background: var(--match-primary); + color: white; + border-color: var(--match-primary); + transform: scale(1.05); + box-shadow: 0 0 15px rgba(139, 92, 246, 0.4); +} + +/* Mensajes y Spinner */ +.match-spinner { + width: 40px; height: 40px; + border: 3px solid rgba(255,255,255,0.1); + border-top-color: var(--match-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 3rem auto; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.match-msg { + text-align: center; + color: var(--match-text-muted); + margin-top: 3rem; + font-size: 0.95rem; +} +.match-msg.error { color: #ef4444; } + +/* Responsive */ +@media (max-width: 768px) { + .match-modal-content { + height: 100%; + max-height: 100vh; + border-radius: 0; + border: none; + } + .match-search-container { + flex-direction: column; + gap: 0.8rem; + } + .match-search-btn { + width: 100%; + padding: 1rem; + } +} \ No newline at end of file diff --git a/docker/src/api/books/books.controller.ts b/docker/src/api/books/books.controller.ts index 04be2f4..414465f 100644 --- a/docker/src/api/books/books.controller.ts +++ b/docker/src/api/books/books.controller.ts @@ -88,9 +88,10 @@ export async function getChapters(req: any, reply: FastifyReply) { const { id } = req.params; const source = req.query.source || 'anilist'; const provider = req.query.provider; + const extensionBookId = req.query.extensionBookId; const isExternal = source !== 'anilist'; - return await booksService.getChaptersForBook(id, isExternal, provider); + return await booksService.getChaptersForBook(id, isExternal, provider, extensionBookId); } catch (err) { console.error(err); return { chapters: [] }; diff --git a/docker/src/api/books/books.service.ts b/docker/src/api/books/books.service.ts index 47d832c..0a87060 100644 --- a/docker/src/api/books/books.service.ts +++ b/docker/src/api/books/books.service.ts @@ -326,7 +326,8 @@ export async function searchBooksInExtension(ext: Extension | null, name: string averageScore: m.rating || m.score || null, format: m.format, seasonYear: null, - isExtensionResult: true + isExtensionResult: true, + url: m.url, })); } @@ -361,42 +362,42 @@ async function fetchBookMetadata(id: string): Promise { } } -async function searchChaptersInExtension(ext: Extension, name: string, searchTitle: string, search: boolean, origin: string): Promise { - const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${searchTitle}`; - const cached = await getCache(cacheKey); +async function searchChaptersInExtension(ext: Extension, name: string, lookupId: string, cacheId: string, search: boolean, origin: string, disableCache = false): Promise { + const cacheKey = `chapters:${name}:${origin}:${search ? "search" : "id"}:${cacheId}`; + if (!disableCache) { + const cached = await getCache(cacheKey); - if (cached) { - const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; + if (cached) { + const isExpired = Date.now() - cached.created_at > CACHE_TTL_MS; - if (!isExpired) { - console.log(`[${name}] Chapters cache hit for: ${searchTitle}`); - try { - return JSON.parse(cached.result) as ChapterWithProvider[]; - } catch (e) { - console.error(`[${name}] Error parsing cached chapters:`, e); + if (!isExpired) { + console.log(`[${name}] Chapters cache hit for: ${lookupId}`); + try { + return JSON.parse(cached.result) as ChapterWithProvider[]; + } catch (e) { + console.error(`[${name}] Error parsing cached chapters:`, e); + } } - } else { - console.log(`[${name}] Chapters cache expired for: ${searchTitle}`); } } try { - console.log(`[${name}] Searching chapters for: ${searchTitle}`); + console.log(`[${name}] Searching chapters for: ${lookupId}`); let mediaId: string; if (search) { const matches = await ext.search!({ - query: searchTitle, + query: lookupId, media: { - romajiTitle: searchTitle, - englishTitle: searchTitle, + romajiTitle: lookupId, + englishTitle: lookupId, startDate: { year: 0, month: 0, day: 0 } } }); if (!matches?.length) return []; - const nq = normalize(searchTitle); + const nq = normalize(lookupId); const scored = matches.map(m => { const nt = normalize(m.title); @@ -414,7 +415,7 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit mediaId = scored[0].m.id; } else { - const match = await ext.getMetadata(searchTitle); + const match = await ext.getMetadata(lookupId); mediaId = match.id; } @@ -445,7 +446,7 @@ async function searchChaptersInExtension(ext: Extension, name: string, searchTit } } -export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string): Promise<{ chapters: ChapterWithProvider[] }> { +export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider?: string, extensionBookId?: string): Promise<{ chapters: ChapterWithProvider[] }> { let bookData: Book | null = null; let searchTitle: string = ""; @@ -475,11 +476,30 @@ export async function getChaptersForBook(id: string, ext: Boolean, onlyProvider? for (const [name, ext] of bookExtensions) { if (onlyProvider && name !== onlyProvider) continue; - if (name == extension) { - const chapters = await searchChaptersInExtension(ext, name, id, false, exts); + if (extensionBookId && name === onlyProvider) { + const targetId = extensionBookId ?? id; + + const chapters = await searchChaptersInExtension( + ext, + name, + targetId, // lookup + id, // cache siempre con el id normal + false, + exts, + Boolean(extensionBookId) + ); + allChapters.push(...chapters); } else { - const chapters = await searchChaptersInExtension(ext, name, searchTitle, true, exts); + const chapters = await searchChaptersInExtension( + ext, + name, + searchTitle, + id, // cache con id normal + true, + exts + ); + allChapters.push(...chapters); } } diff --git a/docker/src/scripts/anime/player.js b/docker/src/scripts/anime/player.js index b4ee5bb..afd2376 100644 --- a/docker/src/scripts/anime/player.js +++ b/docker/src/scripts/anime/player.js @@ -23,7 +23,6 @@ const AnimePlayer = (function() { let hlsInstance = null; let _manualExtensionId = null; - let _searchTimeout = null; const els = { wrapper: null, @@ -46,10 +45,6 @@ const AnimePlayer = (function() { dlConfirmBtn: null, dlCancelBtn: null, manualMatchBtn: null, - matchModal: null, - matchInput: null, - matchList: null, - closeMatchModalBtn: null }; function init(animeId, initialSource, isLocal, animeData) { @@ -83,31 +78,6 @@ const AnimePlayer = (function() { els.dlConfirmBtn = document.getElementById('confirm-dl-btn'); els.dlCancelBtn = document.getElementById('cancel-dl-btn'); els.manualMatchBtn = document.getElementById('manual-match-btn'); - els.matchModal = document.getElementById('match-modal'); - els.matchInput = document.getElementById('match-search-input'); - els.matchList = document.getElementById('match-results-list'); - els.closeMatchModalBtn = document.getElementById('close-match-modal'); - - // Event Listeners para Manual Match - if (els.manualMatchBtn) els.manualMatchBtn.addEventListener('click', openMatchModal); - if (els.closeMatchModalBtn) els.closeMatchModalBtn.addEventListener('click', closeMatchModal); - - // Cerrar modal al hacer click fuera - if (els.matchModal) { - els.matchModal.addEventListener('click', (e) => { - if (e.target === els.matchModal) closeMatchModal(); - }); - } - - // Input de búsqueda con Debounce - if (els.matchInput) { - els.matchInput.addEventListener('input', (e) => { - clearTimeout(_searchTimeout); - _searchTimeout = setTimeout(() => { - executeMatchSearch(e.target.value); - }, 500); // Esperar 500ms tras dejar de escribir - }); - } const closeDlModalBtn = document.getElementById('close-download-modal'); @@ -168,112 +138,33 @@ const AnimePlayer = (function() { if(els.subDubToggle) els.subDubToggle.addEventListener('click', toggleAudioMode); if(els.serverSelect) els.serverSelect.addEventListener('change', () => loadStream()); if(els.extSelect) els.extSelect.addEventListener('change', () => handleExtensionChange(true)); + if (els.manualMatchBtn) { + els.manualMatchBtn.addEventListener('click', openMatchModal); + } loadExtensionsList(); } function openMatchModal() { - if (!els.matchModal) return; + const currentExt = els.extSelect.value; + if (!currentExt || currentExt === 'local') return; - // Limpiar contenido previo - els.matchInput.value = ''; - els.matchList.innerHTML = `
Type to search in ${els.extSelect.value}...
`; - - // 1. Mostrar el contenedor (para que el navegador calcule el layout) - els.matchModal.style.display = 'flex'; - - // 2. Pequeño delay o forzar reflow para que la transición de opacidad funcione - requestAnimationFrame(() => { - els.matchModal.classList.add('show'); - }); - - setTimeout(() => els.matchInput.focus(), 100); - } - - function closeMatchModal() { - if (!els.matchModal) return; - els.matchModal.classList.remove('show'); - setTimeout(() => { - if (!els.matchModal.classList.contains('show')) { - els.matchModal.style.display = 'none'; + MatchModal.open({ + provider: currentExt, + initialQuery: _animeTitle, // Variable existente en player.js + onSearch: async (query, prov) => { + const res = await fetch(`/api/search/${prov}?q=${encodeURIComponent(query)}`); + const data = await res.json(); + return data.results || []; + }, + onSelect: (item) => { + console.log("Selected Anime ID:", item.id); + _manualExtensionId = item.id; + loadStream(); } - }, 300); - } - - async function executeMatchSearch(query) { - if (!query || query.trim().length < 2) return; - - const ext = els.extSelect.value; - if (!ext || ext === 'local') return; - - els.matchList.innerHTML = '
'; - - try { - const res = await fetch(`/api/search/${ext}?q=${encodeURIComponent(query)}`); - const data = await res.json(); - - renderMatchResults(data.results || []); - } catch (e) { - console.error("Match Search Error:", e); - els.matchList.innerHTML = '

Error searching extension.

'; - } - } - - function renderMatchResults(results) { - els.matchList.innerHTML = ''; - - if (results.length === 0) { - els.matchList.innerHTML = '

No results found.

'; - return; - } - - results.forEach(item => { - const div = document.createElement('div'); - div.className = 'match-item dl-item'; - - const img = (item.coverImage && item.coverImage.large) ? item.coverImage.large : "/public/assets/placeholder.svg"; - const title = item.title.english || item.title.romaji || item.title || 'Unknown'; - const externalUrl = item.url || '#'; // El parámetro URL del JSON - - div.innerHTML = ` - cover -
- ${title} - ${item.releaseDate || item.year || ''} -
- ${item.url ? ` - - - - - - - - ` : ''} - `; - - div.onclick = (e) => { - if (e.target.closest('.btn-view-source')) return; - selectManualMatch(item); - }; - - els.matchList.appendChild(div); }); } - function selectManualMatch(item) { - // 1. Guardar el ID de la extensión - _manualExtensionId = item.id; - - console.log("Manual Match Selected:", _manualExtensionId, "for extension:", els.extSelect.value); - - // 2. Cerrar modal - closeMatchModal(); - - // 3. Recargar el stream con el nuevo ID - loadStream(); - } - async function openInMPV() { if (!_rawVideoData) { alert("No video loaded yet."); diff --git a/docker/src/scripts/books/book.js b/docker/src/scripts/books/book.js index 4b07b18..89d70bf 100644 --- a/docker/src/scripts/books/book.js +++ b/docker/src/scripts/books/book.js @@ -11,6 +11,7 @@ let isLocal = false; let currentLanguage = null; let uniqueLanguages = []; let isSortAscending = true; +let manualExtensionBookId = null; const chapterPagination = Object.create(PaginationManager); chapterPagination.init(6, () => renderChapterList()); @@ -36,6 +37,37 @@ async function init() { await loadChapters(); await setupAddToListButton(); + document.getElementById('manual-match-btn')?.addEventListener('click', () => { + const select = document.getElementById('provider-filter'); + const provider = select.value; + + // Obtener título para prellenar + const currentTitle = bookData?.title?.romaji || bookData?.title?.english || ''; + + MatchModal.open({ + provider: provider, + initialQuery: currentTitle, + // Define CÓMO buscar + onSearch: async (query, prov) => { + const res = await fetch(`/api/search/books/${prov}?q=${encodeURIComponent(query)}`); + const data = await res.json(); + return data.results || []; + }, + // Define QUÉ hacer al seleccionar + onSelect: (item) => { + console.log("Selected Book ID:", item.id); + manualExtensionBookId = item.id; + + // Lógica existente de tu book.js para recargar caps + loadChapters(provider); + + // Feedback visual en el botón + const btn = document.getElementById('manual-match-btn'); + if(btn) btn.style.color = '#22c55e'; + } + }); + }); + } catch (err) { console.error("Init Error:", err); showError("Error loading book"); @@ -311,6 +343,9 @@ async function loadChapters(targetProvider = null) { const source = extensionName || 'anilist'; fetchUrl = `/api/book/${bookId}/chapters?source=${source}`; if (targetProvider !== 'all') fetchUrl += `&provider=${targetProvider}`; + if (manualExtensionBookId && targetProvider !== 'all') { + fetchUrl += `&extensionBookId=${manualExtensionBookId}`; + } } const res = await fetch(fetchUrl); @@ -515,6 +550,8 @@ async function loadAvailableExtensions() { function setupProviderFilter() { const select = document.getElementById('provider-filter'); + const manualBtn = document.getElementById('manual-match-btn'); // NUEVO + if (!select) return; select.style.display = 'inline-block'; select.innerHTML = ''; @@ -538,11 +575,32 @@ function setupProviderFilter() { select.appendChild(opt); }); + // Lógica de selección inicial if (isLocal) select.value = 'local'; else if (extensionName && availableExtensions.includes(extensionName)) select.value = extensionName; else if (availableExtensions.length > 0) select.value = availableExtensions[0]; - select.onchange = () => loadChapters(select.value); + // Visibilidad inicial del botón manual + updateManualButtonVisibility(select.value); + + select.onchange = () => { + // Al cambiar de proveedor, reseteamos la selección manual para evitar conflictos + manualExtensionBookId = null; + updateManualButtonVisibility(select.value); + loadChapters(select.value); + }; +} + +function updateManualButtonVisibility(provider) { + const btn = document.getElementById('manual-match-btn'); + if (!btn) return; + + // Solo mostrar si es un proveedor específico (no 'all' ni 'local') + if (provider !== 'all' && provider !== 'local') { + btn.style.display = 'flex'; + } else { + btn.style.display = 'none'; + } } function updateExtensionPill() { @@ -569,9 +627,9 @@ function updateCustomAddButton() { } function setupModalClickOutside() { - const modal = document.getElementById('add-list-modal'); - if (modal) { - modal.addEventListener('click', (e) => { + const addListModal = document.getElementById('add-list-modal'); + if (addListModal) { + addListModal.addEventListener('click', (e) => { if (e.target.id === 'add-list-modal') ListModalManager.close(); }); } diff --git a/docker/src/scripts/utils/match-modal.js b/docker/src/scripts/utils/match-modal.js new file mode 100644 index 0000000..6308e60 --- /dev/null +++ b/docker/src/scripts/utils/match-modal.js @@ -0,0 +1,171 @@ +const MatchModal = (function() { + let _config = { + onSearch: async (query, provider) => [], // Debe devolver Array de objetos + onSelect: (item, provider) => {}, + provider: 'generic' + }; + + let elements = {}; + let searchTimeout = null; + + function init() { + if (document.getElementById('waifu-match-modal')) return; + + // Inyectar HTML + const modalHTML = ` +
+
+
+

Manual Match

+ +
+ +
+ + +
+ +
+
Type to start searching...
+
+
+
+ `; + + document.body.insertAdjacentHTML('beforeend', modalHTML); + + // Cachear elementos + elements = { + overlay: document.getElementById('waifu-match-modal'), + input: document.getElementById('match-input'), + results: document.getElementById('match-results-container'), + badge: document.getElementById('match-provider-badge'), + closeBtn: document.getElementById('match-close-btn'), + searchBtn: document.getElementById('match-btn-action') + }; + + // Event Listeners + elements.closeBtn.onclick = close; + elements.overlay.onclick = (e) => { if(e.target === elements.overlay) close(); }; + + // Búsqueda al hacer clic + elements.searchBtn.onclick = () => performSearch(elements.input.value); + + // Búsqueda al escribir (Debounce) + elements.input.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + if(e.target.value.trim().length === 0) return; + searchTimeout = setTimeout(() => performSearch(e.target.value), 600); + }); + + // Enter key + elements.input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') performSearch(elements.input.value); + }); + } + + function open(options) { + init(); // Asegurar que el DOM existe + + _config = { ..._config, ...options }; + + // Resetear UI + elements.input.value = options.initialQuery || ''; + elements.results.innerHTML = '
Search above to find matches...
'; + elements.badge.innerText = options.provider ? `(${options.provider})` : ''; + + // Mostrar Modal + elements.overlay.classList.add('active'); + + // Auto-search si hay query inicial + if (options.initialQuery) { + performSearch(options.initialQuery); + } + + setTimeout(() => elements.input.focus(), 100); + } + + function close() { + if(elements.overlay) elements.overlay.classList.remove('active'); + } + + async function performSearch(query) { + if (!query || query.trim().length < 2) return; + + elements.results.innerHTML = '
'; + + try { + // Ejecutar la función de búsqueda pasada en la config + const results = await _config.onSearch(query, _config.provider); + renderResults(results); + } catch (err) { + console.error(err); + elements.results.innerHTML = '
Error searching provider.
'; + } + } + + function renderResults(results) { + elements.results.innerHTML = ''; + + if (!results || results.length === 0) { + elements.results.innerHTML = '
No matches found.
'; + return; + } + + const grid = document.createElement('div'); + grid.className = 'match-list-grid'; + + results.forEach(item => { + const el = document.createElement('div'); + el.className = 'match-item'; + + // Normalización de datos para asegurar compatibilidad con Anime/Libros + const img = item.coverImage?.large || item.coverImage || item.image || '/public/assets/no-image.png'; + const title = item.title?.english || item.title?.romaji || item.title || 'Unknown Title'; + const meta = item.releaseDate || item.year || item.startDate?.year || ''; + const url = item.url || item.externalUrl || null; + + el.innerHTML = ` + +
+
${title}
+
${meta}
+
+ `; + + // Botón de enlace externo (si existe URL) + if (url) { + const linkBtn = document.createElement('a'); + linkBtn.href = url; + linkBtn.target = "_blank"; + linkBtn.className = "match-link-btn"; + linkBtn.title = "View Source"; + linkBtn.innerHTML = ` + + + + + + `; + // Evitar que el click en el enlace dispare el select + linkBtn.onclick = (e) => e.stopPropagation(); + el.appendChild(linkBtn); + } + + // Click en la tarjeta selecciona + el.onclick = () => { + _config.onSelect(item); + close(); + }; + + grid.appendChild(el); + }); + + elements.results.appendChild(grid); + } + + return { + open, + close + }; +})(); \ No newline at end of file diff --git a/docker/views/anime/anime.html b/docker/views/anime/anime.html index 9cb1499..f98598f 100644 --- a/docker/views/anime/anime.html +++ b/docker/views/anime/anime.html @@ -14,6 +14,7 @@ + @@ -80,21 +81,6 @@
- -
@@ -212,6 +198,7 @@ + diff --git a/docker/views/books/book.html b/docker/views/books/book.html index 8589459..c12a96a 100644 --- a/docker/views/books/book.html +++ b/docker/views/books/book.html @@ -7,9 +7,10 @@ WaifuBoard Book - + + @@ -80,6 +81,12 @@

Chapters

+ @@ -109,9 +116,6 @@
- - - @@ -139,6 +143,7 @@ + diff --git a/docker/views/css/anime/player.css b/docker/views/css/anime/player.css index b599430..1d1414d 100644 --- a/docker/views/css/anime/player.css +++ b/docker/views/css/anime/player.css @@ -935,78 +935,4 @@ body.stop-scrolling { background: rgba(255, 255, 255, 0.15); border-color: var(--brand-color); box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2); -} - -/* Reutilización para los resultados de búsqueda */ -.match-item { - display: flex; - align-items: center; - gap: 12px; - background: rgba(255, 255, 255, 0.05); - padding: 10px; - border-radius: 6px; - cursor: pointer; - transition: background 0.2s; -} - -.match-item:hover { - background: rgba(255, 255, 255, 0.15); -} - -.match-item img { - width: 40px; - height: 56px; - object-fit: cover; - border-radius: 4px; - background: #222; -} - -.match-title { - font-weight: 600; - font-size: 0.95rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: #fff; -} - -.match-meta { - font-size: 0.8rem; - color: #aaa; -} - -/* Estado activo (seleccionado actualmente si quisieras marcarlo) */ -.match-item.active { - border: 1px solid var(--brand-color); - background: rgba(139, 92, 246, 0.1); -} - -.btn-view-source { - display: flex; - align-items: center; - justify-content: center; - padding: 8px; - background: rgba(255, 255, 255, 0.1); - border-radius: 6px; - color: rgba(255, 255, 255, 0.7); - transition: all 0.2s ease; - border: 1px solid rgba(255, 255, 255, 0.1); - text-decoration: none; - margin-left: auto; /* Empuja el botón a la derecha */ -} - -.btn-view-source:hover { - background: var(--brand-color); - color: white; - border-color: var(--brand-color-light); - transform: scale(1.1); -} - -/* Ajuste para que el texto no choque con el botón */ -.match-info { - flex: 1; - margin-right: 10px; - display: flex; - flex-direction: column; - overflow: hidden; } \ No newline at end of file diff --git a/docker/views/css/components/match-modal.css b/docker/views/css/components/match-modal.css new file mode 100644 index 0000000..99442ee --- /dev/null +++ b/docker/views/css/components/match-modal.css @@ -0,0 +1,282 @@ +/* match-modal.css */ +:root { + --match-primary: #8b5cf6; + --match-bg: rgba(15, 15, 15, 0.95); + --match-border: rgba(255, 255, 255, 0.1); + --match-input-bg: rgba(0, 0, 0, 0.4); + --match-text-muted: #a1a1aa; +} + +/* Overlay Global con efecto Glass */ +.match-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; +} + +.match-modal-overlay.active { + opacity: 1; + visibility: visible; +} + +/* Contenido del Modal */ +.match-modal-content { + background: var(--match-bg); + border: 1px solid var(--match-border); + border-radius: 16px; + width: 100%; + max-width: 700px; /* Un poco más ancho para mejor lectura */ + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: + 0 25px 80px rgba(0, 0, 0, 0.9), + 0 0 0 1px rgba(255, 255, 255, 0.05); + transform: scale(0.95) translateY(10px); + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; +} + +.match-modal-overlay.active .match-modal-content { + transform: scale(1) translateY(0); +} + +/* Header Estilizado */ +.match-header { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--match-border); + display: flex; + justify-content: space-between; + align-items: center; + background: linear-gradient(to bottom, rgba(255,255,255,0.02), transparent); +} + +.match-title { + font-size: 1.5rem; + font-weight: 800; + margin: 0; + letter-spacing: -0.02em; + /* Efecto gradiente en el texto como en anilist-modal */ + background: linear-gradient(to right, #fff, #aaa); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +#match-provider-badge { + opacity: 0.6; + font-size: 0.8em; + margin-left: 8px; + font-weight: normal; + -webkit-text-fill-color: #888; /* Resetear el gradiente para el badge */ +} + +.match-close-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--match-border); + color: #ccc; + width: 32px; + height: 32px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.match-close-btn:hover { + background: #ef4444; + border-color: #ef4444; + color: white; + transform: rotate(90deg); +} + +/* Buscador y Controles */ +.match-search-container { + padding: 1.5rem 2rem 1rem 2rem; + display: flex; + gap: 1rem; + background: rgba(0,0,0,0.2); +} + +.match-input { + flex: 1; + background: var(--match-input-bg); + border: 1px solid var(--match-border); + color: white; + padding: 1rem 1.2rem; + border-radius: 10px; + font-size: 1rem; + font-family: inherit; + font-weight: 500; + transition: all 0.2s ease; + outline: none; +} + +.match-input:focus { + background: rgba(0,0,0,0.6); + border-color: var(--match-primary); + box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.15); +} + +.match-search-btn { + padding: 0 1.8rem; + background: var(--match-primary); + color: white; + border: none; + border-radius: 10px; + font-weight: 700; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 4px 15px rgba(139, 92, 246, 0.3); +} + +.match-search-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(139, 92, 246, 0.4); + filter: brightness(1.1); +} + +/* Lista de Resultados */ +.match-results-body { + flex: 1; + overflow-y: auto; + padding: 0.5rem 2rem 2rem 2rem; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.2) transparent; +} + +.match-results-body::-webkit-scrollbar { width: 6px; } +.match-results-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 10px; } + +.match-list-grid { + display: flex; + flex-direction: column; + gap: 0.8rem; + margin-top: 1rem; +} + +/* Tarjeta de Resultado Mejorada */ +.match-item { + display: flex; + align-items: center; + gap: 1.2rem; + background: rgba(255, 255, 255, 0.02); + padding: 0.8rem; + border-radius: 12px; + border: 1px solid transparent; + transition: all 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); + cursor: pointer; + position: relative; +} + +.match-item:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.1); + transform: translateX(4px); + box-shadow: 0 4px 20px rgba(0,0,0,0.2); +} + +.match-poster { + width: 50px; + height: 72px; + object-fit: cover; + border-radius: 8px; + background: #222; + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.match-info { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.3rem; + min-width: 0; +} + +.match-item-title { + font-size: 1rem; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.match-item-meta { + font-size: 0.85rem; + color: var(--match-text-muted); + font-weight: 500; +} + +/* Botón de enlace externo refinado */ +.match-link-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + color: rgba(255, 255, 255, 0.4); + transition: all 0.2s; + border: 1px solid rgba(255, 255, 255, 0.05); + margin-left: 0.5rem; +} + +.match-link-btn:hover { + background: var(--match-primary); + color: white; + border-color: var(--match-primary); + transform: scale(1.05); + box-shadow: 0 0 15px rgba(139, 92, 246, 0.4); +} + +/* Mensajes y Spinner */ +.match-spinner { + width: 40px; height: 40px; + border: 3px solid rgba(255,255,255,0.1); + border-top-color: var(--match-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 3rem auto; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.match-msg { + text-align: center; + color: var(--match-text-muted); + margin-top: 3rem; + font-size: 0.95rem; +} +.match-msg.error { color: #ef4444; } + +/* Responsive */ +@media (max-width: 768px) { + .match-modal-content { + height: 100%; + max-height: 100vh; + border-radius: 0; + border: none; + } + .match-search-container { + flex-direction: column; + gap: 0.8rem; + } + .match-search-btn { + width: 100%; + padding: 1rem; + } +} \ No newline at end of file