From b37bc763dc4808597afcd66f37d20cbd7a1321aa Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 27 Aug 2025 09:31:45 -0700 Subject: [PATCH] feat: Add local image loader with lightbox support --- web/kiko_local_image_loader.js | 755 +++++++++++++++++++++++++++++++++ 1 file changed, 755 insertions(+) create mode 100644 web/kiko_local_image_loader.js diff --git a/web/kiko_local_image_loader.js b/web/kiko_local_image_loader.js new file mode 100644 index 0000000..cdc1287 --- /dev/null +++ b/web/kiko_local_image_loader.js @@ -0,0 +1,755 @@ +import { app } from "/scripts/app.js"; +import { api } from "/scripts/api.js"; + +// Setup global lightbox for image preview +function setupGlobalLightbox() { + if (document.getElementById('kiko-image-lightbox')) return; + + const lightboxId = 'kiko-image-lightbox'; + const lightboxHTML = ` + + `; + + const lightboxCSS = ` + #${lightboxId} { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + display: none; + align-items: center; + justify-content: center; + z-index: 10000; + box-sizing: border-box; + -webkit-user-select: none; + user-select: none; + } + #${lightboxId} .lightbox-content { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + #${lightboxId} img, #${lightboxId} video { + max-width: 95%; + max-height: 95%; + object-fit: contain; + transition: transform 0.1s ease-out; + transform: scale(1) translate(0, 0); + } + #${lightboxId} audio { + width: 80%; + max-width: 600px; + } + #${lightboxId} img { + cursor: grab; + } + #${lightboxId} img.panning { + cursor: grabbing; + } + #${lightboxId} .lightbox-close { + position: absolute; + top: 15px; + right: 20px; + width: 35px; + height: 35px; + background-color: rgba(0,0,0,0.5); + color: #fff; + border-radius: 50%; + border: 2px solid #fff; + font-size: 24px; + line-height: 30px; + text-align: center; + cursor: pointer; + z-index: 10002; + } + #${lightboxId} .lightbox-prev, #${lightboxId} .lightbox-next { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 45px; + height: 60px; + background-color: rgba(0,0,0,0.4); + color: #fff; + border: none; + font-size: 30px; + cursor: pointer; + z-index: 10001; + transition: background-color 0.2s; + } + #${lightboxId} .lightbox-prev:hover, #${lightboxId} .lightbox-next:hover { + background-color: rgba(0,0,0,0.7); + } + #${lightboxId} .lightbox-prev { + left: 15px; + } + #${lightboxId} .lightbox-next { + right: 15px; + } + #${lightboxId} [disabled] { + display: none; + } + #${lightboxId} .lightbox-dimensions { + position: absolute; + bottom: 0px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.7); + color: #fff; + padding: 2px 4px; + border-radius: 5px; + font-size: 14px; + z-index: 10001; + } + `; + + document.body.insertAdjacentHTML('beforeend', lightboxHTML); + const styleEl = document.createElement('style'); + styleEl.textContent = lightboxCSS; + document.head.appendChild(styleEl); +} + +setupGlobalLightbox(); + +app.registerExtension({ + name: "KikoTools.LocalImageLoader", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "KikoLocalImageLoader") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated?.apply(this, arguments); + + const galleryContainer = document.createElement("div"); + const uniqueId = `kiko-gallery-${Math.random().toString(36).substring(2, 9)}`; + galleryContainer.id = uniqueId; + + const folderSVG = ``; + const videoSVG = ``; + const audioSVG = ``; + + galleryContainer.innerHTML = ` + + +
+ +
+
+ + +
+
+ + +
+ + + +
+ + +
+ + + + + +
+ + +
+
Loading...
+
+ + +
+
+ `; + + // Add the widget to the node + this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, { + serialize: false, + }); + + // Set initial size for the node + this.size = [800, 600]; + this.setSize(this.size); + + // Initialize gallery functionality + const node = this; + const container = galleryContainer.querySelector('.kiko-container-wrapper'); + const pathInput = container.querySelector('.kiko-path-input'); + const pathPresets = container.querySelector('.kiko-path-presets'); + const savePathBtn = container.querySelector('.kiko-save-path'); + const browseBtn = container.querySelector('.kiko-browse'); + const upFolderBtn = container.querySelector('.kiko-up-folder'); + const showVideos = container.querySelector('.kiko-show-videos'); + const showAudio = container.querySelector('.kiko-show-audio'); + const sortBy = container.querySelector('.kiko-sort-by'); + const sortOrder = container.querySelector('.kiko-sort-order'); + const refreshBtn = container.querySelector('.kiko-refresh'); + const cardHolder = container.querySelector('.kiko-cardholder'); + const pagination = container.querySelector('.kiko-pagination'); + const statusMessage = container.querySelector('.kiko-status-message'); + + let currentPage = 1; + let totalPages = 1; + let currentDirectory = ''; + let parentDirectory = null; + let currentItems = []; + let selectedPaths = { + image: null, + video: null, + audio: null + }; + + // Utility: Debounce function + const debounce = (func, delay) => { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(this, args), delay); + }; + }; + + // Apply responsive masonry layout + const applyMasonryLayout = () => { + const minCardWidth = 120; + const gap = 5; + const containerWidth = cardHolder.clientWidth - 10; // Account for padding + if (containerWidth <= 0) return; + + const columnCount = Math.max(1, Math.floor(containerWidth / (minCardWidth + gap))); + const totalGapSpace = (columnCount - 1) * gap; + const actualCardWidth = Math.floor((containerWidth - totalGapSpace) / columnCount); + const columnHeights = new Array(columnCount).fill(0); + + const cards = cardHolder.querySelectorAll('.kiko-gallery-card'); + cards.forEach(card => { + card.style.width = `${actualCardWidth}px`; + const minHeight = Math.min(...columnHeights); + const columnIndex = columnHeights.indexOf(minHeight); + card.style.left = `${columnIndex * (actualCardWidth + gap)}px`; + card.style.top = `${minHeight}px`; + columnHeights[columnIndex] += card.offsetHeight + gap; + }); + + // Set container height - let scrollbar handle overflow + const maxHeight = Math.max(...columnHeights); + if (maxHeight > 0) { + cardHolder.style.minHeight = `${Math.min(200, maxHeight)}px`; + } + }; + + const debouncedLayout = debounce(applyMasonryLayout, 20); + + // Set up ResizeObserver for responsive layout + new ResizeObserver(debouncedLayout).observe(cardHolder); + + // Load saved paths + async function loadSavedPaths() { + try { + const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths'); + const data = await response.json(); + + pathPresets.innerHTML = ''; + data.saved_paths?.forEach(path => { + const option = document.createElement('option'); + option.value = path; + option.textContent = path.split('/').pop() || path; + option.title = path; + pathPresets.appendChild(option); + }); + } catch (error) { + console.error('Error loading saved paths:', error); + } + } + + // Save current path + savePathBtn.onclick = async () => { + if (!currentDirectory) return; + + try { + const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths'); + const data = await response.json(); + const savedPaths = data.saved_paths || []; + + if (!savedPaths.includes(currentDirectory)) { + savedPaths.push(currentDirectory); + await api.fetchApi('/kiko_local_image_loader/save_paths', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: savedPaths }) + }); + await loadSavedPaths(); + } + } catch (error) { + console.error('Error saving path:', error); + } + }; + + // Navigate to parent directory + upFolderBtn.onclick = () => { + if (parentDirectory) { + loadImages(parentDirectory); + } + }; + + // Load images from directory + async function loadImages(directory, page = 1) { + if (!directory) return; + + statusMessage.style.display = 'block'; + statusMessage.textContent = 'Loading...'; + + const params = new URLSearchParams({ + directory: directory, + page: page.toString(), + per_page: '50', + show_videos: showVideos.checked, + show_audio: showAudio.checked, + sort_by: sortBy.value, + sort_order: sortOrder.value + }); + + try { + const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`); + const data = await response.json(); + + if (data.error) { + statusMessage.textContent = data.error; + return; + } + + currentDirectory = data.current_directory; + parentDirectory = data.parent_directory; + currentPage = data.current_page; + totalPages = data.total_pages; + currentItems = data.items; + + pathInput.value = currentDirectory; + upFolderBtn.disabled = !parentDirectory; + + renderGallery(); + renderPagination(); + + statusMessage.style.display = 'none'; + } catch (error) { + console.error('Error loading images:', error); + statusMessage.textContent = 'Error loading directory'; + } + } + + // Render gallery cards + function renderGallery() { + cardHolder.innerHTML = ''; + + if (currentItems.length === 0) { + cardHolder.innerHTML = '
No items found
'; + return; + } + + currentItems.forEach((item, index) => { + const card = document.createElement('div'); + card.className = 'kiko-gallery-card'; + + if (item.type === 'dir') { + card.innerHTML = ` +
+
+
${folderSVG}
+
${item.name}
+
+
+ `; + card.onclick = () => { + loadImages(item.path); + }; + } else if (item.type === 'image') { + const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`; + card.innerHTML = ` +
+ ${item.name} +
+
${item.name}
+ `; + + // Trigger layout when image loads + const img = card.querySelector('img'); + if (img) { + img.onload = debouncedLayout; + } + + // Single click to select + card.onclick = () => selectMedia(item, 'image', card); + + // Double click to open in new tab + card.ondblclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const fullImageUrl = `/kiko_local_image_loader/view?filepath=${encodeURIComponent(item.path)}`; + window.open(fullImageUrl, '_blank'); + }; + } else if (item.type === 'video') { + const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`; + card.innerHTML = ` +
+ ${item.name} +
${videoSVG}
+
+
${item.name}
+ `; + + const img = card.querySelector('img'); + if (img) { + img.onload = debouncedLayout; + } + + // Single click to select + card.onclick = () => selectMedia(item, 'video', card); + + // Double click to open in new tab + card.ondblclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const fullVideoUrl = `/kiko_local_image_loader/view?filepath=${encodeURIComponent(item.path)}`; + window.open(fullVideoUrl, '_blank'); + }; + } else if (item.type === 'audio') { + card.innerHTML = ` +
+
+
${audioSVG}
+
${item.name}
+
+
+ `; + + // Single click to select + card.onclick = () => selectMedia(item, 'audio', card); + + // Double click to open in new tab + card.ondblclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + const fullAudioUrl = `/kiko_local_image_loader/view?filepath=${encodeURIComponent(item.path)}`; + window.open(fullAudioUrl, '_blank'); + }; + } + + // Check if selected + if (selectedPaths[item.type] === item.path) { + card.classList.add('kiko-selected'); + } + + cardHolder.appendChild(card); + }); + + // Apply layout after all cards are added + requestAnimationFrame(debouncedLayout); + } + + // Select media + async function selectMedia(item, type, cardElement) { + // Update selection + selectedPaths[type] = item.path; + + // Update UI + cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => { + card.classList.remove('kiko-selected'); + }); + cardElement.classList.add('kiko-selected'); + + // Send to backend + try { + await api.fetchApi('/kiko_local_image_loader/set_node_selection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + node_id: node.id, + path: item.path, + type: type + }) + }); + + // Trigger node update + node.setDirtyCanvas(true); + } catch (error) { + console.error('Error setting selection:', error); + } + } + + // Render pagination + function renderPagination() { + pagination.innerHTML = ''; + + if (totalPages <= 1) return; + + const createButton = (text, page) => { + const btn = document.createElement('button'); + btn.textContent = text; + btn.style.cssText = 'background: #333; color: #ccc; border: 1px solid #555; padding: 1px 6px; cursor: pointer; font-size: 11px;'; + if (page === currentPage) { + btn.style.background = '#555'; + } + btn.onclick = () => loadImages(currentDirectory, page); + return btn; + }; + + if (currentPage > 1) { + pagination.appendChild(createButton('◀', currentPage - 1)); + } + + for (let i = 1; i <= totalPages; i++) { + if (i === 1 || i === totalPages || (i >= currentPage - 2 && i <= currentPage + 2)) { + pagination.appendChild(createButton(i.toString(), i)); + } else if (i === currentPage - 3 || i === currentPage + 3) { + const span = document.createElement('span'); + span.textContent = '...'; + span.style.padding = '0 5px'; + pagination.appendChild(span); + } + } + + if (currentPage < totalPages) { + pagination.appendChild(createButton('▶', currentPage + 1)); + } + } + + // Event handlers + browseBtn.onclick = () => loadImages(pathInput.value || currentDirectory); + refreshBtn.onclick = () => loadImages(currentDirectory, currentPage); + + pathInput.onkeydown = (e) => { + if (e.key === 'Enter') { + loadImages(pathInput.value); + } + }; + + pathPresets.onchange = () => { + if (pathPresets.value) { + loadImages(pathPresets.value); + } + }; + + showVideos.onchange = () => loadImages(currentDirectory, 1); + showAudio.onchange = () => loadImages(currentDirectory, 1); + sortBy.onchange = () => loadImages(currentDirectory, 1); + sortOrder.onchange = () => loadImages(currentDirectory, 1); + + // Load initial data + loadSavedPaths(); + + // Try to load last path + api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => { + const data = await response.json(); + if (data.last_path) { + loadImages(data.last_path); + } + }); + + return r; + }; + } + } +}); \ No newline at end of file