diff --git a/desktop/src/scripts/search.js b/desktop/src/scripts/search.js new file mode 100644 index 0000000..12c4886 --- /dev/null +++ b/desktop/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} +
+
+

${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/desktop/src/views/views.routes.ts b/desktop/src/views/views.routes.ts index c5ad3a4..71ed9f4 100644 --- a/desktop/src/views/views.routes.ts +++ b/desktop/src/views/views.routes.ts @@ -23,7 +23,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(`( 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 + + + + + + + + + + +
+ + + +
+ +
+ + Browse +
+ +
+
+

Trending Now

+ 20 results +
+ +
+ + +
+
+
+ +
+ + + +
+ +
+ +
+ + + + + + + \ 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} +
+
+

${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(`( diff --git a/docker/views/css/components/navbar.css b/docker/views/css/components/navbar.css index 84a1871..2cbb4bc 100644 --- a/docker/views/css/components/navbar.css +++ b/docker/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/docker/views/css/search.css b/docker/views/css/search.css new file mode 100644 index 0000000..93d57d7 --- /dev/null +++ b/docker/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/docker/views/search.html b/docker/views/search.html new file mode 100644 index 0000000..1493063 --- /dev/null +++ b/docker/views/search.html @@ -0,0 +1,143 @@ + + + + + + Browse - WaifuBoard + + + + + + + + + +
+ + + +
+ +
+ + Browse +
+ +
+
+

Trending Now

+ 20 results +
+ +
+ + +
+
+
+ +
+ + + +
+ +
+ +
+ + + + + + + + \ No newline at end of file