browser / index.html
sayantan47's picture
minor fix - renaming "model vault" to "Mal's Models"
319b439
raw
history blame
49.4 kB
<!DOCTYPE html>
<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>&copy; 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>