NikaMimi commited on
Commit
be95c7a
·
verified ·
1 Parent(s): adcdb8a

Upload 5 files

Browse files
Files changed (5) hide show
  1. app.py +320 -0
  2. index.html +188 -0
  3. requirements.txt +6 -0
  4. static/app.js +1309 -0
  5. static/styles.css +1280 -0
app.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException, Request
2
+ from fastapi.staticfiles import StaticFiles
3
+ from fastapi.responses import HTMLResponse, FileResponse
4
+ from pydantic import BaseModel
5
+ import os
6
+ import json
7
+ import firebase_admin
8
+ from firebase_admin import credentials, db
9
+ import base64
10
+ import time
11
+ from dotenv import load_dotenv
12
+ from typing import List, Dict, Any, Optional
13
+
14
+ # Load environment variables
15
+ load_dotenv()
16
+
17
+ app = FastAPI(title="CatGPT Model Manager", description="Modern web interface for managing Discord bot AI models")
18
+
19
+ class FirebaseManager:
20
+ def __init__(self):
21
+ self.app = None
22
+ self.db_ref = None
23
+ self.initialize()
24
+
25
+ def initialize(self):
26
+ """Initialize Firebase Admin SDK"""
27
+ try:
28
+ database_url = os.getenv('FIREBASE_URL', 'https://genaibot-28c30-default-rtdb.asia-southeast1.firebasedatabase.app/')
29
+ service_account_b64 = os.getenv('FIREBASE_SERVICE_ACCOUNT_B64')
30
+
31
+ if not service_account_b64:
32
+ print("Warning: FIREBASE_SERVICE_ACCOUNT_B64 not set, using mock data")
33
+ return False
34
+
35
+ # Decode base64 service account
36
+ service_account_json = base64.b64decode(service_account_b64).decode('utf-8')
37
+ service_account_dict = json.loads(service_account_json)
38
+
39
+ # Initialize Firebase Admin
40
+ if not firebase_admin._apps:
41
+ cred = credentials.Certificate(service_account_dict)
42
+ self.app = firebase_admin.initialize_app(cred, {
43
+ 'databaseURL': database_url
44
+ })
45
+ else:
46
+ self.app = firebase_admin.get_app()
47
+
48
+ self.db_ref = db.reference('catgptbot')
49
+ print("Firebase initialized successfully")
50
+ return True
51
+
52
+ except Exception as e:
53
+ print(f"Firebase initialization error: {e}")
54
+ # Use mock data for development
55
+ self.use_mock_data()
56
+ return False
57
+
58
+ def use_mock_data(self):
59
+ """Use mock data when Firebase is not available"""
60
+ self.mock_data = {
61
+ "pony": {
62
+ "models": {
63
+ "1640995200000": {
64
+ "displayName": "Pony Diffusion V6",
65
+ "urn": "@cf/bytedance/stable-diffusion-xl-lightning",
66
+ "category": "pony",
67
+ "isActive": True,
68
+ "tags": ["anime", "realistic"],
69
+ "metadata": {"nsfw": False}
70
+ },
71
+ "1640995300000": {
72
+ "displayName": "Pony XL Enhanced",
73
+ "urn": "@cf/stabilityai/stable-diffusion-xl-base-1.0",
74
+ "category": "pony",
75
+ "isActive": True,
76
+ "tags": ["anime", "pony"],
77
+ "metadata": {"nsfw": True}
78
+ }
79
+ }
80
+ },
81
+ "illustrious": {
82
+ "models": {
83
+ "1640995400000": {
84
+ "displayName": "Illustrious XL",
85
+ "urn": "@cf/black-forest-labs/flux-1-schnell",
86
+ "category": "illustrious",
87
+ "isActive": False,
88
+ "tags": ["illustration", "art"],
89
+ "metadata": {"nsfw": True}
90
+ }
91
+ }
92
+ },
93
+ "sdxl": {
94
+ "models": {
95
+ "1640995500000": {
96
+ "displayName": "SDXL Lightning",
97
+ "urn": "@cf/bytedance/stable-diffusion-xl-lightning",
98
+ "category": "sdxl",
99
+ "isActive": True,
100
+ "tags": ["realistic", "fast"],
101
+ "metadata": {"nsfw": False}
102
+ }
103
+ }
104
+ }
105
+ }
106
+ print("Using mock data for development")
107
+
108
+ def get_models_by_category(self, category: str) -> Dict[str, Any]:
109
+ """Get all models for a specific category"""
110
+ try:
111
+ if self.db_ref:
112
+ models_ref = self.db_ref.child(f'models/categories/{category}/models')
113
+ return models_ref.get() or {}
114
+ else:
115
+ # Return mock data
116
+ return self.mock_data.get(category, {}).get("models", {})
117
+ except Exception as e:
118
+ print(f"Error getting models for {category}: {e}")
119
+ return {}
120
+
121
+ def get_all_models(self) -> Dict[str, Any]:
122
+ """Get all models from all categories"""
123
+ try:
124
+ if self.db_ref:
125
+ models_ref = self.db_ref.child('models/categories')
126
+ return models_ref.get() or {}
127
+ else:
128
+ return self.mock_data
129
+ except Exception as e:
130
+ print(f"Error getting all models: {e}")
131
+ return {}
132
+
133
+ def add_model(self, category: str, model_data: Dict[str, Any]) -> tuple[bool, str]:
134
+ """Add a new model"""
135
+ try:
136
+ # Check active model limit
137
+ active_count = self.count_active_models(category)
138
+ if active_count >= 25:
139
+ return False, f"Cannot add model. {category.upper()} already has 25 models (limit reached)."
140
+
141
+ model_id = str(int(time.time() * 1000))
142
+
143
+ if self.db_ref:
144
+ model_path = f'models/categories/{category}/models/{model_id}'
145
+ self.db_ref.child(model_path).set(model_data)
146
+ else:
147
+ # Add to mock data
148
+ if category not in self.mock_data:
149
+ self.mock_data[category] = {"models": {}}
150
+ self.mock_data[category]["models"][model_id] = model_data
151
+
152
+ return True, f"Model added successfully with ID: {model_id}"
153
+ except Exception as e:
154
+ return False, f"Error adding model: {str(e)}"
155
+
156
+ def update_model(self, category: str, model_id: str, field: str, value: Any) -> tuple[bool, str]:
157
+ """Update a specific field of a model"""
158
+ try:
159
+ if field == 'isActive' and value:
160
+ active_count = self.count_active_models(category)
161
+ if active_count >= 25:
162
+ return False, f"Cannot activate model. {category.upper()} already has 25 active models."
163
+
164
+ if self.db_ref:
165
+ model_path = f'models/categories/{category}/models/{model_id}'
166
+ if field == 'tags':
167
+ tag_list = [tag.strip() for tag in value.split(',') if tag.strip()]
168
+ self.db_ref.child(f'{model_path}/tags').set(tag_list)
169
+ elif field == 'nsfw':
170
+ self.db_ref.child(f'{model_path}/metadata/nsfw').set(value)
171
+ else:
172
+ self.db_ref.child(f'{model_path}/{field}').set(value)
173
+ else:
174
+ # Update mock data
175
+ if category in self.mock_data and model_id in self.mock_data[category]["models"]:
176
+ if field == 'tags':
177
+ self.mock_data[category]["models"][model_id][field] = [tag.strip() for tag in value.split(',') if tag.strip()]
178
+ elif field == 'nsfw':
179
+ self.mock_data[category]["models"][model_id]["metadata"]["nsfw"] = value
180
+ else:
181
+ self.mock_data[category]["models"][model_id][field] = value
182
+
183
+ return True, "Model updated successfully"
184
+ except Exception as e:
185
+ return False, f"Error updating model: {str(e)}"
186
+
187
+ def delete_model(self, category: str, model_id: str) -> tuple[bool, str]:
188
+ """Delete a model"""
189
+ try:
190
+ if self.db_ref:
191
+ model_path = f'models/categories/{category}/models/{model_id}'
192
+ self.db_ref.child(model_path).delete()
193
+ else:
194
+ # Delete from mock data
195
+ if category in self.mock_data and model_id in self.mock_data[category]["models"]:
196
+ del self.mock_data[category]["models"][model_id]
197
+
198
+ return True, "Model deleted successfully"
199
+ except Exception as e:
200
+ return False, f"Error deleting model: {str(e)}"
201
+
202
+ def count_active_models(self, category: str) -> int:
203
+ """Count active models in a category"""
204
+ try:
205
+ models = self.get_models_by_category(category)
206
+ return sum(1 for model in models.values() if model.get('isActive', True))
207
+ except Exception as e:
208
+ print(f"Error counting models for {category}: {e}")
209
+ return 0
210
+
211
+ # Initialize Firebase
212
+ firebase = FirebaseManager()
213
+
214
+ # Pydantic models
215
+ class ModelCreate(BaseModel):
216
+ displayName: str
217
+ urn: str
218
+ tags: str = ""
219
+ isActive: bool = True
220
+ nsfw: bool = False
221
+
222
+ class ModelUpdate(BaseModel):
223
+ field: str
224
+ value: Any
225
+
226
+ # API Routes
227
+ @app.get("/", response_class=HTMLResponse)
228
+ async def read_root():
229
+ """Serve the main HTML page"""
230
+ return FileResponse('index.html')
231
+
232
+ @app.get("/api/models")
233
+ async def get_all_models():
234
+ """Get all models from all categories"""
235
+ models = firebase.get_all_models()
236
+ return {"models": models}
237
+
238
+ @app.get("/api/models/{category}")
239
+ async def get_models_by_category(category: str):
240
+ """Get models for a specific category"""
241
+ if category not in ["pony", "illustrious", "sdxl"]:
242
+ raise HTTPException(status_code=400, detail="Invalid category")
243
+
244
+ models = firebase.get_models_by_category(category)
245
+ active_count = firebase.count_active_models(category)
246
+
247
+ return {
248
+ "category": category,
249
+ "models": models,
250
+ "activeCount": active_count,
251
+ "maxModels": 25
252
+ }
253
+
254
+ @app.post("/api/models/{category}")
255
+ async def add_model(category: str, model: ModelCreate):
256
+ """Add a new model to a category"""
257
+ if category not in ["pony", "illustrious", "sdxl"]:
258
+ raise HTTPException(status_code=400, detail="Invalid category")
259
+
260
+ model_data = {
261
+ 'displayName': model.displayName,
262
+ 'urn': model.urn,
263
+ 'category': category,
264
+ 'isActive': model.isActive,
265
+ 'tags': [tag.strip() for tag in model.tags.split(',') if tag.strip()],
266
+ 'metadata': {'nsfw': model.nsfw}
267
+ }
268
+
269
+ success, message = firebase.add_model(category, model_data)
270
+
271
+ if not success:
272
+ raise HTTPException(status_code=400, detail=message)
273
+
274
+ return {"message": message}
275
+
276
+ @app.put("/api/models/{category}/{model_id}")
277
+ async def update_model(category: str, model_id: str, update: ModelUpdate):
278
+ """Update a specific field of a model"""
279
+ if category not in ["pony", "illustrious", "sdxl"]:
280
+ raise HTTPException(status_code=400, detail="Invalid category")
281
+
282
+ success, message = firebase.update_model(category, model_id, update.field, update.value)
283
+
284
+ if not success:
285
+ raise HTTPException(status_code=400, detail=message)
286
+
287
+ return {"message": message}
288
+
289
+ @app.delete("/api/models/{category}/{model_id}")
290
+ async def delete_model(category: str, model_id: str):
291
+ """Delete a model"""
292
+ if category not in ["pony", "illustrious", "sdxl"]:
293
+ raise HTTPException(status_code=400, detail="Invalid category")
294
+
295
+ success, message = firebase.delete_model(category, model_id)
296
+
297
+ if not success:
298
+ raise HTTPException(status_code=400, detail=message)
299
+
300
+ return {"message": message}
301
+
302
+ # Serve static files
303
+ app.mount("/static", StaticFiles(directory="static"), name="static")
304
+
305
+ # Add CORS middleware for development
306
+ from fastapi.middleware.cors import CORSMiddleware
307
+
308
+ app.add_middleware(
309
+ CORSMiddleware,
310
+ allow_origins=["*"],
311
+ allow_credentials=True,
312
+ allow_methods=["*"],
313
+ allow_headers=["*"],
314
+ )
315
+
316
+ if __name__ == "__main__":
317
+ import uvicorn
318
+ port = int(os.getenv('PORT', 8000))
319
+ print(f"Starting CatGPT Model Manager on http://127.0.0.1:{port}")
320
+ uvicorn.run(app, host="0.0.0.0", port=port)
index.html ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>CatGPT Model Manager</title>
7
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <link href="/static/styles.css" rel="stylesheet">
10
+ </head>
11
+ <body>
12
+ <div class="app">
13
+ <!-- Header -->
14
+ <header class="header">
15
+ <div class="container">
16
+ <div class="header-content">
17
+ <div class="logo">
18
+ <i class="fas fa-robot"></i>
19
+ <h1>CatGPT Model Manager</h1>
20
+ </div>
21
+ <div class="header-stats">
22
+ <button class="theme-toggle" id="themeToggle" title="Toggle dark mode">
23
+ <i class="fas fa-moon" id="themeIcon"></i>
24
+ </button>
25
+ <div class="stat-card">
26
+ <span class="stat-value" id="totalModels">0</span>
27
+ <span class="stat-label">Total Models</span>
28
+ </div>
29
+ <div class="stat-card">
30
+ <span class="stat-value" id="activeModels">0</span>
31
+ <span class="stat-label">Active Models</span>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </header>
37
+
38
+ <!-- Main Content -->
39
+ <main class="main">
40
+ <div class="container">
41
+ <!-- Category Tabs -->
42
+ <div class="tabs">
43
+ <button class="tab-button active" data-category="pony">
44
+ <i class="fas fa-horse"></i>
45
+ <span>Pony Models</span>
46
+ <span class="badge" id="ponyCount">0</span>
47
+ </button>
48
+ <button class="tab-button" data-category="illustrious">
49
+ <i class="fas fa-palette"></i>
50
+ <span>Illustrious Models</span>
51
+ <span class="badge" id="illustriousCount">0</span>
52
+ </button>
53
+ <button class="tab-button" data-category="sdxl">
54
+ <i class="fas fa-image"></i>
55
+ <span>SDXL Models</span>
56
+ <span class="badge" id="sdxlCount">0</span>
57
+ </button>
58
+ </div>
59
+
60
+ <!-- Tab Content -->
61
+ <div class="tab-content">
62
+ <!-- Add Model Form -->
63
+ <div class="add-model-section">
64
+ <div class="section-header">
65
+ <h2><i class="fas fa-plus-circle"></i> Add New Model</h2>
66
+ </div>
67
+ <form class="add-model-form" id="addModelForm">
68
+ <div class="form-row">
69
+ <div class="form-group">
70
+ <label for="displayName">Display Name</label>
71
+ <input type="text" id="displayName" name="displayName" placeholder="e.g., Pony Diffusion V6" required>
72
+ </div>
73
+ <div class="form-group">
74
+ <label for="urn">URN</label>
75
+ <input type="text" id="urn" name="urn" placeholder="e.g., @cf/bytedance/stable-diffusion-xl-lightning" required>
76
+ </div>
77
+ </div>
78
+ <button type="submit" class="btn btn-primary">
79
+ <i class="fas fa-plus"></i>
80
+ Add Model
81
+ </button>
82
+ </form>
83
+ </div>
84
+
85
+ <!-- Models List -->
86
+ <div class="models-section">
87
+ <div class="section-header">
88
+ <h2><i class="fas fa-list"></i> Models (<span id="currentCategory">Pony</span>)</h2>
89
+ </div>
90
+
91
+ <div class="image-filters">
92
+ <div class="filter-group">
93
+ <label for="sortFilter">Sort by:</label>
94
+ <select id="sortFilter" class="filter-select">
95
+ <option value="Most Reactions">Most Reactions</option>
96
+ <option value="Most Comments">Most Comments</option>
97
+ <option value="Newest">Newest</option>
98
+ </select>
99
+ </div>
100
+ <div class="filter-group">
101
+ <label for="nsfwFilter">NSFW:</label>
102
+ <select id="nsfwFilter" class="filter-select">
103
+ <option value="None">None</option>
104
+ <option value="Soft">Soft</option>
105
+ <option value="Mature">Mature</option>
106
+ <option value="X">X</option>
107
+ </select>
108
+ </div>
109
+ <div class="filter-group">
110
+ <label for="periodFilter">Period:</label>
111
+ <select id="periodFilter" class="filter-select">
112
+ <option value="AllTime">All Time</option>
113
+ <option value="Year">Year</option>
114
+ <option value="Month">Month</option>
115
+ <option value="Week">Week</option>
116
+ <option value="Day">Day</option>
117
+ </select>
118
+ </div>
119
+ <button class="btn btn-outline" id="refreshBtn">
120
+ <i class="fas fa-sync-alt"></i>
121
+ Refresh
122
+ </button>
123
+ </div>
124
+
125
+ <div class="models-grid" id="modelsGrid">
126
+ <!-- Models will be loaded here -->
127
+ </div>
128
+
129
+ <div class="empty-state" id="emptyState" style="display: none;">
130
+ <i class="fas fa-inbox"></i>
131
+ <h3>No models found</h3>
132
+ <p>Add your first model using the form above.</p>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ </main>
138
+
139
+ <!-- Loading Overlay -->
140
+ <div class="loading-overlay" id="loadingOverlay">
141
+ <div class="loading-spinner">
142
+ <i class="fas fa-spinner fa-spin"></i>
143
+ <span>Loading...</span>
144
+ </div>
145
+ </div>
146
+
147
+ <!-- Toast Notifications -->
148
+ <div class="toast-container" id="toastContainer"></div>
149
+
150
+ <!-- Edit Model Modal -->
151
+ <div class="modal-overlay" id="modalOverlay">
152
+ <div class="modal">
153
+ <div class="modal-header">
154
+ <h3><i class="fas fa-edit"></i> Edit Model</h3>
155
+ <button class="modal-close" id="modalClose">
156
+ <i class="fas fa-times"></i>
157
+ </button>
158
+ </div>
159
+ <div class="modal-body">
160
+ <form id="editModelForm">
161
+ <div class="form-group">
162
+ <label for="editDisplayName">Display Name</label>
163
+ <input type="text" id="editDisplayName" name="displayName" required>
164
+ </div>
165
+ <div class="form-group">
166
+ <label for="editUrn">URN</label>
167
+ <input type="text" id="editUrn" name="urn" required>
168
+ </div>
169
+ </form>
170
+ </div>
171
+ <div class="modal-footer">
172
+ <button class="btn btn-outline" id="modalCancel">Cancel</button>
173
+ <button class="btn btn-primary" id="modalSave">
174
+ <i class="fas fa-save"></i>
175
+ Save Changes
176
+ </button>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ </div>
181
+
182
+ <footer class="app-footer">
183
+ <p>For aRfy's Cat Bot</p>
184
+ </footer>
185
+
186
+ <script src="/static/app.js"></script>
187
+ </body>
188
+ </html>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio==4.44.0
2
+ firebase-admin==6.5.0
3
+ python-dotenv==1.0.0
4
+ pandas==2.2.0
5
+ fastapi==0.104.1
6
+ uvicorn==0.24.0
static/app.js ADDED
@@ -0,0 +1,1309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Modern JavaScript Application
2
+ class ModelManager {
3
+ constructor() {
4
+ this.currentCategory = 'pony';
5
+ this.models = {};
6
+ this.editingModel = null;
7
+ this.imageCache = new Map(); // Cache for model images
8
+
9
+ this.init();
10
+ }
11
+
12
+ init() {
13
+ this.initTheme();
14
+ this.bindEvents();
15
+ this.loadAllModels();
16
+ }
17
+
18
+ bindEvents() {
19
+ // Tab switching
20
+ document.querySelectorAll('.tab-button').forEach(button => {
21
+ button.addEventListener('click', (e) => {
22
+ const category = e.currentTarget.dataset.category;
23
+ this.switchCategory(category);
24
+ });
25
+ });
26
+
27
+ // Add model form
28
+ document.getElementById('addModelForm').addEventListener('submit', (e) => {
29
+ e.preventDefault();
30
+ this.addModel();
31
+ });
32
+
33
+ // Refresh button
34
+ document.getElementById('refreshBtn').addEventListener('click', () => {
35
+ this.loadModels(this.currentCategory);
36
+ });
37
+
38
+ // Image filter controls
39
+ document.getElementById('sortFilter').addEventListener('change', () => {
40
+ this.refreshAllImages(true);
41
+ });
42
+
43
+ document.getElementById('nsfwFilter').addEventListener('change', () => {
44
+ this.refreshAllImages(true);
45
+ });
46
+
47
+ document.getElementById('periodFilter').addEventListener('change', () => {
48
+ this.refreshAllImages(true);
49
+ });
50
+
51
+ // Modal events
52
+ document.getElementById('modalClose').addEventListener('click', () => {
53
+ this.closeModal();
54
+ });
55
+
56
+ document.getElementById('modalCancel').addEventListener('click', () => {
57
+ this.closeModal();
58
+ });
59
+
60
+ document.getElementById('modalSave').addEventListener('click', () => {
61
+ this.saveModelEdit();
62
+ });
63
+
64
+ document.getElementById('modalOverlay').addEventListener('click', (e) => {
65
+ if (e.target === e.currentTarget) {
66
+ this.closeModal();
67
+ }
68
+ });
69
+
70
+ // Edit form
71
+ document.getElementById('editModelForm').addEventListener('submit', (e) => {
72
+ e.preventDefault();
73
+ this.saveModelEdit();
74
+ });
75
+
76
+ // Theme toggle
77
+ document.getElementById('themeToggle').addEventListener('click', () => {
78
+ this.toggleTheme();
79
+ });
80
+ }
81
+
82
+ async loadAllModels() {
83
+ this.showLoading(true);
84
+ try {
85
+ const response = await fetch('/api/models');
86
+ if (!response.ok) throw new Error('Failed to load models');
87
+
88
+ const data = await response.json();
89
+ this.models = data.models;
90
+
91
+ this.updateStats();
92
+ this.updateCategoryCounts();
93
+ this.renderModels();
94
+ } catch (error) {
95
+ this.showToast('Error loading models', error.message, 'error');
96
+ console.error('Error loading models:', error);
97
+ } finally {
98
+ this.showLoading(false);
99
+ }
100
+ }
101
+
102
+ async loadModels(category) {
103
+ this.showLoading(true);
104
+ try {
105
+ const response = await fetch(`/api/models/${category}`);
106
+ if (!response.ok) throw new Error('Failed to load models');
107
+
108
+ const data = await response.json();
109
+ this.models[category] = { models: data.models };
110
+
111
+ this.updateStats();
112
+ this.updateCategoryCounts();
113
+ this.renderModels();
114
+ } catch (error) {
115
+ this.showToast('Error loading models', error.message, 'error');
116
+ console.error('Error loading models:', error);
117
+ } finally {
118
+ this.showLoading(false);
119
+ }
120
+ }
121
+
122
+ switchCategory(category) {
123
+ this.currentCategory = category;
124
+
125
+ // Update active tab
126
+ document.querySelectorAll('.tab-button').forEach(btn => {
127
+ btn.classList.remove('active');
128
+ });
129
+ document.querySelector(`[data-category="${category}"]`).classList.add('active');
130
+
131
+ // Update current category display
132
+ document.getElementById('currentCategory').textContent =
133
+ category.charAt(0).toUpperCase() + category.slice(1);
134
+
135
+ this.renderModels();
136
+ }
137
+
138
+ async addModel() {
139
+ const form = document.getElementById('addModelForm');
140
+ const formData = new FormData(form);
141
+
142
+ const modelData = {
143
+ displayName: formData.get('displayName'),
144
+ urn: formData.get('urn'),
145
+ tags: '',
146
+ isActive: true,
147
+ nsfw: false
148
+ };
149
+
150
+ this.showLoading(true);
151
+ try {
152
+ const response = await fetch(`/api/models/${this.currentCategory}`, {
153
+ method: 'POST',
154
+ headers: {
155
+ 'Content-Type': 'application/json'
156
+ },
157
+ body: JSON.stringify(modelData)
158
+ });
159
+
160
+ if (!response.ok) {
161
+ const error = await response.json();
162
+ throw new Error(error.detail || 'Failed to add model');
163
+ }
164
+
165
+ const result = await response.json();
166
+ this.showToast('Success', result.message, 'success');
167
+
168
+ // Clear form
169
+ form.reset();
170
+
171
+ // Reload models
172
+ await this.loadModels(this.currentCategory);
173
+
174
+ } catch (error) {
175
+ this.showToast('Error adding model', error.message, 'error');
176
+ console.error('Error adding model:', error);
177
+ } finally {
178
+ this.showLoading(false);
179
+ }
180
+ }
181
+
182
+ renderModels() {
183
+ const grid = document.getElementById('modelsGrid');
184
+ const emptyState = document.getElementById('emptyState');
185
+
186
+ const categoryModels = this.models[this.currentCategory]?.models || {};
187
+ const modelEntries = Object.entries(categoryModels);
188
+
189
+ if (modelEntries.length === 0) {
190
+ grid.style.display = 'none';
191
+ emptyState.style.display = 'block';
192
+ return;
193
+ }
194
+
195
+ grid.style.display = 'grid';
196
+ emptyState.style.display = 'none';
197
+
198
+ grid.innerHTML = modelEntries.map(([id, model]) => this.createModelCard(id, model)).join('');
199
+
200
+ // Load thumbnails for each model
201
+ modelEntries.forEach(([id, model]) => {
202
+ this.loadModelImages(id, model.urn);
203
+ });
204
+
205
+ // Bind events for action buttons
206
+ this.bindModelCardEvents();
207
+ }
208
+
209
+ createModelCard(id, model) {
210
+ return `
211
+ <div class="model-card" data-model-id="${id}">
212
+ <div class="model-thumbnail-carousel" id="carousel-${id}">
213
+ <div class="carousel-container">
214
+ <button class="carousel-btn carousel-prev" data-model-id="${id}">
215
+ <i class="fas fa-chevron-left"></i>
216
+ </button>
217
+
218
+ <div class="carousel-images" id="images-${id}">
219
+ <div class="thumbnail-placeholder">
220
+ <i class="fas fa-image"></i>
221
+ <span>Loading images...</span>
222
+ </div>
223
+ </div>
224
+
225
+ <button class="carousel-btn carousel-next" data-model-id="${id}">
226
+ <i class="fas fa-chevron-right"></i>
227
+ </button>
228
+ </div>
229
+
230
+ <div class="carousel-indicators" id="indicators-${id}"></div>
231
+ </div>
232
+
233
+ <div class="model-content">
234
+ <div class="model-header">
235
+ <div>
236
+ ${this.createModelTitleLink(model.displayName, model.urn)}
237
+ </div>
238
+ </div>
239
+
240
+ <div class="model-urn">${model.urn}</div>
241
+
242
+ <div class="model-actions">
243
+ <button class="btn btn-sm btn-outline edit-btn" data-model-id="${id}">
244
+ <i class="fas fa-edit"></i>
245
+ Edit
246
+ </button>
247
+ <button class="btn btn-sm btn-danger delete-btn" data-model-id="${id}">
248
+ <i class="fas fa-trash"></i>
249
+ Delete
250
+ </button>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ `;
255
+ }
256
+
257
+ bindModelCardEvents() {
258
+ // Edit buttons
259
+ document.querySelectorAll('.edit-btn').forEach(btn => {
260
+ btn.addEventListener('click', (e) => {
261
+ const modelId = e.currentTarget.dataset.modelId;
262
+ this.editModel(modelId);
263
+ });
264
+ });
265
+
266
+
267
+ // Delete buttons
268
+ document.querySelectorAll('.delete-btn').forEach(btn => {
269
+ btn.addEventListener('click', (e) => {
270
+ const modelId = e.currentTarget.dataset.modelId;
271
+ this.deleteModel(modelId);
272
+ });
273
+ });
274
+
275
+ // Carousel navigation buttons
276
+ document.querySelectorAll('.carousel-prev').forEach(btn => {
277
+ btn.addEventListener('click', (e) => {
278
+ e.stopPropagation();
279
+ const modelId = e.currentTarget.dataset.modelId;
280
+ this.navigateCarousel(modelId, -1);
281
+ });
282
+ });
283
+
284
+ document.querySelectorAll('.carousel-next').forEach(btn => {
285
+ btn.addEventListener('click', (e) => {
286
+ e.stopPropagation();
287
+ const modelId = e.currentTarget.dataset.modelId;
288
+ this.navigateCarousel(modelId, 1);
289
+ });
290
+ });
291
+ }
292
+
293
+ editModel(modelId) {
294
+ const model = this.models[this.currentCategory]?.models[modelId];
295
+ if (!model) return;
296
+
297
+ this.editingModel = { id: modelId, category: this.currentCategory };
298
+
299
+ // Populate form
300
+ document.getElementById('editDisplayName').value = model.displayName;
301
+ document.getElementById('editUrn').value = model.urn;
302
+
303
+ this.showModal();
304
+ }
305
+
306
+ async saveModelEdit() {
307
+ if (!this.editingModel) return;
308
+
309
+ const form = document.getElementById('editModelForm');
310
+ const formData = new FormData(form);
311
+
312
+ const updates = [
313
+ { field: 'displayName', value: formData.get('displayName') },
314
+ { field: 'urn', value: formData.get('urn') }
315
+ ];
316
+
317
+ this.showLoading(true);
318
+ try {
319
+ for (const update of updates) {
320
+ const response = await fetch(`/api/models/${this.editingModel.category}/${this.editingModel.id}`, {
321
+ method: 'PUT',
322
+ headers: {
323
+ 'Content-Type': 'application/json'
324
+ },
325
+ body: JSON.stringify(update)
326
+ });
327
+
328
+ if (!response.ok) {
329
+ const error = await response.json();
330
+ throw new Error(error.detail || 'Failed to update model');
331
+ }
332
+ }
333
+
334
+ this.showToast('Success', 'Model updated successfully', 'success');
335
+ this.closeModal();
336
+ await this.loadModels(this.currentCategory);
337
+
338
+ } catch (error) {
339
+ this.showToast('Error updating model', error.message, 'error');
340
+ console.error('Error updating model:', error);
341
+ } finally {
342
+ this.showLoading(false);
343
+ }
344
+ }
345
+
346
+
347
+ async deleteModel(modelId) {
348
+ const model = this.models[this.currentCategory]?.models[modelId];
349
+ if (!model) return;
350
+
351
+ if (!confirm(`Are you sure you want to delete "${model.displayName}"? This action cannot be undone.`)) {
352
+ return;
353
+ }
354
+
355
+ this.showLoading(true);
356
+ try {
357
+ const response = await fetch(`/api/models/${this.currentCategory}/${modelId}`, {
358
+ method: 'DELETE'
359
+ });
360
+
361
+ if (!response.ok) {
362
+ const error = await response.json();
363
+ throw new Error(error.detail || 'Failed to delete model');
364
+ }
365
+
366
+ const result = await response.json();
367
+ this.showToast('Success', result.message, 'success');
368
+
369
+ await this.loadModels(this.currentCategory);
370
+
371
+ } catch (error) {
372
+ this.showToast('Error deleting model', error.message, 'error');
373
+ console.error('Error deleting model:', error);
374
+ } finally {
375
+ this.showLoading(false);
376
+ }
377
+ }
378
+
379
+ updateStats() {
380
+ let totalModels = 0;
381
+ let activeModels = 0;
382
+
383
+ Object.values(this.models).forEach(category => {
384
+ if (category.models) {
385
+ Object.values(category.models).forEach(model => {
386
+ totalModels++;
387
+ if (model.isActive) activeModels++;
388
+ });
389
+ }
390
+ });
391
+
392
+ document.getElementById('totalModels').textContent = totalModels;
393
+ document.getElementById('activeModels').textContent = activeModels;
394
+ }
395
+
396
+ updateCategoryCounts() {
397
+ ['pony', 'illustrious', 'sdxl'].forEach(category => {
398
+ const models = this.models[category]?.models || {};
399
+ const activeCount = Object.values(models).filter(model => model.isActive).length;
400
+ document.getElementById(`${category}Count`).textContent = activeCount;
401
+ });
402
+ }
403
+
404
+ showModal() {
405
+ document.getElementById('modalOverlay').classList.add('active');
406
+ document.body.style.overflow = 'hidden';
407
+ }
408
+
409
+ closeModal() {
410
+ document.getElementById('modalOverlay').classList.remove('active');
411
+ document.body.style.overflow = '';
412
+ this.editingModel = null;
413
+ }
414
+
415
+ showLoading(show) {
416
+ const overlay = document.getElementById('loadingOverlay');
417
+ if (show) {
418
+ overlay.classList.add('active');
419
+ } else {
420
+ overlay.classList.remove('active');
421
+ }
422
+ }
423
+
424
+ showToast(title, message, type = 'success') {
425
+ const container = document.getElementById('toastContainer');
426
+ const toast = document.createElement('div');
427
+ toast.className = `toast ${type}`;
428
+
429
+ const iconMap = {
430
+ success: 'fas fa-check-circle',
431
+ error: 'fas fa-exclamation-circle',
432
+ warning: 'fas fa-exclamation-triangle'
433
+ };
434
+
435
+ toast.innerHTML = `
436
+ <i class="${iconMap[type]}"></i>
437
+ <div class="toast-content">
438
+ <div class="toast-title">${title}</div>
439
+ <div class="toast-message">${message}</div>
440
+ </div>
441
+ <button class="toast-close">
442
+ <i class="fas fa-times"></i>
443
+ </button>
444
+ `;
445
+
446
+ container.appendChild(toast);
447
+
448
+ // Animate in
449
+ setTimeout(() => toast.classList.add('show'), 100);
450
+
451
+ // Bind close button
452
+ toast.querySelector('.toast-close').addEventListener('click', () => {
453
+ this.removeToast(toast);
454
+ });
455
+
456
+ // Auto remove after 5 seconds
457
+ setTimeout(() => {
458
+ if (toast.parentNode) {
459
+ this.removeToast(toast);
460
+ }
461
+ }, 5000);
462
+ }
463
+
464
+ removeToast(toast) {
465
+ toast.classList.remove('show');
466
+ setTimeout(() => {
467
+ if (toast.parentNode) {
468
+ toast.parentNode.removeChild(toast);
469
+ }
470
+ }, 300);
471
+ }
472
+
473
+ // URN Parsing and Image Loading Functions
474
+ createModelTitleLink(displayName, urn) {
475
+ const urnData = this.parseUrn(urn);
476
+
477
+ if (urnData && urnData.source === 'civitai') {
478
+ const civitaiUrl = `https://civitai.com/models/${urnData.modelId}?modelVersionId=${urnData.modelVersionId}`;
479
+ return `<a href="${civitaiUrl}" target="_blank" class="model-title-link" title="View on Civitai">
480
+ <div class="model-title">${displayName}</div>
481
+ <i class="fas fa-external-link-alt model-external-link"></i>
482
+ </a>`;
483
+ } else {
484
+ // Fallback for non-Civitai models
485
+ return `<div class="model-title">${displayName}</div>`;
486
+ }
487
+ }
488
+
489
+ parseUrn(urn) {
490
+ // Parse URN format: urn:air:sdxl:checkpoint:civitai:1465491@1892573
491
+ // Extract modelId and modelVersionId from civitai section
492
+ if (!urn.includes('civitai:')) {
493
+ return null;
494
+ }
495
+
496
+ const civitaiPart = urn.split('civitai:')[1];
497
+ if (!civitaiPart) return null;
498
+
499
+ const parts = civitaiPart.split('@');
500
+ const modelId = parts[0];
501
+ const modelVersionId = parts[1];
502
+
503
+ return {
504
+ modelId: modelId,
505
+ modelVersionId: modelVersionId,
506
+ source: 'civitai'
507
+ };
508
+ }
509
+
510
+ async loadModelImages(cardId, urn, forceRefresh = false) {
511
+ const imagesContainer = document.getElementById(`images-${cardId}`);
512
+ const indicatorsContainer = document.getElementById(`indicators-${cardId}`);
513
+ if (!imagesContainer || !indicatorsContainer) return;
514
+
515
+ // Show loading state
516
+ imagesContainer.innerHTML = '<div class="loading-spinner">Loading images...</div>';
517
+ indicatorsContainer.innerHTML = '';
518
+
519
+ // Get current filter settings
520
+ const sortFilter = document.getElementById('sortFilter')?.value || 'Most Reactions';
521
+ const nsfwFilter = document.getElementById('nsfwFilter')?.value || 'None';
522
+ const periodFilter = document.getElementById('periodFilter')?.value || 'AllTime';
523
+
524
+ // Create cache key with filters
525
+ const cacheKey = `${urn}-${sortFilter}-${nsfwFilter}-${periodFilter}`;
526
+
527
+ // Check cache first (unless force refresh)
528
+ if (!forceRefresh && this.imageCache.has(cacheKey)) {
529
+ const cachedImages = this.imageCache.get(cacheKey);
530
+ this.displayCarousel(cardId, cachedImages);
531
+ return;
532
+ }
533
+
534
+ const urnData = this.parseUrn(urn);
535
+ if (!urnData) {
536
+ this.displayFallbackCarousel(imagesContainer, 'Invalid URN format');
537
+ return;
538
+ }
539
+
540
+ try {
541
+ // Build API URL with dynamic filters
542
+ let apiUrl = `https://civitai.com/api/v1/images?modelVersionId=${urnData.modelVersionId}&limit=20`;
543
+
544
+ // Add sort parameter
545
+ const sortParam = encodeURIComponent(sortFilter);
546
+ apiUrl += `&sort=${sortParam}`;
547
+
548
+ // Add period parameter
549
+ if (periodFilter !== 'AllTime') {
550
+ apiUrl += `&period=${periodFilter}`;
551
+ }
552
+
553
+ // Add NSFW filtering based on level
554
+ if (nsfwFilter === 'None') {
555
+ apiUrl += '&nsfw=false';
556
+ } else {
557
+ apiUrl += '&nsfw=true';
558
+ // Note: Civitai API doesn't support specific NSFW levels in filtering
559
+ // We'll filter client-side if needed
560
+ }
561
+
562
+ console.log('Fetching images with filters:', { sort: sortFilter, nsfw: nsfwFilter, period: periodFilter, url: apiUrl });
563
+
564
+ // Fetch images from Civitai API
565
+ const response = await fetch(apiUrl,
566
+ {
567
+ method: 'GET',
568
+ headers: {
569
+ 'Accept': 'application/json',
570
+ }
571
+ }
572
+ );
573
+
574
+ if (!response.ok) {
575
+ throw new Error(`API response: ${response.status}`);
576
+ }
577
+
578
+ const data = await response.json();
579
+
580
+ if (data.items && data.items.length > 0) {
581
+ // Filter images based on NSFW level
582
+ let filteredImages = data.items;
583
+
584
+ if (nsfwFilter === 'None') {
585
+ filteredImages = data.items.filter(img => !img.nsfw);
586
+ } else if (nsfwFilter === 'Soft') {
587
+ filteredImages = data.items.filter(img => !img.nsfw || img.nsfwLevel <= 1);
588
+ } else if (nsfwFilter === 'Mature') {
589
+ filteredImages = data.items.filter(img => !img.nsfw || img.nsfwLevel <= 2);
590
+ }
591
+ // For 'X' level, we include all images (no additional filtering)
592
+
593
+ if (filteredImages.length === 0) {
594
+ const filterMsg = this.getFilterMessage(sortFilter, nsfwFilter, periodFilter);
595
+ this.displayFallbackCarousel(imagesContainer, `No results for ${filterMsg}`);
596
+ return;
597
+ }
598
+
599
+ const selectedImages = filteredImages.slice(0, 10).map(img => {
600
+ // Get the original full-size URL by removing width parameter
601
+ const originalUrl = img.url.replace(/\/width=\d+/, '');
602
+ console.log('Original URL:', img.url, '-> Full size:', originalUrl);
603
+ console.log('Image metadata:', img.meta);
604
+ return {
605
+ url: img.url, // For thumbnail display
606
+ originalUrl: originalUrl, // For modal display
607
+ width: img.width,
608
+ height: img.height,
609
+ nsfw: img.nsfw,
610
+ meta: img.meta || {} // Include metadata from Civitai
611
+ };
612
+ });
613
+
614
+ // Cache the result with filter-specific key
615
+ this.imageCache.set(cacheKey, selectedImages);
616
+
617
+ this.displayCarousel(cardId, selectedImages);
618
+ } else {
619
+ const filterMsg = this.getFilterMessage(sortFilter, nsfwFilter, periodFilter);
620
+ this.displayFallbackCarousel(imagesContainer, `No results for ${filterMsg}`);
621
+ }
622
+
623
+ } catch (error) {
624
+ console.error('Error loading images:', error);
625
+ this.displayFallbackCarousel(imagesContainer, 'Failed to load');
626
+ }
627
+ }
628
+
629
+ displayCarousel(cardId, images) {
630
+ const imagesContainer = document.getElementById(`images-${cardId}`);
631
+ const indicatorsContainer = document.getElementById(`indicators-${cardId}`);
632
+ const carouselContainer = document.getElementById(`carousel-${cardId}`);
633
+
634
+ if (!imagesContainer || !indicatorsContainer) return;
635
+
636
+ // Store images data on the carousel container
637
+ carouselContainer.dataset.images = JSON.stringify(images);
638
+ carouselContainer.dataset.currentIndex = '0';
639
+
640
+ // Create image elements
641
+ imagesContainer.innerHTML = images.map((img, index) => {
642
+ console.log(`Creating image ${index}:`, img.url, 'Full:', img.originalUrl, img.width, img.height); // Debug log
643
+ return `
644
+ <img src="${img.url}"
645
+ alt="Model image ${index + 1}"
646
+ class="carousel-image ${index === 0 ? 'active' : ''}"
647
+ data-index="${index}"
648
+ data-full-url="${img.originalUrl || img.url}"
649
+ data-width="${img.width}"
650
+ data-height="${img.height}"
651
+ onload="this.classList.add('loaded')"
652
+ onerror="this.style.display='none'">
653
+ `;
654
+ }).join('');
655
+
656
+ // Create indicators
657
+ if (images.length > 1) {
658
+ indicatorsContainer.innerHTML = images.map((_, index) => `
659
+ <button class="carousel-indicator ${index === 0 ? 'active' : ''}"
660
+ data-index="${index}"
661
+ onclick="modelManager.goToSlide('${cardId}', ${index})"></button>
662
+ `).join('');
663
+ indicatorsContainer.style.display = 'flex';
664
+ } else {
665
+ indicatorsContainer.style.display = 'none';
666
+ }
667
+
668
+ // Show/hide navigation buttons
669
+ const prevBtn = carouselContainer.querySelector('.carousel-prev');
670
+ const nextBtn = carouselContainer.querySelector('.carousel-next');
671
+ if (images.length > 1) {
672
+ prevBtn.style.display = 'flex';
673
+ nextBtn.style.display = 'flex';
674
+ } else {
675
+ prevBtn.style.display = 'none';
676
+ nextBtn.style.display = 'none';
677
+ }
678
+
679
+ // Add click event listener to the images container (event delegation)
680
+ imagesContainer.addEventListener('click', (e) => {
681
+ if (e.target.classList.contains('carousel-image')) {
682
+ // CSS pointer-events should ensure only active images are clickable
683
+ const url = e.target.dataset.fullUrl;
684
+ const width = parseInt(e.target.dataset.width);
685
+ const height = parseInt(e.target.dataset.height);
686
+ const index = parseInt(e.target.dataset.index);
687
+
688
+ // Add bounce animation to clicked thumbnail
689
+ const clickedImage = e.target;
690
+ clickedImage.style.transform = 'scale(0.9)';
691
+ setTimeout(() => {
692
+ clickedImage.style.transform = 'scale(1)';
693
+ }, 100);
694
+
695
+ console.log('Clicked image index:', index, 'URL:', url, 'Dimensions:', width, 'x', height);
696
+ this.openImageModal(url, width, height, cardId, index);
697
+ }
698
+ });
699
+ }
700
+
701
+ async refreshAllImages(forceRefresh = false) {
702
+ try {
703
+ // Get all currently displayed models
704
+ const modelCards = document.querySelectorAll('.model-card');
705
+ const refreshPromises = [];
706
+
707
+ modelCards.forEach(card => {
708
+ const cardId = card.id;
709
+ const urnElement = card.querySelector('.model-urn');
710
+ if (urnElement) {
711
+ const urn = urnElement.textContent.trim();
712
+ refreshPromises.push(this.loadModelImages(cardId, urn, forceRefresh));
713
+ }
714
+ });
715
+
716
+ // Wait for all images to refresh
717
+ await Promise.all(refreshPromises);
718
+
719
+ } catch (error) {
720
+ console.error('Error refreshing images:', error);
721
+ }
722
+ }
723
+
724
+ getFilterMessage(sortFilter, nsfwFilter, periodFilter) {
725
+ const sortMsg = sortFilter === 'Most Reactions' ? 'most liked' :
726
+ sortFilter === 'Most Comments' ? 'most commented' : 'newest';
727
+ const nsfwMsg = nsfwFilter === 'None' ? 'safe content' :
728
+ nsfwFilter === 'Soft' ? 'soft content' :
729
+ nsfwFilter === 'Mature' ? 'mature content' : 'all content';
730
+ const periodMsg = periodFilter === 'AllTime' ? '' :
731
+ periodFilter === 'Year' ? 'this year' :
732
+ periodFilter === 'Month' ? 'this month' :
733
+ periodFilter === 'Week' ? 'this week' : 'today';
734
+
735
+ const timePrefix = periodMsg ? `${periodMsg} ` : '';
736
+ return `${timePrefix}${sortMsg} ${nsfwMsg} filter`;
737
+ }
738
+
739
+ displayFallbackCarousel(container, message) {
740
+ container.innerHTML = `
741
+ <div class="thumbnail-fallback">
742
+ <i class="fas fa-image"></i>
743
+ <span>${message}</span>
744
+ </div>
745
+ `;
746
+ }
747
+
748
+ navigateCarousel(cardId, direction) {
749
+ const carouselContainer = document.getElementById(`carousel-${cardId}`);
750
+ if (!carouselContainer) return;
751
+
752
+ const images = JSON.parse(carouselContainer.dataset.images || '[]');
753
+ const currentIndex = parseInt(carouselContainer.dataset.currentIndex || '0');
754
+ const newIndex = (currentIndex + direction + images.length) % images.length;
755
+
756
+ this.goToSlide(cardId, newIndex);
757
+ }
758
+
759
+ goToSlide(cardId, index) {
760
+ const carouselContainer = document.getElementById(`carousel-${cardId}`);
761
+ const imagesContainer = document.getElementById(`images-${cardId}`);
762
+ const indicatorsContainer = document.getElementById(`indicators-${cardId}`);
763
+
764
+ if (!carouselContainer || !imagesContainer) return;
765
+
766
+ // Update current index
767
+ carouselContainer.dataset.currentIndex = index.toString();
768
+
769
+ // Update active image
770
+ const imageElements = imagesContainer.querySelectorAll('.carousel-image');
771
+ imageElements.forEach((img, i) => {
772
+ img.classList.toggle('active', i === index);
773
+ });
774
+
775
+ // Update active indicator
776
+ const indicatorElements = indicatorsContainer.querySelectorAll('.carousel-indicator');
777
+ indicatorElements.forEach((indicator, i) => {
778
+ indicator.classList.toggle('active', i === index);
779
+ });
780
+ }
781
+
782
+ openImageModal(imageUrl, width, height, cardId = null, imageIndex = 0) {
783
+ console.log('Opening modal with:', imageUrl, width, height); // Debug log
784
+
785
+ // Create modal if it doesn't exist
786
+ let modal = document.getElementById('imageModal');
787
+ if (!modal) {
788
+ modal = document.createElement('div');
789
+ modal.id = 'imageModal';
790
+ modal.className = 'image-modal-overlay';
791
+ modal.innerHTML = `
792
+ <div class="image-modal">
793
+ <button class="image-modal-close">
794
+ <i class="fas fa-times"></i>
795
+ </button>
796
+
797
+ <button class="modal-nav modal-prev" id="modalPrev">
798
+ <i class="fas fa-chevron-left"></i>
799
+ </button>
800
+
801
+ <button class="modal-nav modal-next" id="modalNext">
802
+ <i class="fas fa-chevron-right"></i>
803
+ </button>
804
+
805
+ <div class="image-modal-content" id="modalContent">
806
+ <img id="modalImage" src="" alt="Full size image">
807
+ </div>
808
+
809
+ <div class="image-modal-controls">
810
+ <button class="zoom-btn" id="zoomIn" title="Zoom In">
811
+ <i class="fas fa-search-plus"></i>
812
+ </button>
813
+ <button class="zoom-btn" id="zoomOut" title="Zoom Out">
814
+ <i class="fas fa-search-minus"></i>
815
+ </button>
816
+ <button class="zoom-btn" id="zoomReset" title="Reset Zoom">
817
+ <i class="fas fa-expand-arrows-alt"></i>
818
+ </button>
819
+ </div>
820
+
821
+ <div class="image-modal-info">
822
+ <span id="imageDimensions"></span>
823
+ <span id="imageCounter"></span>
824
+ </div>
825
+
826
+ <div class="image-modal-metadata">
827
+ <button class="metadata-toggle" id="metadataToggle">
828
+ <i class="fas fa-info-circle"></i>
829
+ <span>Image Details</span>
830
+ <i class="fas fa-chevron-up toggle-icon"></i>
831
+ </button>
832
+ <div class="metadata-content" id="metadataContent">
833
+ <div class="metadata-grid">
834
+ <div class="metadata-item">
835
+ <span class="metadata-label">Size:</span>
836
+ <span class="metadata-value" id="metaSize">-</span>
837
+ </div>
838
+ <div class="metadata-item">
839
+ <span class="metadata-label">Seed:</span>
840
+ <span class="metadata-value" id="metaSeed">-</span>
841
+ </div>
842
+ <div class="metadata-item">
843
+ <span class="metadata-label">Steps:</span>
844
+ <span class="metadata-value" id="metaSteps">-</span>
845
+ </div>
846
+ <div class="metadata-item">
847
+ <span class="metadata-label">Sampler:</span>
848
+ <span class="metadata-value" id="metaSampler">-</span>
849
+ </div>
850
+ <div class="metadata-item">
851
+ <span class="metadata-label">CFG Scale:</span>
852
+ <span class="metadata-value" id="metaCfgScale">-</span>
853
+ </div>
854
+ <div class="metadata-item">
855
+ <span class="metadata-label">CLIP Skip:</span>
856
+ <span class="metadata-value" id="metaClipSkip">-</span>
857
+ </div>
858
+ </div>
859
+ <div class="metadata-prompt">
860
+ <span class="metadata-label">Prompt:</span>
861
+ <div class="metadata-value" id="metaPrompt">-</div>
862
+ </div>
863
+ <div class="metadata-prompt" id="negativePromptSection">
864
+ <span class="metadata-label">Negative Prompt:</span>
865
+ <div class="metadata-value" id="metaNegativePrompt">-</div>
866
+ </div>
867
+ </div>
868
+ </div>
869
+ </div>
870
+ `;
871
+ document.body.appendChild(modal);
872
+
873
+ this.initializeModalEvents(modal);
874
+ }
875
+
876
+ // Store modal state
877
+ modal.dataset.cardId = cardId || '';
878
+ modal.dataset.currentIndex = imageIndex.toString();
879
+
880
+ // Show modal with bouncy entrance animation
881
+ modal.classList.add('active');
882
+ document.body.style.overflow = 'hidden';
883
+
884
+ // Set image and show modal after a small delay for better animation
885
+ setTimeout(() => {
886
+ this.updateModalImage(imageUrl, width, height);
887
+ this.updateModalNavigation();
888
+ this.updateModalMetadata(cardId, imageIndex);
889
+
890
+ // Ensure image starts at fitted size
891
+ this.resetModalZoom();
892
+ }, 50);
893
+ }
894
+
895
+ initializeModalEvents(modal) {
896
+ // Initialize zoom state on the modal element
897
+ this.initializeModalZoomState(modal);
898
+
899
+ // Close modal events
900
+ modal.addEventListener('click', (e) => {
901
+ if (e.target === modal) {
902
+ this.closeImageModal();
903
+ }
904
+ });
905
+
906
+ modal.querySelector('.image-modal-close').addEventListener('click', () => {
907
+ this.closeImageModal();
908
+ });
909
+
910
+ // Metadata toggle functionality
911
+ const metadataToggle = modal.querySelector('#metadataToggle');
912
+ const metadataContent = modal.querySelector('#metadataContent');
913
+
914
+ metadataToggle.addEventListener('click', () => {
915
+ const isExpanded = metadataContent.classList.contains('expanded');
916
+
917
+ if (isExpanded) {
918
+ metadataContent.classList.remove('expanded');
919
+ metadataToggle.classList.remove('expanded');
920
+ } else {
921
+ metadataContent.classList.add('expanded');
922
+ metadataToggle.classList.add('expanded');
923
+ }
924
+ });
925
+
926
+ // Navigation events
927
+ document.getElementById('modalPrev').addEventListener('click', () => {
928
+ this.navigateModalImage(-1);
929
+ });
930
+
931
+ document.getElementById('modalNext').addEventListener('click', () => {
932
+ this.navigateModalImage(1);
933
+ });
934
+
935
+ // Zoom events
936
+ const modalImage = document.getElementById('modalImage');
937
+ const modalContent = document.getElementById('modalContent');
938
+
939
+ document.getElementById('zoomIn').addEventListener('click', () => {
940
+ modal.zoomLevel = Math.min(modal.zoomLevel * 1.5, 5);
941
+ this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY);
942
+ });
943
+
944
+ document.getElementById('zoomOut').addEventListener('click', () => {
945
+ modal.zoomLevel = Math.max(modal.zoomLevel / 1.5, 0.5);
946
+ this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY);
947
+ });
948
+
949
+ document.getElementById('zoomReset').addEventListener('click', () => {
950
+ this.resetModalZoom();
951
+ });
952
+
953
+ // Double-click to zoom
954
+ modalImage.addEventListener('dblclick', () => {
955
+ if (modal.zoomLevel === 1) {
956
+ modal.zoomLevel = 2;
957
+ this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY);
958
+ } else {
959
+ this.resetModalZoom();
960
+ }
961
+ });
962
+
963
+ // Drag to pan when zoomed
964
+ modalImage.addEventListener('mousedown', (e) => {
965
+ if (modal.zoomLevel > 1) {
966
+ modal.isDragging = true;
967
+ modal.startX = e.clientX - modal.currentX;
968
+ modal.startY = e.clientY - modal.currentY;
969
+ modalImage.style.cursor = 'grabbing';
970
+ }
971
+ });
972
+
973
+ document.addEventListener('mousemove', (e) => {
974
+ if (modal.isDragging && modal.zoomLevel > 1) {
975
+ modal.currentX = e.clientX - modal.startX;
976
+ modal.currentY = e.clientY - modal.startY;
977
+ this.applyZoom(modalImage, modal.zoomLevel, modal.currentX, modal.currentY);
978
+ }
979
+ });
980
+
981
+ document.addEventListener('mouseup', () => {
982
+ if (modal.isDragging) {
983
+ modal.isDragging = false;
984
+ modalImage.style.cursor = modal.zoomLevel > 1 ? 'grab' : 'pointer';
985
+ }
986
+ });
987
+
988
+ // Keyboard navigation
989
+ document.addEventListener('keydown', (e) => {
990
+ if (modal.classList.contains('active')) {
991
+ switch(e.key) {
992
+ case 'Escape':
993
+ this.closeImageModal();
994
+ break;
995
+ case 'ArrowLeft':
996
+ this.navigateModalImage(-1);
997
+ break;
998
+ case 'ArrowRight':
999
+ this.navigateModalImage(1);
1000
+ break;
1001
+ case '+':
1002
+ case '=':
1003
+ document.getElementById('zoomIn').click();
1004
+ break;
1005
+ case '-':
1006
+ document.getElementById('zoomOut').click();
1007
+ break;
1008
+ case '0':
1009
+ document.getElementById('zoomReset').click();
1010
+ break;
1011
+ }
1012
+ }
1013
+ });
1014
+ }
1015
+
1016
+ applyZoom(image, zoom, x, y) {
1017
+ image.style.transform = `scale(${zoom}) translate(${x/zoom}px, ${y/zoom}px)`;
1018
+ image.style.cursor = zoom > 1 ? 'grab' : 'pointer';
1019
+ }
1020
+
1021
+ updateModalImage(imageUrl, width, height, direction = 0) {
1022
+ const modalImage = document.getElementById('modalImage');
1023
+ const imageDimensions = document.getElementById('imageDimensions');
1024
+
1025
+ // Enhanced bouncy slide-out animation based on direction
1026
+ modalImage.style.opacity = '0';
1027
+ const slideDirection = direction > 0 ? '80px' : direction < 0 ? '-80px' : '0px';
1028
+ const scaleDirection = direction !== 0 ? '0.85' : '0.9';
1029
+ modalImage.style.transform = `scale(${scaleDirection}) translateX(${slideDirection}) rotateY(${direction * 5}deg)`;
1030
+
1031
+ // Update dimensions immediately
1032
+ imageDimensions.textContent = `${width} × ${height}px`;
1033
+
1034
+ // Preload the image to avoid flicker
1035
+ const tempImage = new Image();
1036
+ tempImage.onload = () => {
1037
+ // Clear any existing styles that might interfere
1038
+ modalImage.style.width = '';
1039
+ modalImage.style.height = '';
1040
+
1041
+ // Set the source
1042
+ modalImage.src = imageUrl;
1043
+
1044
+ // Apply proper sizing immediately
1045
+ this.ensureImageFitted(tempImage, modalImage);
1046
+
1047
+ // Enhanced bouncy slide-in animation with multiple stages
1048
+ setTimeout(() => {
1049
+ modalImage.style.opacity = '1';
1050
+ modalImage.style.transform = 'scale(1.05) translateX(0px) rotateY(0deg)';
1051
+
1052
+ // Second bounce stage for extra bounciness
1053
+ setTimeout(() => {
1054
+ modalImage.style.transform = 'scale(1) translateX(0px) rotateY(0deg)';
1055
+ }, 200);
1056
+ }, direction !== 0 ? 100 : 50);
1057
+ };
1058
+
1059
+ tempImage.onerror = () => {
1060
+ // Fallback if image fails to load with gentle animation
1061
+ modalImage.src = imageUrl;
1062
+ modalImage.style.opacity = '1';
1063
+ modalImage.style.transform = 'scale(1) translateX(0px) rotateY(0deg)';
1064
+ };
1065
+
1066
+ // Start preloading
1067
+ tempImage.src = imageUrl;
1068
+ }
1069
+
1070
+ updateModalMetadata(cardId, imageIndex) {
1071
+ // Get images from the carousel container where they're actually stored
1072
+ const carouselContainer = document.getElementById(`carousel-${cardId}`);
1073
+ if (!carouselContainer || !carouselContainer.dataset.images) {
1074
+ console.log('No carousel container or images data found for metadata:', cardId);
1075
+ this.clearModalMetadata();
1076
+ return;
1077
+ }
1078
+
1079
+ const images = JSON.parse(carouselContainer.dataset.images || '[]');
1080
+ if (!images[imageIndex]) {
1081
+ console.log('No image found at index:', imageIndex, 'in images:', images);
1082
+ this.clearModalMetadata();
1083
+ return;
1084
+ }
1085
+
1086
+ const imageData = images[imageIndex];
1087
+ console.log('Image data for metadata:', imageData);
1088
+ console.log('Image meta property:', imageData.meta);
1089
+
1090
+ const meta = imageData.meta || {};
1091
+ console.log('Processed meta object:', meta);
1092
+
1093
+ // Update metadata values
1094
+ document.getElementById('metaSize').textContent = meta.Size || '-';
1095
+ document.getElementById('metaSeed').textContent = meta.seed || '-';
1096
+ document.getElementById('metaSteps').textContent = meta.steps || '-';
1097
+ document.getElementById('metaSampler').textContent = meta.sampler || '-';
1098
+ document.getElementById('metaCfgScale').textContent = meta.cfgScale || '-';
1099
+ document.getElementById('metaClipSkip').textContent = meta.clipSkip || '-';
1100
+
1101
+ // Update prompt (handle multiline text)
1102
+ const promptElement = document.getElementById('metaPrompt');
1103
+ promptElement.textContent = meta.prompt || '-';
1104
+
1105
+ // Update negative prompt and show/hide section
1106
+ const negativePromptElement = document.getElementById('metaNegativePrompt');
1107
+ const negativePromptSection = document.getElementById('negativePromptSection');
1108
+
1109
+ if (meta.negativePrompt) {
1110
+ negativePromptElement.textContent = meta.negativePrompt;
1111
+ negativePromptSection.style.display = 'block';
1112
+ } else {
1113
+ negativePromptSection.style.display = 'none';
1114
+ }
1115
+ }
1116
+
1117
+ clearModalMetadata() {
1118
+ // Clear all metadata values
1119
+ const metaElements = [
1120
+ 'metaSize', 'metaSeed', 'metaSteps', 'metaSampler',
1121
+ 'metaCfgScale', 'metaClipSkip', 'metaPrompt', 'metaNegativePrompt'
1122
+ ];
1123
+
1124
+ metaElements.forEach(id => {
1125
+ const element = document.getElementById(id);
1126
+ if (element) element.textContent = '-';
1127
+ });
1128
+
1129
+ // Hide negative prompt section
1130
+ document.getElementById('negativePromptSection').style.display = 'none';
1131
+ }
1132
+
1133
+ initializeModalZoomState(modal) {
1134
+ modal.zoomLevel = 1;
1135
+ modal.isDragging = false;
1136
+ modal.currentX = 0;
1137
+ modal.currentY = 0;
1138
+ modal.startX = 0;
1139
+ modal.startY = 0;
1140
+ }
1141
+
1142
+ ensureImageFitted(sourceImage, targetImage = null) {
1143
+ const modalImage = targetImage || sourceImage;
1144
+ const modalContent = modalImage.parentElement;
1145
+
1146
+ if (!modalContent) return;
1147
+
1148
+ const containerWidth = modalContent.clientWidth;
1149
+ const containerHeight = modalContent.clientHeight;
1150
+ const imageWidth = sourceImage.naturalWidth || sourceImage.width;
1151
+ const imageHeight = sourceImage.naturalHeight || sourceImage.height;
1152
+
1153
+ console.log('Container:', containerWidth, 'x', containerHeight); // Debug log
1154
+ console.log('Image natural size:', imageWidth, 'x', imageHeight); // Debug log
1155
+
1156
+ // Calculate scale to fit entire image within container
1157
+ const scaleX = containerWidth / imageWidth;
1158
+ const scaleY = containerHeight / imageHeight;
1159
+ const scale = Math.min(scaleX, scaleY, 1); // Don't scale up, only down
1160
+
1161
+ console.log('Calculated scale:', scale); // Debug log
1162
+
1163
+ // Apply the fitted size
1164
+ modalImage.style.width = `${imageWidth * scale}px`;
1165
+ modalImage.style.height = `${imageHeight * scale}px`;
1166
+ modalImage.style.transform = 'scale(1) translate(0px, 0px)';
1167
+ }
1168
+
1169
+ resetModalZoom() {
1170
+ const modal = document.getElementById('imageModal');
1171
+ const modalImage = document.getElementById('modalImage');
1172
+
1173
+ if (modal && modalImage) {
1174
+ // Reset zoom state
1175
+ modal.zoomLevel = 1;
1176
+ modal.currentX = 0;
1177
+ modal.currentY = 0;
1178
+ modal.isDragging = false;
1179
+
1180
+ // Clear any transforms and let CSS handle fitting
1181
+ modalImage.style.transform = 'scale(1) translate(0px, 0px)';
1182
+ modalImage.style.cursor = 'pointer';
1183
+
1184
+ // Ensure proper fitting
1185
+ if (modalImage.complete && modalImage.naturalWidth > 0) {
1186
+ this.ensureImageFitted(modalImage);
1187
+ }
1188
+
1189
+ console.log('Modal zoom reset to fitted size'); // Debug log
1190
+ }
1191
+ }
1192
+
1193
+ updateModalNavigation() {
1194
+ const modal = document.getElementById('imageModal');
1195
+ const cardId = modal.dataset.cardId;
1196
+ const currentIndex = parseInt(modal.dataset.currentIndex || '0');
1197
+
1198
+ const prevBtn = document.getElementById('modalPrev');
1199
+ const nextBtn = document.getElementById('modalNext');
1200
+ const counter = document.getElementById('imageCounter');
1201
+
1202
+ if (cardId) {
1203
+ const carouselContainer = document.getElementById(`carousel-${cardId}`);
1204
+ if (carouselContainer) {
1205
+ const images = JSON.parse(carouselContainer.dataset.images || '[]');
1206
+ const totalImages = images.length;
1207
+
1208
+ // Show/hide navigation buttons
1209
+ prevBtn.style.display = totalImages > 1 ? 'flex' : 'none';
1210
+ nextBtn.style.display = totalImages > 1 ? 'flex' : 'none';
1211
+
1212
+ // Update counter
1213
+ counter.textContent = totalImages > 1 ? `${currentIndex + 1} / ${totalImages}` : '';
1214
+
1215
+ return;
1216
+ }
1217
+ }
1218
+
1219
+ // Hide navigation if no carousel context
1220
+ prevBtn.style.display = 'none';
1221
+ nextBtn.style.display = 'none';
1222
+ counter.textContent = '';
1223
+ }
1224
+
1225
+ navigateModalImage(direction) {
1226
+ const modal = document.getElementById('imageModal');
1227
+ const cardId = modal.dataset.cardId;
1228
+ const currentIndex = parseInt(modal.dataset.currentIndex || '0');
1229
+
1230
+ if (!cardId) return;
1231
+
1232
+ const carouselContainer = document.getElementById(`carousel-${cardId}`);
1233
+ if (!carouselContainer) return;
1234
+
1235
+ const images = JSON.parse(carouselContainer.dataset.images || '[]');
1236
+ const newIndex = (currentIndex + direction + images.length) % images.length;
1237
+ const newImage = images[newIndex];
1238
+
1239
+ if (newImage) {
1240
+ modal.dataset.currentIndex = newIndex.toString();
1241
+ this.updateModalImage(newImage.originalUrl || newImage.url, newImage.width, newImage.height, direction);
1242
+ this.updateModalNavigation();
1243
+ this.updateModalMetadata(cardId, newIndex);
1244
+
1245
+ // Reset zoom and position when navigating to new image
1246
+ this.resetModalZoom();
1247
+ }
1248
+ }
1249
+
1250
+ closeImageModal() {
1251
+ const modal = document.getElementById('imageModal');
1252
+ if (modal) {
1253
+ modal.classList.remove('active');
1254
+ document.body.style.overflow = '';
1255
+ }
1256
+ }
1257
+
1258
+ // Theme Management
1259
+ initTheme() {
1260
+ // Check for saved theme preference or default to light mode
1261
+ const savedTheme = localStorage.getItem('theme') || 'light';
1262
+ this.setTheme(savedTheme);
1263
+ }
1264
+
1265
+ toggleTheme() {
1266
+ const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
1267
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
1268
+ this.setTheme(newTheme);
1269
+ }
1270
+
1271
+ setTheme(theme) {
1272
+ document.documentElement.setAttribute('data-theme', theme);
1273
+ localStorage.setItem('theme', theme);
1274
+
1275
+ // Update toggle button icon
1276
+ const themeIcon = document.getElementById('themeIcon');
1277
+ if (theme === 'dark') {
1278
+ themeIcon.className = 'fas fa-sun';
1279
+ themeIcon.parentElement.title = 'Switch to light mode';
1280
+ } else {
1281
+ themeIcon.className = 'fas fa-moon';
1282
+ themeIcon.parentElement.title = 'Switch to dark mode';
1283
+ }
1284
+ }
1285
+ }
1286
+
1287
+ // Initialize the application
1288
+ let modelManager;
1289
+ document.addEventListener('DOMContentLoaded', () => {
1290
+ modelManager = new ModelManager();
1291
+ });
1292
+
1293
+ // Handle keyboard shortcuts
1294
+ document.addEventListener('keydown', (e) => {
1295
+ // Escape to close modal
1296
+ if (e.key === 'Escape') {
1297
+ const modal = document.getElementById('modalOverlay');
1298
+ if (modal.classList.contains('active')) {
1299
+ modal.classList.remove('active');
1300
+ document.body.style.overflow = '';
1301
+ }
1302
+ }
1303
+
1304
+ // Ctrl/Cmd + R to refresh
1305
+ if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
1306
+ e.preventDefault();
1307
+ location.reload();
1308
+ }
1309
+ });
static/styles.css ADDED
@@ -0,0 +1,1280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Modern CSS Variables */
2
+ :root {
3
+ --primary-color: #6366f1;
4
+ --primary-hover: #5b5df7;
5
+ --secondary-color: #f1f5f9;
6
+ --success-color: #22c55e;
7
+ --warning-color: #f59e0b;
8
+ --danger-color: #ef4444;
9
+ --text-primary: #1e293b;
10
+ --text-secondary: #64748b;
11
+ --text-muted: #94a3b8;
12
+ --border-color: #e2e8f0;
13
+ --background: #f8fafc;
14
+ --surface: #ffffff;
15
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
16
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
17
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
18
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1);
19
+ --radius: 8px;
20
+ --radius-lg: 12px;
21
+ }
22
+
23
+ /* Dark Mode Variables */
24
+ [data-theme="dark"] {
25
+ --primary-color: #8b5cf6;
26
+ --primary-hover: #7c3aed;
27
+ --secondary-color: #374151;
28
+ --success-color: #10b981;
29
+ --warning-color: #f59e0b;
30
+ --danger-color: #ef4444;
31
+ --text-primary: #f8fafc;
32
+ --text-secondary: #cbd5e1;
33
+ --text-muted: #94a3b8;
34
+ --border-color: #4b5563;
35
+ --background: #111827;
36
+ --surface: #1f2937;
37
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
38
+ --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
39
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4);
40
+ --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.5);
41
+ --radius: 8px;
42
+ --radius-lg: 12px;
43
+ }
44
+
45
+ /* Reset and Base Styles */
46
+ * {
47
+ margin: 0;
48
+ padding: 0;
49
+ box-sizing: border-box;
50
+ }
51
+
52
+ body {
53
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
54
+ background: var(--background);
55
+ color: var(--text-primary);
56
+ line-height: 1.6;
57
+ overflow-x: hidden;
58
+ }
59
+
60
+ .container {
61
+ max-width: 1400px;
62
+ margin: 0 auto;
63
+ padding: 0 20px;
64
+ }
65
+
66
+ /* Header */
67
+ .header {
68
+ background: var(--surface);
69
+ border-bottom: 1px solid var(--border-color);
70
+ box-shadow: var(--shadow-sm);
71
+ position: sticky;
72
+ top: 0;
73
+ z-index: 100;
74
+ }
75
+
76
+ .header-content {
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: space-between;
80
+ padding: 1rem 0;
81
+ }
82
+
83
+ .logo {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 12px;
87
+ }
88
+
89
+ .logo i {
90
+ font-size: 2rem;
91
+ color: var(--primary-color);
92
+ }
93
+
94
+ .logo h1 {
95
+ font-size: 1.75rem;
96
+ font-weight: 700;
97
+ color: var(--text-primary);
98
+ }
99
+
100
+ .header-stats {
101
+ display: flex;
102
+ gap: 24px;
103
+ align-items: center;
104
+ }
105
+
106
+ .theme-toggle {
107
+ background: var(--secondary-color);
108
+ border: 1px solid var(--border-color);
109
+ color: var(--text-secondary);
110
+ width: 44px;
111
+ height: 44px;
112
+ border-radius: 50%;
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ cursor: pointer;
117
+ transition: all 0.2s ease;
118
+ font-size: 1.125rem;
119
+ }
120
+
121
+ .theme-toggle:hover {
122
+ background: var(--primary-color);
123
+ color: white;
124
+ border-color: var(--primary-color);
125
+ transform: scale(1.05);
126
+ }
127
+
128
+ .stat-card {
129
+ text-align: center;
130
+ padding: 8px 16px;
131
+ background: var(--secondary-color);
132
+ border-radius: var(--radius);
133
+ }
134
+
135
+ .stat-value {
136
+ display: block;
137
+ font-size: 1.5rem;
138
+ font-weight: 700;
139
+ color: var(--primary-color);
140
+ }
141
+
142
+ .stat-label {
143
+ font-size: 0.875rem;
144
+ color: var(--text-secondary);
145
+ }
146
+
147
+ /* Main Content */
148
+ .main {
149
+ padding: 2rem 0;
150
+ }
151
+
152
+ /* Tabs */
153
+ .tabs {
154
+ display: flex;
155
+ gap: 4px;
156
+ margin-bottom: 2rem;
157
+ background: var(--surface);
158
+ padding: 4px;
159
+ border-radius: var(--radius-lg);
160
+ box-shadow: var(--shadow-sm);
161
+ }
162
+
163
+ .tab-button {
164
+ flex: 1;
165
+ display: flex;
166
+ align-items: center;
167
+ justify-content: center;
168
+ gap: 8px;
169
+ padding: 12px 16px;
170
+ background: transparent;
171
+ border: none;
172
+ border-radius: var(--radius);
173
+ cursor: pointer;
174
+ transition: all 0.2s ease;
175
+ font-weight: 500;
176
+ color: var(--text-secondary);
177
+ }
178
+
179
+ .tab-button:hover {
180
+ background: var(--secondary-color);
181
+ color: var(--text-primary);
182
+ }
183
+
184
+ .tab-button.active {
185
+ background: var(--primary-color);
186
+ color: white;
187
+ box-shadow: var(--shadow-md);
188
+ }
189
+
190
+ .tab-button i {
191
+ font-size: 1.25rem;
192
+ }
193
+
194
+ .badge {
195
+ background: rgba(255, 255, 255, 0.2);
196
+ padding: 2px 8px;
197
+ border-radius: 12px;
198
+ font-size: 0.75rem;
199
+ font-weight: 600;
200
+ }
201
+
202
+ .tab-button.active .badge {
203
+ background: rgba(255, 255, 255, 0.3);
204
+ }
205
+
206
+ /* Sections */
207
+ .add-model-section,
208
+ .models-section {
209
+ background: var(--surface);
210
+ border-radius: var(--radius-lg);
211
+ padding: 1.5rem;
212
+ margin-bottom: 2rem;
213
+ box-shadow: var(--shadow-md);
214
+ }
215
+
216
+ .section-header {
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: space-between;
220
+ margin-bottom: 1.5rem;
221
+ padding-bottom: 1rem;
222
+ border-bottom: 1px solid var(--border-color);
223
+ }
224
+
225
+ .section-header h2 {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 8px;
229
+ font-size: 1.25rem;
230
+ font-weight: 600;
231
+ color: var(--text-primary);
232
+ }
233
+
234
+ .section-header i {
235
+ color: var(--primary-color);
236
+ }
237
+
238
+ .section-actions {
239
+ display: flex;
240
+ gap: 12px;
241
+ }
242
+
243
+ /* Forms */
244
+ .add-model-form {
245
+ display: flex;
246
+ flex-direction: column;
247
+ gap: 1rem;
248
+ }
249
+
250
+ .form-row {
251
+ display: grid;
252
+ grid-template-columns: 1fr 1fr;
253
+ gap: 1rem;
254
+ }
255
+
256
+ .form-group {
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 6px;
260
+ }
261
+
262
+ .form-group label {
263
+ font-weight: 500;
264
+ color: var(--text-primary);
265
+ font-size: 0.875rem;
266
+ }
267
+
268
+ .form-group input {
269
+ padding: 12px 16px;
270
+ border: 1px solid var(--border-color);
271
+ border-radius: var(--radius);
272
+ font-size: 1rem;
273
+ transition: all 0.2s ease;
274
+ background: var(--surface);
275
+ }
276
+
277
+ .form-group input:focus {
278
+ outline: none;
279
+ border-color: var(--primary-color);
280
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
281
+ }
282
+
283
+ .form-checkboxes {
284
+ flex-direction: row;
285
+ gap: 1.5rem;
286
+ align-items: center;
287
+ }
288
+
289
+ .checkbox-group {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: 8px;
293
+ cursor: pointer;
294
+ font-weight: 500;
295
+ }
296
+
297
+ .checkbox-group input[type="checkbox"] {
298
+ display: none;
299
+ }
300
+
301
+ .checkmark {
302
+ width: 20px;
303
+ height: 20px;
304
+ border: 2px solid var(--border-color);
305
+ border-radius: 4px;
306
+ display: flex;
307
+ align-items: center;
308
+ justify-content: center;
309
+ transition: all 0.2s ease;
310
+ }
311
+
312
+ .checkbox-group input[type="checkbox"]:checked + .checkmark {
313
+ background: var(--primary-color);
314
+ border-color: var(--primary-color);
315
+ }
316
+
317
+ .checkbox-group input[type="checkbox"]:checked + .checkmark::after {
318
+ content: '✓';
319
+ color: white;
320
+ font-size: 14px;
321
+ font-weight: bold;
322
+ }
323
+
324
+ /* Buttons */
325
+ .btn {
326
+ display: inline-flex;
327
+ align-items: center;
328
+ gap: 8px;
329
+ padding: 12px 20px;
330
+ border: none;
331
+ border-radius: var(--radius);
332
+ font-size: 0.875rem;
333
+ font-weight: 500;
334
+ cursor: pointer;
335
+ transition: all 0.2s ease;
336
+ text-decoration: none;
337
+ justify-content: center;
338
+ }
339
+
340
+ .btn-primary {
341
+ background: var(--primary-color);
342
+ color: white;
343
+ }
344
+
345
+ .btn-primary:hover {
346
+ background: var(--primary-hover);
347
+ transform: translateY(-1px);
348
+ box-shadow: var(--shadow-lg);
349
+ }
350
+
351
+ .btn-outline {
352
+ background: transparent;
353
+ color: var(--text-secondary);
354
+ border: 1px solid var(--border-color);
355
+ }
356
+
357
+ .btn-outline:hover {
358
+ background: var(--secondary-color);
359
+ color: var(--text-primary);
360
+ }
361
+
362
+ .btn-danger {
363
+ background: var(--danger-color);
364
+ color: white;
365
+ }
366
+
367
+ .btn-danger:hover {
368
+ background: #dc2626;
369
+ }
370
+
371
+ .btn-sm {
372
+ padding: 6px 12px;
373
+ font-size: 0.75rem;
374
+ }
375
+
376
+ /* Models Grid */
377
+ .models-grid {
378
+ display: grid;
379
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
380
+ gap: 1rem;
381
+ }
382
+
383
+ .model-card {
384
+ background: var(--surface);
385
+ border: 1px solid var(--border-color);
386
+ border-radius: var(--radius-lg);
387
+ overflow: hidden;
388
+ transition: all 0.2s ease;
389
+ position: relative;
390
+ display: flex;
391
+ flex-direction: column;
392
+ }
393
+
394
+ .model-card:hover {
395
+ transform: translateY(-2px);
396
+ box-shadow: var(--shadow-lg);
397
+ border-color: var(--primary-color);
398
+ }
399
+
400
+ /* Carousel Styles */
401
+ .model-thumbnail-carousel {
402
+ width: 100%;
403
+ height: 0;
404
+ padding-bottom: 177.78%; /* 16:9 aspect ratio (9/16 * 100) */
405
+ background: var(--secondary-color);
406
+ position: relative;
407
+ overflow: hidden;
408
+ }
409
+
410
+ .carousel-container {
411
+ position: absolute;
412
+ top: 0;
413
+ left: 0;
414
+ width: 100%;
415
+ height: 100%;
416
+ display: flex;
417
+ align-items: center;
418
+ }
419
+
420
+ .carousel-images {
421
+ width: 100%;
422
+ height: 100%;
423
+ position: relative;
424
+ display: flex;
425
+ align-items: center;
426
+ justify-content: center;
427
+ }
428
+
429
+ .carousel-image {
430
+ width: 100%;
431
+ height: 100%;
432
+ object-fit: cover;
433
+ position: absolute;
434
+ top: 0;
435
+ left: 0;
436
+ opacity: 0;
437
+ transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
438
+ cursor: pointer;
439
+ pointer-events: none; /* Disable clicks on inactive images */
440
+ }
441
+
442
+ .carousel-image.active {
443
+ opacity: 1;
444
+ pointer-events: auto; /* Enable clicks only on active image */
445
+ }
446
+
447
+ .carousel-image.active:hover {
448
+ transform: scale(1.05);
449
+ }
450
+
451
+ .carousel-image.active:active {
452
+ transform: scale(0.95);
453
+ }
454
+
455
+ .carousel-image.loaded {
456
+ /* Already handled by active class */
457
+ }
458
+
459
+ .carousel-btn {
460
+ position: absolute;
461
+ top: 50%;
462
+ transform: translateY(-50%);
463
+ background: rgba(0, 0, 0, 0.7);
464
+ color: white;
465
+ border: none;
466
+ width: 32px;
467
+ height: 32px;
468
+ border-radius: 50%;
469
+ display: flex;
470
+ align-items: center;
471
+ justify-content: center;
472
+ cursor: pointer;
473
+ transition: all 0.2s ease;
474
+ z-index: 10;
475
+ font-size: 0.875rem;
476
+ }
477
+
478
+ .carousel-btn:hover {
479
+ background: rgba(0, 0, 0, 0.9);
480
+ transform: translateY(-50%) scale(1.1);
481
+ }
482
+
483
+ .carousel-prev {
484
+ left: 8px;
485
+ }
486
+
487
+ .carousel-next {
488
+ right: 8px;
489
+ }
490
+
491
+ .carousel-indicators {
492
+ position: absolute;
493
+ bottom: 8px;
494
+ left: 50%;
495
+ transform: translateX(-50%);
496
+ display: flex;
497
+ gap: 6px;
498
+ z-index: 10;
499
+ }
500
+
501
+ .carousel-indicator {
502
+ width: 8px;
503
+ height: 8px;
504
+ border-radius: 50%;
505
+ border: none;
506
+ background: rgba(255, 255, 255, 0.5);
507
+ cursor: pointer;
508
+ transition: all 0.2s ease;
509
+ }
510
+
511
+ .carousel-indicator.active {
512
+ background: white;
513
+ transform: scale(1.2);
514
+ }
515
+
516
+ .thumbnail-placeholder,
517
+ .thumbnail-fallback,
518
+ .thumbnail-error {
519
+ display: flex;
520
+ flex-direction: column;
521
+ align-items: center;
522
+ justify-content: center;
523
+ gap: 8px;
524
+ color: var(--text-muted);
525
+ font-size: 0.875rem;
526
+ width: 100%;
527
+ height: 100%;
528
+ text-align: center;
529
+ }
530
+
531
+ .thumbnail-placeholder i,
532
+ .thumbnail-fallback i,
533
+ .thumbnail-error i {
534
+ font-size: 2rem;
535
+ color: var(--text-muted);
536
+ }
537
+
538
+ .thumbnail-error {
539
+ color: var(--danger-color);
540
+ }
541
+
542
+ .thumbnail-error i {
543
+ color: var(--danger-color);
544
+ }
545
+
546
+ /* Image Modal Styles */
547
+ .image-modal-overlay {
548
+ position: fixed;
549
+ top: 0;
550
+ left: 0;
551
+ right: 0;
552
+ bottom: 0;
553
+ background: rgba(0, 0, 0, 0);
554
+ display: none;
555
+ align-items: center;
556
+ justify-content: center;
557
+ z-index: 3000;
558
+ backdrop-filter: blur(0px);
559
+ transition: background-color 0.3s ease, backdrop-filter 0.3s ease;
560
+ }
561
+
562
+ .image-modal-overlay.active {
563
+ display: flex;
564
+ background: rgba(0, 0, 0, 0.9);
565
+ backdrop-filter: blur(4px);
566
+ }
567
+
568
+ .image-modal {
569
+ position: relative;
570
+ max-width: 95vw;
571
+ max-height: 95vh;
572
+ background: var(--surface);
573
+ border-radius: var(--radius-lg);
574
+ overflow: hidden;
575
+ box-shadow: var(--shadow-xl);
576
+ display: flex;
577
+ flex-direction: column;
578
+ transform: scale(0.8);
579
+ opacity: 0;
580
+ transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
581
+ }
582
+
583
+ .image-modal-overlay.active .image-modal {
584
+ transform: scale(1);
585
+ opacity: 1;
586
+ }
587
+
588
+ .image-modal-close {
589
+ position: absolute;
590
+ top: 16px;
591
+ right: 16px;
592
+ background: rgba(0, 0, 0, 0.7);
593
+ color: white;
594
+ border: none;
595
+ width: 40px;
596
+ height: 40px;
597
+ border-radius: 50%;
598
+ display: flex;
599
+ align-items: center;
600
+ justify-content: center;
601
+ cursor: pointer;
602
+ transition: all 0.2s ease;
603
+ z-index: 10;
604
+ font-size: 1.25rem;
605
+ }
606
+
607
+ .image-modal-close:hover {
608
+ background: rgba(0, 0, 0, 0.9);
609
+ transform: scale(1.1);
610
+ }
611
+
612
+ .modal-nav {
613
+ position: absolute;
614
+ top: 50%;
615
+ transform: translateY(-50%);
616
+ background: rgba(0, 0, 0, 0.7);
617
+ color: white;
618
+ border: none;
619
+ width: 50px;
620
+ height: 50px;
621
+ border-radius: 50%;
622
+ display: flex;
623
+ align-items: center;
624
+ justify-content: center;
625
+ cursor: pointer;
626
+ transition: all 0.2s ease;
627
+ z-index: 10;
628
+ font-size: 1.25rem;
629
+ }
630
+
631
+ .modal-nav:hover {
632
+ background: rgba(0, 0, 0, 0.9);
633
+ transform: translateY(-50%) scale(1.1);
634
+ }
635
+
636
+ .modal-prev {
637
+ left: 20px;
638
+ }
639
+
640
+ .modal-next {
641
+ right: 20px;
642
+ }
643
+
644
+ .image-modal-content {
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: center;
648
+ flex: 1;
649
+ padding: 20px;
650
+ overflow: hidden;
651
+ position: relative;
652
+ }
653
+
654
+ .image-modal-content img {
655
+ max-width: 100%;
656
+ max-height: 100%;
657
+ width: auto;
658
+ height: auto;
659
+ object-fit: contain;
660
+ border-radius: var(--radius);
661
+ transition: all 0.6s cubic-bezier(0.68, -0.55, 0.265, 1.55);
662
+ cursor: pointer;
663
+ user-select: none;
664
+ display: block;
665
+ margin: 0 auto;
666
+ }
667
+
668
+ .image-modal-controls {
669
+ position: absolute;
670
+ top: 20px;
671
+ right: 70px;
672
+ display: flex;
673
+ gap: 8px;
674
+ z-index: 10;
675
+ }
676
+
677
+ .zoom-btn {
678
+ background: rgba(0, 0, 0, 0.7);
679
+ color: white;
680
+ border: none;
681
+ width: 40px;
682
+ height: 40px;
683
+ border-radius: 50%;
684
+ display: flex;
685
+ align-items: center;
686
+ justify-content: center;
687
+ cursor: pointer;
688
+ transition: all 0.2s ease;
689
+ font-size: 1rem;
690
+ }
691
+
692
+ .zoom-btn:hover {
693
+ background: rgba(0, 0, 0, 0.9);
694
+ transform: scale(1.1);
695
+ }
696
+
697
+ .image-modal-info {
698
+ background: var(--secondary-color);
699
+ padding: 12px 20px;
700
+ display: flex;
701
+ justify-content: space-between;
702
+ align-items: center;
703
+ font-size: 0.875rem;
704
+ color: var(--text-secondary);
705
+ border-top: 1px solid var(--border-color);
706
+ position: relative;
707
+ z-index: 1;
708
+ }
709
+
710
+ .image-modal-info span {
711
+ font-weight: 500;
712
+ }
713
+
714
+ /* Image Modal Metadata Styles */
715
+ .image-modal-metadata {
716
+ position: absolute;
717
+ bottom: 0;
718
+ left: 0;
719
+ right: 0;
720
+ background: rgba(0, 0, 0, 0.8);
721
+ backdrop-filter: blur(8px);
722
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
723
+ overflow: hidden;
724
+ z-index: 10;
725
+ }
726
+
727
+ .metadata-toggle {
728
+ width: 100%;
729
+ padding: 16px 20px;
730
+ background: none;
731
+ border: none;
732
+ display: flex;
733
+ align-items: center;
734
+ gap: 10px;
735
+ color: rgba(255, 255, 255, 0.9);
736
+ cursor: pointer;
737
+ font-size: 14px;
738
+ font-weight: 500;
739
+ transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
740
+ }
741
+
742
+ .metadata-toggle:hover {
743
+ background: rgba(255, 255, 255, 0.1);
744
+ }
745
+
746
+ .metadata-toggle .toggle-icon {
747
+ margin-left: auto;
748
+ transition: transform 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
749
+ }
750
+
751
+ .metadata-toggle.expanded .toggle-icon {
752
+ transform: rotate(180deg);
753
+ }
754
+
755
+ .metadata-content {
756
+ max-height: 0;
757
+ overflow: hidden;
758
+ transition: max-height 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
759
+ background: rgba(0, 0, 0, 0.3);
760
+ }
761
+
762
+ .metadata-content.expanded {
763
+ max-height: 400px;
764
+ }
765
+
766
+ .metadata-grid {
767
+ display: grid;
768
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
769
+ gap: 16px 12px;
770
+ padding: 20px;
771
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
772
+ }
773
+
774
+ .metadata-item {
775
+ display: flex;
776
+ flex-direction: column;
777
+ gap: 4px;
778
+ }
779
+
780
+ .metadata-label {
781
+ font-size: 12px;
782
+ font-weight: 600;
783
+ color: rgba(255, 255, 255, 0.7);
784
+ text-transform: uppercase;
785
+ letter-spacing: 0.5px;
786
+ }
787
+
788
+ .metadata-value {
789
+ font-size: 14px;
790
+ color: rgba(255, 255, 255, 0.95);
791
+ word-break: break-word;
792
+ }
793
+
794
+ .metadata-prompt {
795
+ padding: 20px;
796
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
797
+ }
798
+
799
+ .metadata-prompt:last-child {
800
+ border-bottom: none;
801
+ }
802
+
803
+ .metadata-prompt .metadata-label {
804
+ display: block;
805
+ margin-bottom: 8px;
806
+ }
807
+
808
+ .metadata-prompt .metadata-value {
809
+ line-height: 1.5;
810
+ white-space: pre-wrap;
811
+ max-height: 120px;
812
+ overflow-y: auto;
813
+ padding: 8px;
814
+ background: rgba(0, 0, 0, 0.4);
815
+ border: 1px solid rgba(255, 255, 255, 0.2);
816
+ border-radius: var(--radius);
817
+ font-size: 13px;
818
+ }
819
+
820
+ /* Image Filters */
821
+ .image-filters {
822
+ display: flex;
823
+ align-items: center;
824
+ gap: 20px;
825
+ padding: 16px 0;
826
+ margin-bottom: 20px;
827
+ border-bottom: 1px solid var(--border);
828
+ flex-wrap: wrap;
829
+ }
830
+
831
+ .filter-group {
832
+ display: flex;
833
+ align-items: center;
834
+ gap: 8px;
835
+ }
836
+
837
+ .filter-group label {
838
+ font-size: 14px;
839
+ font-weight: 500;
840
+ color: var(--text-secondary);
841
+ white-space: nowrap;
842
+ }
843
+
844
+ .filter-select {
845
+ padding: 6px 12px;
846
+ border: 1px solid var(--border);
847
+ border-radius: var(--radius);
848
+ background: var(--surface);
849
+ color: var(--text-primary);
850
+ font-size: 14px;
851
+ min-width: 140px;
852
+ cursor: pointer;
853
+ }
854
+
855
+ .filter-select:focus {
856
+ outline: none;
857
+ border-color: var(--primary-color);
858
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
859
+ }
860
+
861
+ .image-filters #refreshBtn {
862
+ margin-left: auto;
863
+ display: flex;
864
+ align-items: center;
865
+ gap: 6px;
866
+ }
867
+
868
+
869
+ /* App Footer */
870
+ .app-footer {
871
+ background: var(--surface);
872
+ border-top: 1px solid var(--border);
873
+ padding: 20px;
874
+ text-align: center;
875
+ margin-top: 40px;
876
+ }
877
+
878
+ .app-footer p {
879
+ margin: 0;
880
+ color: var(--text-secondary);
881
+ font-size: 0.875rem;
882
+ font-weight: 500;
883
+ }
884
+
885
+ /* Model Content */
886
+ .model-content {
887
+ padding: 1rem;
888
+ flex: 1;
889
+ display: flex;
890
+ flex-direction: column;
891
+ }
892
+
893
+ .model-header {
894
+ display: flex;
895
+ align-items: flex-start;
896
+ justify-content: space-between;
897
+ margin-bottom: 1rem;
898
+ }
899
+
900
+ .model-title {
901
+ font-size: 1.125rem;
902
+ font-weight: 600;
903
+ color: var(--text-primary);
904
+ margin-bottom: 4px;
905
+ }
906
+
907
+ .model-title-link {
908
+ text-decoration: none;
909
+ color: inherit;
910
+ display: flex;
911
+ align-items: center;
912
+ gap: 8px;
913
+ transition: all 0.2s ease;
914
+ border-radius: var(--radius);
915
+ padding: 4px 0;
916
+ }
917
+
918
+ .model-title-link:hover {
919
+ color: var(--primary-color);
920
+ }
921
+
922
+ .model-title-link:hover .model-title {
923
+ color: var(--primary-color);
924
+ }
925
+
926
+ .model-external-link {
927
+ font-size: 0.875rem;
928
+ opacity: 0.6;
929
+ transition: opacity 0.2s ease;
930
+ }
931
+
932
+ .model-title-link:hover .model-external-link {
933
+ opacity: 1;
934
+ }
935
+
936
+ .model-status {
937
+ display: flex;
938
+ align-items: center;
939
+ gap: 4px;
940
+ font-size: 0.75rem;
941
+ font-weight: 600;
942
+ padding: 4px 8px;
943
+ border-radius: 12px;
944
+ }
945
+
946
+ .model-status.active {
947
+ background: rgba(34, 197, 94, 0.1);
948
+ color: var(--success-color);
949
+ }
950
+
951
+ .model-status.inactive {
952
+ background: rgba(148, 163, 184, 0.1);
953
+ color: var(--text-muted);
954
+ }
955
+
956
+ .model-urn {
957
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
958
+ font-size: 0.875rem;
959
+ color: var(--text-secondary);
960
+ background: var(--secondary-color);
961
+ padding: 8px 12px;
962
+ border-radius: var(--radius);
963
+ margin-bottom: 1rem;
964
+ word-break: break-all;
965
+ }
966
+
967
+ .model-tags {
968
+ display: flex;
969
+ flex-wrap: wrap;
970
+ gap: 6px;
971
+ margin-bottom: 1rem;
972
+ }
973
+
974
+ .tag {
975
+ font-size: 0.75rem;
976
+ padding: 4px 8px;
977
+ background: var(--primary-color);
978
+ color: white;
979
+ border-radius: 12px;
980
+ font-weight: 500;
981
+ }
982
+
983
+ .tag.nsfw {
984
+ background: var(--danger-color);
985
+ }
986
+
987
+ .model-actions {
988
+ display: flex;
989
+ gap: 8px;
990
+ justify-content: flex-end;
991
+ margin-top: auto;
992
+ }
993
+
994
+ /* Empty State */
995
+ .empty-state {
996
+ text-align: center;
997
+ padding: 3rem 2rem;
998
+ color: var(--text-muted);
999
+ }
1000
+
1001
+ .empty-state i {
1002
+ font-size: 3rem;
1003
+ margin-bottom: 1rem;
1004
+ color: var(--text-muted);
1005
+ }
1006
+
1007
+ .empty-state h3 {
1008
+ font-size: 1.25rem;
1009
+ margin-bottom: 0.5rem;
1010
+ color: var(--text-secondary);
1011
+ }
1012
+
1013
+ /* Modal */
1014
+ .modal-overlay {
1015
+ position: fixed;
1016
+ top: 0;
1017
+ left: 0;
1018
+ right: 0;
1019
+ bottom: 0;
1020
+ background: rgba(0, 0, 0, 0.5);
1021
+ display: none;
1022
+ align-items: center;
1023
+ justify-content: center;
1024
+ z-index: 1000;
1025
+ backdrop-filter: blur(4px);
1026
+ }
1027
+
1028
+ .modal-overlay.active {
1029
+ display: flex;
1030
+ }
1031
+
1032
+ .modal {
1033
+ background: var(--surface);
1034
+ border-radius: var(--radius-lg);
1035
+ box-shadow: var(--shadow-xl);
1036
+ width: 100%;
1037
+ max-width: 500px;
1038
+ margin: 20px;
1039
+ max-height: 90vh;
1040
+ overflow: auto;
1041
+ }
1042
+
1043
+ .modal-header {
1044
+ display: flex;
1045
+ align-items: center;
1046
+ justify-content: space-between;
1047
+ padding: 1.5rem;
1048
+ border-bottom: 1px solid var(--border-color);
1049
+ }
1050
+
1051
+ .modal-header h3 {
1052
+ display: flex;
1053
+ align-items: center;
1054
+ gap: 8px;
1055
+ font-size: 1.25rem;
1056
+ font-weight: 600;
1057
+ color: var(--text-primary);
1058
+ }
1059
+
1060
+ .modal-close {
1061
+ background: none;
1062
+ border: none;
1063
+ font-size: 1.25rem;
1064
+ color: var(--text-muted);
1065
+ cursor: pointer;
1066
+ padding: 4px;
1067
+ border-radius: var(--radius);
1068
+ transition: all 0.2s ease;
1069
+ }
1070
+
1071
+ .modal-close:hover {
1072
+ background: var(--secondary-color);
1073
+ color: var(--text-primary);
1074
+ }
1075
+
1076
+ .modal-body {
1077
+ padding: 1.5rem;
1078
+ }
1079
+
1080
+ .modal-footer {
1081
+ display: flex;
1082
+ gap: 12px;
1083
+ justify-content: flex-end;
1084
+ padding: 1.5rem;
1085
+ border-top: 1px solid var(--border-color);
1086
+ }
1087
+
1088
+ /* Loading */
1089
+ .loading-overlay {
1090
+ position: fixed;
1091
+ top: 0;
1092
+ left: 0;
1093
+ right: 0;
1094
+ bottom: 0;
1095
+ background: rgba(255, 255, 255, 0.9);
1096
+ display: none;
1097
+ align-items: center;
1098
+ justify-content: center;
1099
+ z-index: 2000;
1100
+ backdrop-filter: blur(2px);
1101
+ }
1102
+
1103
+ .loading-overlay.active {
1104
+ display: flex;
1105
+ }
1106
+
1107
+ .loading-spinner {
1108
+ display: flex;
1109
+ flex-direction: column;
1110
+ align-items: center;
1111
+ gap: 1rem;
1112
+ color: var(--primary-color);
1113
+ }
1114
+
1115
+ .loading-spinner i {
1116
+ font-size: 2rem;
1117
+ }
1118
+
1119
+ .loading-spinner span {
1120
+ font-weight: 500;
1121
+ }
1122
+
1123
+ /* Toast Notifications */
1124
+ .toast-container {
1125
+ position: fixed;
1126
+ top: 20px;
1127
+ right: 20px;
1128
+ z-index: 3000;
1129
+ display: flex;
1130
+ flex-direction: column;
1131
+ gap: 12px;
1132
+ }
1133
+
1134
+ .toast {
1135
+ background: var(--surface);
1136
+ border: 1px solid var(--border-color);
1137
+ border-radius: var(--radius);
1138
+ padding: 12px 16px;
1139
+ box-shadow: var(--shadow-lg);
1140
+ display: flex;
1141
+ align-items: center;
1142
+ gap: 12px;
1143
+ min-width: 300px;
1144
+ max-width: 400px;
1145
+ transform: translateX(100%);
1146
+ transition: transform 0.3s ease;
1147
+ }
1148
+
1149
+ .toast.show {
1150
+ transform: translateX(0);
1151
+ }
1152
+
1153
+ .toast.success {
1154
+ border-left: 4px solid var(--success-color);
1155
+ }
1156
+
1157
+ .toast.error {
1158
+ border-left: 4px solid var(--danger-color);
1159
+ }
1160
+
1161
+ .toast.warning {
1162
+ border-left: 4px solid var(--warning-color);
1163
+ }
1164
+
1165
+ .toast-content {
1166
+ flex: 1;
1167
+ }
1168
+
1169
+ .toast-title {
1170
+ font-weight: 600;
1171
+ font-size: 0.875rem;
1172
+ margin-bottom: 2px;
1173
+ }
1174
+
1175
+ .toast-message {
1176
+ font-size: 0.75rem;
1177
+ color: var(--text-secondary);
1178
+ }
1179
+
1180
+ .toast-close {
1181
+ background: none;
1182
+ border: none;
1183
+ color: var(--text-muted);
1184
+ cursor: pointer;
1185
+ padding: 4px;
1186
+ border-radius: var(--radius);
1187
+ transition: all 0.2s ease;
1188
+ }
1189
+
1190
+ .toast-close:hover {
1191
+ background: var(--secondary-color);
1192
+ color: var(--text-primary);
1193
+ }
1194
+
1195
+ /* Responsive Design */
1196
+ @media (max-width: 768px) {
1197
+ .container {
1198
+ padding: 0 16px;
1199
+ }
1200
+
1201
+ .header-content {
1202
+ flex-direction: column;
1203
+ gap: 1rem;
1204
+ text-align: center;
1205
+ }
1206
+
1207
+ .logo h1 {
1208
+ font-size: 1.5rem;
1209
+ }
1210
+
1211
+ .tabs {
1212
+ flex-direction: column;
1213
+ }
1214
+
1215
+ .form-row {
1216
+ grid-template-columns: 1fr;
1217
+ }
1218
+
1219
+ .models-grid {
1220
+ grid-template-columns: 1fr;
1221
+ }
1222
+
1223
+ .form-checkboxes {
1224
+ flex-direction: column;
1225
+ align-items: flex-start;
1226
+ gap: 1rem;
1227
+ }
1228
+
1229
+ .modal {
1230
+ margin: 10px;
1231
+ }
1232
+
1233
+ .toast-container {
1234
+ left: 20px;
1235
+ right: 20px;
1236
+ }
1237
+
1238
+ .toast {
1239
+ min-width: auto;
1240
+ max-width: none;
1241
+ }
1242
+ }
1243
+
1244
+ /* Animations */
1245
+ @keyframes fadeIn {
1246
+ from {
1247
+ opacity: 0;
1248
+ transform: translateY(20px);
1249
+ }
1250
+ to {
1251
+ opacity: 1;
1252
+ transform: translateY(0);
1253
+ }
1254
+ }
1255
+
1256
+ .model-card {
1257
+ animation: fadeIn 0.3s ease-out;
1258
+ }
1259
+
1260
+ .toast {
1261
+ animation: fadeIn 0.3s ease-out;
1262
+ }
1263
+
1264
+ /* Scrollbar Styling */
1265
+ ::-webkit-scrollbar {
1266
+ width: 8px;
1267
+ }
1268
+
1269
+ ::-webkit-scrollbar-track {
1270
+ background: var(--secondary-color);
1271
+ }
1272
+
1273
+ ::-webkit-scrollbar-thumb {
1274
+ background: var(--border-color);
1275
+ border-radius: 4px;
1276
+ }
1277
+
1278
+ ::-webkit-scrollbar-thumb:hover {
1279
+ background: var(--text-muted);
1280
+ }