💬 Commit message: Update 2026-02-14 03:24:57, 4 files, 682 lines

📁 Files changed: 4
📝 Lines changed: 682

  • .coverage
  • __init__.py
  • civitai_routes.py
  • index.html
This commit is contained in:
Adam Ladachowski
2026-02-14 03:24:57 +01:00
parent af4fc5be4c
commit 24e39ed225
4 changed files with 682 additions and 0 deletions
BIN
View File
Binary file not shown.
+2
View File
@@ -11,6 +11,7 @@ import httpx
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from tensors.server.civitai_routes import create_civitai_router
from tensors.server.db_routes import create_db_router from tensors.server.db_routes import create_db_router
from tensors.server.download_routes import create_download_router from tensors.server.download_routes import create_download_router
from tensors.server.gallery_routes import create_gallery_router from tensors.server.gallery_routes import create_gallery_router
@@ -56,6 +57,7 @@ def create_app(config: ServerConfig | None = None) -> FastAPI:
async def gallery_ui() -> FileResponse: async def gallery_ui() -> FileResponse:
return FileResponse(static_dir / "index.html") return FileResponse(static_dir / "index.html")
app.include_router(create_civitai_router()) # Must be before catch-all proxy
app.include_router(create_db_router()) # Must be before catch-all proxy app.include_router(create_db_router()) # Must be before catch-all proxy
app.include_router(create_gallery_router()) # Must be before catch-all proxy app.include_router(create_gallery_router()) # Must be before catch-all proxy
app.include_router(create_models_router(pm)) # Must be before catch-all proxy app.include_router(create_models_router(pm)) # Must be before catch-all proxy
+88
View File
@@ -0,0 +1,88 @@
"""FastAPI route handlers for CivitAI API endpoints."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from fastapi import APIRouter, Query, Response
from fastapi.responses import JSONResponse
from tensors.config import CIVITAI_API_BASE, load_api_key
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/civitai", tags=["civitai"])
def _get_headers(api_key: str | None) -> dict[str, str]:
"""Get headers for CivitAI API requests."""
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
return headers
@router.get("/search", response_model=None)
async def search_models(
query: str | None = Query(default=None, description="Search query"),
types: str | None = Query(default=None, description="Model type (Checkpoint, LORA, LoCon, etc.)"),
base_models: str | None = Query(default=None, alias="baseModels", description="Base model (SD 1.5, SDXL 1.0, Pony, etc.)"),
sort: str = Query(default="Most Downloaded", description="Sort order"),
limit: int = Query(default=20, le=100, description="Max results"),
nsfw: bool = Query(default=True, description="Include NSFW models"),
) -> dict[str, Any] | Response:
"""Search CivitAI models."""
api_key = load_api_key()
params: dict[str, Any] = {
"limit": min(limit, 100),
"nsfw": str(nsfw).lower(),
"sort": sort,
}
if query:
params["query"] = query
if types:
params["types"] = types
if base_models:
params["baseModels"] = base_models
url = f"{CIVITAI_API_BASE}/models"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params, headers=_get_headers(api_key))
response.raise_for_status()
result: dict[str, Any] = response.json()
return result
except httpx.HTTPStatusError as e:
logger.error("CivitAI API error: %s", e.response.status_code)
return JSONResponse({"error": f"API error: {e.response.status_code}"}, status_code=e.response.status_code)
except httpx.RequestError as e:
logger.error("CivitAI request error: %s", e)
return JSONResponse({"error": f"Request error: {e}"}, status_code=500)
@router.get("/model/{model_id}", response_model=None)
async def get_model(model_id: int) -> dict[str, Any] | Response:
"""Get model details from CivitAI."""
api_key = load_api_key()
url = f"{CIVITAI_API_BASE}/models/{model_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=_get_headers(api_key))
response.raise_for_status()
result: dict[str, Any] = response.json()
return result
except httpx.HTTPStatusError:
return JSONResponse({"error": "Model not found"}, status_code=404)
except httpx.RequestError as e:
return JSONResponse({"error": f"Request error: {e}"}, status_code=500)
def create_civitai_router() -> APIRouter:
"""Return the CivitAI API router."""
return router
+592
View File
@@ -210,6 +210,296 @@
.empty { text-align: center; padding: 4rem 1rem; color: #666; } .empty { text-align: center; padding: 4rem 1rem; color: #666; }
.error { color: #dc2626; } .error { color: #dc2626; }
/* Search view */
#search-view {
display: flex;
flex-direction: column;
}
.search-header {
padding: 1rem;
background: #0f0f0f;
border-bottom: 1px solid #333;
}
@media (min-width: 768px) {
.search-header { padding: 1.5rem; }
}
.search-row {
display: flex;
gap: 10px;
max-width: 1000px;
margin: 0 auto;
flex-wrap: wrap;
}
.search-row input[type="text"] {
flex: 1;
min-width: 200px;
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a1a;
color: #e5e5e5;
font-size: 1rem;
}
.search-row input[type="text"]::placeholder { color: #666; }
.search-row input[type="text"]:focus {
outline: none;
border-color: #4ade80;
}
.search-row select {
padding: 0.75rem 1rem;
border: 1px solid #333;
border-radius: 8px;
background: #1a1a1a;
color: #e5e5e5;
font-size: 0.9rem;
cursor: pointer;
}
.search-row select:focus {
outline: none;
border-color: #4ade80;
}
.search-row button {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
background: #4ade80;
color: #0f0f0f;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.search-row button:hover { background: #22c55e; }
.search-row button:disabled { background: #333; color: #666; cursor: not-allowed; }
.search-results {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
@media (min-width: 768px) {
.search-results { padding: 1.5rem; }
}
/* Model cards */
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
@media (min-width: 768px) {
.model-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1.5rem; }
}
.model-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
overflow: hidden;
transition: border-color 0.2s, transform 0.2s;
}
.model-card:hover {
border-color: #4ade80;
transform: translateY(-2px);
}
.model-card-image {
width: 100%;
aspect-ratio: 16/10;
object-fit: cover;
background: #0f0f0f;
}
.model-card-body {
padding: 1rem;
}
.model-card-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #e5e5e5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.model-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.model-tag {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.model-tag.type {
background: #4ade80;
color: #0f0f0f;
}
.model-tag.base {
background: #333;
color: #e5e5e5;
}
.model-tag.nsfw {
background: #dc2626;
color: #fff;
}
.model-card-stats {
display: flex;
gap: 1rem;
font-size: 0.8rem;
color: #888;
}
.model-card-stats span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.model-card-stats svg {
width: 14px;
height: 14px;
}
.model-card-footer {
padding: 0.75rem 1rem;
border-top: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.model-card-creator {
font-size: 0.8rem;
color: #888;
}
.model-card-actions {
display: flex;
gap: 0.5rem;
}
.model-card-actions button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-view {
background: #333;
color: #e5e5e5;
}
.btn-view:hover { background: #444; }
.btn-download {
background: #4ade80;
color: #0f0f0f;
}
.btn-download:hover { background: #22c55e; }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-overlay.active { display: flex; }
.modal {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 1rem;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
font-size: 1.1rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 0.25rem;
}
.modal-close:hover { color: #e5e5e5; }
.modal-body {
padding: 1rem;
}
.modal-images {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.modal-images img {
width: 120px;
height: 120px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
border: 2px solid transparent;
}
.modal-images img:hover { border-color: #4ade80; }
.modal-info {
display: grid;
gap: 0.75rem;
}
.modal-info-row {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
}
.modal-info-row label {
color: #888;
}
.modal-info-row span {
color: #e5e5e5;
}
.modal-versions {
margin-top: 1rem;
}
.modal-versions h4 {
font-size: 0.9rem;
color: #888;
margin-bottom: 0.5rem;
}
.version-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.version-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #0f0f0f;
border-radius: 8px;
}
.version-name {
font-size: 0.9rem;
}
.version-base {
font-size: 0.75rem;
color: #888;
}
</style> </style>
</head> </head>
<body> <body>
@@ -219,6 +509,11 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.5 19.5h3m-1.5-1.5v3" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.5 19.5h3m-1.5-1.5v3" />
</svg> </svg>
</button> </button>
<button id="nav-search" title="Search CivitAI">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</button>
<button id="nav-gallery" title="Gallery"> <button id="nav-gallery" title="Gallery">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
@@ -239,18 +534,69 @@
</div> </div>
</div> </div>
<!-- Search View -->
<div id="search-view" class="view">
<div class="search-header">
<div class="search-row">
<input type="text" id="search-input" placeholder="Search CivitAI models...">
<select id="search-type">
<option value="">All Types</option>
<option value="Checkpoint">Checkpoint</option>
<option value="LORA">LoRA</option>
<option value="LoCon">LoCon</option>
<option value="TextualInversion">Embedding</option>
<option value="VAE">VAE</option>
<option value="Controlnet">ControlNet</option>
</select>
<select id="search-base">
<option value="">All Base Models</option>
<option value="SD 1.5">SD 1.5</option>
<option value="SDXL 1.0">SDXL</option>
<option value="Pony">Pony</option>
<option value="Illustrious">Illustrious</option>
<option value="Flux.1 D">Flux</option>
</select>
<select id="search-sort">
<option value="Most Downloaded">Most Downloaded</option>
<option value="Highest Rated">Highest Rated</option>
<option value="Newest">Newest</option>
</select>
<button id="search-btn">Search</button>
</div>
</div>
<div class="search-results" id="search-results">
<div class="empty">Search for models on CivitAI</div>
</div>
</div>
<!-- Gallery View --> <!-- Gallery View -->
<div id="gallery-view" class="view"> <div id="gallery-view" class="view">
<div id="gallery" class="gallery"></div> <div id="gallery" class="gallery"></div>
</div> </div>
</main> </main>
<!-- Model Detail Modal -->
<div class="modal-overlay" id="model-modal">
<div class="modal">
<div class="modal-header">
<h3 id="modal-title">Model Details</h3>
<button class="modal-close" onclick="closeModal()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/glightbox/dist/js/glightbox.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/glightbox/dist/js/glightbox.min.js"></script>
<script> <script>
let lightbox = null; let lightbox = null;
// Navigation // Navigation
document.getElementById('nav-generate').onclick = () => switchView('generate'); document.getElementById('nav-generate').onclick = () => switchView('generate');
document.getElementById('nav-search').onclick = () => switchView('search');
document.getElementById('nav-gallery').onclick = () => switchView('gallery'); document.getElementById('nav-gallery').onclick = () => switchView('gallery');
function switchView(view) { function switchView(view) {
@@ -356,6 +702,252 @@
chatArea.scrollTop = chatArea.scrollHeight; chatArea.scrollTop = chatArea.scrollHeight;
} }
// Search
const searchInput = document.getElementById('search-input');
const searchType = document.getElementById('search-type');
const searchBase = document.getElementById('search-base');
const searchSort = document.getElementById('search-sort');
const searchBtn = document.getElementById('search-btn');
const searchResults = document.getElementById('search-results');
searchInput.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
searchModels();
}
});
searchBtn.onclick = searchModels;
async function searchModels() {
const query = searchInput.value.trim();
const type = searchType.value;
const base = searchBase.value;
const sort = searchSort.value;
searchBtn.disabled = true;
searchResults.innerHTML = '<div class="empty">Searching...</div>';
try {
const params = new URLSearchParams({ limit: '24', sort });
if (query) params.append('query', query);
if (type) params.append('types', type);
if (base) params.append('baseModels', base);
const res = await fetch('/api/civitai/search?' + params.toString());
const data = await res.json();
if (data.error) {
searchResults.innerHTML = `<div class="empty error">${escapeHtml(data.error)}</div>`;
return;
}
const items = data.items || [];
if (items.length === 0) {
searchResults.innerHTML = '<div class="empty">No models found</div>';
return;
}
searchResults.innerHTML = `<div class="model-grid">${items.map(renderModelCard).join('')}</div>`;
} catch (e) {
searchResults.innerHTML = '<div class="empty error">Search failed: ' + escapeHtml(e.message) + '</div>';
}
searchBtn.disabled = false;
}
function renderModelCard(model) {
const preview = getPreviewImage(model);
const version = model.modelVersions?.[0];
const baseModel = version?.baseModel || '';
const tags = [];
tags.push(`<span class="model-tag type">${escapeHtml(model.type)}</span>`);
if (baseModel) tags.push(`<span class="model-tag base">${escapeHtml(baseModel)}</span>`);
if (model.nsfw) tags.push(`<span class="model-tag nsfw">NSFW</span>`);
return `
<div class="model-card">
<img class="model-card-image" src="${preview}" alt="${escapeHtml(model.name)}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect fill=%22%231a1a1a%22 width=%22100%22 height=%22100%22/><text x=%2250%22 y=%2250%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23666%22 font-size=%2212%22>No Image</text></svg>'">
<div class="model-card-body">
<div class="model-card-title">${escapeHtml(model.name)}</div>
<div class="model-card-meta">${tags.join('')}</div>
<div class="model-card-stats">
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
${formatNumber(model.stats?.downloadCount || 0)}
</span>
<span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.5c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 012.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 00.322-1.672V3a.75.75 0 01.75-.75A2.25 2.25 0 0116.5 4.5c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 01-2.649 7.521c-.388.482-.987.729-1.605.729H13.48a4.53 4.53 0 01-1.423-.23l-3.114-1.04a4.501 4.501 0 00-1.423-.23H5.904M14.25 9h2.25M5.904 18.75c.083.205.173.405.27.602.197.4-.078.898-.523.898h-.908c-.889 0-1.713-.518-1.972-1.368a12 12 0 01-.521-3.507c0-1.553.295-3.036.831-4.398C3.387 10.203 4.167 9.75 5 9.75h1.053c.472 0 .745.556.5.96a8.958 8.958 0 00-1.302 4.665c0 1.194.232 2.333.654 3.375z" />
</svg>
${formatNumber(model.stats?.thumbsUpCount || 0)}
</span>
</div>
</div>
<div class="model-card-footer">
<span class="model-card-creator">by ${escapeHtml(model.creator?.username || 'Unknown')}</span>
<div class="model-card-actions">
<button class="btn-view" onclick="viewModel(${model.id})">View</button>
<button class="btn-download" onclick="downloadModel(${model.id}, '${escapeHtml(model.name)}')">Download</button>
</div>
</div>
</div>
`;
}
function getPreviewImage(model) {
const version = model.modelVersions?.[0];
if (version?.images?.length > 0) {
const img = version.images[0];
return img.url || '';
}
return '';
}
function formatNumber(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return n.toString();
}
// Modal
async function viewModel(modelId) {
const modal = document.getElementById('model-modal');
const modalBody = document.getElementById('modal-body');
const modalTitle = document.getElementById('modal-title');
modal.classList.add('active');
modalBody.innerHTML = '<div class="empty">Loading...</div>';
try {
const res = await fetch(`/api/civitai/model/${modelId}`);
const model = await res.json();
if (model.error) {
modalBody.innerHTML = `<div class="empty error">${escapeHtml(model.error)}</div>`;
return;
}
modalTitle.textContent = model.name;
const version = model.modelVersions?.[0];
const images = version?.images || [];
const trainedWords = version?.trainedWords || [];
let html = '';
// Images
if (images.length > 0) {
html += `<div class="modal-images">
${images.slice(0, 6).map(img => `<img src="${img.url}" alt="Preview" class="glightbox-modal" data-type="image" data-href="${img.url}">`).join('')}
</div>`;
}
// Info
html += `<div class="modal-info">
<div class="modal-info-row"><label>Type</label><span>${escapeHtml(model.type)}</span></div>
<div class="modal-info-row"><label>Creator</label><span>${escapeHtml(model.creator?.username || 'Unknown')}</span></div>
<div class="modal-info-row"><label>Downloads</label><span>${formatNumber(model.stats?.downloadCount || 0)}</span></div>
<div class="modal-info-row"><label>Rating</label><span>${formatNumber(model.stats?.thumbsUpCount || 0)} likes</span></div>
</div>`;
// Trigger words
if (trainedWords.length > 0) {
html += `<div class="modal-versions">
<h4>Trigger Words</h4>
<div class="version-list">
<div class="version-item">
<span class="version-name" style="word-break: break-all;">${trainedWords.map(w => escapeHtml(w)).join(', ')}</span>
</div>
</div>
</div>`;
}
// Versions
if (model.modelVersions?.length > 0) {
html += `<div class="modal-versions">
<h4>Versions</h4>
<div class="version-list">
${model.modelVersions.slice(0, 5).map(v => `
<div class="version-item">
<div>
<div class="version-name">${escapeHtml(v.name)}</div>
<div class="version-base">${escapeHtml(v.baseModel || '')}</div>
</div>
<button class="btn-download" onclick="downloadVersion(${v.id}, '${escapeHtml(model.name)}')">Download</button>
</div>
`).join('')}
</div>
</div>`;
}
modalBody.innerHTML = html;
// Init lightbox for modal images
GLightbox({ selector: '.glightbox-modal' });
} catch (e) {
modalBody.innerHTML = '<div class="empty error">Failed to load model: ' + escapeHtml(e.message) + '</div>';
}
}
function closeModal() {
document.getElementById('model-modal').classList.remove('active');
}
// Close modal on overlay click
document.getElementById('model-modal').addEventListener('click', e => {
if (e.target.classList.contains('modal-overlay')) closeModal();
});
// Close modal on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
// Download
async function downloadModel(modelId, modelName) {
if (!confirm(`Download "${modelName}" to the server?`)) return;
try {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId })
});
const data = await res.json();
if (data.error) {
alert('Download failed: ' + data.error);
} else {
alert('Download started! Check server for progress.');
}
} catch (e) {
alert('Download failed: ' + e.message);
}
}
async function downloadVersion(versionId, modelName) {
if (!confirm(`Download "${modelName}" (version ${versionId}) to the server?`)) return;
try {
const res = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version_id: versionId })
});
const data = await res.json();
if (data.error) {
alert('Download failed: ' + data.error);
} else {
alert('Download started! Check server for progress.');
}
} catch (e) {
alert('Download failed: ' + e.message);
}
}
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;