+
diff --git a/desktop/views/css/components/navbar.css b/desktop/views/css/components/navbar.css
index 84a1871..2cbb4bc 100644
--- a/desktop/views/css/components/navbar.css
+++ b/desktop/views/css/components/navbar.css
@@ -22,6 +22,14 @@
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
+.nav-button[data-page="search"] {
+ padding: 0.6rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+}
+
.nav-brand {
font-weight: 900;
font-size: 1.5rem;
diff --git a/desktop/views/css/search.css b/desktop/views/css/search.css
new file mode 100644
index 0000000..93d57d7
--- /dev/null
+++ b/desktop/views/css/search.css
@@ -0,0 +1,410 @@
+/* search.css - Fixed & Unified */
+
+:root {
+ /* Dimensiones */
+ --sidebar-width: 260px;
+ --nav-height: 80px;
+ --content-max-width: 1800px;
+
+ /* Colores Base (Dark Mode) */
+ --c-bg-page: #09090b; /* Zinc-950 */
+ --c-bg-input: #18181b; /* Zinc-900 */
+ --c-bg-input-hover: #27272a;/* Zinc-800 */
+
+ /* Textos */
+ --c-text-main: #f4f4f5; /* Zinc-100 */
+ --c-text-muted: #a1a1aa; /* Zinc-400 */
+
+ /* Bordes y Acentos */
+ --c-border: rgba(255, 255, 255, 0.08);
+ --c-accent: #8b5cf6; /* Violet-500 */
+}
+
+/* =========================================
+ 1. LAYOUT & BASE
+ ========================================= */
+.app-layout {
+ display: grid;
+ grid-template-columns: var(--sidebar-width) 1fr;
+ max-width: var(--content-max-width);
+ min-height: 100vh;
+ padding-top: var(--nav-height);
+}
+
+.main-view {
+ padding: 2.5rem 3rem;
+ width: 100%;
+}
+
+/* =========================================
+ 2. SIDEBAR & FILTROS (DISEÑO MEJORADO)
+ ========================================= */
+.filters-sidebar {
+ position: sticky;
+ top: var(--nav-height);
+ height: calc(100vh - var(--nav-height));
+ border-right: 1px solid var(--c-border);
+ padding: 2rem 1.5rem 2rem 0;
+ margin-left: 2rem;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ /* Scrollbar oculta */
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+.filters-sidebar::-webkit-scrollbar { display: none; }
+
+.filter-group {
+ margin-bottom: 1.5rem;
+ position: relative;
+}
+
+.filter-group label {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--c-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.8rem;
+ display: block;
+}
+
+/* --- Mode Switcher --- */
+.mode-switcher {
+ display: flex;
+ background: var(--c-bg-input);
+ padding: 4px;
+ border-radius: 8px;
+ border: 1px solid var(--c-border);
+}
+
+.mode-btn {
+ flex: 1;
+ background: transparent;
+ border: none;
+ color: var(--c-text-muted);
+ padding: 8px;
+ font-size: 0.85rem;
+ font-weight: 600;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.mode-btn:hover { color: var(--c-text-main); }
+.mode-btn.active {
+ background: var(--c-bg-input-hover);
+ color: white;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.3);
+}
+
+/* --- Inputs y Selects Modernos (Boxed Style) --- */
+.custom-select select,
+.filter-input {
+ width: 100%;
+ background-color: var(--c-bg-input);
+ border: 1px solid var(--c-border);
+ border-radius: 8px;
+ color: var(--c-text-main);
+ padding: 10px 12px;
+ font-size: 0.9rem;
+ cursor: pointer;
+ transition: all 0.2s;
+ appearance: none;
+}
+
+/* Icono flecha para selects */
+.custom-select { position: relative; }
+.custom-select::after {
+ content: '';
+ position: absolute;
+ right: 12px; top: 50%;
+ transform: translateY(-50%);
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 5px solid var(--c-text-muted);
+ pointer-events: none;
+}
+
+.custom-select select:focus,
+.filter-input:focus {
+ outline: none;
+ border-color: var(--c-accent);
+ background-color: var(--c-bg-page);
+ box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
+}
+
+/* --- Checkbox Simple & Toggle --- */
+.checkbox-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 0;
+ cursor: pointer;
+}
+.checkbox-wrapper input[type="checkbox"] {
+ accent-color: var(--c-accent);
+ width: 18px; height: 18px;
+ cursor: pointer;
+}
+.checkbox-label {
+ font-size: 0.9rem;
+ color: var(--c-text-main);
+}
+.input-hint {
+ font-size: 0.7rem; color: var(--c-text-muted); margin-top: 4px; display: block;
+}
+
+/* =========================================
+ 3. MULTISELECT AVANZADO (Searchable)
+ ========================================= */
+.multiselect-container {
+ background: var(--c-bg-input);
+ border: 1px solid var(--c-border);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Buscador interno */
+.multiselect-search-wrapper {
+ padding: 8px;
+ border-bottom: 1px solid var(--c-border);
+ background: var(--c-bg-input);
+ position: sticky; top: 0; z-index: 2;
+}
+
+.multiselect-search-input {
+ width: 100%;
+ background: rgba(255,255,255,0.05);
+ border: none;
+ padding: 6px 10px;
+ border-radius: 4px;
+ color: var(--c-text-main);
+ font-size: 0.8rem;
+}
+.multiselect-search-input:focus { outline: none; background: rgba(255,255,255,0.1); }
+
+/* Lista Items */
+.multiselect-group {
+ max-height: 220px;
+ overflow-y: auto;
+ padding: 4px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--c-border) transparent;
+}
+.multiselect-group::-webkit-scrollbar { width: 6px; }
+.multiselect-group::-webkit-scrollbar-thumb { background-color: #3f3f46; border-radius: 3px; }
+
+/* Item Individual */
+.checkbox-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.85rem;
+ color: var(--c-text-muted);
+ transition: background 0.15s;
+}
+
+.checkbox-item:hover { background-color: rgba(255,255,255,0.05); color: var(--c-text-main); }
+.checkbox-item.is-selected {
+ background-color: rgba(139, 92, 246, 0.15);
+ color: #ddd6fe;
+}
+
+/* Checkbox Custom */
+.checkbox-item input[type="checkbox"] {
+ appearance: none;
+ width: 16px; height: 16px;
+ border: 2px solid #52525b;
+ border-radius: 4px;
+ display: grid; place-content: center;
+ margin: 0;
+ background: transparent;
+}
+.checkbox-item input[type="checkbox"]::before {
+ content: ""; width: 8px; height: 8px;
+ transform: scale(0);
+ box-shadow: inset 1em 1em white;
+ transform-origin: center;
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ background-color: white;
+ transition: 0.1s transform ease-in-out;
+}
+.checkbox-item input[type="checkbox"]:checked { background-color: var(--c-accent); border-color: var(--c-accent); }
+.checkbox-item input[type="checkbox"]:checked::before { transform: scale(1); }
+
+/* =========================================
+ 4. HEADER & GRID RESULTADOS
+ ========================================= */
+.content-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ margin-bottom: 2.5rem;
+ flex-wrap: wrap;
+ gap: 1.5rem;
+}
+.header-left h1 { font-size: 2.5rem; font-weight: 800; margin: 0; line-height: 1; color: var(--c-text-main); }
+#result-count { font-size: 0.9rem; color: var(--c-text-muted); margin-top: 5px; display: block; }
+
+/* Barra de búsqueda */
+.search-container { position: relative; width: 350px; max-width: 100%; }
+#main-search {
+ width: 100%;
+ background: var(--c-bg-input);
+ border: 1px solid var(--c-border);
+ border-radius: 12px;
+ padding: 12px 16px 12px 42px;
+ color: white;
+ font-size: 0.95rem;
+}
+#main-search:focus {
+ border-color: var(--c-accent);
+ box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1);
+ outline: none;
+}
+.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); color: var(--c-text-muted); font-size: 1.1rem; }
+.loader-spinner {
+ position: absolute; right: 12px; top: 50%; margin-top: -8px;
+ width: 16px; height: 16px;
+ border: 2px solid rgba(255,255,255,0.1); border-top-color: var(--c-accent);
+ border-radius: 50%; animation: spin 0.8s linear infinite; display: none;
+}
+
+/* Grid */
+.media-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 3rem 2rem;
+ padding-bottom: 2rem;
+}
+
+/* Cards */
+.card { cursor: pointer; display: flex; flex-direction: column; }
+.card:hover .card-img-wrap img { transform: scale(1.05); }
+.card:hover h3 { color: var(--c-accent); }
+
+.card-img-wrap {
+ width: 100%; aspect-ratio: 2 / 3;
+ border-radius: 12px; overflow: hidden;
+ background-color: var(--c-bg-input);
+ margin-bottom: 1rem;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
+}
+.card-img-wrap img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
+
+.card-content h3 {
+ font-size: 1rem; font-weight: 600; margin: 0 0 0.4rem 0;
+ color: var(--c-text-main);
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+}
+.card-content p { font-size: 0.85rem; color: var(--c-text-muted); margin: 0; }
+
+/* =========================================
+ 5. RESPONSIVE (MÓVIL)
+ ========================================= */
+
+/* Por defecto ocultamos los controles móviles en escritorio */
+.mobile-header-controls { display: none; }
+.overlay-backdrop {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px);
+ z-index: 900; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;
+}
+.overlay-backdrop.active { opacity: 1; pointer-events: auto; }
+
+@media (max-width: 1024px) {
+ :root { --sidebar-width: 0px; }
+
+ .app-layout { display: block; }
+ .main-view { padding: 1.5rem; }
+
+ /* Mostrar controles de cabecera en móvil */
+ .mobile-header-controls {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 2rem;
+ }
+
+ .mobile-title { font-size: 1.25rem; font-weight: 700; color: var(--c-text-main); }
+
+ /* Botón hamburguesa */
+ .icon-btn-plain {
+ background: none; border: none; padding: 0;
+ color: var(--c-text-main); font-size: 1.5rem; cursor: pointer;
+ }
+
+ /* Sidebar Móvil (Drawer) */
+ .filters-sidebar {
+ position: fixed;
+ left: 0; top: 0;
+ height: 100dvh;
+ width: 85%; max-width: 320px;
+ background: var(--c-bg-page);
+ z-index: 1000;
+ transform: translateX(-100%);
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ margin-left: 0;
+ padding: 2rem; /* Restauramos padding normal para el drawer */
+ box-shadow: 10px 0 30px rgba(0,0,0,0.5);
+ border-right: 1px solid var(--c-border);
+ }
+
+ .filters-sidebar.active { transform: translateX(0); }
+
+ .sidebar-header.mobile-only {
+ display: flex !important;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .media-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 2rem 1rem; }
+}
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+@media (max-width: 1024px) {
+ /* Aseguramos que el contenedor se muestre */
+ .mobile-header-controls {
+ display: flex !important;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ width: 100%;
+ position: relative;
+ z-index: 5; /* Por encima de elementos flotantes */
+ }
+
+ /* Estilo explícito para el botón */
+ #toggle-sidebar {
+ display: flex !important;
+ align-items: center;
+ justify-content: center;
+ width: 44px; /* Tamaño táctil mínimo */
+ height: 44px;
+ background: transparent;
+ border: 1px solid var(--c-border); /* Borde sutil para verlo si falla el icono */
+ border-radius: 8px;
+ color: var(--c-text-main);
+ font-size: 1.5rem; /* Tamaño del icono */
+ cursor: pointer;
+ padding: 0;
+ }
+
+ #toggle-sidebar:active {
+ background-color: var(--c-bg-input-hover);
+ }
+
+ /* Ajuste del título móvil */
+ .mobile-title {
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: var(--c-text-main);
+ }
+}
\ No newline at end of file
diff --git a/desktop/views/search.html b/desktop/views/search.html
new file mode 100644
index 0000000..626424a
--- /dev/null
+++ b/desktop/views/search.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+
Browse - WaifuBoard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docker/src/scripts/search.js b/docker/src/scripts/search.js
new file mode 100644
index 0000000..12c4886
--- /dev/null
+++ b/docker/src/scripts/search.js
@@ -0,0 +1,511 @@
+document.addEventListener('DOMContentLoaded', () => {
+ // --- Constants ---
+ const GENRES = [
+ "Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy",
+ "Horror", "Mahou Shoujo", "Mecha", "Music", "Mystery",
+ "Psychological", "Romance", "Sci-Fi", "Slice of Life",
+ "Sports", "Supernatural", "Thriller"
+ ];
+
+ const FORMATS = {
+ anime: [
+ { val: 'TV', label: 'TV Show' },
+ { val: 'MOVIE', label: 'Movie' },
+ { val: 'OVA', label: 'OVA' },
+ { val: 'SPECIAL', label: 'Special' }
+ ],
+ books: [
+ { val: 'MANGA', label: 'Manga' },
+ { val: 'NOVEL', label: 'Light Novel' }
+ ]
+ };
+
+ // --- State ---
+ const state = {
+ mode: 'anime', // 'anime' | 'books'
+ source: 'anilist',
+ query: '',
+ // Filtros estáticos para Anilist
+ anilistFilters: {
+ year: '', season: '', status: '', format: '',
+ genre: '', sort: 'TRENDING_DESC'
+ },
+ // Filtros dinámicos para Extensiones
+ extensionFilters: {}, // Almacena los valores actuales de la extensión
+ isLoading: false
+ };
+
+ // --- DOM Elements ---
+ const els = {
+ sidebar: document.getElementById('sidebar'),
+ toggleBtn: document.getElementById('toggle-sidebar'),
+ overlay: document.getElementById('mobile-overlay'),
+ closeMobileBtn: document.getElementById('close-sidebar-mobile'),
+
+ input: document.getElementById('main-search'),
+ grid: document.getElementById('results-grid'),
+ title: document.getElementById('results-title'),
+ count: document.getElementById('result-count'),
+ loader: document.getElementById('search-loader'),
+
+ modeBtns: document.querySelectorAll('.mode-btn'),
+ sourceSelect: document.getElementById('source-select'),
+
+ // Paneles de Filtros
+ anilistPanel: document.getElementById('anilist-filters'),
+ extensionPanel: document.getElementById('extension-filters'),
+
+ // Inputs estáticos de Anilist
+ staticFilters: {
+ year: document.getElementById('filter-year'),
+ season: document.getElementById('filter-season'),
+ status: document.getElementById('filter-status'),
+ format: document.getElementById('filter-format'),
+ genre: document.getElementById('filter-genre'),
+ sort: document.getElementById('filter-sort')
+ }
+ };
+
+ // --- Init ---
+ init();
+
+ async function init() {
+ populateStaticSelects();
+ setupEvents();
+ // Carga inicial
+ await loadExtensionsForMode();
+ performSearch();
+ }
+
+ // --- Setup Helpers ---
+ function populateStaticSelects() {
+ GENRES.forEach(g => els.staticFilters.genre.add(new Option(g, g)));
+
+ const currentYear = new Date().getFullYear() + 1;
+ for (let i = currentYear; i >= 1970; i--) {
+ els.staticFilters.year.add(new Option(i, i));
+ }
+ updateFormatSelect();
+ }
+
+ function updateFormatSelect() {
+ const currentVal = els.staticFilters.format.value;
+ els.staticFilters.format.innerHTML = '
';
+ const list = state.mode === 'anime' ? FORMATS.anime : FORMATS.books;
+ list.forEach(f => els.staticFilters.format.add(new Option(f.label, f.val)));
+
+ // Restaurar valor si existe en el nuevo modo
+ const exists = list.find(f => f.val === currentVal);
+ els.staticFilters.format.value = exists ? currentVal : "";
+ state.anilistFilters.format = els.staticFilters.format.value;
+ }
+
+ async function loadExtensionsForMode() {
+ const endpoint = state.mode === 'anime' ? '/api/extensions/anime' : '/api/extensions/book';
+ try {
+ const res = await fetch(endpoint);
+ const data = await res.json();
+ const extensions = data.extensions || [];
+ updateSourceDropdown(extensions);
+ } catch (e) {
+ console.error("Failed to load extensions", e);
+ updateSourceDropdown([]);
+ }
+ }
+
+ function updateSourceDropdown(extensions) {
+ // Guardar la selección actual si es posible
+ const currentSource = els.sourceSelect.value;
+
+ els.sourceSelect.innerHTML = `
`;
+ extensions.forEach(ext => els.sourceSelect.add(new Option(ext, ext)));
+
+ // Si la fuente actual existe en la nueva lista, mantenerla; si no, volver a anilist
+ if (extensions.includes(currentSource) || currentSource === 'anilist') {
+ els.sourceSelect.value = currentSource;
+ } else {
+ els.sourceSelect.value = 'anilist';
+ state.source = 'anilist';
+ handleSourceChange(); // Resetear UI
+ }
+ }
+
+ function setupEvents() {
+ const toggleSidebar = () => {
+ els.sidebar.classList.toggle('active');
+ els.overlay.classList.toggle('active');
+ };
+
+ els.toggleBtn.addEventListener('click', toggleSidebar);
+ els.overlay.addEventListener('click', toggleSidebar);
+ els.closeMobileBtn.addEventListener('click', toggleSidebar);
+
+ let debounce;
+ els.input.addEventListener('input', (e) => {
+ state.query = e.target.value.trim();
+ clearTimeout(debounce);
+ debounce = setTimeout(performSearch, 500);
+ });
+
+ // Cambio de Modo (Anime <-> Manga)
+ els.modeBtns.forEach(btn => {
+ btn.addEventListener('click', async () => {
+ if(btn.classList.contains('active')) return;
+ els.modeBtns.forEach(b => b.classList.remove('active'));
+ btn.classList.add('active');
+
+ state.mode = btn.dataset.mode;
+ updateFormatSelect();
+
+ // Recargar lista de extensiones para el nuevo modo
+ await loadExtensionsForMode();
+
+ // Forzar vuelta a anilist al cambiar de modo para evitar inconsistencias
+ state.source = 'anilist';
+ els.sourceSelect.value = 'anilist';
+ handleSourceChange();
+
+ performSearch();
+ });
+ });
+
+ // Cambio de Fuente (Anilist <-> Extensiones)
+ els.sourceSelect.addEventListener('change', (e) => {
+ state.source = e.target.value;
+ handleSourceChange();
+ });
+
+ // Eventos para filtros de Anilist
+ Object.entries(els.staticFilters).forEach(([key, element]) => {
+ element.addEventListener('change', (e) => {
+ state.anilistFilters[key] = e.target.value;
+ performSearch();
+ });
+ });
+ }
+
+ async function handleSourceChange() {
+ // Limpiar resultados al cambiar fuente
+ els.grid.innerHTML = '';
+ els.count.textContent = '';
+
+ if (state.source === 'anilist') {
+ els.anilistPanel.style.display = 'flex';
+ els.extensionPanel.style.display = 'none';
+ els.extensionPanel.innerHTML = ''; // Limpiar DOM
+ state.extensionFilters = {};
+ performSearch();
+ } else {
+ els.anilistPanel.style.display = 'none';
+ els.extensionPanel.style.display = 'flex';
+ await loadExtensionFilters(state.source);
+ performSearch();
+ }
+ }
+
+ // --- Extension Filters Logic ---
+
+ async function loadExtensionFilters(extensionName) {
+ els.extensionPanel.innerHTML = '
';
+ state.extensionFilters = {}; // Resetear filtros actuales
+
+ try {
+ const res = await fetch(`/api/extensions/${extensionName}/filters`);
+ const data = await res.json();
+
+ // Renderizar
+ els.extensionPanel.innerHTML = '';
+
+ // Data.filters puede ser un objeto (diccionario) o un array. Lo normalizamos.
+ let filters = [];
+ if (Array.isArray(data.filters)) {
+ filters = data.filters;
+ } else if (data.filters && typeof data.filters === 'object') {
+ // Si es objeto, convertimos values a array asegurando que la key esté dentro
+ filters = Object.entries(data.filters).map(([k, v]) => ({ ...v, key: k }));
+ }
+
+ if (filters.length === 0) {
+ els.extensionPanel.innerHTML = '
No filters available for this source.
';
+ return;
+ }
+
+ filters.forEach(filter => {
+ const filterEl = createDynamicFilter(filter);
+ els.extensionPanel.appendChild(filterEl);
+
+ // Set default if exists
+ if (filter.default !== undefined) {
+ state.extensionFilters[filter.key] = filter.default;
+ }
+ });
+
+ } catch (e) {
+ console.error(e);
+ els.extensionPanel.innerHTML = '
Error loading filters.
';
+ }
+ }
+
+ function createDynamicFilter(filter) {
+ const container = document.createElement('div');
+ container.className = 'filter-group';
+
+ const label = document.createElement('label');
+ label.textContent = filter.label || filter.key;
+
+ const updateState = (val) => {
+ state.extensionFilters[filter.key] = val;
+ performSearch();
+ };
+
+ switch (filter.type) {
+ case 'select':
+ container.appendChild(label);
+ const wrapper = document.createElement('div');
+ wrapper.className = 'custom-select';
+ const sel = document.createElement('select');
+ sel.add(new Option('Any', ''));
+ (filter.options || []).forEach(opt => sel.add(new Option(opt.label, opt.value)));
+ sel.addEventListener('change', e => updateState(e.target.value));
+ wrapper.appendChild(sel);
+ container.appendChild(wrapper);
+ break;
+
+ case 'multiselect':
+ container.appendChild(label);
+
+ // Contenedor principal
+ const msContainer = document.createElement('div');
+ msContainer.className = 'multiselect-container';
+
+ // BUSCADOR: Solo si hay más de 8 opciones
+ if ((filter.options || []).length > 8) {
+ const searchWrapper = document.createElement('div');
+ searchWrapper.className = 'multiselect-search-wrapper';
+
+ const searchInput = document.createElement('input');
+ searchInput.type = 'text';
+ searchInput.className = 'multiselect-search-input';
+ searchInput.placeholder = 'Search...';
+
+ // Filtrar opciones en tiempo real
+ searchInput.addEventListener('input', (e) => {
+ const term = e.target.value.toLowerCase();
+ const items = groupWrapper.querySelectorAll('.checkbox-item');
+ items.forEach(item => {
+ const text = item.textContent.toLowerCase();
+ item.style.display = text.includes(term) ? 'flex' : 'none';
+ });
+ });
+ searchWrapper.appendChild(searchInput);
+ msContainer.appendChild(searchWrapper);
+ }
+
+ // Lista Scrollable
+ const groupWrapper = document.createElement('div');
+ groupWrapper.className = 'multiselect-group';
+
+ const getCheckedValues = () => {
+ return Array.from(groupWrapper.querySelectorAll('input:checked')).map(cb => cb.value);
+ };
+
+ (filter.options || []).forEach(opt => {
+ const itemLabel = document.createElement('label');
+ itemLabel.className = 'checkbox-item';
+
+ const chk = document.createElement('input');
+ chk.type = 'checkbox';
+ chk.value = opt.value;
+
+ // Restaurar estado si ya estaba seleccionado
+ const currentVals = state.extensionFilters[filter.key] || [];
+ if (Array.isArray(currentVals) && currentVals.includes(opt.value)) {
+ chk.checked = true;
+ itemLabel.classList.add('is-selected');
+ }
+
+ chk.addEventListener('change', (e) => {
+ if(e.target.checked) itemLabel.classList.add('is-selected');
+ else itemLabel.classList.remove('is-selected');
+ updateState(getCheckedValues());
+ });
+
+ const spanText = document.createElement('span');
+ spanText.textContent = opt.label;
+
+ itemLabel.appendChild(chk);
+ itemLabel.appendChild(spanText);
+ groupWrapper.appendChild(itemLabel);
+ });
+
+ msContainer.appendChild(groupWrapper);
+ container.appendChild(msContainer);
+ break;
+
+ case 'checkbox':
+ const checkWrapper = document.createElement('div');
+ checkWrapper.className = 'checkbox-wrapper';
+ const checkLabel = document.createElement('span');
+ checkLabel.className = 'checkbox-label';
+ checkLabel.textContent = filter.label || filter.key;
+
+ const chk = document.createElement('input');
+ chk.type = 'checkbox';
+ chk.checked = !!filter.default;
+ chk.addEventListener('change', e => updateState(e.target.checked));
+
+ checkWrapper.appendChild(checkLabel);
+ checkWrapper.appendChild(chk);
+ container.appendChild(checkWrapper);
+ break;
+
+ case 'text':
+ case 'number':
+ case 'tags':
+ container.appendChild(label);
+ const inp = document.createElement('input');
+ inp.type = filter.type === 'number' ? 'number' : 'text';
+ inp.className = 'filter-input';
+
+ let timer;
+ inp.addEventListener('input', (e) => {
+ clearTimeout(timer);
+ timer = setTimeout(() => {
+ let val = e.target.value;
+ if (filter.type === 'tags') val = val.replace(/,\s+/g, ',');
+ updateState(val);
+ }, 600);
+ });
+ container.appendChild(inp);
+ if(filter.type === 'tags') {
+ const hint = document.createElement('span');
+ hint.className = 'input-hint';
+ hint.textContent = 'e.g. action, comedy';
+ container.appendChild(hint);
+ }
+ break;
+
+ default:
+ container.appendChild(label);
+ const defInp = document.createElement('input');
+ defInp.className = 'filter-input';
+ defInp.addEventListener('change', e => updateState(e.target.value));
+ container.appendChild(defInp);
+ }
+ return container;
+ }
+
+ // --- Search Logic ---
+ async function performSearch() {
+ state.isLoading = true;
+ els.loader.style.display = 'block';
+ els.grid.style.opacity = '0.5';
+
+ let url = '';
+
+ if (state.source === 'anilist') {
+ // Lógica existente de Anilist
+ const p = new URLSearchParams({
+ type: state.mode === 'anime' ? 'ANIME' : 'MANGA',
+ sort: state.anilistFilters.sort
+ });
+ if(state.query) p.append('q', state.query);
+ if(state.anilistFilters.year) p.append('year', state.anilistFilters.year);
+ if(state.anilistFilters.season) p.append('season', state.anilistFilters.season);
+ if(state.anilistFilters.genre) p.append('genre', state.anilistFilters.genre);
+ if(state.anilistFilters.status) p.append('status', state.anilistFilters.status);
+ if(state.anilistFilters.format) p.append('format', state.anilistFilters.format);
+ url = `/api/search/advanced?${p}`;
+ } else {
+ // Lógica para Extensiones
+ // Endpoint: /api/search/{source} o /api/search/books/{source}
+ const basePath = state.mode === 'anime'
+ ? `/api/search/${state.source}`
+ : `/api/search/books/${state.source}`;
+
+ const p = new URLSearchParams();
+ if (state.query) p.append('q', state.query);
+
+ // Añadir filtros dinámicos a la URL
+ Object.entries(state.extensionFilters).forEach(([key, val]) => {
+ if (val === null || val === undefined || val === '') return;
+
+ if (Array.isArray(val)) {
+ // Si es array (multiselect), unir por comas para el backend
+ if (val.length > 0) p.append(key, val.join(','));
+ } else {
+ p.append(key, val);
+ }
+ });
+
+ url = `${basePath}?${p.toString()}`;
+ }
+
+ try {
+ const res = await fetch(url);
+ if(!res.ok) throw new Error('Network err');
+ const data = await res.json();
+ render(data.results || []);
+ } catch(e) {
+ console.error(e);
+ els.grid.innerHTML = `
Error loading results from ${state.source}.
`;
+ } finally {
+ state.isLoading = false;
+ els.loader.style.display = 'none';
+ els.grid.style.opacity = '1';
+ }
+ }
+
+ function render(results) {
+ els.grid.innerHTML = '';
+ els.count.textContent = `${results.length} results`;
+
+ if(state.query) els.title.textContent = `Results for "${state.query}"`;
+ else if(state.source === 'anilist' && state.anilistFilters.sort === 'TRENDING_DESC') els.title.textContent = 'Trending Now';
+ else els.title.textContent = 'Explore';
+
+ if(results.length === 0) {
+ els.grid.innerHTML = `
No results found.
`;
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+
+ results.forEach(item => {
+ const div = document.createElement('div');
+ div.className = 'card';
+
+ const title = item.title?.userPreferred || item.title?.english || item.title?.romaji || 'Unknown';
+ const img = item.coverImage?.large || item.coverImage || '/public/assets/placeholder.svg';
+ const year = item.year || item.startDate?.year || '';
+ const type = item.format || (state.mode === 'anime' ? 'TV' : 'MANGA');
+
+ // Construir URL: si es extensión, la URL incluye la fuente
+ const typePath = state.mode === 'anime' ? 'anime' : 'book';
+
+ let href;
+ if (state.source === 'anilist') {
+ href = `/${typePath}/${item.id}`;
+ } else {
+ // En resultados de extensiones, el ID puede necesitar codificación
+ // Además, pasamos explícitamente la fuente en la URL
+ href = `/${typePath}/${state.source}/${encodeURIComponent(item.id)}`;
+ }
+
+ div.innerHTML = `
+
+

+
+
+
${title}
+
${year ? year + ' • ' : ''}${type}
+
+ `;
+
+ div.addEventListener('click', () => window.location.href = href);
+ fragment.appendChild(div);
+ });
+
+ els.grid.appendChild(fragment);
+ }
+});
\ No newline at end of file
diff --git a/docker/src/views/views.routes.ts b/docker/src/views/views.routes.ts
index 57f50d2..6be326d 100644
--- a/docker/src/views/views.routes.ts
+++ b/docker/src/views/views.routes.ts
@@ -12,7 +12,7 @@ function getNavbarHTML(activePage: string, showSearch: boolean = true): string {
let navbar = cachedNavbar;
- const pages = ['anime', 'books', 'gallery', 'schedule' , 'marketplace'];
+ const pages = ['search', 'anime', 'books', 'gallery', 'schedule' , 'marketplace'];
pages.forEach(page => {
const regex = new RegExp(`(