Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Model Gallery</title> | |
| <!-- Preconnect to image CDN for faster handshake --> | |
| <link rel="preconnect" href="https://wsrv.nl" /> | |
| <!-- Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <!-- Lucide Icons --> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <!-- Font --> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| fontFamily: { | |
| sans: ['Inter', 'sans-serif'], | |
| }, | |
| colors: { | |
| zinc: { | |
| 850: '#202023', // Custom intermediate dark shade | |
| } | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <style> | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #18181b; | |
| /* zinc-900 */ | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #3f3f46; | |
| /* zinc-700 */ | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #52525b; | |
| /* zinc-600 */ | |
| } | |
| .glass-panel { | |
| background: rgba(24, 24, 27, 0.8); | |
| /* zinc-900 with opacity */ | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| } | |
| .card-hover:hover { | |
| transform: translateY(-4px); | |
| box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.5); | |
| } | |
| .line-clamp-1 { | |
| display: -webkit-box; | |
| -webkit-line-clamp: 1; | |
| -webkit-box-orient: vertical; | |
| overflow: hidden; | |
| } | |
| /* Smooth image fading */ | |
| .img-fade-in { | |
| opacity: 0; | |
| transition: opacity 0.5s ease-in-out; | |
| } | |
| .img-loaded { | |
| opacity: 1; | |
| } | |
| /* Checkbox Custom Style */ | |
| .custom-checkbox:checked { | |
| background-color: #4f46e5; | |
| border-color: #4f46e5; | |
| } | |
| /* Radio Custom Style for Sort */ | |
| .custom-radio:checked { | |
| background-color: #4f46e5; | |
| border-color: #4f46e5; | |
| } | |
| </style> | |
| </head> | |
| <body | |
| class="bg-zinc-900 text-zinc-100 min-h-screen flex flex-col font-sans selection:bg-indigo-500 selection:text-white"> | |
| <!-- Header --> | |
| <header class="sticky top-0 z-40 w-full glass-panel border-b border-zinc-700/50"> | |
| <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> | |
| <!-- Left: Logo --> | |
| <div class="flex items-center gap-2 flex-shrink-0"> | |
| <i data-lucide="layers" class="text-indigo-400 h-6 w-6"></i> | |
| <h1 | |
| class="text-xl font-bold bg-gradient-to-r from-indigo-400 to-cyan-400 bg-clip-text text-transparent hidden sm:block"> | |
| Mal's Models | |
| </h1> | |
| <h1 | |
| class="text-xl font-bold bg-gradient-to-r from-indigo-400 to-cyan-400 bg-clip-text text-transparent sm:hidden"> | |
| MV | |
| </h1> | |
| </div> | |
| <!-- Center: Item Count + Search + Sort + Filter --> | |
| <div class="hidden sm:flex items-center gap-3 flex-grow justify-center px-4"> | |
| <!-- Search Input Container (Reduced Width) --> | |
| <div class="relative w-full max-w-md flex items-center gap-2"> | |
| <div class="relative flex-grow"> | |
| <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> | |
| <i data-lucide="search" class="h-4 w-4 text-zinc-400"></i> | |
| </div> | |
| <input type="text" id="searchInput" | |
| class="block w-full pl-10 pr-10 py-2 border border-zinc-700 rounded-lg leading-5 bg-zinc-800/50 text-zinc-300 placeholder-zinc-500 focus:outline-none focus:bg-zinc-800 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 sm:text-sm transition-all duration-200" | |
| placeholder="Search models..."> | |
| <button id="clearSearchBtn" | |
| class="absolute inset-y-0 right-0 pr-3 flex items-center hidden text-zinc-400 hover:text-white transition-colors"> | |
| <i data-lucide="x" class="h-4 w-4"></i> | |
| </button> | |
| </div> | |
| <!-- Sort Button --> | |
| <div class="relative"> | |
| <button id="sortBtn" | |
| class="p-2 rounded-lg border border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:text-white hover:border-indigo-500 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500" | |
| title="Sort"> | |
| <i data-lucide="arrow-up-down" class="h-5 w-5"></i> | |
| </button> | |
| <div id="sortMenu" | |
| class="hidden absolute right-0 mt-2 w-48 rounded-xl bg-zinc-850 border border-zinc-700 shadow-xl z-50 overflow-hidden transform transition-all duration-200 origin-top-right"> | |
| <div class="px-4 py-2 border-b border-zinc-700/50 bg-zinc-800/50"> | |
| <h3 class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Sort By</h3> | |
| </div> | |
| <div class="p-2 space-y-1" id="sortOptionsContainer"> | |
| <label class="flex items-center space-x-3 p-2 rounded-lg hover:bg-zinc-700/50 cursor-pointer"> | |
| <input type="radio" name="sort" value="date_new" | |
| class="form-radio h-4 w-4 text-indigo-600 border-zinc-600 bg-zinc-700 focus:ring-indigo-500 custom-radio"> | |
| <span class="text-sm text-zinc-300">Date (Newest)</span> | |
| </label> | |
| <label class="flex items-center space-x-3 p-2 rounded-lg hover:bg-zinc-700/50 cursor-pointer"> | |
| <input type="radio" name="sort" value="date_old" | |
| class="form-radio h-4 w-4 text-indigo-600 border-zinc-600 bg-zinc-700 focus:ring-indigo-500 custom-radio"> | |
| <span class="text-sm text-zinc-300">Date (Oldest)</span> | |
| </label> | |
| <div class="h-px bg-zinc-700/50 my-1"></div> | |
| <label class="flex items-center space-x-3 p-2 rounded-lg hover:bg-zinc-700/50 cursor-pointer"> | |
| <input type="radio" name="sort" value="name_asc" | |
| class="form-radio h-4 w-4 text-indigo-600 border-zinc-600 bg-zinc-700 focus:ring-indigo-500 custom-radio" | |
| checked> | |
| <span class="text-sm text-zinc-300">Name (A-Z)</span> | |
| </label> | |
| <label class="flex items-center space-x-3 p-2 rounded-lg hover:bg-zinc-700/50 cursor-pointer"> | |
| <input type="radio" name="sort" value="name_desc" | |
| class="form-radio h-4 w-4 text-indigo-600 border-zinc-600 bg-zinc-700 focus:ring-indigo-500 custom-radio"> | |
| <span class="text-sm text-zinc-300">Name (Z-A)</span> | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Filter Button --> | |
| <div class="relative"> | |
| <button id="filterBtn" | |
| class="p-2 rounded-lg border border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:text-white hover:border-indigo-500 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500" | |
| title="Filter"> | |
| <i data-lucide="filter" class="h-5 w-5"></i> | |
| </button> | |
| <div id="filterMenu" | |
| class="hidden absolute right-0 mt-2 w-56 rounded-xl bg-zinc-850 border border-zinc-700 shadow-xl z-50 overflow-hidden transform transition-all duration-200 origin-top-right"> | |
| <div class="px-4 py-3 border-b border-zinc-700/50 bg-zinc-800/50"> | |
| <h3 class="text-sm font-semibold text-white flex justify-between items-center"> | |
| Filter Models | |
| <button id="clearFiltersBtn" | |
| class="text-xs text-indigo-400 hover:text-indigo-300 hidden">Clear</button> | |
| </h3> | |
| </div> | |
| <div class="p-2 space-y-1 max-h-64 overflow-y-auto" id="filterOptionsContainer"> | |
| <!-- Dynamic Filter Options --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Item Count (Moved Right) --> | |
| <span | |
| class="text-xs font-medium text-zinc-500 px-2 py-1 bg-zinc-800 rounded border border-zinc-700 whitespace-nowrap item-count-display">0 | |
| Items</span> | |
| </div> | |
| <!-- Right: Socials --> | |
| <div class="flex items-center gap-4 flex-shrink-0"> | |
| <!-- Civitai --> | |
| <a href="https://civitai.com/user/malcolmrey" target="_blank" title="Civitai" | |
| class="opacity-60 hover:opacity-100 transition-opacity"> | |
| <img src="https://civitai.com/favicon.ico" alt="Civitai" class="w-8 h-8 rounded-sm"> | |
| </a> | |
| <!-- Hugging Face --> | |
| <a href="https://huggingface.com/malcolmrey" target="_blank" title="Hugging Face" | |
| class="opacity-60 hover:opacity-100 transition-opacity"> | |
| <img src="https://huggingface.co/favicon.ico" alt="Hugging Face" class="w-8 h-8 rounded-sm"> | |
| </a> | |
| <!-- Reddit --> | |
| <a href="https://reddit.com/r/malcolmrey" target="_blank" title="Reddit" | |
| class="opacity-60 hover:opacity-100 transition-opacity"> | |
| <img src="https://www.reddit.com/favicon.ico" alt="Reddit" class="w-8 h-8 rounded-sm"> | |
| </a> | |
| <!-- BuyMeACoffee --> | |
| <a href="https://buymeacoffee.com/malcolmrey" target="_blank" title="Buy Me A Coffee" | |
| class="opacity-60 hover:opacity-100 transition-opacity"> | |
| <img src="https://buymeacoffee.com/favicon.ico" alt="Buy Me A Coffee" class="w-8 h-8 rounded-sm"> | |
| </a> | |
| </div> | |
| </div> | |
| <!-- Mobile Search & Tools --> | |
| <div class="sm:hidden px-4 pb-3 flex flex-col gap-2"> | |
| <!-- Mobile Item Count --> | |
| <div class="flex justify-between items-center mb-1"> | |
| <span class="text-xs font-medium text-zinc-500 item-count-display">0 Items</span> | |
| </div> | |
| <div class="relative"> | |
| <input type="text" id="mobileSearchInput" | |
| class="block w-full px-3 py-2 pr-10 border border-zinc-700 rounded-lg bg-zinc-800/50 text-zinc-300 placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-sm" | |
| placeholder="Search models..."> | |
| <!-- Mobile Clear Button --> | |
| <button id="mobileClearSearchBtn" | |
| class="absolute inset-y-0 right-0 pr-3 flex items-center hidden text-zinc-400 hover:text-white transition-colors"> | |
| <i data-lucide="x" class="h-4 w-4"></i> | |
| </button> | |
| </div> | |
| <div class="flex gap-2"> | |
| <button id="mobileSortBtn" | |
| class="flex-1 flex justify-center items-center gap-2 p-2 rounded-lg border border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:text-white transition-colors text-sm"> | |
| <i data-lucide="arrow-up-down" class="h-4 w-4"></i> Sort | |
| </button> | |
| <button id="mobileFilterBtn" | |
| class="flex-1 flex justify-center items-center gap-2 p-2 rounded-lg border border-zinc-700 bg-zinc-800/50 text-zinc-400 hover:text-white transition-colors text-sm"> | |
| <i data-lucide="filter" class="h-4 w-4"></i> Filter | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main class="flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full relative"> | |
| <!-- Loading State --> | |
| <div id="loader" class="flex flex-col items-center justify-center py-20"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mb-4"></div> | |
| <p class="text-zinc-400 animate-pulse">Loading library...</p> | |
| </div> | |
| <!-- Error State --> | |
| <div id="errorState" class="hidden flex-col items-center justify-center py-20 text-center"> | |
| <i data-lucide="alert-triangle" class="h-12 w-12 text-red-500 mb-4"></i> | |
| <h3 class="text-lg font-medium text-white">Failed to load data</h3> | |
| <p class="text-zinc-400 mt-1 max-w-md">Could not read unified-data.json.</p> | |
| </div> | |
| <!-- Gallery Grid --> | |
| <div id="galleryGrid" | |
| class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6 opacity-0 transition-opacity duration-500"> | |
| <!-- Cards will be injected here --> | |
| </div> | |
| <!-- Infinite Scroll Sentinel / Loading Indicator --> | |
| <div id="sentinel" class="h-24 w-full flex items-center justify-center opacity-0 transition-opacity duration-300"> | |
| <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-500"></div> | |
| </div> | |
| <!-- Empty State (Search) --> | |
| <div id="noResults" class="hidden flex-col items-center justify-center py-20"> | |
| <i data-lucide="search-x" class="h-12 w-12 text-zinc-600 mb-4"></i> | |
| <p class="text-zinc-400">No models found matching your filters.</p> | |
| </div> | |
| </main> | |
| <!-- Footer | |
| <footer class="border-t border-zinc-800 py-6 mt-8"> | |
| <div class="max-w-7xl mx-auto px-4 text-center text-zinc-500 text-sm"> | |
| <p>© 2025 ModelVault Gallery. Local JSON Viewer.</p> | |
| </div> | |
| </footer> --> | |
| <!-- Modal --> | |
| <div id="modalBackdrop" | |
| class="fixed inset-0 z-50 hidden bg-zinc-900/80 backdrop-blur-sm transition-opacity duration-300 opacity-0" | |
| aria-labelledby="modal-title" role="dialog" aria-modal="true"> | |
| <div class="fixed inset-0 z-10 overflow-y-auto"> | |
| <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> | |
| <div id="modalPanel" | |
| class="relative transform overflow-hidden rounded-2xl bg-zinc-850 border border-zinc-700 text-left shadow-2xl transition-all sm:my-8 sm:w-full sm:max-w-4xl opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"> | |
| <!-- Modal Header --> | |
| <div class="bg-zinc-800/50 px-4 py-3 sm:px-6 flex justify-between items-center border-b border-zinc-700"> | |
| <h3 class="text-lg font-semibold leading-6 text-white" id="modalTitle">Model Name</h3> | |
| <div class="flex items-center gap-3"> | |
| <button type="button" id="closeModalBtn" | |
| class="rounded-md bg-transparent text-zinc-400 hover:text-white focus:outline-none transition-colors"> | |
| <i data-lucide="x" class="h-6 w-6"></i> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Modal Body --> | |
| <div class="px-4 py-5 sm:p-6"> | |
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> | |
| <!-- Left: Images --> | |
| <div class="lg:col-span-1 space-y-4"> | |
| <div | |
| class="aspect-[2/3] w-full rounded-xl overflow-hidden bg-zinc-800 border border-zinc-700 relative group"> | |
| <img id="modalMainImage" src="" alt="Model preview" | |
| class="w-full h-full object-cover transition-opacity duration-300"> | |
| <!-- Image source badge for modal main image --> | |
| <div class="absolute top-2 right-2"> | |
| <span id="modalImageBadge" | |
| class="px-2 py-1 bg-black/60 backdrop-blur-md rounded text-[10px] font-bold text-white border border-white/10 uppercase tracking-wider hidden"></span> | |
| </div> | |
| <div id="imageOverlay" | |
| class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity hidden"> | |
| <a id="openOriginalLink" href="#" target="_blank" | |
| class="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-full backdrop-blur-md text-sm font-medium transition-colors border border-white/20"> | |
| View Original | |
| </a> | |
| </div> | |
| </div> | |
| <!-- Thumbnails Grid --> | |
| <div id="modalThumbnails" class="grid grid-cols-4 gap-2"> | |
| <!-- Dynamic thumbnails --> | |
| </div> | |
| </div> | |
| <!-- Right: Info --> | |
| <div class="lg:col-span-2 flex flex-col h-full"> | |
| <div class="mb-6"> | |
| <span class="text-xs font-mono text-indigo-400 uppercase tracking-wider">Last Updated</span> | |
| <p id="modalDate" class="text-sm text-zinc-300 mt-1">Dec 28, 2025</p> | |
| </div> | |
| <div class="flex-grow"> | |
| <div class="flex items-center justify-between mb-3"> | |
| <h4 class="text-sm font-semibold text-white flex items-center gap-2"> | |
| <i data-lucide="box" class="h-4 w-4 text-indigo-500"></i> | |
| Available Versions | |
| </h4> | |
| </div> | |
| <div | |
| class="bg-zinc-900/50 rounded-xl border border-zinc-700/50 overflow-hidden max-h-[400px] overflow-y-auto"> | |
| <div class="overflow-x-auto"> | |
| <table class="min-w-full divide-y divide-zinc-800"> | |
| <thead class="bg-zinc-800 sticky top-0 z-10"> | |
| <tr> | |
| <th scope="col" | |
| class="px-4 py-3 text-left text-xs font-medium text-zinc-400 uppercase tracking-wider w-1/4"> | |
| Model Type</th> | |
| <th scope="col" | |
| class="px-4 py-3 text-left text-xs font-medium text-zinc-400 uppercase tracking-wider"> | |
| Versions (Downloads)</th> | |
| </tr> | |
| </thead> | |
| <tbody id="filesTableBody" class="divide-y divide-zinc-800 bg-transparent"> | |
| <!-- Dynamic Rows --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Scripts --> | |
| <script> | |
| // --- 1. Data Management --- | |
| const BASE_URLS = { | |
| "locon": "https://huggingface.co/malcolmrey/lycoris/resolve/main/", | |
| "lora": "https://huggingface.co/malcolmrey/small-loras/resolve/main/", | |
| "embedding": "https://huggingface.co/malcolmrey/embeddings/resolve/main/", | |
| "flux": "https://huggingface.co/malcolmrey/flux/resolve/main/", | |
| "wan": "https://huggingface.co/malcolmrey/wan/resolve/main/wan2.1/", | |
| "sdxl": "https://huggingface.co/malcolmrey/sdxl/tree/main/", | |
| "zimage": "https://huggingface.co/malcolmrey/zimage/tree/main/" | |
| }; | |
| const TYPE_DISPLAY_NAMES = { | |
| "locon": "SD Locon", | |
| "lora": "SD LoRA", | |
| "embedding": "SD Embedding", | |
| "flux": "Flux", | |
| "wan": "Wan", | |
| "zimage": "ZImage", | |
| "sdxl": "SDXL", | |
| "qwen": "Qwen" | |
| }; | |
| const TYPE_COLORS = { | |
| 'locon': 'text-emerald-400 bg-emerald-400/10 border-emerald-400/20', | |
| 'lora': 'text-blue-400 bg-blue-400/10 border-blue-400/20', | |
| 'embedding': 'text-purple-400 bg-purple-400/10 border-purple-400/20', | |
| 'flux': 'text-orange-400 bg-orange-400/10 border-orange-400/20', | |
| 'wan': 'text-pink-400 bg-pink-400/10 border-pink-400/20', | |
| 'zimage': 'text-cyan-400 bg-cyan-400/10 border-cyan-400/20', | |
| 'sdxl': 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20', | |
| 'qwen': 'text-indigo-400 bg-indigo-400/10 border-indigo-400/20' | |
| }; | |
| // Included sample data as fallback | |
| const SAMPLE_DATA = { | |
| "aaliyah": { | |
| "lastUpdated": "2025-12-28T23:28:29+01:00", | |
| "models": { | |
| "locon": [{ "filename": "locon_aaliyah_v1_from_v1_64_32.safetensors", "uploadedAt": "2025-06-20T16:10:36Z" }], | |
| "lora": [{ "filename": "lora-small-aaliyah-v1.safetensors", "uploadedAt": "2025-06-20T13:39:55Z" }], | |
| "embedding": [{ "filename": "aaliyah-ti.safetensors", "uploadedAt": "2025-06-20T13:22:11Z" }], | |
| "flux": { | |
| "loras": [{ "filename": "flux_aaliyah_v1-step00000400.safetensors", "uploadedAt": "2025-12-28T23:28:29+01:00" }], | |
| "images": ["https://huggingface.co/datasets/malcolmrey/samples/resolve/main/flux/flux_aaliyah_00001_.png"] | |
| }, | |
| "wan": { | |
| "loras": [{ "filename": "wan_aaliyah_v1.safetensors", "uploadedAt": "2025-11-01T22:13:14+01:00" }], | |
| "images": ["https://huggingface.co/datasets/malcolmrey/samples/resolve/main/wan/wan_aaliyah_00001_.png"] | |
| }, | |
| "zimage": { | |
| "loras": [{ "filename": "zimage_aaliyah_v1.safetensors", "uploadedAt": "2025-12-06T15:54:41+01:00" }], | |
| "images": ["https://huggingface.co/datasets/malcolmrey/samples/resolve/main/zimage/zimage_aaliyah_00001_.png"] | |
| }, | |
| "sdxl": [], | |
| "qwen": [] | |
| } | |
| }, | |
| "aarontaylorjohnson": { | |
| "lastUpdated": "2025-12-28T23:31:50+01:00", | |
| "models": { | |
| "locon": [{ "filename": "locon_aarontaylorjohnson_v1_from_v1_64_32.safetensors", "uploadedAt": "2025-06-20T21:37:02Z" }], | |
| "lora": [{ "filename": "lora-small-aaron-taylor-johnson-v1.safetensors", "uploadedAt": "2025-06-20T13:41:35Z" }], | |
| "embedding": [{ "filename": "aarontaylorjohnson-ti.safetensors", "uploadedAt": "2025-06-20T13:22:11Z" }], | |
| "flux": [], | |
| "wan": { | |
| "loras": [{ "filename": "wan_aarontaylorjohnson_v1.safetensors", "uploadedAt": "2025-11-09T15:45:28+01:00" }], | |
| "images": [ | |
| "https://huggingface.co/datasets/malcolmrey/samples/resolve/main/wan/wan_aarontaylorjohnson_00001_.png", | |
| "https://huggingface.co/datasets/malcolmrey/samples/resolve/main/wan/wan_aarontaylorjohnson_00002_.png", | |
| "https://huggingface.co/datasets/malcolmrey/samples/resolve/main/wan/wan_aarontaylorjohnson_00003_.png" | |
| ] | |
| }, | |
| "zimage": { | |
| "loras": [{ "filename": "zimage_aarontaylorjohnson_v1.safetensors", "uploadedAt": "2025-12-28T23:31:50+01:00" }], | |
| "images": ["https://huggingface.co/datasets/malcolmrey/samples/resolve/main/zimage/zimage_aarontaylorjohnson_00001_.png"] | |
| }, | |
| "sdxl": [], | |
| "qwen": [] | |
| } | |
| }, | |
| "angelinajolie": { | |
| "lastUpdated": "2025-12-01T21:07:59+01:00", | |
| "models": { | |
| "locon": [ | |
| { "filename": "locon_angelinajolie_v1_from_v3_64_32.safetensors", "uploadedAt": "2025-06-20T16:10:36Z" } | |
| ], | |
| "lora": [ | |
| { "filename": "lora-small-angelina-jolie-v1.safetensors", "uploadedAt": "2025-06-20T13:39:55Z" }, | |
| { "filename": "lora-small-angelina-jolie-v2.safetensors", "uploadedAt": "2025-06-20T13:39:55Z" }, | |
| { "filename": "lora-small-angelina-jolie-v3.safetensors", "uploadedAt": "2025-06-20T13:39:55Z" } | |
| ], | |
| "embedding": [ | |
| { "filename": "angelinajolie-ti.safetensors", "uploadedAt": "2025-06-20T13:22:11Z" }, | |
| { "filename": "angelinajolie-v2-ti.safetensors", "uploadedAt": "2025-06-20T13:22:11Z" } | |
| ], | |
| "flux": { | |
| "loras": [ | |
| { "filename": "flux_angelinajolie_v2-step00000400.safetensors", "uploadedAt": "2025-07-06T15:21:44Z" }, | |
| { "filename": "flux_angelinajolie_v3-step00000400.safetensors", "uploadedAt": "2025-07-06T15:21:44Z" }, | |
| { "filename": "flux_angelinajolie_v1-step00000400.safetensors", "uploadedAt": "2025-06-19T18:00:10Z" } | |
| ], | |
| "images": [ | |
| "https://huggingface.co/datasets/malcolmrey/samples/resolve/main/flux/flux_angelinajolie_00001_.png", | |
| "https://huggingface.co/datasets/malcolmrey/samples/resolve/main/flux/flux_angelinajolie_00002_.png" | |
| ] | |
| }, | |
| "wan": { | |
| "loras": [ | |
| { "filename": "wan_angelinajolie_v1.safetensors", "uploadedAt": "2025-09-21T11:35:32+02:00" } | |
| ], | |
| "images": [ | |
| "https://huggingface.co/datasets/malcolmrey/samples/resolve/main/wan/wan_angelinajolie_00001_.png" | |
| ] | |
| }, | |
| "zimage": { | |
| "loras": [ | |
| { "filename": "zimage_angelinajolie_v1.safetensors", "uploadedAt": "2025-12-01T21:07:59+01:00" } | |
| ], | |
| "images": [ | |
| "https://huggingface.co/datasets/malcolmrey/samples/resolve/main/zimage/zimage_angelinajolie_00001_.png" | |
| ] | |
| }, | |
| "sdxl": [], | |
| "qwen": [] | |
| } | |
| } | |
| }; | |
| let db = {}; | |
| // --- State for Infinite Scroll, Filter & Sort --- | |
| let filteredKeys = []; | |
| let loadedCount = 0; | |
| const BATCH_SIZE = 24; | |
| let observer; | |
| let activeFilters = new Set(); | |
| let currentSearchQuery = ""; | |
| let currentSort = "name_asc"; // Default changed to Name A-Z | |
| // --- 2. Logic & Parsing --- | |
| function getOptimizedImageUrl(url, width = 'auto') { | |
| if (!url || url.includes('placehold.co')) return url; | |
| return `https://wsrv.nl/?url=${encodeURIComponent(url)}&w=${width}&q=80&output=webp`; | |
| } | |
| function getPrimaryImageInfo(entry) { | |
| const models = entry.models || {}; | |
| const priorities = [ | |
| { type: 'flux', list: models.flux?.images }, | |
| { type: 'wan', list: models.wan?.images }, | |
| { type: 'zimage', list: models.zimage?.images }, | |
| { type: 'sdxl', list: models.sdxl?.images }, | |
| { type: 'qwen', list: models.qwen?.images } | |
| ]; | |
| for (const item of priorities) { | |
| if (Array.isArray(item.list) && item.list.length > 0) { | |
| return { url: item.list[0], type: item.type }; | |
| } | |
| } | |
| return { url: 'https://placehold.co/600x800/1e293b/FFF?text=No+Preview', type: null }; | |
| } | |
| function getAllImages(entry) { | |
| const models = entry.models || {}; | |
| let images = []; | |
| const categories = ['flux', 'wan', 'zimage', 'sdxl', 'qwen']; | |
| categories.forEach(cat => { | |
| if (models[cat] && Array.isArray(models[cat].images)) { | |
| models[cat].images.forEach(url => { | |
| images.push({ url: url, type: cat }); | |
| }); | |
| } | |
| }); | |
| return images.length > 0 ? images : [{ url: 'https://placehold.co/600x800/1e293b/FFF?text=No+Preview', type: null }]; | |
| } | |
| function getGroupedFiles(entry) { | |
| const models = entry.models || {}; | |
| const groups = {}; | |
| const addFile = (type, f) => { | |
| if (!groups[type]) groups[type] = []; | |
| groups[type].push({ ...f, type }); | |
| }; | |
| ['locon', 'lora', 'embedding'].forEach(type => { | |
| if (Array.isArray(models[type])) { | |
| models[type].forEach(f => addFile(type, f)); | |
| } | |
| }); | |
| ['flux', 'wan', 'zimage', 'sdxl', 'qwen'].forEach(type => { | |
| if (models[type] && Array.isArray(models[type].loras)) { | |
| models[type].loras.forEach(f => addFile(type, f)); | |
| } | |
| }); | |
| Object.keys(groups).forEach(key => { | |
| groups[key].sort((a, b) => a.filename.localeCompare(b.filename)); | |
| }); | |
| return groups; | |
| } | |
| function getCleanVersionLabel(filename) { | |
| const vMatch = filename.match(/v(\d+(?:\.\d+)?)/i); | |
| const stepMatch = filename.match(/step(\d+)/i); | |
| if (vMatch) return `v${vMatch[1]}`; | |
| if (stepMatch) return `Step ${parseInt(stepMatch[1])}`; | |
| return "v1"; | |
| } | |
| function hasModelType(entry, type) { | |
| const modelData = entry.models?.[type]; | |
| if (!modelData) return false; | |
| if (Array.isArray(modelData)) return modelData.length > 0; | |
| return modelData.loras && Array.isArray(modelData.loras) && modelData.loras.length > 0; | |
| } | |
| // --- 3. UI Rendering & Infinite Scroll --- | |
| // Helper: Sorting Function | |
| function sortKeys(keys) { | |
| return keys.sort((a, b) => { | |
| const entryA = db[a]; | |
| const entryB = db[b]; | |
| if (currentSort === 'name_asc') { | |
| return a.localeCompare(b); | |
| } else if (currentSort === 'name_desc') { | |
| return b.localeCompare(a); | |
| } else if (currentSort === 'date_new') { | |
| return new Date(entryB.lastUpdated) - new Date(entryA.lastUpdated); | |
| } else if (currentSort === 'date_old') { | |
| return new Date(entryA.lastUpdated) - new Date(entryB.lastUpdated); | |
| } | |
| return 0; | |
| }); | |
| } | |
| function renderGallery() { | |
| const grid = document.getElementById('galleryGrid'); | |
| const noResults = document.getElementById('noResults'); | |
| const itemCountElements = document.querySelectorAll('.item-count-display'); // Update for class selector | |
| const sentinel = document.getElementById('sentinel'); | |
| // 1. Filter | |
| filteredKeys = Object.keys(db).filter(name => { | |
| const matchesSearch = name.toLowerCase().includes(currentSearchQuery.toLowerCase()); | |
| if (activeFilters.size === 0) return matchesSearch; | |
| const matchesFilter = Array.from(activeFilters).some(type => hasModelType(db[name], type)); | |
| return matchesSearch && matchesFilter; | |
| }); | |
| // 2. Sort | |
| sortKeys(filteredKeys); | |
| // Update Header (all instances) | |
| itemCountElements.forEach(el => { | |
| el.textContent = `${filteredKeys.length} Items`; | |
| }); | |
| // Reset UI state | |
| grid.innerHTML = ''; | |
| loadedCount = 0; | |
| window.scrollTo(0, 0); | |
| if (filteredKeys.length === 0) { | |
| grid.classList.add('hidden'); | |
| noResults.classList.remove('hidden'); | |
| noResults.classList.add('flex'); | |
| sentinel.classList.add('hidden'); | |
| return; | |
| } | |
| noResults.classList.add('hidden'); | |
| noResults.classList.remove('flex'); | |
| grid.classList.remove('hidden'); | |
| sentinel.classList.remove('hidden'); | |
| renderBatch(); | |
| } | |
| function renderBatch() { | |
| const grid = document.getElementById('galleryGrid'); | |
| const sentinel = document.getElementById('sentinel'); | |
| const nextBatch = filteredKeys.slice(loadedCount, loadedCount + BATCH_SIZE); | |
| if (nextBatch.length === 0) { | |
| sentinel.classList.add('hidden'); | |
| sentinel.classList.remove('opacity-100'); | |
| return; | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| nextBatch.forEach(key => { | |
| const entry = db[key]; | |
| const imgInfo = getPrimaryImageInfo(entry); | |
| const optimizedUrl = getOptimizedImageUrl(imgInfo.url, 450); | |
| const fileCount = Object.values(getGroupedFiles(entry)).flat().length; | |
| const displayName = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim(); | |
| const typeBadge = imgInfo.type | |
| ? `<div class="absolute top-2 right-2 px-2 py-1 bg-black/60 backdrop-blur-md rounded text-[10px] font-bold text-white border border-white/10 uppercase tracking-wider shadow-sm z-10">${imgInfo.type}</div>` | |
| : ''; | |
| const card = document.createElement('div'); | |
| card.className = 'group relative bg-zinc-800 rounded-xl overflow-hidden border border-zinc-700 shadow-lg cursor-pointer card-hover flex flex-col h-full'; | |
| card.onclick = () => openModal(key, entry); | |
| card.innerHTML = ` | |
| <div class="aspect-[3/4] overflow-hidden bg-zinc-900 relative"> | |
| <img src="${optimizedUrl}" loading="lazy" alt="${key}" class="w-full h-full object-cover transition-all duration-700 group-hover:scale-105 opacity-0 group-hover:opacity-100 img-fade-in" onload="this.classList.add('img-loaded'); this.classList.remove('opacity-0')"> | |
| <div class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent opacity-60"></div> | |
| ${typeBadge} | |
| <!-- Search Icon Button --> | |
| <a href="https://www.google.com/search?q=${encodeURIComponent(key)}" target="_blank" onclick="event.stopPropagation()" class="absolute top-2 left-2 p-2 bg-black/50 hover:bg-indigo-600 backdrop-blur-md rounded-lg text-white border border-white/10 transition-colors z-20 opacity-0 group-hover:opacity-100" title="Search ${displayName}"> | |
| <i data-lucide="search" class="h-3 w-3"></i> | |
| </a> | |
| <div class="absolute bottom-0 left-0 right-0 p-4 transform translate-y-2 group-hover:translate-y-0 transition-transform duration-300"> | |
| <h3 class="text-lg font-bold text-white leading-tight truncate">${displayName}</h3> | |
| <div class="flex items-center gap-2 mt-1 text-xs text-indigo-300 opacity-0 group-hover:opacity-100 transition-opacity duration-300 delay-75"> | |
| <i data-lucide="file-box" class="h-3 w-3"></i> | |
| <span>${fileCount} files available</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="px-4 py-3 bg-zinc-800 border-t border-zinc-700/50 flex justify-between items-center"> | |
| <span class="text-xs text-zinc-500 font-mono">Updated: ${new Date(entry.lastUpdated).toLocaleDateString()}</span> | |
| <div class="h-6 w-6 rounded-full bg-zinc-700 flex items-center justify-center text-zinc-300 group-hover:bg-indigo-600 group-hover:text-white transition-colors"> | |
| <i data-lucide="arrow-right" class="h-3 w-3"></i> | |
| </div> | |
| </div> | |
| `; | |
| fragment.appendChild(card); | |
| }); | |
| grid.appendChild(fragment); | |
| loadedCount += nextBatch.length; | |
| lucide.createIcons(); | |
| if (loadedCount >= filteredKeys.length) { | |
| sentinel.classList.add('hidden'); | |
| sentinel.classList.remove('opacity-100'); | |
| } else { | |
| sentinel.classList.remove('hidden'); | |
| } | |
| } | |
| function initObserver() { | |
| const options = { | |
| root: null, | |
| rootMargin: '200px', | |
| threshold: 0.1 | |
| }; | |
| observer = new IntersectionObserver((entries) => { | |
| entries.forEach(entry => { | |
| if (entry.isIntersecting && loadedCount < filteredKeys.length) { | |
| const sentinel = document.getElementById('sentinel'); | |
| sentinel.classList.add('opacity-100'); | |
| renderBatch(); | |
| } | |
| }); | |
| }, options); | |
| const sentinel = document.getElementById('sentinel'); | |
| if (sentinel) observer.observe(sentinel); | |
| } | |
| // --- 4. Filtering & Sorting UI Logic --- | |
| function renderFilterMenu() { | |
| const container = document.getElementById('filterOptionsContainer'); | |
| container.innerHTML = ''; | |
| const types = Object.keys(TYPE_DISPLAY_NAMES); | |
| types.forEach(type => { | |
| const label = TYPE_DISPLAY_NAMES[type]; | |
| const labelEl = document.createElement('label'); | |
| labelEl.className = "flex items-center space-x-3 p-2 rounded-lg hover:bg-zinc-700/50 cursor-pointer transition-colors"; | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.className = "form-checkbox h-4 w-4 text-indigo-600 rounded border-zinc-600 bg-zinc-700 focus:ring-indigo-500 focus:ring-offset-zinc-900 custom-checkbox"; | |
| checkbox.value = type; | |
| if (activeFilters.has(type)) checkbox.checked = true; | |
| checkbox.addEventListener('change', (e) => { | |
| if (e.target.checked) { | |
| activeFilters.add(type); | |
| } else { | |
| activeFilters.delete(type); | |
| } | |
| toggleClearButton(); | |
| renderGallery(); | |
| }); | |
| const textSpan = document.createElement('span'); | |
| textSpan.className = "text-sm text-zinc-300 select-none"; | |
| textSpan.textContent = label; | |
| labelEl.appendChild(checkbox); | |
| labelEl.appendChild(textSpan); | |
| container.appendChild(labelEl); | |
| }); | |
| } | |
| function toggleClearButton() { | |
| const btn = document.getElementById('clearFiltersBtn'); | |
| const filterBtn = document.getElementById('filterBtn'); | |
| const mobileFilterBtn = document.getElementById('mobileFilterBtn'); | |
| if (activeFilters.size > 0) { | |
| btn.classList.remove('hidden'); | |
| // Highlight filter buttons | |
| [filterBtn, mobileFilterBtn].forEach(el => { | |
| if (el) { | |
| el.classList.add('text-indigo-400', 'border-indigo-500'); | |
| el.classList.remove('text-zinc-400', 'border-zinc-700'); | |
| } | |
| }); | |
| } else { | |
| btn.classList.add('hidden'); | |
| [filterBtn, mobileFilterBtn].forEach(el => { | |
| if (el) { | |
| el.classList.remove('text-indigo-400', 'border-indigo-500'); | |
| el.classList.add('text-zinc-400', 'border-zinc-700'); | |
| } | |
| }); | |
| } | |
| } | |
| function setupUIListeners() { | |
| // -- Filter Logic -- | |
| const filterBtn = document.getElementById('filterBtn'); | |
| const filterMenu = document.getElementById('filterMenu'); | |
| const clearBtn = document.getElementById('clearFiltersBtn'); | |
| const mobileFilterBtn = document.getElementById('mobileFilterBtn'); | |
| const toggleFilterMenu = (e) => { | |
| e.stopPropagation(); | |
| // Close sort menu if open | |
| document.getElementById('sortMenu').classList.add('hidden'); | |
| // Position logic for mobile | |
| if (e.currentTarget === mobileFilterBtn) { | |
| const rect = mobileFilterBtn.getBoundingClientRect(); | |
| filterMenu.style.top = `${rect.bottom + 5}px`; | |
| filterMenu.style.right = '16px'; // Align with padding | |
| } else { | |
| filterMenu.style.top = ''; // Reset inline style | |
| filterMenu.style.right = ''; | |
| } | |
| filterMenu.classList.toggle('hidden'); | |
| }; | |
| filterBtn.addEventListener('click', toggleFilterMenu); | |
| if (mobileFilterBtn) mobileFilterBtn.addEventListener('click', toggleFilterMenu); | |
| clearBtn.addEventListener('click', () => { | |
| activeFilters.clear(); | |
| const checkboxes = document.querySelectorAll('#filterOptionsContainer input[type="checkbox"]'); | |
| checkboxes.forEach(cb => cb.checked = false); | |
| toggleClearButton(); | |
| renderGallery(); | |
| }); | |
| renderFilterMenu(); | |
| // -- Sort Logic -- | |
| const sortBtn = document.getElementById('sortBtn'); | |
| const mobileSortBtn = document.getElementById('mobileSortBtn'); | |
| const sortMenu = document.getElementById('sortMenu'); | |
| const toggleSortMenu = (e) => { | |
| e.stopPropagation(); | |
| document.getElementById('filterMenu').classList.add('hidden'); // Close filter | |
| if (e.currentTarget === mobileSortBtn) { | |
| const rect = mobileSortBtn.getBoundingClientRect(); | |
| sortMenu.style.top = `${rect.bottom + 5}px`; | |
| sortMenu.style.left = '16px'; | |
| } else { | |
| sortMenu.style.top = ''; | |
| sortMenu.style.left = ''; | |
| } | |
| sortMenu.classList.toggle('hidden'); | |
| }; | |
| sortBtn.addEventListener('click', toggleSortMenu); | |
| if (mobileSortBtn) mobileSortBtn.addEventListener('click', toggleSortMenu); | |
| // Handle radio changes for sorting | |
| const sortRadios = document.querySelectorAll('input[name="sort"]'); | |
| sortRadios.forEach(radio => { | |
| radio.addEventListener('change', (e) => { | |
| currentSort = e.target.value; | |
| renderGallery(); | |
| // Optional: Close menu on selection | |
| // sortMenu.classList.add('hidden'); | |
| }); | |
| }); | |
| // -- Global Click Outside -- | |
| document.addEventListener('click', (e) => { | |
| const clickedInsideFilter = filterMenu.contains(e.target) || filterBtn.contains(e.target) || (mobileFilterBtn && mobileFilterBtn.contains(e.target)); | |
| const clickedInsideSort = sortMenu.contains(e.target) || sortBtn.contains(e.target) || (mobileSortBtn && mobileSortBtn.contains(e.target)); | |
| if (!clickedInsideFilter) filterMenu.classList.add('hidden'); | |
| if (!clickedInsideSort) sortMenu.classList.add('hidden'); | |
| }); | |
| } | |
| // --- 5. Modal Logic --- | |
| const modalBackdrop = document.getElementById('modalBackdrop'); | |
| const modalPanel = document.getElementById('modalPanel'); | |
| function openModal(key, entry) { | |
| const displayName = key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()).trim(); | |
| const images = getAllImages(entry); | |
| const groupedFiles = getGroupedFiles(entry); | |
| const imgInfo = getPrimaryImageInfo(entry); | |
| document.getElementById('modalTitle').textContent = displayName; | |
| document.getElementById('modalDate').textContent = new Date(entry.lastUpdated).toLocaleString(); | |
| const mainImg = document.getElementById('modalMainImage'); | |
| const openLink = document.getElementById('openOriginalLink'); | |
| const imageBadge = document.getElementById('modalImageBadge'); | |
| if (images.length > 0) { | |
| mainImg.src = getOptimizedImageUrl(images[0].url, 1200); | |
| openLink.href = images[0].url; | |
| const currentType = images[0].type || imgInfo.type; | |
| if (currentType) { | |
| imageBadge.textContent = currentType; | |
| imageBadge.classList.remove('hidden'); | |
| } else { | |
| imageBadge.classList.add('hidden'); | |
| } | |
| } | |
| const thumbContainer = document.getElementById('modalThumbnails'); | |
| thumbContainer.innerHTML = ''; | |
| if (images.length > 1) { | |
| images.forEach(imgObj => { | |
| const btn = document.createElement('button'); | |
| const thumbUrl = getOptimizedImageUrl(imgObj.url, 200); | |
| btn.className = 'aspect-square rounded-lg overflow-hidden border border-zinc-700 hover:border-indigo-500 transition-all opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 relative'; | |
| btn.innerHTML = `<img src="${thumbUrl}" class="w-full h-full object-cover">`; | |
| btn.onclick = () => { | |
| mainImg.src = getOptimizedImageUrl(imgObj.url, 1200); | |
| openLink.href = imgObj.url; | |
| if (imgObj.type) { | |
| imageBadge.textContent = imgObj.type; | |
| imageBadge.classList.remove('hidden'); | |
| } else { | |
| imageBadge.classList.add('hidden'); | |
| } | |
| }; | |
| thumbContainer.appendChild(btn); | |
| }); | |
| } | |
| const tableBody = document.getElementById('filesTableBody'); | |
| tableBody.innerHTML = ''; | |
| const sortedTypes = Object.keys(groupedFiles).sort(); | |
| sortedTypes.forEach(type => { | |
| const files = groupedFiles[type]; | |
| const displayType = TYPE_DISPLAY_NAMES[type] || type.toUpperCase(); | |
| const colorClass = TYPE_COLORS[type] || 'text-zinc-400 bg-zinc-400/10 border-zinc-400/20'; | |
| files.sort((a, b) => new Date(b.uploadedAt) - new Date(a.uploadedAt)); | |
| let buttonsHtml = '<div class="flex flex-col gap-2 w-full">'; | |
| files.forEach(file => { | |
| const label = getCleanVersionLabel(file.filename); | |
| const downloadUrl = BASE_URLS[file.type] ? BASE_URLS[file.type] + file.filename : '#'; | |
| const dateStr = new Date(file.uploadedAt).toLocaleDateString(); | |
| buttonsHtml += ` | |
| <div class="flex items-center justify-between bg-zinc-900/40 px-3 py-2 rounded-lg border border-zinc-700/50 hover:border-indigo-500/30 transition-colors group/file"> | |
| <a href="${downloadUrl}" target="_blank" title="${file.filename}" | |
| class="flex items-center gap-2 text-sm font-medium text-zinc-200 hover:text-white transition-colors"> | |
| <span class="px-2 py-0.5 rounded-md bg-zinc-700 text-xs text-white border border-zinc-600 group-hover/file:bg-indigo-600 group-hover/file:border-indigo-500 transition-colors">${label}</span> | |
| <span class="hidden sm:inline opacity-70 text-xs truncate max-w-[150px]">${file.filename}</span> | |
| <i data-lucide="download" class="h-3 w-3 opacity-50 group-hover/file:opacity-100"></i> | |
| </a> | |
| <span class="text-xs text-zinc-500 font-mono whitespace-nowrap">${dateStr}</span> | |
| </div> | |
| `; | |
| }); | |
| buttonsHtml += '</div>'; | |
| const row = document.createElement('tr'); | |
| row.className = "hover:bg-zinc-800/50 transition-colors"; | |
| row.innerHTML = ` | |
| <td class="px-4 py-4 align-top w-1/4"> | |
| <span class="px-2 py-1 inline-flex text-xs leading-5 font-bold rounded border ${colorClass}"> | |
| ${displayType} | |
| </span> | |
| </td> | |
| <td class="px-4 py-4 align-top w-3/4"> | |
| ${buttonsHtml} | |
| </td> | |
| `; | |
| tableBody.appendChild(row); | |
| }); | |
| modalBackdrop.classList.remove('hidden'); | |
| void modalBackdrop.offsetWidth; | |
| modalBackdrop.classList.remove('opacity-0'); | |
| modalPanel.classList.remove('opacity-0', 'translate-y-4', 'sm:translate-y-0', 'sm:scale-95'); | |
| modalPanel.classList.add('opacity-100', 'translate-y-0', 'sm:scale-100'); | |
| document.body.style.overflow = 'hidden'; | |
| lucide.createIcons(); | |
| } | |
| function closeModal() { | |
| modalBackdrop.classList.remove('opacity-100'); | |
| modalPanel.classList.remove('opacity-100', 'translate-y-0', 'sm:scale-100'); | |
| modalPanel.classList.add('opacity-0', 'translate-y-4', 'sm:translate-y-0', 'sm:scale-95'); | |
| setTimeout(() => { | |
| modalBackdrop.classList.add('hidden'); | |
| document.body.style.overflow = 'auto'; | |
| }, 300); | |
| } | |
| // --- 6. Initialization --- | |
| document.getElementById('closeModalBtn').addEventListener('click', closeModal); | |
| document.getElementById('modalBackdrop').addEventListener('click', (e) => { | |
| if (e.target === modalBackdrop) closeModal(); | |
| }); | |
| // Search Input Logic | |
| const handleSearch = (e) => { | |
| currentSearchQuery = e.target.value; | |
| toggleSearchClearButtons(); | |
| renderGallery(); | |
| }; | |
| // Clear Search Logic | |
| const clearSearch = () => { | |
| currentSearchQuery = ""; | |
| document.getElementById('searchInput').value = ""; | |
| document.getElementById('mobileSearchInput').value = ""; | |
| toggleSearchClearButtons(); | |
| renderGallery(); | |
| } | |
| function toggleSearchClearButtons() { | |
| const deskBtn = document.getElementById('clearSearchBtn'); | |
| const mobBtn = document.getElementById('mobileClearSearchBtn'); | |
| const hasText = currentSearchQuery.length > 0; | |
| if (hasText) { | |
| deskBtn.classList.remove('hidden'); | |
| mobBtn.classList.remove('hidden'); | |
| } else { | |
| deskBtn.classList.add('hidden'); | |
| mobBtn.classList.add('hidden'); | |
| } | |
| } | |
| document.getElementById('searchInput').addEventListener('input', handleSearch); | |
| document.getElementById('mobileSearchInput').addEventListener('input', handleSearch); | |
| document.getElementById('clearSearchBtn').addEventListener('click', clearSearch); | |
| document.getElementById('mobileClearSearchBtn').addEventListener('click', clearSearch); | |
| async function init() { | |
| const loader = document.getElementById('loader'); | |
| const gallery = document.getElementById('galleryGrid'); | |
| const errorState = document.getElementById('errorState'); | |
| const sentinel = document.getElementById('sentinel'); | |
| try { | |
| const response = await fetch('unified-data.json'); | |
| if (!response.ok) { | |
| throw new Error('File not found'); | |
| } | |
| db = await response.json(); | |
| console.log("Loaded data from unified-data.json"); | |
| } catch (err) { | |
| console.warn("Could not load external JSON. Using embedded sample data for demo.", err); | |
| db = SAMPLE_DATA; | |
| if (!db) { | |
| loader.classList.add('hidden'); | |
| errorState.classList.remove('hidden'); | |
| errorState.classList.add('flex'); | |
| return; | |
| } | |
| } | |
| setupUIListeners(); | |
| // Reduced artificial delay for snappier feel | |
| setTimeout(() => { | |
| loader.classList.add('hidden'); | |
| gallery.classList.remove('opacity-0'); | |
| // Initialize observer before rendering | |
| initObserver(); | |
| renderGallery(); | |
| }, 100); | |
| } | |
| // Start | |
| init(); | |
| </script> | |
| </body> | |
| </html> |