diff --git a/favicon.go b/favicon.go index e338fc9..6b9dbf2 100644 --- a/favicon.go +++ b/favicon.go @@ -50,6 +50,30 @@ var ( iconLinkRegex = regexp.MustCompile(`]+rel=["'](?:icon|shortcut icon|apple-touch-icon)["'][^>]+href=["']([^"']+)["']`) ) +// Add this near the top with other vars +var ( + faviconDownloadQueue = make(chan faviconDownloadRequest, 1000) +) + +type faviconDownloadRequest struct { + faviconURL string + pageURL string + cacheID string +} + +func init() { + // Start 5 worker goroutines to process favicon downloads + for i := 0; i < 5; i++ { + go faviconDownloadWorker() + } +} + +func faviconDownloadWorker() { + for req := range faviconDownloadQueue { + cacheFavicon(req.faviconURL, req.cacheID) + } +} + // Generates a cache ID from URL func faviconIDFromURL(rawURL string) string { hasher := md5.New() @@ -312,14 +336,14 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string { return fmt.Sprintf("/image/%s_thumb.webp", cacheID) } - // Resolve URL (but ignore resolved ID — we always use the one from pageURL) + // Resolve URL faviconURL, _ := resolveFaviconURL(rawFavicon, pageURL) if faviconURL == "" { recordInvalidImageID(cacheID) return "/static/images/missing.svg" } - // Avoid re-downloading + // Check if already downloading faviconCache.RLock() downloading := faviconCache.m[cacheID] faviconCache.RUnlock() @@ -329,17 +353,12 @@ func getFaviconProxyURL(rawFavicon, pageURL string) string { faviconCache.m[cacheID] = true faviconCache.Unlock() - go func() { - defer func() { - faviconCache.Lock() - delete(faviconCache.m, cacheID) - faviconCache.Unlock() - }() - _, _, err := cacheFavicon(faviconURL, cacheID) - if err != nil { - recordInvalidImageID(cacheID) - } - }() + // Send to download queue instead of starting goroutine + faviconDownloadQueue <- faviconDownloadRequest{ + faviconURL: faviconURL, + pageURL: pageURL, + cacheID: cacheID, + } } return fmt.Sprintf("/image/%s_thumb.webp", cacheID) diff --git a/static/js/dynamicscrollingtext.js b/static/js/dynamicscrollingtext.js new file mode 100644 index 0000000..557fc34 --- /dev/null +++ b/static/js/dynamicscrollingtext.js @@ -0,0 +1,248 @@ +(function() { + // Get template data and configuration + const templateData = document.getElementById('template-data'); + const type = templateData.getAttribute('data-type'); + const hardCacheEnabled = templateData.getAttribute('data-hard-cache-enabled') === 'true'; + + // Track all favicon/image elements and their IDs + const allMediaElements = []; + const allMediaIds = []; + let statusCheckTimeout = null; + + // Add loading effects to image/favicon and associated text + function addLoadingEffects(imgElement) { + const container = imgElement.closest(type === 'image' ? '.image' : '.result_item'); + if (!container) return; + + const titleSelector = type === 'image' ? '.img_title' : '.result-url'; + const title = container.querySelector(titleSelector); + imgElement.closest('.favicon-wrapper')?.classList.add('loading'); + // if (title) title.classList.add('title-loading'); + } + + // Remove loading effects when image/favicon loads + function removeLoadingEffects(imgElement) { + const container = imgElement.closest(type === 'image' ? '.image' : '.result_item'); + const titleSelector = type === 'image' ? '.img_title' : '.result-url'; + const title = container?.querySelector(titleSelector); + imgElement.closest('.favicon-wrapper')?.classList.remove('loading'); + if (title) title.classList.remove('title-loading'); + + if (type === 'image' && imgElement.src.endsWith('/images/missing.svg')) { + container.remove(); + } + } + + // Handle image/favicon loading errors + function handleImageError(imgElement) { + const container = imgElement.closest(type === 'image' ? '.image' : '.result_item'); + const titleSelector = type === 'image' ? '.img_title' : '.result-url'; + const title = container?.querySelector(titleSelector); + + imgElement.closest('.favicon-wrapper')?.classList.remove('loading'); + if (title) title.classList.remove('title-loading'); + + if (type === 'image') { + container.style.display = 'none'; + } else { + imgElement.src = '/static/images/missing.svg'; + } + } + + // Shared configuration + const statusCheckInterval = 500; + const scrollThreshold = 500; + const loadingIndicator = document.getElementById('message-bottom-right'); + let loadingTimer; + let isFetching = false; + let page = parseInt(templateData.getAttribute('data-page')) || 1; + let query = templateData.getAttribute('data-query'); + let noMoreImages = false; + + function showLoadingMessage() { + loadingIndicator.classList.add('visible'); + } + + function hideLoadingMessage() { + loadingIndicator.classList.remove('visible'); + } + + function ensureScrollable() { + if (noMoreImages) return; + if (document.body.scrollHeight <= window.innerHeight) { + fetchNextPage(); + } + } + + // Register a new media element for tracking + function registerMediaElement(imgElement) { + const id = imgElement.getAttribute('data-id'); + if (!id || allMediaIds.includes(id)) return; + + // Wrap the image in a .favicon-wrapper if not already + if (!imgElement.parentElement.classList.contains('favicon-wrapper')) { + const wrapper = document.createElement('span'); + wrapper.classList.add('favicon-wrapper'); + imgElement.parentElement.replaceChild(wrapper, imgElement); + wrapper.appendChild(imgElement); + } + + // Track and style + allMediaElements.push(imgElement); + allMediaIds.push(id); + addLoadingEffects(imgElement); + + + if (hardCacheEnabled) { + imgElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + } else { + imgElement.src = '/static/images/placeholder.svg'; + } + + // Schedule a status check if not already pending + if (!statusCheckTimeout) { + statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval); + } + } + + // Check status of all tracked media elements + function checkMediaStatus() { + statusCheckTimeout = null; + + if (allMediaIds.length === 0) return; + + // Group IDs to avoid very long URLs + const idGroups = []; + for (let i = 0; i < allMediaIds.length; i += 50) { + idGroups.push(allMediaIds.slice(i, i + 50)); + } + + const checkGroup = (group) => { + return fetch(`/image_status?image_ids=${group.join(',')}`) + .then(response => response.json()) + .then(statusMap => { + const pendingElements = []; + const pendingIds = []; + + allMediaElements.forEach((imgElement, index) => { + const id = allMediaIds[index]; + if (group.includes(id)) { + if (statusMap[id]) { + if (imgElement.src !== statusMap[id]) { + imgElement.src = statusMap[id]; + imgElement.onload = () => removeLoadingEffects(imgElement); + imgElement.onerror = () => handleImageError(imgElement); + } + } else { + pendingElements.push(imgElement); + pendingIds.push(id); + } + } + }); + + // Update global arrays with remaining pending items + allMediaElements = pendingElements; + allMediaIds = pendingIds; + }); + }; + + // Process all groups sequentially + const processGroups = async () => { + for (const group of idGroups) { + try { + await checkGroup(group); + } catch (error) { + console.error('Status check error:', error); + } + } + + // If we still have pending items, schedule another check + if (allMediaIds.length > 0) { + statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval); + } + }; + + processGroups(); + } + + function fetchNextPage() { + if (isFetching || noMoreImages) return; + + loadingTimer = setTimeout(() => { + showLoadingMessage(); + }, 150); + + isFetching = true; + page += 1; + + fetch(`/search?q=${encodeURIComponent(query)}&t=${type}&p=${page}&ajax=true`) + .then(response => response.text()) + .then(html => { + clearTimeout(loadingTimer); + hideLoadingMessage(); + + let tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + let newItems = tempDiv.querySelectorAll(type === 'image' ? '.image' : '.result_item'); + + if (newItems.length > 0) { + let resultsContainer = document.querySelector(type === 'image' ? '.images' : '.results'); + newItems.forEach(item => { + let clonedItem = item.cloneNode(true); + resultsContainer.appendChild(clonedItem); + + // Register any new media elements + const img = clonedItem.querySelector('img[data-id]'); + if (img) { + registerMediaElement(img); + } + }); + + ensureScrollable(); + } else { + noMoreImages = true; + } + isFetching = false; + }) + .catch(error => { + clearTimeout(loadingTimer); + hideLoadingMessage(); + console.error('Fetch error:', error); + isFetching = false; + }); + } + + // Initialize all existing media elements + function initializeMediaElements() { + document.querySelectorAll('img[data-id]').forEach(img => { + registerMediaElement(img); + }); + + // Start periodic checks if hard cache is enabled + if (hardCacheEnabled && allMediaIds.length > 0) { + statusCheckTimeout = setTimeout(checkMediaStatus, statusCheckInterval); + } + } + + // Initialize when DOM is ready + if (document.readyState === 'complete') { + initializeMediaElements(); + } else { + window.addEventListener('load', initializeMediaElements); + } + + // Infinite scroll handler + window.addEventListener('scroll', () => { + if (isFetching || noMoreImages) return; + if (window.innerHeight + window.scrollY >= document.body.offsetHeight - scrollThreshold) { + fetchNextPage(); + } + }); + + // Clean up on page unload + window.addEventListener('beforeunload', () => { + if (statusCheckTimeout) { + clearTimeout(statusCheckTimeout); + } + }); +})(); \ No newline at end of file diff --git a/templates/text.html b/templates/text.html index b04f4f1..2f5f2fb 100755 --- a/templates/text.html +++ b/templates/text.html @@ -17,6 +17,45 @@ +
@@ -194,10 +233,9 @@ - + - diff --git a/text.go b/text.go index 7dedb63..63ce269 100755 --- a/text.go +++ b/text.go @@ -1,7 +1,10 @@ package main import ( + "fmt" "net/http" + "os" + "path/filepath" "time" ) @@ -52,12 +55,11 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string elapsedTime := time.Since(startTime) - // Prepare safe decorated results + // Simplified result structure without waiting for favicons type DecoratedResult struct { TextSearchResult - FaviconURL string - FaviconID string PrettyLink LinkParts + FaviconID string // Just the ID, URL will be generated client-side } var decoratedResults []DecoratedResult @@ -66,39 +68,52 @@ func HandleTextSearch(w http.ResponseWriter, settings UserSettings, query string continue } - // First format the link prettyLink := FormatLinkHTML(r.URL) faviconID := faviconIDFromURL(prettyLink.RootURL) - faviconURL := getFaviconProxyURL("", prettyLink.RootURL) decoratedResults = append(decoratedResults, DecoratedResult{ TextSearchResult: r, PrettyLink: prettyLink, FaviconID: faviconID, - FaviconURL: faviconURL, }) + + // Start async favicon fetch if not already cached + go ensureFaviconIsCached(faviconID, prettyLink.RootURL) } data := map[string]interface{}{ - "Results": decoratedResults, - "Query": query, - "Fetched": FormatElapsedTime(elapsedTime), - "Page": page, - "HasPrevPage": hasPrevPage, - "HasNextPage": len(combinedResults) >= 50, - "NoResults": len(combinedResults) == 0, - "LanguageOptions": languageOptions, - "CurrentLang": settings.SearchLanguage, - "Theme": settings.Theme, - "Safe": settings.SafeSearch, - "IsThemeDark": settings.IsThemeDark, - "Trans": Translate, + "Results": decoratedResults, + "Query": query, + "Fetched": FormatElapsedTime(elapsedTime), + "Page": page, + "HasPrevPage": hasPrevPage, + "HasNextPage": len(combinedResults) >= 50, + "NoResults": len(combinedResults) == 0, + "LanguageOptions": languageOptions, + "CurrentLang": settings.SearchLanguage, + "Theme": settings.Theme, + "Safe": settings.SafeSearch, + "IsThemeDark": settings.IsThemeDark, + "Trans": Translate, + "HardCacheEnabled": config.DriveCacheEnabled, } - // Render the template renderTemplate(w, "text.html", data) } +func ensureFaviconIsCached(faviconID, rootURL string) { + // Check if already exists in cache + filename := fmt.Sprintf("%s_thumb.webp", faviconID) + cachedPath := filepath.Join(config.DriveCache.Path, "images", filename) + + if _, err := os.Stat(cachedPath); err == nil { + return // Already cached + } + + // Not cached, initiate download + getFaviconProxyURL("", rootURL) // This will trigger async download +} + func getTextResultsFromCacheOrFetch(cacheKey CacheKey, query, safe, lang string, page int) []TextSearchResult { cacheChan := make(chan []SearchResult) var combinedResults []TextSearchResult