| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Mal's Models</title> |
| |
| <link rel="preconnect" href="https://wsrv.nl" /> |
|
|
| |
| <script src="https://cdn.tailwindcss.com"></script> |
| |
| <script src="https://unpkg.com/lucide@latest"></script> |
| |
| <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', |
| } |
| } |
| } |
| } |
| } |
| </script> |
| <style> |
| |
| ::-webkit-scrollbar { |
| width: 8px; |
| height: 8px; |
| } |
| |
| ::-webkit-scrollbar-track { |
| background: #18181b; |
| |
| } |
| |
| ::-webkit-scrollbar-thumb { |
| background: #3f3f46; |
| |
| border-radius: 4px; |
| } |
| |
| ::-webkit-scrollbar-thumb:hover { |
| background: #52525b; |
| |
| } |
| |
| .glass-panel { |
| background: rgba(24, 24, 27, 0.8); |
| |
| 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; |
| } |
| |
| |
| .img-fade-in { |
| opacity: 0; |
| transition: opacity 0.5s ease-in-out; |
| } |
| |
| .img-loaded { |
| opacity: 1; |
| } |
| |
| |
| .slide-transition { |
| transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1); |
| |
| } |
| |
| |
| .type-badge { |
| transition: opacity 0.3s ease; |
| } |
| |
| |
| .custom-checkbox:checked { |
| background-color: #4f46e5; |
| border-color: #4f46e5; |
| } |
| |
| |
| .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 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"> |
| |
| <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"> |
| MM |
| </h1> |
| </div> |
|
|
| |
| <div class="hidden sm:flex items-center gap-3 flex-grow justify-center px-4"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <span |
| class="text-xs font-medium text-zinc-500 px-3 py-1.5 bg-zinc-900/50 rounded-lg border border-zinc-800 whitespace-nowrap item-count-display shadow-sm">0 |
| Items</span> |
|
|
| |
| <div class="flex items-center gap-3 ml-2 pl-3 border-l border-zinc-800 h-8" title="Toggle Auto-Rotation"> |
| <span class="text-xs font-medium text-zinc-400 select-none cursor-pointer" |
| onclick="toggleSlideshowState()">Auto-Play</span> |
| <button id="slideshowToggle" type="button" |
| class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2 focus:ring-offset-zinc-900 bg-indigo-600" |
| aria-pressed="true"> |
| <span class="sr-only">Use setting</span> |
| <span id="slideshowKnob" aria-hidden="true" |
| class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out translate-x-4"></span> |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="flex items-center gap-4 flex-shrink-0"> |
| |
| <a href="https://civitai.com/user/malcolmrey" target="_blank" title="Civitai" |
| class="opacity-60 hover:opacity-100 transition-opacity hover:scale-110 duration-200"> |
| <img src="https://civitai.com/favicon.ico" alt="Civitai" class="w-8 h-8 rounded-md shadow-sm"> |
| </a> |
| |
| <a href="https://huggingface.com/malcolmrey" target="_blank" title="Hugging Face" |
| class="opacity-60 hover:opacity-100 transition-opacity hover:scale-110 duration-200"> |
| <img src="https://huggingface.co/favicon.ico" alt="Hugging Face" class="w-8 h-8 rounded-md shadow-sm"> |
| </a> |
| |
| <a href="https://reddit.com/r/malcolmrey" target="_blank" title="Reddit" |
| class="opacity-60 hover:opacity-100 transition-opacity hover:scale-110 duration-200"> |
| <img src="https://www.reddit.com/favicon.ico" alt="Reddit" class="w-8 h-8 rounded-md shadow-sm"> |
| </a> |
| |
| <a href="https://buymeacoffee.com/malcolmrey" target="_blank" title="Buy Me A Coffee" |
| class="opacity-60 hover:opacity-100 transition-opacity hover:scale-110 duration-200"> |
| <img src="https://buymeacoffee.com/favicon.ico" alt="Buy Me A Coffee" class="w-8 h-8 rounded-md shadow-sm"> |
| </a> |
| </div> |
| </div> |
| |
| <div class="sm:hidden px-4 pb-3 flex flex-col gap-2"> |
| |
| <div class="flex justify-between items-center mb-1"> |
| <span |
| class="text-xs font-medium text-zinc-500 px-3 py-1 bg-zinc-900/50 rounded border border-zinc-800 item-count-display">0 |
| Items</span> |
|
|
| <div class="flex items-center gap-2"> |
| <span class="text-xs font-medium text-zinc-400" onclick="toggleSlideshowState()">Auto-Play</span> |
| <button id="mobileSlideshowToggle" type="button" |
| class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none bg-indigo-600" |
| aria-pressed="true"> |
| <span id="mobileSlideshowKnob" aria-hidden="true" |
| class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out translate-x-4"></span> |
| </button> |
| </div> |
| </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..."> |
| |
| <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 class="flex-grow max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 w-full relative"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
| |
| </div> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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"> |
|
|
| |
| <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> |
|
|
| |
| <div class="px-4 py-5 sm:p-6"> |
|
|
| <div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> |
|
|
| |
| <div class="lg:col-span-1 flex flex-col gap-3"> |
| |
| <div |
| class="aspect-[2/3] w-full rounded-xl overflow-hidden bg-zinc-800 border border-zinc-700 relative group shadow-lg"> |
| <img id="modalMainImage" src="" alt="Model preview" |
| class="w-full h-full object-cover transition-opacity duration-300"> |
|
|
| |
| <a id="openOriginalLink" href="#" target="_blank" |
| class="absolute top-2 left-2 p-2 bg-black/40 hover:bg-black/70 backdrop-blur-md rounded-lg text-white/80 hover:text-white border border-white/10 transition-all z-20 focus:outline-none focus:ring-2 focus:ring-indigo-500" |
| title="View Original Image"> |
| <i data-lucide="external-link" class="h-4 w-4"></i> |
| </a> |
|
|
| |
| <div class="absolute top-2 right-2 z-10"> |
| <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> |
|
|
| |
| <div class="w-full"> |
| <a id="downloadImageBtn" href="#" target="_blank" download |
| class="w-full group flex items-center justify-center px-4 py-3.5 bg-indigo-600 hover:bg-indigo-500 border border-indigo-500 hover:border-indigo-400 rounded-xl transition-all duration-200 shadow-lg shadow-indigo-900/20 focus:outline-none focus:ring-2 focus:ring-indigo-500/50"> |
| <i data-lucide="download" |
| class="h-5 w-5 text-white mr-2.5 transition-transform group-hover:-translate-y-0.5"></i> |
| <span class="text-sm font-semibold text-white leading-none">Download Original Image</span> |
| </a> |
| </div> |
|
|
| |
| <div id="modalThumbnails" class="grid grid-cols-4 gap-2 pt-2 border-t border-zinc-800/50"> |
| |
| </div> |
| </div> |
|
|
| |
| <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"> |
| |
| </tbody> |
| </table> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <script> |
| |
| |
| 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/resolve/main/", |
| "zimage": "https://huggingface.co/malcolmrey/zimage/resolve/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' |
| }; |
| |
| |
| 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 = {}; |
| |
| |
| let filteredKeys = []; |
| let loadedCount = 0; |
| const BATCH_SIZE = 24; |
| let observer; |
| let activeFilters = new Set(); |
| let currentSearchQuery = ""; |
| let currentSort = "name_asc"; |
| let isSlideshowEnabled = true; |
| |
| |
| |
| 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; |
| } |
| |
| |
| |
| |
| 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'); |
| const sentinel = document.getElementById('sentinel'); |
| |
| |
| 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; |
| }); |
| |
| |
| sortKeys(filteredKeys); |
| |
| |
| itemCountElements.forEach(el => { |
| el.textContent = `${filteredKeys.length} Items`; |
| }); |
| |
| |
| 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 allImages = getAllImages(entry); |
| let validImages = allImages; |
| |
| |
| const visualTypes = new Set(['flux', 'wan', 'zimage', 'sdxl', 'qwen']); |
| const activeVisualFilters = Array.from(activeFilters).filter(f => visualTypes.has(f)); |
| |
| if (activeVisualFilters.length > 0) { |
| const filtered = allImages.filter(img => activeFilters.has(img.type)); |
| if (filtered.length > 0) { |
| validImages = filtered; |
| } |
| } |
| |
| |
| const displayImgObj = validImages[0]; |
| const optimizedUrl = getOptimizedImageUrl(displayImgObj.url, 450); |
| |
| |
| |
| const rotationDataList = validImages.map(i => ({ |
| url: getOptimizedImageUrl(i.url, 450), |
| type: i.type |
| })); |
| |
| const hasRotation = rotationDataList.length > 1; |
| |
| const fileCount = Object.values(getGroupedFiles(entry)).flat().length; |
| const displayName = key; |
| |
| |
| const initialType = displayImgObj.type || ''; |
| const typeBadge = initialType |
| ? `<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-30 pointer-events-none type-badge">${initialType}</div>` |
| : '<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-30 pointer-events-none type-badge hidden"></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'; |
| |
| |
| if (hasRotation) { |
| card.classList.add('auto-rotate-card'); |
| card.rotationImages = rotationDataList; |
| card.rotationIndex = 0; |
| } |
| |
| card.onclick = () => openModal(key, entry); |
| |
| |
| |
| |
| let imgHTML = ''; |
| if (hasRotation) { |
| imgHTML = ` |
| <div class="slide-container w-full h-full relative"> |
| <img class="img-front absolute inset-0 w-full h-full object-cover z-10 translate-x-0" src="${optimizedUrl}" loading="lazy" alt="${key}"> |
| <img class="img-back absolute inset-0 w-full h-full object-cover z-20 translate-x-full" src="${rotationDataList[1].url}" loading="lazy" alt="${key}"> |
| </div> |
| `; |
| } else { |
| imgHTML = ` |
| <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 img-front" onload="this.classList.add('img-loaded'); this.classList.remove('opacity-0')"> |
| `; |
| } |
| |
| card.innerHTML = ` |
| <div class="aspect-[3/4] overflow-hidden bg-zinc-900 relative"> |
| ${imgHTML} |
| <div class="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent opacity-60 z-20 pointer-events-none"></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-40 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 z-20 pointer-events-none"> |
| <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 relative z-20"> |
| <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); |
| } |
| |
| |
| |
| function rotationLoop() { |
| |
| if (!isSlideshowEnabled) { |
| requestAnimationFrame(rotationLoop); |
| return; |
| } |
| |
| const now = Date.now(); |
| const rotatableCards = document.querySelectorAll('.auto-rotate-card'); |
| |
| rotatableCards.forEach(card => { |
| |
| if (!card.nextRotateTime) { |
| card.nextRotateTime = now + Math.random() * 5000 + 4000; |
| } |
| |
| if (now < card.nextRotateTime) return; |
| |
| |
| const rect = card.getBoundingClientRect(); |
| if (rect.bottom < 0 || rect.top > window.innerHeight) { |
| card.nextRotateTime = now + 1000; |
| return; |
| } |
| |
| const images = card.rotationImages; |
| if (!images || images.length <= 1) return; |
| |
| |
| const frontImg = card.querySelector('.img-front'); |
| const backImg = card.querySelector('.img-back'); |
| const badgeEl = card.querySelector('.type-badge'); |
| |
| if (frontImg && backImg) { |
| |
| let currentIndex = card.rotationIndex || 0; |
| const nextIndex = (currentIndex + 1) % images.length; |
| const nextImageObj = images[nextIndex]; |
| |
| |
| |
| backImg.src = nextImageObj.url; |
| |
| |
| frontImg.classList.add('slide-transition'); |
| backImg.classList.add('slide-transition'); |
| |
| |
| |
| requestAnimationFrame(() => { |
| frontImg.style.transform = 'translateX(-100%)'; |
| backImg.style.transform = 'translateX(0)'; |
| }); |
| |
| |
| |
| setTimeout(() => { |
| if (badgeEl) { |
| if (nextImageObj.type) { |
| badgeEl.textContent = nextImageObj.type; |
| badgeEl.classList.remove('hidden'); |
| } else { |
| badgeEl.classList.add('hidden'); |
| } |
| } |
| }, 300); |
| |
| |
| setTimeout(() => { |
| |
| frontImg.classList.remove('slide-transition'); |
| backImg.classList.remove('slide-transition'); |
| |
| |
| |
| |
| |
| frontImg.src = backImg.src; |
| |
| |
| frontImg.style.transform = 'translateX(0)'; |
| backImg.style.transform = 'translateX(100%)'; |
| |
| |
| |
| const nextNextIndex = (nextIndex + 1) % images.length; |
| backImg.src = images[nextNextIndex].url; |
| |
| card.rotationIndex = nextIndex; |
| }, 600); |
| } |
| |
| |
| card.nextRotateTime = now + 4000 + Math.random() * 3000; |
| }); |
| |
| requestAnimationFrame(rotationLoop); |
| } |
| |
| |
| requestAnimationFrame(rotationLoop); |
| |
| |
| function toggleSlideshowState() { |
| isSlideshowEnabled = !isSlideshowEnabled; |
| updateSlideshowUI(); |
| } |
| |
| function updateSlideshowUI() { |
| const configs = [ |
| { track: document.getElementById('slideshowToggle'), knob: document.getElementById('slideshowKnob') }, |
| { track: document.getElementById('mobileSlideshowToggle'), knob: document.getElementById('mobileSlideshowKnob') } |
| ]; |
| |
| configs.forEach(({ track, knob }) => { |
| if (!track || !knob) return; |
| |
| if (isSlideshowEnabled) { |
| |
| track.classList.remove('bg-zinc-700'); |
| track.classList.add('bg-indigo-600'); |
| knob.classList.remove('translate-x-0'); |
| knob.classList.add('translate-x-4'); |
| } else { |
| |
| track.classList.remove('bg-indigo-600'); |
| track.classList.add('bg-zinc-700'); |
| knob.classList.remove('translate-x-4'); |
| knob.classList.add('translate-x-0'); |
| } |
| }); |
| } |
| |
| document.getElementById('slideshowToggle').addEventListener('click', toggleSlideshowState); |
| document.getElementById('mobileSlideshowToggle').addEventListener('click', toggleSlideshowState); |
| |
| |
| |
| 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'); |
| |
| [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() { |
| |
| 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(); |
| |
| document.getElementById('sortMenu').classList.add('hidden'); |
| |
| |
| if (e.currentTarget === mobileFilterBtn) { |
| const rect = mobileFilterBtn.getBoundingClientRect(); |
| filterMenu.style.top = `${rect.bottom + 5}px`; |
| filterMenu.style.right = '16px'; |
| } else { |
| filterMenu.style.top = ''; |
| 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(); |
| |
| |
| 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'); |
| |
| 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); |
| |
| |
| const sortRadios = document.querySelectorAll('input[name="sort"]'); |
| sortRadios.forEach(radio => { |
| radio.addEventListener('change', (e) => { |
| currentSort = e.target.value; |
| renderGallery(); |
| |
| |
| }); |
| }); |
| |
| |
| |
| 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'); |
| }); |
| |
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape' && !modalBackdrop.classList.contains('hidden')) { |
| closeModal(); |
| } |
| }); |
| } |
| |
| |
| document.getElementById('downloadImageBtn').addEventListener('click', async function (e) { |
| e.preventDefault(); |
| const url = this.href; |
| if (!url || url === '#' || url.includes('placehold.co')) return; |
| |
| const filename = url.split('/').pop() || 'image.png'; |
| const btn = this; |
| const originalHtml = btn.innerHTML; |
| |
| |
| btn.innerHTML = `<div class="h-4 w-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mr-2"></div><span class="text-sm font-semibold">Saving...</span>`; |
| |
| try { |
| |
| const response = await fetch(url); |
| if (!response.ok) throw new Error('Network error'); |
| const blob = await response.blob(); |
| const blobUrl = window.URL.createObjectURL(blob); |
| |
| |
| const link = document.createElement('a'); |
| link.href = blobUrl; |
| link.download = filename; |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| window.URL.revokeObjectURL(blobUrl); |
| } catch (error) { |
| console.warn('Direct download failed, opening in new tab:', error); |
| |
| window.open(url, '_blank'); |
| } finally { |
| |
| btn.innerHTML = originalHtml; |
| lucide.createIcons(); |
| } |
| }); |
| |
| |
| |
| const modalBackdrop = document.getElementById('modalBackdrop'); |
| const modalPanel = document.getElementById('modalPanel'); |
| |
| function openModal(key, entry) { |
| const displayName = key; |
| 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 downloadLink = document.getElementById('downloadImageBtn'); |
| const imageBadge = document.getElementById('modalImageBadge'); |
| |
| if (images.length > 0) { |
| mainImg.src = getOptimizedImageUrl(images[0].url, 1200); |
| openLink.href = images[0].url; |
| downloadLink.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; |
| downloadLink.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); |
| } |
| |
| |
| |
| document.getElementById('closeModalBtn').addEventListener('click', closeModal); |
| document.getElementById('modalBackdrop').addEventListener('click', (e) => { |
| |
| if (modalPanel && !modalPanel.contains(e.target)) { |
| closeModal(); |
| } |
| }); |
| |
| |
| const handleSearch = (e) => { |
| currentSearchQuery = e.target.value; |
| toggleSearchClearButtons(); |
| renderGallery(); |
| }; |
| |
| |
| 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(); |
| |
| |
| setTimeout(() => { |
| loader.classList.add('hidden'); |
| gallery.classList.remove('opacity-0'); |
| |
| |
| initObserver(); |
| renderGallery(); |
| }, 100); |
| } |
| |
| |
| init(); |
| |
| </script> |
| </body> |
|
|
| </html> |