Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Local Image Viewer</title> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| colors: { | |
| primary: '#6366F1', | |
| secondary: '#8B5CF6', | |
| accent: '#EC4899', | |
| dark: '#1E293B', | |
| light: '#F8FAFC' | |
| }, | |
| fontFamily: { | |
| sans: ['Inter', 'sans-serif'], | |
| }, | |
| } | |
| } | |
| } | |
| </script> | |
| <!-- Font Awesome --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <!-- Google Fonts --> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --transition-speed: 0.3s; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: #F1F5F9; | |
| } | |
| .dropzone { | |
| border: 3px dashed #CBD5E1; | |
| transition: all var(--transition-speed) ease; | |
| background-color: rgba(248, 250, 252, 0.7); | |
| backdrop-filter: blur(4px); | |
| } | |
| .dropzone.active { | |
| border-color: #6366F1; | |
| background-color: rgba(99, 102, 241, 0.1); | |
| box-shadow: 0 4px 15px rgba(99, 102, 241, 0.1); | |
| } | |
| .image-container { | |
| transition: transform var(--transition-speed) ease; | |
| box-shadow: 0 4px 25px rgba(0, 0, 0, 0.05); | |
| background: linear-gradient(135deg, #F8FAFC 0%, #E2E8F0 100%); | |
| } | |
| .image-container:hover { | |
| transform: translateY(-2px); | |
| } | |
| .nav-btn { | |
| transition: all 0.2s ease; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .nav-btn:hover { | |
| transform: scale(1.1); | |
| box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); | |
| } | |
| .nav-btn:active { | |
| transform: scale(0.95); | |
| } | |
| .file-item { | |
| transition: all var(--transition-speed) ease; | |
| } | |
| .file-item:hover { | |
| background-color: rgba(99, 102, 241, 0.05); | |
| transform: translateX(2px); | |
| } | |
| .file-item.active { | |
| background-color: rgba(99, 102, 241, 0.1); | |
| border-left: 3px solid #6366F1; | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(10px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .fade-in { | |
| animation: fadeIn 0.4s ease-out forwards; | |
| } | |
| #fullscreenContainer { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background-color: rgba(15, 23, 42, 0.95); | |
| z-index: 1000; | |
| justify-content: center; | |
| align-items: center; | |
| flex-direction: column; | |
| } | |
| #fullscreenImage { | |
| max-width: 90%; | |
| max-height: 90%; | |
| object-fit: contain; | |
| transform: translate3d(0, 0, 0); | |
| } | |
| #fullscreenControls { | |
| position: absolute; | |
| bottom: 20px; | |
| display: flex; | |
| gap: 10px; | |
| } | |
| .sort-option:hover { | |
| background-color: rgba(99, 102, 241, 0.05); | |
| } | |
| .thumbnail-placeholder { | |
| background: linear-gradient(135deg, #E2E8F0 0%, #CBD5E1 100%); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 4px; | |
| } | |
| .thumbnail-placeholder i { | |
| color: #94A3B8; | |
| } | |
| .progress-track { | |
| background-color: #E2E8F0; | |
| } | |
| .progress-thumb { | |
| background: linear-gradient(90deg, #6366F1 0%, #8B5CF6 100%); | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #6366F1 0%, #8B5CF6 100%); | |
| color: white; | |
| transition: all var(--transition-speed) ease; | |
| box-shadow: 0 4px 6px rgba(99, 102, 241, 0.2); | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 12px rgba(99, 102, 241, 0.3); | |
| } | |
| .btn-primary:active { | |
| transform: translateY(0); | |
| } | |
| .glass-effect { | |
| background: rgba(255, 255, 255, 0.2); | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| border: 1px solid rgba(255, 255, 255, 0.2); | |
| } | |
| .zoom-controls { | |
| background: rgba(248, 250, 252, 0.8); | |
| backdrop-filter: blur(4px); | |
| border-radius: 8px; | |
| box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); | |
| } | |
| .file-list-container { | |
| scrollbar-width: thin; | |
| scrollbar-color: #6366F1 #E2E8F0; | |
| } | |
| .file-list-container::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| .file-list-container::-webkit-scrollbar-track { | |
| background: #E2E8F0; | |
| } | |
| .file-list-container::-webkit-scrollbar-thumb { | |
| background-color: #6366F1; | |
| border-radius: 3px; | |
| } | |
| .gpu-accelerate { | |
| transform: translate3d(0, 0, 0); | |
| will-change: transform; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-light min-h-screen"> | |
| <div class="container mx-auto px-4 py-8"> | |
| <div class="max-w-6xl mx-auto"> | |
| <div class="text-center mb-8"> | |
| <h1 class="text-4xl font-bold text-dark mb-2">Local Image Viewer</h1> | |
| <p class="text-lg text-slate-600">View your local WebP, PNG, JPEG, AVIF, and HEIC files with ease</p> | |
| </div> | |
| <div class="bg-white rounded-xl shadow-xl overflow-hidden mb-8"> | |
| <!-- Dropzone area --> | |
| <div id="dropzone" class="dropzone p-12 text-center cursor-pointer rounded-xl" role="region" | |
| aria-label="File drop zone"> | |
| <div class="flex flex-col items-center justify-center"> | |
| <div class="relative mb-6"> | |
| <div class="absolute inset-0 bg-primary opacity-10 rounded-full blur-md"></div> | |
| <i class="fas fa-images text-5xl text-primary relative z-10" aria-hidden="true"></i> | |
| </div> | |
| <h3 class="text-xl font-semibold text-dark mb-2">Drag & Drop Images Here</h3> | |
| <p class="text-slate-500 mb-4">or</p> | |
| <button id="browseBtn" | |
| class="btn-primary font-medium py-3 px-8 rounded-lg transition" | |
| aria-label="Browse files"> | |
| Browse Files | |
| </button> | |
| <input type="file" id="fileInput" class="hidden" | |
| accept=".webp,.png,.jpg,.jpeg,.avif,.heic,.heif" multiple> | |
| </div> | |
| </div> | |
| <!-- Main viewer area (hidden initially) --> | |
| <div id="viewerArea" class="hidden"> | |
| <div class="flex flex-col md:flex-row h-[70vh]"> | |
| <!-- Sidebar with file list --> | |
| <div class="w-full md:w-1/4 bg-slate-50 border-r border-slate-200 file-list-container overflow-y-auto"> | |
| <div class="p-4 border-b border-slate-200 flex justify-between items-center bg-white"> | |
| <h3 class="font-medium text-dark">Files (<span id="fileCount">0</span>)</h3> | |
| <div class="relative"> | |
| <button id="sortBtn" class="text-slate-600 hover:text-primary transition-colors" | |
| aria-label="Sort options" aria-haspopup="true" aria-expanded="false"> | |
| <i class="fas fa-sort" aria-hidden="true"></i> | |
| </button> | |
| <div id="sortDropdown" | |
| class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-10 py-1 border border-slate-200"> | |
| <div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" | |
| data-sort="name-asc" role="menuitem">Name (A-Z)</div> | |
| <div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" | |
| data-sort="name-desc" role="menuitem">Name (Z-A)</div> | |
| <div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" | |
| data-sort="size-asc" role="menuitem">Size (Small to Large)</div> | |
| <div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" | |
| data-sort="size-desc" role="menuitem">Size (Large to Small)</div> | |
| <div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" | |
| data-sort="date-asc" role="menuitem">Date (Oldest First)</div> | |
| <div class="sort-option px-4 py-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 transition-colors" | |
| data-sort="date-desc" role="menuitem">Date (Newest First)</div> | |
| </div> | |
| </div> | |
| </div> | |
| <ul id="fileList" class="divide-y divide-slate-200" role="list"> | |
| <!-- Files will be listed here --> | |
| </ul> | |
| </div> | |
| <!-- Main image display --> | |
| <div class="w-full md:w-3/4 p-4 flex flex-col items-center justify-center bg-white"> | |
| <div class="relative w-full h-full max-w-4xl"> | |
| <!-- Navigation buttons --> | |
| <button id="prevBtn" | |
| class="nav-btn absolute left-0 top-1/2 -translate-y-1/2 bg-white hover:bg-primary text-primary hover:text-white p-3 rounded-full shadow-md ml-4 z-10 glass-effect" | |
| aria-label="Previous image"> | |
| <i class="fas fa-chevron-left text-xl" aria-hidden="true"></i> | |
| </button> | |
| <button id="nextBtn" | |
| class="nav-btn absolute right-0 top-1/2 -translate-y-1/2 bg-white hover:bg-primary text-primary hover:text-white p-3 rounded-full shadow-md mr-4 z-10 glass-effect" | |
| aria-label="Next image"> | |
| <i class="fas fa-chevron-right text-xl" aria-hidden="true"></i> | |
| </button> | |
| <!-- Image display area --> | |
| <div class="image-container bg-gradient-to-br from-slate-50 to-slate-100 rounded-xl overflow-hidden flex items-center justify-center h-full w-full gpu-accelerate"> | |
| <div id="imageDisplay" class="p-4 w-full h-full flex items-center justify-center"> | |
| <p class="text-slate-400">Select an image to view</p> | |
| </div> | |
| </div> | |
| <!-- Image info --> | |
| <div class="mt-4 bg-slate-50 rounded-lg p-4 zoom-controls"> | |
| <div class="flex justify-between items-center"> | |
| <div class="max-w-[70%]"> | |
| <h4 id="fileName" class="font-medium text-dark truncate">No image selected</h4> | |
| <p id="fileInfo" class="text-sm text-slate-500">-</p> | |
| </div> | |
| <div class="flex space-x-2"> | |
| <button id="zoomInBtn" | |
| class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" | |
| aria-label="Zoom in"> | |
| <i class="fas fa-search-plus" aria-hidden="true"></i> | |
| </button> | |
| <button id="zoomOutBtn" | |
| class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" | |
| aria-label="Zoom out"> | |
| <i class="fas fa-search-minus" aria-hidden="true"></i> | |
| </button> | |
| <button id="resetZoomBtn" | |
| class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" | |
| aria-label="Reset zoom"> | |
| <i class="fas fa-expand" aria-hidden="true"></i> | |
| </button> | |
| <button id="fullscreenBtn" | |
| class="nav-btn bg-white hover:bg-primary hover:text-white text-slate-700 p-2 rounded transition-colors" | |
| aria-label="Fullscreen"> | |
| <i class="fas fa-expand-arrows-alt" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <div class="mt-3"> | |
| <div class="w-full progress-track rounded-full h-2"> | |
| <div id="progressBar" class="progress-thumb h-2 rounded-full" | |
| style="width: 0%"></div> | |
| </div> | |
| <div class="flex justify-between text-xs text-slate-500 mt-1"> | |
| <span id="currentIndex">0</span> | |
| <span id="totalImages">0</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="text-center text-slate-500 text-sm mt-8"> | |
| <p>Use arrow keys to navigate between images • Press F for fullscreen</p> | |
| <p class="mt-1">Supported formats: WebP, PNG, JPEG, AVIF, HEIC</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Fullscreen container --> | |
| <div id="fullscreenContainer" role="dialog" aria-modal="true" aria-label="Fullscreen image viewer"> | |
| <img id="fullscreenImage" src="" alt="Fullscreen Image" class="gpu-accelerate"> | |
| <div id="fullscreenControls"> | |
| <button id="fsPrevBtn" class="nav-btn bg-white/20 hover:bg-white/40 text-white p-3 rounded-full glass-effect" | |
| aria-label="Previous image"> | |
| <i class="fas fa-chevron-left text-xl" aria-hidden="true"></i> | |
| </button> | |
| <button id="fsCloseBtn" class="nav-btn bg-white/20 hover:bg-white/40 text-white p-3 rounded-full glass-effect" | |
| aria-label="Close fullscreen"> | |
| <i class="fas fa-times text-xl" aria-hidden="true"></i> | |
| </button> | |
| <button id="fsNextBtn" class="nav-btn bg-white/20 hover:bg-white/40 text-white p-3 rounded-full glass-effect" | |
| aria-label="Next image"> | |
| <i class="fas fa-chevron-right text-xl" aria-hidden="true"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function () { | |
| // Performance optimization utilities | |
| const throttleRAF = (func) => { | |
| let running = false; | |
| return function() { | |
| if (!running) { | |
| running = true; | |
| window.requestAnimationFrame(() => { | |
| func.apply(this, arguments); | |
| running = false; | |
| }); | |
| } | |
| }; | |
| }; | |
| const debounce = (func, delay) => { | |
| let timeoutId; | |
| return (...args) => { | |
| clearTimeout(timeoutId); | |
| timeoutId = setTimeout(() => func.apply(this, args), delay); | |
| }; | |
| }; | |
| // Memory leak prevention | |
| const cleanupHandlers = []; | |
| const addCleanupHandler = (handler) => cleanupHandlers.push(handler); | |
| // Clean up all event listeners when the page unloads | |
| window.addEventListener('beforeunload', () => { | |
| cleanupHandlers.forEach(handler => handler()); | |
| }); | |
| // DOM elements | |
| const elements = { | |
| dropzone: document.getElementById('dropzone'), | |
| browseBtn: document.getElementById('browseBtn'), | |
| fileInput: document.getElementById('fileInput'), | |
| viewerArea: document.getElementById('viewerArea'), | |
| fileList: document.getElementById('fileList'), | |
| imageDisplay: document.getElementById('imageDisplay'), | |
| fileName: document.getElementById('fileName'), | |
| fileInfo: document.getElementById('fileInfo'), | |
| fileCount: document.getElementById('fileCount'), | |
| prevBtn: document.getElementById('prevBtn'), | |
| nextBtn: document.getElementById('nextBtn'), | |
| zoomInBtn: document.getElementById('zoomInBtn'), | |
| zoomOutBtn: document.getElementById('zoomOutBtn'), | |
| resetZoomBtn: document.getElementById('resetZoomBtn'), | |
| fullscreenBtn: document.getElementById('fullscreenBtn'), | |
| progressBar: document.getElementById('progressBar'), | |
| currentIndex: document.getElementById('currentIndex'), | |
| totalImages: document.getElementById('totalImages'), | |
| sortBtn: document.getElementById('sortBtn'), | |
| sortDropdown: document.getElementById('sortDropdown'), | |
| fullscreenContainer: document.getElementById('fullscreenContainer'), | |
| fullscreenImage: document.getElementById('fullscreenImage'), | |
| fsPrevBtn: document.getElementById('fsPrevBtn'), | |
| fsNextBtn: document.getElementById('fsNextBtn'), | |
| fsCloseBtn: document.getElementById('fsCloseBtn') | |
| }; | |
| // State variables | |
| const state = { | |
| files: [], | |
| currentFileIndex: -1, | |
| zoomLevel: 1, | |
| maxZoom: 3, | |
| minZoom: 0.5, | |
| zoomStep: 0.1, | |
| currentSortMethod: 'name-asc', | |
| thumbnailObserver: null, | |
| isDragging: false, | |
| startX: 0, | |
| startY: 0, | |
| translateX: 0, | |
| translateY: 0, | |
| activeImage: null | |
| }; | |
| // Initialize the app | |
| const init = () => { | |
| setupEventListeners(); | |
| setupThumbnailObserver(); | |
| }; | |
| // Set up all event listeners | |
| const setupEventListeners = () => { | |
| // Dropzone events | |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { | |
| elements.dropzone.addEventListener(eventName, preventDefaults, false); | |
| addCleanupHandler(() => { | |
| elements.dropzone.removeEventListener(eventName, preventDefaults, false); | |
| }); | |
| }); | |
| ['dragenter', 'dragover'].forEach(eventName => { | |
| elements.dropzone.addEventListener(eventName, highlight, false); | |
| addCleanupHandler(() => { | |
| elements.dropzone.removeEventListener(eventName, highlight, false); | |
| }); | |
| }); | |
| ['dragleave', 'drop'].forEach(eventName => { | |
| elements.dropzone.addEventListener(eventName, unhighlight, false); | |
| addCleanupHandler(() => { | |
| elements.dropzone.removeEventListener(eventName, unhighlight, false); | |
| }); | |
| }); | |
| elements.dropzone.addEventListener('drop', handleDrop, false); | |
| addCleanupHandler(() => { | |
| elements.dropzone.removeEventListener('drop', handleDrop, false); | |
| }); | |
| elements.browseBtn.addEventListener('click', () => elements.fileInput.click()); | |
| elements.fileInput.addEventListener('change', () => { | |
| if (elements.fileInput.files.length > 0) { | |
| handleFiles(elements.fileInput.files); | |
| } | |
| }); | |
| addCleanupHandler(() => { | |
| elements.browseBtn.removeEventListener('click', () => elements.fileInput.click()); | |
| elements.fileInput.removeEventListener('change', () => { | |
| if (elements.fileInput.files.length > 0) { | |
| handleFiles(elements.fileInput.files); | |
| } | |
| }); | |
| }); | |
| // Navigation events | |
| elements.prevBtn.addEventListener('click', showPreviousImage); | |
| elements.nextBtn.addEventListener('click', showNextImage); | |
| addCleanupHandler(() => { | |
| elements.prevBtn.removeEventListener('click', showPreviousImage); | |
| elements.nextBtn.removeEventListener('click', showNextImage); | |
| }); | |
| // Zoom events with debouncing | |
| elements.zoomInBtn.addEventListener('click', debounce(zoomIn, 100)); | |
| elements.zoomOutBtn.addEventListener('click', debounce(zoomOut, 100)); | |
| elements.resetZoomBtn.addEventListener('click', resetZoom); | |
| elements.fullscreenBtn.addEventListener('click', openFullscreen); | |
| addCleanupHandler(() => { | |
| elements.zoomInBtn.removeEventListener('click', debounce(zoomIn, 100)); | |
| elements.zoomOutBtn.removeEventListener('click', debounce(zoomOut, 100)); | |
| elements.resetZoomBtn.removeEventListener('click', resetZoom); | |
| elements.fullscreenBtn.removeEventListener('click', openFullscreen); | |
| }); | |
| // Sort events | |
| elements.sortBtn.addEventListener('click', toggleSortDropdown); | |
| document.querySelectorAll('.sort-option').forEach(option => { | |
| option.addEventListener('click', handleSortOptionClick); | |
| }); | |
| document.addEventListener('click', closeSortDropdown); | |
| addCleanupHandler(() => { | |
| elements.sortBtn.removeEventListener('click', toggleSortDropdown); | |
| document.querySelectorAll('.sort-option').forEach(option => { | |
| option.removeEventListener('click', handleSortOptionClick); | |
| }); | |
| document.removeEventListener('click', closeSortDropdown); | |
| }); | |
| // Fullscreen events | |
| elements.fsPrevBtn.addEventListener('click', () => { | |
| showPreviousImage(); | |
| updateFullscreenImage(); | |
| }); | |
| elements.fsNextBtn.addEventListener('click', () => { | |
| showNextImage(); | |
| updateFullscreenImage(); | |
| }); | |
| elements.fsCloseBtn.addEventListener('click', closeFullscreen); | |
| addCleanupHandler(() => { | |
| elements.fsPrevBtn.removeEventListener('click', () => { | |
| showPreviousImage(); | |
| updateFullscreenImage(); | |
| }); | |
| elements.fsNextBtn.removeEventListener('click', () => { | |
| showNextImage(); | |
| updateFullscreenImage(); | |
| }); | |
| elements.fsCloseBtn.removeEventListener('click', closeFullscreen); | |
| }); | |
| // Keyboard events | |
| const keyboardHandler = handleKeyboardNavigation; | |
| document.addEventListener('keydown', keyboardHandler); | |
| addCleanupHandler(() => { | |
| document.removeEventListener('keydown', keyboardHandler); | |
| }); | |
| }; | |
| // Set up Intersection Observer for lazy loading thumbnails | |
| const setupThumbnailObserver = () => { | |
| state.thumbnailObserver = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting) { | |
| const thumbnail = entry.target; | |
| const index = parseInt(thumbnail.dataset.index); | |
| loadThumbnail(index); | |
| state.thumbnailObserver.unobserve(thumbnail); | |
| } | |
| }); | |
| }, { | |
| root: elements.fileList, | |
| rootMargin: '100px', | |
| threshold: 0.1 | |
| }); | |
| addCleanupHandler(() => { | |
| if (state.thumbnailObserver) { | |
| state.thumbnailObserver.disconnect(); | |
| } | |
| }); | |
| }; | |
| // Dropzone helper functions | |
| const preventDefaults = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| }; | |
| const highlight = () => { | |
| elements.dropzone.classList.add('active'); | |
| }; | |
| const unhighlight = () => { | |
| elements.dropzone.classList.remove('active'); | |
| }; | |
| const handleDrop = (e) => { | |
| const dt = e.dataTransfer; | |
| const droppedFiles = dt.files; | |
| handleFiles(droppedFiles); | |
| }; | |
| // File handling | |
| const handleFiles = (newFiles) => { | |
| const supportedTypes = [ | |
| 'image/webp', | |
| 'image/png', | |
| 'image/jpeg', | |
| 'image/avif', | |
| 'image/heic', | |
| 'image/heif' | |
| ]; | |
| const imageFiles = Array.from(newFiles).filter(file => { | |
| if (supportedTypes.includes(file.type)) return true; | |
| const extension = file.name.split('.').pop().toLowerCase(); | |
| return ['webp', 'png', 'jpg', 'jpeg', 'avif', 'heic', 'heif'].includes(extension); | |
| }); | |
| if (imageFiles.length === 0) { | |
| alert('No supported image files found. Please upload WebP, PNG, JPEG, AVIF, or HEIC files.'); | |
| return; | |
| } | |
| // Clean up previous files and resources | |
| if (state.activeImage) { | |
| cleanupImageDragHandlers(state.activeImage); | |
| } | |
| state.files = imageFiles; | |
| state.currentFileIndex = 0; | |
| state.zoomLevel = 1; | |
| state.translateX = 0; | |
| state.translateY = 0; | |
| sortFiles(); | |
| updateFileList(); | |
| showImage(state.currentFileIndex); | |
| elements.viewerArea.classList.remove('hidden'); | |
| window.scrollTo(0, 0); | |
| }; | |
| // Clean up image drag handlers | |
| const cleanupImageDragHandlers = (img) => { | |
| img.removeEventListener('mousedown', handleImageMouseDown); | |
| img.removeEventListener('mouseenter', handleImageMouseEnter); | |
| img.removeEventListener('mouseleave', handleImageMouseLeave); | |
| }; | |
| // Sort functionality | |
| const sortFiles = () => { | |
| switch (state.currentSortMethod) { | |
| case 'name-asc': | |
| state.files.sort((a, b) => a.name.localeCompare(b.name)); | |
| break; | |
| case 'name-desc': | |
| state.files.sort((a, b) => b.name.localeCompare(a.name)); | |
| break; | |
| case 'size-asc': | |
| state.files.sort((a, b) => a.size - b.size); | |
| break; | |
| case 'size-desc': | |
| state.files.sort((a, b) => b.size - a.size); | |
| break; | |
| case 'date-asc': | |
| state.files.sort((a, b) => a.lastModified - b.lastModified); | |
| break; | |
| case 'date-desc': | |
| state.files.sort((a, b) => b.lastModified - a.lastModified); | |
| break; | |
| } | |
| if (state.currentFileIndex >= 0 && state.files.length > 0) { | |
| state.currentFileIndex = 0; | |
| } | |
| }; | |
| const toggleSortDropdown = (e) => { | |
| e.stopPropagation(); | |
| const isExpanded = elements.sortDropdown.classList.toggle('hidden'); | |
| elements.sortBtn.setAttribute('aria-expanded', !isExpanded); | |
| }; | |
| const handleSortOptionClick = (e) => { | |
| state.currentSortMethod = e.target.dataset.sort; | |
| sortFiles(); | |
| updateFileList(); | |
| showImage(state.currentFileIndex); | |
| closeSortDropdown(); | |
| }; | |
| const closeSortDropdown = () => { | |
| elements.sortDropdown.classList.add('hidden'); | |
| elements.sortBtn.setAttribute('aria-expanded', 'false'); | |
| }; | |
| // File list management | |
| const updateFileList = () => { | |
| elements.fileList.innerHTML = ''; | |
| elements.fileCount.textContent = state.files.length; | |
| elements.totalImages.textContent = state.files.length; | |
| state.files.forEach((file, index) => { | |
| const listItem = document.createElement('li'); | |
| listItem.className = `file-item cursor-pointer ${index === state.currentFileIndex ? 'active' : ''}`; | |
| listItem.setAttribute('role', 'listitem'); | |
| listItem.innerHTML = ` | |
| <div class="flex items-center p-3"> | |
| <div class="flex-shrink-0 h-10 w-10 rounded overflow-hidden thumbnail-placeholder"> | |
| <i class="fas fa-image text-lg"></i> | |
| <img src="#" alt="Thumbnail" class="h-full w-full object-cover hidden thumbnail" data-index="${index}"> | |
| </div> | |
| <div class="ml-3 overflow-hidden"> | |
| <p class="text-sm font-medium text-dark truncate">${file.name}</p> | |
| <p class="text-sm text-slate-500">${formatFileSize(file.size)}</p> | |
| </div> | |
| </div> | |
| `; | |
| listItem.addEventListener('click', () => showImage(index)); | |
| listItem.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| showImage(index); | |
| } | |
| }); | |
| listItem.setAttribute('tabindex', '0'); | |
| elements.fileList.appendChild(listItem); | |
| // Observe the thumbnail for lazy loading | |
| const thumbnail = listItem.querySelector('.thumbnail'); | |
| state.thumbnailObserver.observe(thumbnail); | |
| }); | |
| }; | |
| // Lazy load thumbnail when it comes into view | |
| const loadThumbnail = (index) => { | |
| const thumbnail = document.querySelector(`.thumbnail[data-index="${index}"]`); | |
| if (!thumbnail || thumbnail.src !== '#') return; | |
| const file = state.files[index]; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| thumbnail.src = e.target.result; | |
| thumbnail.classList.remove('hidden'); | |
| thumbnail.previousElementSibling?.remove(); | |
| }; | |
| reader.readAsDataURL(file); | |
| }; | |
| // Image display | |
| const showImage = (index) => { | |
| if (index < 0 || index >= state.files.length) return; | |
| // Clean up previous image handlers | |
| if (state.activeImage) { | |
| cleanupImageDragHandlers(state.activeImage); | |
| } | |
| state.currentFileIndex = index; | |
| const file = state.files[index]; | |
| // Update active item in file list | |
| document.querySelectorAll('.file-item').forEach((item, i) => { | |
| if (i === index) { | |
| item.classList.add('active'); | |
| item.setAttribute('aria-selected', 'true'); | |
| } else { | |
| item.classList.remove('active'); | |
| item.setAttribute('aria-selected', 'false'); | |
| } | |
| }); | |
| // Update progress | |
| elements.currentIndex.textContent = index + 1; | |
| elements.progressBar.style.width = `${((index + 1) / state.files.length) * 100}%`; | |
| // Display the image | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| elements.imageDisplay.innerHTML = ''; | |
| const img = document.createElement('img'); | |
| img.src = e.target.result; | |
| img.className = 'max-w-full max-h-[70vh] object-contain fade-in gpu-accelerate'; | |
| img.style.transform = `scale(${state.zoomLevel}) translate3d(${state.translateX}px, ${state.translateY}px, 0)`; | |
| img.style.transformOrigin = 'center center'; | |
| img.style.transition = 'transform 0.2s ease'; | |
| img.setAttribute('alt', `Preview of ${file.name}`); | |
| // Add drag to pan functionality | |
| img.addEventListener('mousedown', handleImageMouseDown); | |
| img.addEventListener('mouseenter', handleImageMouseEnter); | |
| img.addEventListener('mouseleave', handleImageMouseLeave); | |
| elements.imageDisplay.appendChild(img); | |
| state.activeImage = img; | |
| // Update file info | |
| elements.fileName.textContent = file.name; | |
| elements.fileInfo.textContent = `${getFileType(file)} • ${formatFileSize(file.size)}`; | |
| // Load image dimensions after the image is loaded | |
| img.onload = () => { | |
| elements.fileInfo.textContent = `${img.naturalWidth}×${img.naturalHeight} • ${getFileType(file)} • ${formatFileSize(file.size)}`; | |
| }; | |
| }; | |
| reader.readAsDataURL(file); | |
| // Enable/disable navigation buttons | |
| elements.prevBtn.disabled = index === 0; | |
| elements.nextBtn.disabled = index === state.files.length - 1; | |
| }; | |
| // Image drag handlers with throttle | |
| const handleImageMouseDown = (e) => { | |
| if (state.zoomLevel <= 1) return; | |
| state.isDragging = true; | |
| state.startX = e.clientX - state.translateX; | |
| state.startY = e.clientY - state.translateY; | |
| e.target.style.cursor = 'grabbing'; | |
| // Add global mouse move and up handlers | |
| const mouseMoveHandler = throttleRAF(handleImageMouseMove); | |
| const mouseUpHandler = handleImageMouseUp; | |
| document.addEventListener('mousemove', mouseMoveHandler); | |
| document.addEventListener('mouseup', mouseUpHandler); | |
| // Clean up these handlers when done | |
| const cleanup = () => { | |
| document.removeEventListener('mousemove', mouseMoveHandler); | |
| document.removeEventListener('mouseup', mouseUpHandler); | |
| }; | |
| addCleanupHandler(cleanup); | |
| }; | |
| const handleImageMouseMove = (e) => { | |
| if (!state.isDragging) return; | |
| state.translateX = e.clientX - state.startX; | |
| state.translateY = e.clientY - state.startY; | |
| const img = elements.imageDisplay.querySelector('img'); | |
| if (img) { | |
| img.style.transform = `scale(${state.zoomLevel}) translate3d(${state.translateX}px, ${state.translateY}px, 0)`; | |
| } | |
| }; | |
| const handleImageMouseUp = () => { | |
| state.isDragging = false; | |
| const img = elements.imageDisplay.querySelector('img'); | |
| if (img) img.style.cursor = 'grab'; | |
| }; | |
| const handleImageMouseEnter = (e) => { | |
| if (state.zoomLevel > 1) { | |
| e.target.style.cursor = 'grab'; | |
| } | |
| }; | |
| const handleImageMouseLeave = (e) => { | |
| e.target.style.cursor = 'default'; | |
| }; | |
| // Navigation functions | |
| const showPreviousImage = () => { | |
| if (state.currentFileIndex > 0) { | |
| showImage(state.currentFileIndex - 1); | |
| } | |
| }; | |
| const showNextImage = () => { | |
| if (state.currentFileIndex < state.files.length - 1) { | |
| showImage(state.currentFileIndex + 1); | |
| } | |
| }; | |
| // Zoom functions | |
| const zoomIn = () => { | |
| if (state.zoomLevel < state.maxZoom) { | |
| state.zoomLevel += state.zoomStep; | |
| applyZoom(); | |
| } | |
| }; | |
| const zoomOut = () => { | |
| if (state.zoomLevel > state.minZoom) { | |
| state.zoomLevel -= state.zoomStep; | |
| applyZoom(); | |
| } | |
| }; | |
| const resetZoom = () => { | |
| state.zoomLevel = 1; | |
| state.translateX = 0; | |
| state.translateY = 0; | |
| applyZoom(); | |
| }; | |
| const applyZoom = () => { | |
| const img = elements.imageDisplay.querySelector('img'); | |
| if (img) { | |
| img.style.transform = `scale(${state.zoomLevel}) translate3d(${state.translateX}px, ${state.translateY}px, 0)`; | |
| // Reset pan position when zooming | |
| if (state.zoomLevel <= 1) { | |
| state.translateX = 0; | |
| state.translateY = 0; | |
| img.style.transform = `scale(${state.zoomLevel}) translate3d(0, 0, 0)`; | |
| } | |
| // Update cursor based on zoom level | |
| if (state.zoomLevel > 1) { | |
| img.style.cursor = 'grab'; | |
| } else { | |
| img.style.cursor = 'default'; | |
| } | |
| } | |
| }; | |
| // Fullscreen functions | |
| const openFullscreen = () => { | |
| const img = elements.imageDisplay.querySelector('img'); | |
| if (!img) return; | |
| elements.fullscreenImage.src = img.src; | |
| elements.fullscreenContainer.style.display = 'flex'; | |
| document.body.style.overflow = 'hidden'; | |
| elements.fullscreenContainer.setAttribute('aria-hidden', 'false'); | |
| }; | |
| const closeFullscreen = () => { | |
| elements.fullscreenContainer.style.display = 'none'; | |
| document.body.style.overflow = ''; | |
| elements.fullscreenContainer.setAttribute('aria-hidden', 'true'); | |
| }; | |
| const updateFullscreenImage = () => { | |
| const img = elements.imageDisplay.querySelector('img'); | |
| if (img) { | |
| elements.fullscreenImage.src = img.src; | |
| } | |
| }; | |
| // Keyboard navigation | |
| const handleKeyboardNavigation = (e) => { | |
| if (state.files.length === 0) return; | |
| switch (e.key) { | |
| case 'ArrowLeft': | |
| if (elements.fullscreenContainer.style.display === 'flex') { | |
| showPreviousImage(); | |
| updateFullscreenImage(); | |
| } else { | |
| showPreviousImage(); | |
| } | |
| break; | |
| case 'ArrowRight': | |
| if (elements.fullscreenContainer.style.display === 'flex') { | |
| showNextImage(); | |
| updateFullscreenImage(); | |
| } else { | |
| showNextImage(); | |
| } | |
| break; | |
| case '+': | |
| case '=': | |
| zoomIn(); | |
| break; | |
| case '-': | |
| zoomOut(); | |
| break; | |
| case '0': | |
| resetZoom(); | |
| break; | |
| case 'Escape': | |
| if (elements.fullscreenContainer.style.display === 'flex') { | |
| closeFullscreen(); | |
| } | |
| break; | |
| case 'f': | |
| case 'F': | |
| if (elements.imageDisplay.querySelector('img')) { | |
| if (elements.fullscreenContainer.style.display === 'flex') { | |
| closeFullscreen(); | |
| } else { | |
| openFullscreen(); | |
| } | |
| } | |
| break; | |
| } | |
| }; | |
| // Helper functions | |
| const getFileType = (file) => { | |
| if (file.type) { | |
| const type = file.type.split('/')[1]; | |
| if (type) return type.toUpperCase(); | |
| } | |
| const extension = file.name.split('.').pop().toLowerCase(); | |
| switch (extension) { | |
| case 'jpg': | |
| case 'jpeg': return 'JPEG'; | |
| case 'png': return 'PNG'; | |
| case 'webp': return 'WEBP'; | |
| case 'avif': return 'AVIF'; | |
| case 'heic': | |
| case 'heif': return 'HEIC'; | |
| default: return extension.toUpperCase(); | |
| } | |
| }; | |
| const formatFileSize = (bytes) => { | |
| if (bytes === 0) return '0 Bytes'; | |
| const k = 1024; | |
| const sizes = ['Bytes', 'KB', 'MB', 'GB']; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; | |
| }; | |
| // Initialize the application | |
| init(); | |
| }); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=jsfs11/local-image-viewer-v2" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |