From 7a7b8dc2f1b3f3cf5363a320ae5dae6d2a13aaa1 Mon Sep 17 00:00:00 2001 From: lenafx Date: Mon, 1 Dec 2025 16:54:11 +0100 Subject: [PATCH] finished gallery section --- server.js | 35 +- src/gallery/gallery.controller.ts | 88 ++++- src/gallery/gallery.routes.ts | 4 + src/gallery/gallery.service.ts | 95 +++++- src/scripts/gallery/gallery.js | 545 ++++++++++++++++++------------ src/scripts/gallery/image.js | 377 +++++++++++++++------ src/shared/database.js | 150 +++++++- src/types.ts | 30 ++ src/views/views.routes.ts | 5 + views/css/gallery/gallery.css | 254 +++++++++++--- views/css/gallery/image.css | 293 +++++++++++----- views/gallery-image.html | 25 +- views/gallery.html | 23 +- 13 files changed, 1402 insertions(+), 522 deletions(-) diff --git a/server.js b/server.js index e80fe6c..11f1933 100644 --- a/server.js +++ b/server.js @@ -55,7 +55,7 @@ function startCppScraper() { const env = { ...process.env }; env.PATH = `${dllPath};${env.PATH}`; - + console.log("⚡ Starting WaifuBoard Scraper Engine (C++)..."); const scraper = spawn(exePath, [], { @@ -73,39 +73,10 @@ function startCppScraper() { }); } -function startCppScraper() { - const exePath = path.join(__dirname, 'src', 'metadata', 'anilist.exe'); - const dllPath = path.join(__dirname, 'src', 'metadata', 'binaries'); - - if (!fs.existsSync(exePath)) { - console.error(`❌ C++ Error: Could not find executable at: ${exePath}`); - console.error(" Did you compile it? (g++ src/metadata/anilist.cpp -o src/metadata/anilist.exe ...)"); - return; - } - - const env = { ...process.env }; - env.PATH = `${dllPath};${env.PATH}`; - - console.log("⚡ Starting WaifuBoard Scraper Engine (C++)..."); - - const scraper = spawn(exePath, [], { - stdio: 'inherit', - cwd: __dirname, - env: env - }); - - scraper.on('error', (err) => { - console.error('❌ Failed to spawn C++ process:', err); - }); - - scraper.on('close', (code) => { - console.log(`✅ Scraper process finished with code ${code}`); - }); -} - const start = async () => { try { - initDatabase(); + initDatabase("anilist"); + initDatabase("favorites"); await loadExtensions(); await fastify.listen({ port: 3000, host: '0.0.0.0' }); diff --git a/src/gallery/gallery.controller.ts b/src/gallery/gallery.controller.ts index e61fe53..760bf28 100644 --- a/src/gallery/gallery.controller.ts +++ b/src/gallery/gallery.controller.ts @@ -1,5 +1,6 @@ -import {FastifyReply} from 'fastify'; +import {FastifyReply, FastifyRequest} from 'fastify'; import * as galleryService from './gallery.service'; +import {AddFavoriteBody, RemoveFavoriteParams} from '../types' export async function search(req: any, reply: FastifyReply) { try { @@ -56,4 +57,89 @@ export async function getInfo(req: any, reply: FastifyReply) { console.error("Gallery Info Error:", error.message); return reply.code(404).send({ error: "Gallery item not found" }); } +} + +export async function getFavorites(req: FastifyRequest, reply: FastifyReply) { + try { + const favorites = await galleryService.getFavorites(); + return { favorites }; + } catch (err) { + const error = err as Error; + console.error("Get Favorites Error:", error.message); + return reply.code(500).send({ error: "Failed to retrieve favorites" }); + } +} + +export async function getFavoriteById(req: FastifyRequest, reply: FastifyReply) { + try { + const { id } = req.params as { id: string }; + + const favorite = await galleryService.getFavoriteById(id); + + if (!favorite) { + return reply.code(404).send({ error: "Favorite not found" }); + } + + return { favorite }; + + } catch (err) { + const error = err as Error; + console.error("Get Favorite By ID Error:", error.message); + return reply.code(500).send({ error: "Failed to retrieve favorite" }); + } +} + + +export async function addFavorite(req: FastifyRequest<{ Body: AddFavoriteBody }>, reply: FastifyReply) { + try { + const {id, title, image_url, thumbnail_url, tags, provider, headers} = req.body; + + if (!id || !title || !image_url || !thumbnail_url) { + return reply.code(400).send({ + error: "Missing required fields: id, title, image_url, thumbnail_url" + }); + } + + const result = await galleryService.addFavorite({ + id, + title, + image_url, + thumbnail_url, + tags: tags || '', + provider: provider || "", + headers: headers || "" + }); + + if (result.success) { + return reply.code(201).send(result); + } else { + return reply.code(409).send(result); + } + + } catch (err) { + console.error("Add Favorite Error:", (err as Error).message); + return reply.code(500).send({ error: "Failed to add favorite" }); + } +} + +export async function removeFavorite(req: FastifyRequest<{ Params: RemoveFavoriteParams }>, reply: FastifyReply) { + try { + const { id } = req.params; + + if (!id) { + return reply.code(400).send({ error: "Missing favorite ID" }); + } + + const result = await galleryService.removeFavorite(id); + + if (result.success) { + return { success: true, message: "Favorite removed successfully" }; + } else { + return reply.code(404).send({ error: "Favorite not found" }); + } + } catch (err) { + const error = err as Error; + console.error("Remove Favorite Error:", error.message); + return reply.code(500).send({ error: "Failed to remove favorite" }); + } } \ No newline at end of file diff --git a/src/gallery/gallery.routes.ts b/src/gallery/gallery.routes.ts index cf472c6..5e04a7f 100644 --- a/src/gallery/gallery.routes.ts +++ b/src/gallery/gallery.routes.ts @@ -5,6 +5,10 @@ async function galleryRoutes(fastify: FastifyInstance) { fastify.get('/gallery/search', controller.search); fastify.get('/gallery/fetch/:id', controller.getInfo); fastify.get('/gallery/search/provider', controller.searchInExtension); + fastify.get('/gallery/favorites', controller.getFavorites); + fastify.get('/gallery/favorites/:id', controller.getFavoriteById); + fastify.post('/gallery/favorites', controller.addFavorite); + fastify.delete('/gallery/favorites/:id', controller.removeFavorite); } export default galleryRoutes; \ No newline at end of file diff --git a/src/gallery/gallery.service.ts b/src/gallery/gallery.service.ts index 1402577..c4ceb34 100644 --- a/src/gallery/gallery.service.ts +++ b/src/gallery/gallery.service.ts @@ -1,5 +1,6 @@ import { getAllExtensions, getExtension } from '../shared/extensions'; -import { GallerySearchResult, GalleryInfo } from '../types'; +import { GallerySearchResult, GalleryInfo, Favorite, FavoriteResult } from '../types'; +import { getDatabase } from '../shared/database'; export async function searchGallery(query: string, page: number = 1, perPage: number = 48): Promise { const extensions = getAllExtensions(); @@ -7,7 +8,6 @@ export async function searchGallery(query: string, page: number = 1, perPage: nu for (const [name, ext] of extensions) { if (ext.type === 'image-board' && ext.search) { const result = await searchInExtension(name, query, page, perPage); - console.log(result); if (result.results.length > 0) { return result; } @@ -56,7 +56,7 @@ export async function getGalleryInfo(id: string, providerName?: string): Promise ...info, provider: name }; - } catch (e) { + } catch { continue; } } @@ -66,7 +66,6 @@ export async function getGalleryInfo(id: string, providerName?: string): Promise } export async function searchInExtension(providerName: string, query: string, page: number = 1, perPage: number = 48): Promise { - const ext = getExtension(providerName); if (!ext || ext.type !== 'image-board' || !ext.search) { @@ -101,4 +100,92 @@ export async function searchInExtension(providerName: string, query: string, pag results: [] }; } +} + +export async function getFavorites(): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + db.all('SELECT * FROM favorites', [], (err: Error | null, rows: Favorite[]) => { + if (err) { + console.error('Error getting favorites:', err.message); + resolve([]); + } else { + resolve(rows); + } + }); + }); +} + +export async function getFavoriteById(id: string): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + db.get( + 'SELECT * FROM favorites WHERE id = ?', + [id], + (err: Error | null, row: Favorite | undefined) => { + if (err) { + console.error('Error getting favorite by id:', err.message); + resolve(null); + } else { + resolve(row || null); + } + } + ); + }); +} + +export async function addFavorite(fav: Favorite): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + const stmt = ` + INSERT INTO favorites (id, title, image_url, thumbnail_url, tags, headers, provider) + VALUES (?, ?, ?, ?, ?, ?, ?) + `; + + db.run( + stmt, + [ + fav.id, + fav.title, + fav.image_url, + fav.thumbnail_url, + fav.tags || "", + fav.headers || "", + fav.provider || "" + ], + function (err: any) { + if (err) { + if (err.code && err.code.includes('SQLITE_CONSTRAINT')) { + resolve({ success: false, error: 'Item is already a favorite.' }); + } else { + console.error('Error adding favorite:', err.message); + resolve({ success: false, error: err.message }); + } + } else { + resolve({ success: true, id: fav.id }); + } + } + ); + }); +} + +export async function removeFavorite(id: string): Promise { + const db = getDatabase("favorites"); + + return new Promise((resolve) => { + const stmt = 'DELETE FROM favorites WHERE id = ?'; + + db.run(stmt, [id], function (err: Error | null) { + if (err) { + console.error('Error removing favorite:', err.message); + resolve({ success: false, error: err.message }); + } else { + // @ts-ignore - this.changes existe en el contexto de db.run + resolve({ success: this.changes > 0 }); + } + }); + }); } \ No newline at end of file diff --git a/src/scripts/gallery/gallery.js b/src/scripts/gallery/gallery.js index 6d7fa38..0fa9da5 100644 --- a/src/scripts/gallery/gallery.js +++ b/src/scripts/gallery/gallery.js @@ -1,251 +1,378 @@ -document.addEventListener('DOMContentLoaded', () => { - const providerSelector = document.getElementById('provider-selector'); - const searchInput = document.getElementById('gallery-search-input'); - const resultsContainer = document.getElementById('gallery-results'); - const loadMoreBtn = document.getElementById('load-more-btn'); +const providerSelector = document.getElementById('provider-selector'); +const searchInput = document.getElementById('main-search-input'); +const resultsContainer = document.getElementById('gallery-results'); - let currentPage = 1; - let currentProvider = ''; - let currentQuery = ''; - const perPage = 48; +let currentPage = 1; +let currentProvider = ''; +let currentQuery = ''; +const perPage = 48; +let isLoading = false; +let favorites = new Set(); +let favoritesMode = false; - let msnry = null; +let msnry = null; - // --- MASONRY INITIALIZATION --- - function initializeMasonry() { - if (typeof Masonry === 'undefined') { - setTimeout(initializeMasonry, 100); - return; +const sentinel = document.createElement('div'); +sentinel.id = 'infinite-scroll-sentinel'; +sentinel.style.height = '1px'; +sentinel.style.marginBottom = '300px'; +sentinel.style.display = 'none'; +resultsContainer.parentNode.appendChild(sentinel); + +function initializeMasonry() { + if (typeof Masonry === 'undefined') { + setTimeout(initializeMasonry, 100); + return; + } + if (msnry) msnry.destroy(); + msnry = new Masonry(resultsContainer, { + itemSelector: '.gallery-card', + columnWidth: '.gallery-card', + percentPosition: true, + gutter: 0, + transitionDuration: '0.4s' + }); + msnry.layout(); +} +initializeMasonry(); + +function getTagsArray(item) { + if (Array.isArray(item.tags)) return item.tags; + if (typeof item.tags === 'string') { + return item.tags + .split(',') + .map(t => t.trim()) + .filter(t => t.length > 0); + } + return []; +} + +function getProxiedImageUrl(item) { + const imageUrl = item.image || item.image_url; + + if (!imageUrl || !item.headers) { + return imageUrl; + } + + let proxyUrl = `/api/proxy?url=${encodeURIComponent(imageUrl)}`; + + const headers = item.headers; + const lowerCaseHeaders = {}; + for (const key in headers) { + if (Object.prototype.hasOwnProperty.call(headers, key)) { + lowerCaseHeaders[key.toLowerCase()] = headers[key]; + } + } + + const referer = lowerCaseHeaders.referer; + if (referer) { + proxyUrl += `&referer=${encodeURIComponent(referer)}`; + } + + const origin = lowerCaseHeaders.origin; + if (origin) { + proxyUrl += `&origin=${encodeURIComponent(origin)}`; + } + + const userAgent = lowerCaseHeaders['user-agent']; + if (userAgent) { + proxyUrl += `&userAgent=${encodeURIComponent(userAgent)}`; + } + + return proxyUrl; +} + +async function loadFavorites() { + try { + const res = await fetch('/api/gallery/favorites'); + if (!res.ok) return; + const data = await res.json(); + favorites.clear(); + (data.favorites || []).forEach(fav => favorites.add(fav.id)); + } catch (err) { + console.error('Error loading favorites:', err); + } +} + +async function toggleFavorite(item) { + const id = item.id; + const isFav = favorites.has(id); + + try { + if (isFav) { + await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`, { method: 'DELETE' }); + favorites.delete(id); + } else { + const tagsArray = getTagsArray(item); + + const serializedHeaders = item.headers ? JSON.stringify(item.headers) : ""; + + await fetch('/api/gallery/favorites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: item.id, + title: item.title || tagsArray.join(', ') || 'Untitled', + image_url: item.image || item.image_url, + thumbnail_url: item.image || item.image_url, + tags: tagsArray.join(','), + provider: item.provider || "", + headers: serializedHeaders + }) + }); + + favorites.add(id); } - if (msnry) { - msnry.destroy(); - } - - msnry = new Masonry(resultsContainer, { - itemSelector: '.gallery-card', - columnWidth: '.gallery-card', - percentPosition: true, - gutter: 0, - transitionDuration: '0.4s' + document.querySelectorAll(`.gallery-card[data-id="${CSS.escape(id)}"] .fav-btn`).forEach(btn => { + btn.classList.toggle('favorited', !isFav); + btn.innerHTML = !isFav + ? '' + : ''; }); + + if (providerSelector.value === 'favorites' && isFav) { + setTimeout(() => searchGallery(false), 300); + } + + } catch (err) { + console.error('Error toggling favorite:', err); + alert('Error updating favorite'); + } +} + +function createGalleryCard(item) { + const card = document.createElement('a'); + card.className = 'gallery-card grid-item'; + card.href = `/gallery/${item.provider || currentProvider || 'unknown'}/${encodeURIComponent(item.id)}`; + card.dataset.id = item.id; + + const img = document.createElement('img'); + img.className = 'gallery-card-img'; + + img.src = getProxiedImageUrl(item); + + img.alt = getTagsArray(item).join(', ') || 'Image'; + img.loading = 'lazy'; + + const favBtn = document.createElement('button'); + favBtn.className = 'fav-btn'; + const isFav = favorites.has(item.id); + favBtn.classList.toggle('favorited', isFav); + favBtn.innerHTML = isFav ? '' : ''; + favBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + toggleFavorite(item); + }; + + card.appendChild(favBtn); + card.appendChild(img); + + if (currentProvider !== 'favorites') { + const badge = document.createElement('div'); + badge.className = 'provider-badge'; + badge.textContent = item.provider || 'Global'; + card.appendChild(badge); + } else { + const badge = document.createElement('div'); + badge.className = 'provider-badge'; + badge.textContent = 'Favorites'; + badge.style.background = 'rgba(255,107,107,0.7)'; + card.appendChild(badge); + } + + img.onload = img.onerror = () => { + card.classList.add('is-loaded'); + if (msnry) msnry.layout(); + }; + + return card; +} + +function showSkeletons(count, append = false) { + const skeleton = ``; + if (!append) { + resultsContainer.innerHTML = ''; + initializeMasonry(); + } + const elements = []; + for (let i = 0; i < count; i++) { + const div = document.createElement('div'); + div.innerHTML = skeleton; + const el = div.firstChild; + resultsContainer.appendChild(el); + elements.push(el); + } + if (msnry) { + msnry.appended(elements); msnry.layout(); } + sentinel.style.display = 'none'; + observer.unobserve(sentinel); +} - initializeMasonry(); +async function searchGallery(isLoadMore = false) { + if (isLoading) return; - // --- UTILS --- + const query = searchInput.value.trim(); + const provider = providerSelector.value; + const page = isLoadMore ? currentPage + 1 : 1; - /** - * Crea un elemento de tarjeta de resultado de galería. - */ - function createGalleryCard(item) { - const card = document.createElement('a'); - card.className = 'gallery-card grid-item'; + favoritesMode = (provider === 'favorites'); - const itemProvider = item.provider || currentProvider; + currentQuery = query; + currentProvider = provider; - // ********************************************** - // CAMBIO: Nueva URL para la página de visualización - card.href = `/gallery/${itemProvider}/${item.id}`; - // ********************************************** - - const img = document.createElement('img'); - img.className = 'gallery-card-img'; - img.src = item.image; - img.alt = item.tags ? item.tags.join(', ') : 'Gallery Image'; - img.loading = 'lazy'; - - img.onload = () => { - if (msnry) { - msnry.layout(); - card.classList.add('is-loaded'); - } - }; - img.onerror = () => { - if (msnry) { - msnry.layout(); - card.classList.add('is-loaded'); - } - }; - - card.appendChild(img); - - return card; + if (!isLoadMore) { + currentPage = 1; + showSkeletons(perPage); + } else { + showSkeletons(8, true); } - /** - * Muestra las tarjetas de esqueleto. (Sin cambios) - */ - function showSkeletons(count, append = false) { - const skeletonMarkup = ``; + isLoading = true; - if (!append) { - resultsContainer.innerHTML = ''; - initializeMasonry(); - } + try { + let data; - let newElements = []; - for (let i = 0; i < count; i++) { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = skeletonMarkup.trim(); - const skeletonEl = tempDiv.firstChild; - resultsContainer.appendChild(skeletonEl); - newElements.push(skeletonEl); - } + if (favoritesMode) { + const res = await fetch('/api/gallery/favorites'); + data = await res.json(); - if (msnry) { - msnry.appended(newElements); - msnry.layout(); - } - loadMoreBtn.style.display = 'none'; - } - - // --- FETCH DATA --- (Sin cambios en loadExtensions y searchGallery) - async function loadExtensions() { - try { - const response = await fetch('/api/extensions/gallery'); - const data = await response.json(); - - providerSelector.innerHTML = ''; - - if (data.extensions && data.extensions.length > 0) { - const defaultOption = document.createElement('option'); - defaultOption.value = ''; - defaultOption.textContent = 'Global Search'; - providerSelector.appendChild(defaultOption); - - data.extensions.forEach(ext => { - const option = document.createElement('option'); - option.value = ext; - option.textContent = ext; - providerSelector.appendChild(option); - }); - - currentProvider = ''; - - } else { - providerSelector.innerHTML = ''; + let favoritesResults = data.favorites || []; + if (query) { + const lowerQuery = query.toLowerCase(); + favoritesResults = favoritesResults.filter(fav => + (fav.title && fav.title.toLowerCase().includes(lowerQuery)) || + (fav.tags && fav.tags.toLowerCase().includes(lowerQuery)) + ); } - } catch (error) { - console.error('Error loading gallery extensions:', error); - providerSelector.innerHTML = ''; - } - } + data.results = favoritesResults.map(fav => ({ + id: fav.id, + image: fav.image_url, + image_url: fav.image_url, + provider: fav.provider, + tags: fav.tags + })); + data.hasNextPage = false; - async function searchGallery(isLoadMore = false) { - const query = searchInput.value.trim(); - const provider = providerSelector.value; - const page = isLoadMore ? currentPage + 1 : 1; - - if (!isLoadMore && currentQuery === query && currentProvider === provider && currentPage === 1) { - return; - } - - currentQuery = query; - currentProvider = provider; - - if (!isLoadMore) { - currentPage = 1; - showSkeletons(perPage, false); + } else if (provider && provider !== '') { + const res = await fetch(`/api/gallery/search/provider?provider=${provider}&q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`); + data = await res.json(); } else { - showSkeletons(8, true); + const res = await fetch(`/api/gallery/search?q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`); + data = await res.json(); } - try { - let url; - if (provider && provider !== '') { - url = `/api/gallery/search/provider?provider=${provider}&q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`; - } else { - url = `/api/gallery/search?q=${encodeURIComponent(query)}&page=${page}&perPage=${perPage}`; - } + const results = data.results || []; - const response = await fetch(url); - const data = await response.json(); + const skeletons = resultsContainer.querySelectorAll('.gallery-card.skeleton'); + const toRemove = isLoadMore ? Array.from(skeletons).slice(-8) : skeletons; + if (msnry) msnry.remove(toRemove); + toRemove.forEach(el => el.remove()); - let newRealElements = []; - if (data.results && data.results.length > 0) { - data.results.forEach(item => { - newRealElements.push(createGalleryCard(item)); - }); - } + const newCards = results.map(item => createGalleryCard(item)); + newCards.forEach(card => resultsContainer.appendChild(card)); + if (msnry) msnry.appended(newCards); - const allSkeletons = Array.from(resultsContainer.querySelectorAll('.gallery-card.skeleton')); - let skeletonsToRemove = isLoadMore ? allSkeletons.slice(-8) : allSkeletons; - - if (msnry) { - msnry.remove(skeletonsToRemove); - skeletonsToRemove.forEach(s => s.remove()); - } - - if (newRealElements.length > 0) { - newRealElements.forEach(el => resultsContainer.appendChild(el)); - - if (msnry) { - msnry.appended(newRealElements); - } - currentPage = isLoadMore ? currentPage + 1 : 1; - - } else if (!isLoadMore) { - resultsContainer.innerHTML = '

No results found for this search.

'; - } - - if (msnry) { - msnry.layout(); - } - - if (data.hasNextPage) { - loadMoreBtn.style.display = 'block'; - } else { - loadMoreBtn.style.display = 'none'; - } - - } catch (error) { - console.error('Error during gallery search:', error); - if (!isLoadMore) { - resultsContainer.innerHTML = '

An error occurred while fetching results.

'; - } - loadMoreBtn.style.display = 'none'; + if (results.length === 0 && !isLoadMore) { + const msg = favoritesMode + ? (query ? 'No favorites found matching your search' : 'You don\'t have any favorite images yet') + : 'No results found'; + resultsContainer.innerHTML = `

${msg}

`; } + + if (msnry) msnry.layout(); + + if (!favoritesMode && data.hasNextPage) { + sentinel.style.display = 'block'; + observer.observe(sentinel); + } else { + sentinel.style.display = 'none'; + observer.unobserve(sentinel); + } + + if (isLoadMore) currentPage++; + else currentPage = 1; + + } catch (err) { + console.error('Error:', err); + if (!isLoadMore) { + resultsContainer.innerHTML = '

Error loading gallery

'; + } + } finally { + isLoading = false; } +} +async function loadExtensions() { + try { + const res = await fetch('/api/extensions/gallery'); + const data = await res.json(); - // --- EVENT LISTENERS (Sin cambios) --- + providerSelector.innerHTML = ''; - providerSelector.addEventListener('change', () => { + const global = document.createElement('option'); + global.value = ''; + global.textContent = 'Global Search'; + providerSelector.appendChild(global); + + const favoritesOption = document.createElement('option'); + favoritesOption.value = 'favorites'; + favoritesOption.textContent = 'Favorites'; + providerSelector.appendChild(favoritesOption); + + (data.extensions || []).forEach(ext => { + const opt = document.createElement('option'); + opt.value = ext; + opt.textContent = ext.charAt(0).toUpperCase() + ext.slice(1); + providerSelector.appendChild(opt); + }); + + } catch (err) { + console.error('Error loading extensions:', err); + } +} + +providerSelector.addEventListener('change', () => { + if (providerSelector.value === 'favorites') { + searchInput.placeholder = "Search in favorites..."; + } else { + searchInput.placeholder = "Search in gallery..."; + } + searchGallery(false); +}); + +let searchTimeout; + +searchInput.addEventListener('input', () => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { searchGallery(false); - }); - - let searchTimeout; - searchInput.addEventListener('input', () => { + }, 500); +}); +searchInput.addEventListener('keydown', e => { + if (e.key === 'Enter') { clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => { - searchGallery(false); - }, 500); - }); - searchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - clearTimeout(searchTimeout); - searchGallery(false); - } - }); + searchGallery(false); + } +}); - loadMoreBtn.addEventListener('click', () => { +const observer = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && !isLoading && !favoritesMode) { + observer.unobserve(sentinel); searchGallery(true); - }); + } +}, { rootMargin: '1000px' }); +loadFavorites().then(() => { loadExtensions().then(() => { searchGallery(false); }); +}); - const navbar = document.getElementById('navbar'); - window.addEventListener('scroll', () => { - if (window.scrollY > 50) { - navbar.classList.add('scrolled'); - } else { - navbar.classList.remove('scrolled'); - } - }); - +window.addEventListener('scroll', () => { + document.getElementById('navbar')?.classList.toggle('scrolled', window.scrollY > 50); }); \ No newline at end of file diff --git a/src/scripts/gallery/image.js b/src/scripts/gallery/image.js index ae6eb9b..ab6acc7 100644 --- a/src/scripts/gallery/image.js +++ b/src/scripts/gallery/image.js @@ -1,139 +1,302 @@ -document.addEventListener('DOMContentLoaded', () => { - const itemContentContainer = document.getElementById('item-content'); +const itemMainContentContainer = document.getElementById('item-main-content'); +let currentItem = null; - // Función para parsear la URL y obtener :provider y :id - function getUrlParams() { - // La URL esperada es /gallery/providerName/itemId - const pathSegments = window.location.pathname.split('/').filter(segment => segment); - - // Verifica si la estructura es /gallery/provider/id - if (pathSegments.length >= 3 && pathSegments[0] === 'gallery') { - return { - provider: pathSegments[1], - id: pathSegments.slice(2).join('/') // El ID puede contener barras, así que unimos el resto - }; - } - return null; +function getProxiedItemUrl(url, headers = null) { + if (!url || !headers) { + return url; } - async function fetchGalleryItem(provider, id) { - try { - // Llama al endpoint de la API con los parámetros obtenidos - const url = `/api/gallery/fetch/${id}?provider=${provider}`; - const response = await fetch(url); + let proxyUrl = `/api/proxy?url=${encodeURIComponent(url)}`; - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || `Failed to fetch item from ${provider}.`); - } - - const data = await response.json(); - - // Verifica los campos clave del nuevo formato de respuesta - if (!data.fullImage) { - throw new Error("Invalid item structure: Missing 'fullImage' URL."); - } - - // Normalizar los datos para la plantilla (usando 'publishedBy' como título si 'title' no existe) - data.title = data.publishedBy || data.id; - - renderItem(data); - - } catch (error) { - console.error("Fetch Gallery Item Error:", error); - renderError(`The requested item could not be found or an error occurred. (${error.message})`); + const lowerCaseHeaders = {}; + for (const key in headers) { + if (Object.prototype.hasOwnProperty.call(headers, key)) { + lowerCaseHeaders[key.toLowerCase()] = headers[key]; } } - /** - * Renderiza el contenido del ítem de la galería. - * @param {Object} item - Objeto de respuesta de la API. - */ - function renderItem(item) { - const title = item.title; - document.getElementById('page-title').textContent = `WaifuBoard - ${title}`; + const referer = lowerCaseHeaders.referer; + if (referer) { + proxyUrl += `&referer=${encodeURIComponent(referer)}`; + } - // Limpiar el esqueleto - itemContentContainer.innerHTML = ''; + const origin = lowerCaseHeaders.origin; + if (origin) { + proxyUrl += `&origin=${encodeURIComponent(origin)}`; + } - const imageUrl = item.fullImage || item.resizedImageUrl; // Usar fullImage para la más alta resolución + const userAgent = lowerCaseHeaders['user-agent']; + if (userAgent) { + proxyUrl += `&userAgent=${encodeURIComponent(userAgent)}`; + } - const itemHTML = ` -
- ${title} + return proxyUrl; +} + +function getUrlParams() { + const path = window.location.pathname.split('/').filter(s => s); + if (path.length < 3 || path[0] !== 'gallery') return null; + + if (path[1] === 'favorites' && path[2]) { + return { fromFavorites: true, id: path[2] }; + } else { + return { provider: path[1], id: path.slice(2).join('/') }; + } +} + +async function toggleFavorite() { + if (!currentItem?.id) return; + + const btn = document.getElementById('fav-btn'); + const wasFavorited = btn.classList.contains('favorited'); + + try { + if (wasFavorited) { + await fetch(`/api/gallery/favorites/${encodeURIComponent(currentItem.id)}`, { method: 'DELETE' }); + } else { + const serializedHeaders = currentItem.headers ? JSON.stringify(currentItem.headers) : ""; + const tagsString = Array.isArray(currentItem.tags) ? currentItem.tags.join(',') : (currentItem.tags || ''); + + await fetch('/api/gallery/favorites', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: currentItem.id, + title: currentItem.title || 'Waifu', + image_url: currentItem.originalImage, + thumbnail_url: currentItem.originalImage, + tags: tagsString, + provider: currentItem.provider || "", + headers: serializedHeaders + }) + }); + } + + btn.classList.toggle('favorited', !wasFavorited); + btn.innerHTML = !wasFavorited + ? ` Saved!` + : ` Save Image`; + + } catch (err) { + console.error('Error toggling favorite:', err); + alert('Error updating favorites'); + } +} + +function copyLink() { + navigator.clipboard.writeText(window.location.href); + const btn = document.getElementById('copy-link-btn'); + const old = btn.innerHTML; + btn.innerHTML = ` Copied!`; + setTimeout(() => btn.innerHTML = old, 2000); +} + +async function loadSimilarImages(item) { + if (!item.tags || item.tags.length === 0) { + document.getElementById('similar-section').innerHTML = '

No tags available to search for similar images.

'; + return; + } + + const firstTag = item.tags[0]; + const container = document.getElementById('similar-section'); + + try { + const res = await fetch(`/api/gallery/search?q=${encodeURIComponent(firstTag)}&perPage=20`); + if (!res.ok) throw new Error(); + + const data = await res.json(); + const results = (data.results || []) + .filter(r => r.id !== item.id) + .slice(0, 15); + + if (results.length === 0) { + container.innerHTML = '

No similar images found.

'; + return; + } + + container.innerHTML = ` +

+ More images tagged with "${firstTag}" +

+
+ ${results.map(img => { + const imageUrl = img.image || img.image_url || img.thumbnail_url; + const proxiedUrl = getProxiedItemUrl(imageUrl, img.headers); + + return ` + + Similar +
${img.provider || 'Global'}
+
+ `; + }).join('')}
+ `; + + } catch (err) { + container.innerHTML = '

Could not load similar images.

'; + } +} + +function renderItem(item) { + const proxiedFullImage = getProxiedItemUrl(item.fullImage, item.headers); + + let sourceText; + if (item.fromFavorites) { + sourceText = item.headers && item.provider && item.provider !== 'Favorites' + ? `Source: ${item.provider}` + : 'Favorites'; + } else { + sourceText = `Source: ${item.provider}`; + } + + const originalProviderText = (item.fromFavorites && item.provider && item.provider !== 'Favorites') + ? ` (Original: ${item.provider})` + : ''; + + itemMainContentContainer.innerHTML = ` +
+
+ ${item.title} +
+
- Source: ${item.provider} / Published By: ${item.publishedBy || 'N/A'} -

${title}

+ + ${sourceText}${originalProviderText} + +

${item.title}

+
+ +
+ + +

Tags

-
-
+
+ ${item.tags.length > 0 + ? item.tags.map(tag => ` + ${tag} + `).join('') + : 'No tags' + } +
- - - Download Full Image -
- `; +
+ `; - itemContentContainer.innerHTML = itemHTML; + document.getElementById('fav-btn').addEventListener('click', toggleFavorite); + document.getElementById('copy-link-btn').addEventListener('click', copyLink); - // Renderizar Tags - const tagListContainer = document.getElementById('tag-list'); - if (item.tags && item.tags.length > 0) { - item.tags.forEach(tag => { - const tagEl = document.createElement('a'); - tagEl.className = 'tag-item'; - tagEl.textContent = tag; - // Enlazar a una búsqueda en la galería por el tag y el provider - tagEl.href = `/art?provider=${item.provider}&q=${encodeURIComponent(tag)}`; - tagListContainer.appendChild(tagEl); - }); - } else { - tagListContainer.innerHTML = '

No tags available.

'; - } + loadSimilarImages(item); +} - // Habilitar fade-in de la imagen una vez que se carga - const mainImage = document.getElementById('main-image'); - mainImage.onload = () => { - mainImage.classList.add('loaded'); +function renderError(msg) { + itemMainContentContainer.innerHTML = ` +
+ +

Image Not Available

+

${msg}

+ + Back to Gallery + +
+ `; + + document.getElementById('similar-section').style.display = 'none'; +} + +async function loadFromFavorites(id) { + try { + const res = await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`); + if (!res.ok) throw new Error('Not found'); + + const { favorite: fav } = await res.json(); + + const item = { + id: fav.id, + title: fav.title || 'No title', + fullImage: fav.image_url, + originalImage: fav.image_url, + tags: typeof fav.tags === 'string' ? fav.tags.split(',').map(t => t.trim()).filter(Boolean) : (fav.tags || []), + provider: 'Favorites', + fromFavorites: true, + headers: fav.headers }; - // Para imágenes que ya están en caché - if (mainImage.complete) { - mainImage.classList.add('loaded'); + + currentItem = item; + renderItem(item); + document.getElementById('page-title').textContent = `WaifuBoard - ${item.title}`; + + document.getElementById('fav-btn')?.classList.add('favorited'); + const btn = document.getElementById('fav-btn'); + if (btn) { + btn.innerHTML = ` Saved!`; } - } - function renderError(message) { - itemContentContainer.innerHTML = ` -
- -

Item Not Found

-

${message}

- Go back to Gallery -
- `; + } catch (err) { + renderError('This image is no longer in your favorites.'); } +} - // --- Inicialización --- +async function loadFromProvider(provider, id) { + try { + const res = await fetch(`/api/gallery/fetch/${id}?provider=${provider}`); + if (!res.ok) throw new Error(); + + const data = await res.json(); + if (!data.fullImage) throw new Error(); + + const item = { + id, + title: data.title || data.publishedBy || 'Beautiful Art', + fullImage: data.fullImage, + originalImage: data.fullImage, + tags: data.tags || [], + provider, + headers: data.headers + }; + + currentItem = item; + renderItem(item); + document.getElementById('page-title').textContent = `WaifuBoard - ${item.title}`; + + const favRes = await fetch(`/api/gallery/favorites/${encodeURIComponent(id)}`); + if (favRes.ok) { + document.getElementById('fav-btn')?.classList.add('favorited'); + const btn = document.getElementById('fav-btn'); + if (btn) { + btn.innerHTML = ` Saved!`; + } + } + + } catch (err) { + renderError('Image not found.'); + } +} + +if (itemMainContentContainer) { const params = getUrlParams(); - if (params && params.provider && params.id) { - fetchGalleryItem(params.provider, params.id); + if (!params) { + renderError('Invalid URL'); + } else if (params.fromFavorites) { + loadFromFavorites(params.id); } else { - renderError("Invalid URL format. Expected: /gallery/<provider>/<id>"); + loadFromProvider(params.provider, params.id); } +} else { + document.getElementById('item-content').innerHTML = `

Error: HTML container 'item-main-content' not found. Please update gallery-image.html.

`; + document.getElementById('similar-section').style.display = 'none'; +} - // Estilo de Navbar (reutilizado) - const navbar = document.getElementById('navbar'); - window.addEventListener('scroll', () => { - if (window.scrollY > 50) { - navbar.classList.add('scrolled'); - } else { - navbar.classList.remove('scrolled'); - } - }); +window.addEventListener('scroll', () => { + document.getElementById('navbar')?.classList.toggle('scrolled', window.scrollY > 50); }); \ No newline at end of file diff --git a/src/shared/database.js b/src/shared/database.js index 89047d8..ee1d928 100644 --- a/src/shared/database.js +++ b/src/shared/database.js @@ -1,48 +1,166 @@ const sqlite3 = require('sqlite3').verbose(); -const path = require('path'); +const os = require("os"); +const path = require("path"); +const fs = require("fs"); -const DB_PATH = path.join(__dirname, '..', 'metadata', 'anilist_anime.db'); -let db = null; +const databases = new Map(); -function initDatabase() { - db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READONLY, (err) => { +const DEFAULT_PATHS = { + anilist: path.join(__dirname, '..', 'metadata', 'anilist_anime.db'), + favorites: path.join(os.homedir(), "WaifuBoards", "favorites.db") +}; + +function ensureFavoritesDB(dbPath) { + const dir = path.dirname(dbPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const exists = fs.existsSync(dbPath); + + const db = new sqlite3.Database( + dbPath, + sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE + ); + + return new Promise((resolve, reject) => { + + if (!exists) { + const schema = ` + CREATE TABLE IF NOT EXISTS favorites ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + image_url TEXT NOT NULL, + thumbnail_url TEXT NOT NULL DEFAULT "", + tags TEXT NOT NULL DEFAULT "", + headers TEXT NOT NULL DEFAULT "", + provider TEXT NOT NULL DEFAULT "" + ); + `; + + db.exec(schema, (err) => { + if (err) reject(err); + else resolve(true); + }); + + return; + } + + db.all(`PRAGMA table_info(favorites)`, (err, cols) => { + if (err) return reject(err); + + const hasHeaders = cols.some(c => c.name === "headers"); + const hasProvider = cols.some(c => c.name === "provider"); + + const queries = []; + + if (!hasHeaders) { + queries.push( + `ALTER TABLE favorites ADD COLUMN headers TEXT NOT NULL DEFAULT ""` + ); + } + + if (!hasProvider) { + queries.push( + `ALTER TABLE favorites ADD COLUMN provider TEXT NOT NULL DEFAULT ""` + ); + } + + if (queries.length === 0) { + return resolve(false); + } + + db.exec(queries.join(";"), (err) => { + if (err) reject(err); + else resolve(true); + }); + }); + }); +} + +function initDatabase(name = 'anilist', dbPath = null, readOnly = false) { + if (databases.has(name)) { + return databases.get(name); + } + + const finalPath = dbPath || DEFAULT_PATHS[name] || DEFAULT_PATHS.anilist; + + if (name === "favorites") { + ensureFavoritesDB(finalPath) + .catch(err => console.error("Error creando favorites:", err)); + } + + const mode = readOnly ? sqlite3.OPEN_READONLY : (sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE); + + const db = new sqlite3.Database(finalPath, mode, (err) => { if (err) { - console.error("Database Error:", err.message); + console.error(`Database Error (${name}):`, err.message); } else { - console.log("Connected to local AniList database."); + console.log(`Connected to ${name} database at ${finalPath}`); } }); + + databases.set(name, db); return db; } -function getDatabase() { - if (!db) { - throw new Error("Database not initialized. Call initDatabase() first."); +function getDatabase(name = 'anilist') { + if (!databases.has(name)) { + return initDatabase(name, null, name === 'anilist'); } - return db; + return databases.get(name); } -function queryOne(sql, params = []) { +function queryOne(sql, params = [], dbName = 'anilist') { return new Promise((resolve, reject) => { - getDatabase().get(sql, params, (err, row) => { + getDatabase(dbName).get(sql, params, (err, row) => { if (err) reject(err); else resolve(row); }); }); } -function queryAll(sql, params = []) { +function queryAll(sql, params = [], dbName = 'anilist') { return new Promise((resolve, reject) => { - getDatabase().all(sql, params, (err, rows) => { + getDatabase(dbName).all(sql, params, (err, rows) => { if (err) reject(err); else resolve(rows || []); }); }); } +function run(sql, params = [], dbName = 'anilist') { + return new Promise((resolve, reject) => { + getDatabase(dbName).run(sql, params, function(err) { + if (err) reject(err); + else resolve({ changes: this.changes, lastID: this.lastID }); + }); + }); +} + +function closeDatabase(name = null) { + if (name) { + const db = databases.get(name); + if (db) { + db.close(); + databases.delete(name); + console.log(`Closed ${name} database`); + } + } else { + for (const [dbName, db] of databases) { + db.close(); + console.log(`Closed ${dbName} database`); + } + databases.clear(); + } +} + module.exports = { initDatabase, getDatabase, queryOne, - queryAll + queryAll, + run, + closeDatabase }; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 75b6b0e..87b0f5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -255,4 +255,34 @@ export interface GalleryInfoRequest extends FastifyRequest { query: { provider?: string; }; +} + +export interface AddFavoriteBody { + id: string; + title: string; + image_url: string; + thumbnail_url: string; + tags?: string; + provider: string; + headers: string; +} + +export interface RemoveFavoriteParams { + id: string; +} + +export interface Favorite { + id: string; + title: string; + image_url: string; + thumbnail_url: string; + tags: string; + provider: string; + headers: string; +} + +export interface FavoriteResult { + success: boolean; + error?: string; + id?: string; } \ No newline at end of file diff --git a/src/views/views.routes.ts b/src/views/views.routes.ts index 11cd26b..81baa57 100644 --- a/src/views/views.routes.ts +++ b/src/views/views.routes.ts @@ -29,6 +29,11 @@ async function viewsRoutes(fastify: FastifyInstance) { reply.type('text/html').send(stream); }); + fastify.get('/gallery/favorites/*', (req: FastifyRequest, reply: FastifyReply) => { + const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'gallery-image.html')); + reply.type('text/html').send(stream); + }); + fastify.get('/anime/:id', (req: FastifyRequest, reply: FastifyReply) => { const stream = fs.createReadStream(path.join(__dirname, '..', '..', 'views', 'anime.html')); reply.type('text/html').send(stream); diff --git a/views/css/gallery/gallery.css b/views/css/gallery/gallery.css index 7fbfe0a..0b648c1 100644 --- a/views/css/gallery/gallery.css +++ b/views/css/gallery/gallery.css @@ -1,15 +1,8 @@ -/* NOTA: Este archivo asume que home.css define variables como: - --bg-base, --bg-surface, --accent, --text-primary, --nav-height, etc., - y estilos base para .navbar y .section. -*/ - -/* Placeholder para la altura del navbar fijo */ .gallery-hero-placeholder { height: var(--nav-height); width: 100%; } -/* --- Controles de Búsqueda y Proveedor --- */ .gallery-controls { display: flex; gap: 1.5rem; @@ -17,7 +10,6 @@ margin-bottom: 2rem; padding-top: 1rem; } -/* ... (Estilos de búsqueda y selector sin cambios relevantes) ... */ .provider-selector { appearance: none; @@ -50,46 +42,35 @@ font-size: 0.8rem; } - -/* --- Grid de la Galería (Masonry Setup) --- */ .gallery-results { position: relative; padding-bottom: 3rem; margin: 0 -0.75rem; } -/* El elemento individual (grid-item) - Definición del ancho de columna */ .gallery-card { width: calc(25% - 1.5rem); margin: 0.75rem; - background: var(--bg-surface); border-radius: var(--radius-md); overflow: hidden; cursor: pointer; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255,255,255,0.05); + position: relative; - /* --- INICIO DE CORRECCIÓN (TRANSICIONES) --- */ - /* Define la transición para los cambios de Masonry (top/left) y la aparición (opacity/transform) */ - - - /* Estado inicial: Oculto y ligeramente desplazado */ opacity: 0; transform: translateY(20px) scale(0.98); - /* --- FIN DE CORRECCIÓN --- */ - - border: 1px solid rgba(255,255,255,0.05); } -/* Estado final: Visible y en su posición correcta */ .gallery-card.is-loaded { opacity: 1; transform: translateY(0) scale(1); } .gallery-card:hover { - transform: translateY(-5px); - /* Aseguramos que el hover se aplique solo si ya está cargada/visible */ + transform: translateY(-8px); + z-index: 10; } .gallery-card-img { @@ -97,43 +78,234 @@ height: auto; object-fit: cover; display: block; + transition: transform 0.4s ease; +} + +.gallery-card:hover .gallery-card-img { + transform: scale(1.05); +} + +.fav-btn { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0,0,0,0.6); + color: white; + border: none; + border-radius: 50%; + width: 38px; + height: 38px; + font-size: 1.2rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(6px); + transition: all 0.25s ease; + z-index: 3; +} + +.fav-btn:hover { + background: rgba(0,0,0,0.85); + transform: scale(1.15); +} + +.fav-btn.favorited { + color: #ff6b6b; + background: rgba(255, 107, 107, 0.25); + box-shadow: 0 0 12px rgba(255, 107, 107, 0.4); +} + +.provider-badge { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0,0,0,0.7); + color: white; + font-size: 0.75rem; + font-weight: 500; + padding: 5px 9px; + border-radius: 6px; + backdrop-filter: blur(6px); + z-index: 2; } -.gallery-card:hover .gallery-card-img { transform: scale(1.05); } -/* Estilos de respuesta (Responsiveness) */ @media (max-width: 1200px) { - .gallery-card { - width: calc(33.333% - 1.5rem); - } + .gallery-card { width: calc(33.333% - 1.5rem); } } -/* ... (media queries restantes sin cambios) ... */ - @media (max-width: 900px) { - .gallery-card { - width: calc(50% - 1.5rem); - } + .gallery-card { width: calc(50% - 1.5rem); } } - @media (max-width: 600px) { - .gallery-card { - width: calc(100% - 1.5rem); - } + .gallery-card { width: calc(100% - 1.5rem); } + .fav-btn { width: 42px; height: 42px; font-size: 1.4rem; } } - -/* Estilos para el Skeleton Card */ .gallery-card.skeleton { min-height: 250px; aspect-ratio: 1/1.4; display: flex; align-items: center; justify-content: center; - /* Los esqueletos también deben tener transición para cuando son eliminados/reemplazados */ } -/* --- Botón Cargar Más --- */ .load-more-container { display: flex; justify-content: center; padding: 2rem 0 4rem 0; +} + +.provider-and-fav { + display: flex; + gap: 12px; + align-items: center; + position: relative; +} + +.fav-toggle-btn { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255,255,255,0.07); + color: var(--text-primary); + border: 1px solid rgba(255,255,255,0.15); + padding: 0.7rem 1rem; + border-radius: 99px; + font-size: 0.95rem; + font-family: inherit; + cursor: pointer; + transition: all 0.25s ease; + white-space: nowrap; +} + +.fav-toggle-btn:hover { + background: rgba(255,255,255,0.12); + border-color: var(--accent); +} + +.fav-toggle-btn.active { + background: rgba(255,107,107,0.2); + border-color: #ff6b6b; + color: #ff6b6b; + box-shadow: 0 0 15px rgba(255,107,107,0.3); +} + +.fav-toggle-btn.active i { + color: #ff6b6b; + animation: beat 1.4s ease infinite; +} + +.fav-toggle-btn i { + font-size: 1.1rem; + transition: color 0.25s; +} + +@keyframes beat { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.15); } +} + +@media (max-width: 720px) { + .fav-text { display: none; } + .fav-toggle-btn { padding: 0.7rem 0.9rem; } + .provider-and-fav { gap: 8px; } +} + +.gallery-controls { + display: flex; + gap: 1.5rem; /* Reduced gap since there are fewer items */ + align-items: center; + margin-bottom: 2rem; + padding-top: 1rem; + + /* Center the provider selector since it's the only one */ + justify-content: flex-start; +} + +.provider-selector { + appearance: none; + width: auto; /* Allow it to shrink */ + max-width: 250px; /* Adjusted for better fit */ + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.15); + color: var(--text-primary); + padding: 0.75rem 2.8rem 0.75rem 1rem; + border-radius: 99px; + font-size: 0.95rem; + cursor: pointer; + min-width: 170px; + flex-shrink: 0; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23888888' viewBox='0 0 16 16'%3E%3Cpath d='M4.646 6.646a.5.5 0 0 1 .708 0L8 9.293l2.646-2.647a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 0 1 0-.708z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + transition: all 0.25s ease; +} + +.provider-selector:hover { background-color: rgba(255,255,255,0.12); border-color: var(--accent); } +.provider-selector:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(255,107,107,0.2); } + +.provider-selector option { + background: #111; + color: white; + padding: 12px; +} + +.search-gallery-wrapper { + flex: 1; + min-width: 200px; + max-width: none; +} + +.search-input { + width: 100%; + background: rgba(255,255,255,0.08); + border: 1px solid rgba(255,255,255,0.15); + color: var(--text-primary); + padding: 0.75rem 1.3rem; + border-radius: 99px; + font-size: 1rem; + transition: all 0.25s; +} + +.search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255,107,107,0.2); +} + +.fav-toggle-btn { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 8px; + background: rgba(255,255,255,0.08); + color: var(--text-primary); + border: 1px solid rgba(255,255,255,0.15); + padding: 0.75rem 1.1rem; + border-radius: 99px; + font-size: 0.95rem; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; +} + +.fav-toggle-btn:hover { background: rgba(255,255,255,0.15); border-color: #ff6b6b; } +.fav-toggle-btn.active { + background: rgba(255,107,107,0.25); + border-color: #ff6b6b; + color: #ff6b6b; + box-shadow: 0 0 15px rgba(255,107,107,0.4); +} +.fav-toggle-btn.active i { color: #ff6b6b; animation: heartbeat 1.5s ease infinite; } +@keyframes heartbeat { 0%,100%{transform:scale(1)} 50%{transform:scale(1.2)} } + +@media (max-width: 900px) { + .gallery-controls { + flex-wrap: wrap; + gap: 0.8rem; + } + .provider-selector { max-width: none; width: 48%; } + .search-gallery-wrapper { order: -1; width: 100%; } + .fav-toggle-btn { width: 48%; justify-content: center; } + .fav-text { display: none; } } \ No newline at end of file diff --git a/views/css/gallery/image.css b/views/css/gallery/image.css index 4996568..8d0f593 100644 --- a/views/css/gallery/image.css +++ b/views/css/gallery/image.css @@ -1,145 +1,270 @@ -/* Placeholder para la altura del navbar fijo */ -.gallery-hero-placeholder { - height: var(--nav-height); - width: 100%; +:root { + --accent: #8b5cf6; + --text-primary: #ffffff; + --text-secondary: #a1a1aa; + --radius-lg: 24px; + --radius-full: 9999px; + --glass-border: 1px solid rgba(255, 255, 255, 0.1); + --glass-bg: rgba(20, 20, 23, 0.7); + --nav-height: 80px; } -.item-content { +.gallery-hero-placeholder { height: var(--nav-height); width: 100%; } + +.item-content-flex-wrapper { display: flex; gap: 3rem; - padding-top: 2rem; + padding: 2rem 0 4rem; min-height: 80vh; + max-width: 1400px; + margin: 0 auto; } .image-col { flex: 2; - max-width: 65%; display: flex; justify-content: center; align-items: flex-start; position: relative; - padding-bottom: 2rem; } .item-image { max-width: 100%; max-height: 85vh; - height: auto; - border-radius: var(--radius-md); - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6); + border-radius: var(--radius-lg, 16px); + box-shadow: 0 20px 50px rgba(0,0,0,0.6); object-fit: contain; - opacity: 0; /* Inicialmente oculto para fade-in */ - transition: opacity 0.5s ease; + transition: all 0.4s ease; + cursor: zoom-in; } -.item-image.loaded { - opacity: 1; +.item-image:hover { + transform: scale(1.02); + box-shadow: 0 30px 70px rgba(0,0,0,0.7); } .info-col { flex: 1; - max-width: 35%; display: flex; flex-direction: column; - gap: 1.5rem; + gap: 2rem; + min-width: 300px; } .info-header h1 { - font-size: 2.2rem; - margin: 0 0 0.5rem 0; + font-size: 2.4rem; + margin: 0 0 0.8rem 0; + line-height: 1.2; + word-break: break-word; } -.info-header .provider-name { +.provider-name { color: var(--text-secondary); font-size: 1rem; - margin-bottom: 1rem; - display: block; + opacity: 0.9; } -.tags-section { - border-top: 1px solid rgba(255,255,255,0.1); - padding-top: 1.5rem; +.actions-row { + display: flex; + flex-direction: column; + gap: 1rem; } +.action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 1rem 1.5rem; + border-radius: 99px; + font-size: 1.05rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + border: none; +} + +.fav-action { + background: rgba(255,255,255,0.08); + border: 2px solid rgba(255,255,255,0.2); + color: white; +} + +.fav-action:hover { background: rgba(255,255,255,0.15); } +.fav-action.favorited { + background: linear-gradient(135deg, #ff6b6b, #ff8e8e); + border-color: #ff6b6b; + color: white; + animation: pulse 2s infinite; +} + +.fav-action i { font-size: 1.4rem; } +.fav-action.favorited i { animation: heartbeat 1.4s ease infinite; } + +@keyframes heartbeat { + 0%,100% { transform: scale(1); } + 50% { transform: scale(1.25); } +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(255,107,107,0.4); } + 70% { box-shadow: 0 0 0 12px rgba(255,107,107,0); } + 100% { box-shadow: 0 0 0 0 rgba(255,107,107,0); } +} + +.download-btn { + background: var(--accent); + color: white; +} + +.download-btn:hover { + background: #7c4dff; + transform: translateY(-2px); + box-shadow: 0 10px 30px rgba(139,92,246,0.4); +} + +.copy-link-btn { + background: rgba(255,255,255,0.1); + border: 2px solid rgba(255,255,255,0.2); + color: white; +} + +.copy-link-btn:hover { background: rgba(255,255,255,0.18); } + .tags-section h3 { margin: 0 0 1rem 0; - font-size: 1.2rem; color: var(--text-secondary); + font-weight: 500; } .tag-list { display: flex; flex-wrap: wrap; - gap: 0.5rem; + gap: 0.6rem; } .tag-item { - background: rgba(139, 92, 246, 0.2); + background: rgba(139,92,246,0.2); color: var(--accent); - padding: 0.4rem 0.8rem; + padding: 0.5rem 1rem; border-radius: 99px; - font-size: 0.9rem; + font-size: 0.95rem; text-decoration: none; - transition: background 0.2s; + transition: all 0.2s; + backdrop-filter: blur(4px); } .tag-item:hover { - background: rgba(139, 92, 246, 0.4); + background: rgba(139,92,246,0.4); + transform: translateY(-2px); } -.download-btn { - padding: 1rem 2rem; - background: var(--accent); - color: var(--text-primary); - border: none; - border-radius: var(--radius-md); - font-size: 1.1rem; - font-weight: bold; - cursor: pointer; - text-align: center; - text-decoration: none; - transition: background 0.2s, box-shadow 0.2s; - margin-top: 1rem; /* Espacio después de las etiquetas */ -} - -.download-btn:hover { - background: #7c4dff; - box-shadow: 0 5px 20px var(--accent-glow); -} - - -/* --- Skeleton Styles --- */ -.item-skeleton { - display: flex; - gap: 3rem; - width: 100%; -} - -.image-col-skeleton { - flex: 2; - max-width: 65%; - aspect-ratio: 16/9; - height: 500px; - border-radius: var(--radius-md); - background: var(--bg-surface); -} - -.info-col .provider-skeleton { width: 30%; height: 18px; margin-bottom: 1rem; } -.info-col .title-skeleton { width: 90%; height: 36px; margin-bottom: 3rem; } -.info-col .tag-label-skeleton { width: 40%; height: 20px; margin-bottom: 1rem; } -.info-col .tags-skeleton { width: 80%; height: 50px; margin-bottom: 3rem; } -.info-col .download-skeleton { width: 100%; height: 50px; } - - -/* Media Queries */ @media (max-width: 900px) { - .item-content, .item-skeleton { - flex-direction: column; + .item-content-flex-wrapper { flex-direction: column; align-items: center; gap: 2rem; } + .info-col { width: 100%; max-width: 600px; } + .item-image { max-height: 70vh; } + .actions-row { flex-direction: row; } + .action-btn { flex: 1; } +} + +@media (max-width: 500px) { + .actions-row { flex-direction: column; } + .action-btn { padding: 0.9rem; font-size: 1rem; } +} + +.similar-section { + margin-top: 4rem; + padding: 2rem 0; + border-top: 1px solid rgba(255,255,255,0.1); + max-width: 1400px; + margin-left: auto; + margin-right: auto; +} + +.similar-section h3 { + margin: 0 0 1.5rem 0; + font-size: 1.6rem; + color: var(--text-primary); + text-align: center; +} + +.similar-grid { + + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + padding: 1rem 0; +} + +.similar-card { + + height: 180px; + border-radius: var(--radius-lg, 16px); + overflow: hidden; + position: relative; + cursor: pointer; + transition: transform 0.3s ease; + box-shadow: 0 8px 25px rgba(0,0,0,0.4); +} + +.similar-card:hover { + transform: scale(1.03); +} + +.similar-card img { + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity 0.3s; +} + +.similar-card:hover img { + opacity: 0.9; +} + +.similar-card::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(transparent 60%, rgba(0,0,0,0.7)); + pointer-events: none; +} + +.similar-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 1rem; + color: white; + font-size: 0.9rem; + font-weight: 500; + text-shadow: 0 1px 3px rgba(0,0,0,0.8); +} + +.back-btn { + position: fixed; + top: 5rem; left: 2rem; z-index: 100; + display: flex; align-items: center; gap: 0.5rem; + padding: 0.8rem 1.5rem; + background: var(--glass-bg); backdrop-filter: blur(12px); + border: var(--glass-border); border-radius: var(--radius-full); + color: white; text-decoration: none; font-weight: 600; + transition: all 0.2s ease; +} +.back-btn:hover { background: rgba(255, 255, 255, 0.15); transform: translateX(-5px); } + +@media (max-width: 768px) { + .similar-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + padding: 1rem; } - .image-col, .info-col, .image-col-skeleton { - max-width: 100%; - } - .item-image { - max-height: 80vh; /* Ajustar en móviles */ + .similar-card { height: 140px; } +} + +@media (max-width: 500px) { + .similar-grid { + grid-template-columns: 1fr; } } \ No newline at end of file diff --git a/views/gallery-image.html b/views/gallery-image.html index 6a2cb1a..d68fb0d 100644 --- a/views/gallery-image.html +++ b/views/gallery-image.html @@ -13,7 +13,7 @@ + + + Back to Gallery +
-
-
-
-
-
-
-
-
-
-
-
+
+ +
+

Loading similar images...

diff --git a/views/gallery.html b/views/gallery.html index 7d0840f..fa0f0b0 100644 --- a/views/gallery.html +++ b/views/gallery.html @@ -30,10 +30,14 @@
-
- - -
+
+ +
+ +
@@ -42,16 +46,7 @@