YoBatM commited on
Commit
c1d9028
·
verified ·
1 Parent(s): 75d172e

Upload 4 files

Browse files
Files changed (4) hide show
  1. app.py +304 -98
  2. cloth_loader.py +179 -33
  3. templates/armario.html +872 -197
  4. templates/efectos.html +708 -0
app.py CHANGED
@@ -1,10 +1,26 @@
1
- from flask import Flask, render_template, jsonify, request, redirect,abort,Response
2
- import cloth_loader
3
  import hashlib
4
- import zlib,struct
5
- import requests,json
 
6
  from io import BytesIO
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from PIL import Image
 
 
 
8
  app = Flask(__name__)
9
 
10
  # Configuración
@@ -16,11 +32,44 @@ RESERVED_PARAMS = {"config_url", "page", "per_page", "limit", "offset"}
16
  CACHE = {}
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  def get_or_load_data(config_url: str, extra_vars: dict):
20
  """Carga o devuelve datos cacheados (incluyendo config)."""
21
  url = config_url.strip() or DEFAULT_CONFIG_URL
22
  cache_key_input = f"{url}|{sorted(extra_vars.items())}"
23
- cache_key = hashlib.md5(cache_key_input.encode('utf-8')).hexdigest()
24
 
25
  if cache_key in CACHE:
26
  return CACHE[cache_key]
@@ -28,8 +77,16 @@ def get_or_load_data(config_url: str, extra_vars: dict):
28
  print(f"🔄 Cargando datos desde: {url} con vars {extra_vars}")
29
  try:
30
  # ¡cloth_loader.load_game_data ahora devuelve config!
31
- figuremap, figuredata_flat, palettes, config = cloth_loader.load_game_data(url, extra_vars)
32
- types, category_index = cloth_loader.get_all_part_types_from_data(figuremap, figuredata_flat)
 
 
 
 
 
 
 
 
33
 
34
  data = {
35
  "types": types,
@@ -45,74 +102,33 @@ def get_or_load_data(config_url: str, extra_vars: dict):
45
  raise
46
 
47
 
48
- @app.route('/')
49
  def index():
50
- config_url = request.args.get('config_url', DEFAULT_CONFIG_URL)
51
  extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
52
  try:
53
- data = get_or_load_data(config_url, extra_vars)
54
- return render_template('armario.html', part_types=data["types"], max_per_page=MAX_PER_PAGE)
 
 
55
  except Exception as e:
56
- return f"<h2>Error al cargar la configuración</h2><pre>{str(e)}</pre>", 500
57
-
58
-
59
- @app.route('/api/furnis/<part_type>')
60
- def api_furnis(part_type):
61
- config_url = request.args.get('config_url', DEFAULT_CONFIG_URL)
62
- extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
63
- try:
64
- data = get_or_load_data(config_url, extra_vars)
65
- if part_type not in data["types"]:
66
- return jsonify({"error": f"Tipo '{part_type}' no válido."}), 400
67
-
68
- page = request.args.get('page', default=1, type=int)
69
- per_page = request.args.get('per_page', default=10, type=int)
70
- per_page = max(1, min(per_page, MAX_PER_PAGE))
71
- page = max(1, page)
72
-
73
- all_items = data["_category_index"].get(part_type, [])
74
- total = len(all_items)
75
- total_pages = (total + per_page - 1) // per_page
76
- offset = (page - 1) * per_page
77
- items_slice = all_items[offset : offset + per_page]
78
-
79
- # Usar config para generar image_url
80
- avatar_asset_url = data["config"]["avatar.asset.url"]
81
- print(f[0])
82
- items = [
83
- {
84
- "lib_id": f.lib_id,
85
- "id": f.id,
86
- "type": part_type,
87
- "sprite":1,
88
- "image_url": f"/api/images/{f.lib_id}.nitro?{request.query_string.decode()}"
89
- }
90
- for f in items_slice
91
- ]
92
 
93
- return jsonify({
94
- "items": items,
95
- "pagination": {
96
- "page": page,
97
- "per_page": per_page,
98
- "total": total,
99
- "total_pages": total_pages
100
- }
101
- })
102
 
103
- except Exception as e:
104
- return jsonify({"error": str(e)}), 500
105
 
106
- @app.route('/api/images/<image>.nitro')
107
  def serve_nitro_image(image):
108
  try:
109
  # --- 1. Obtener parámetros ---
110
- config_url = request.args.get('config_url', DEFAULT_CONFIG_URL)
111
- sprite_index_param = request.args.get('sprite')
112
- get_total = request.args.get('get_total') == '1'
113
  extra_vars = {
114
- k: v for k, v in request.args.items()
115
- if k not in RESERVED_PARAMS and k not in ('sprite', 'get_total')
 
116
  }
117
 
118
  # --- 2. Cargar configuración ---
@@ -137,16 +153,16 @@ def serve_nitro_image(image):
137
  # --- 4. Buscar JSON y PNG ---
138
  fly = {}
139
  for name, content in files:
140
- if content.startswith(b'\x89PNG\r\n\x1a\n'):
141
  fly["png"] = {"content": content, "name": name}
142
- elif name.lower().endswith('.json'):
143
  fly["json"] = {"content": content, "name": name}
144
 
145
  if "json" not in fly or "png" not in fly:
146
  abort(404, "❌ No se encontró JSON o PNG en el bundle")
147
 
148
  # --- 5. Parsear JSON ---
149
- json_str = fly["json"]["content"].decode('utf-8')
150
  json_data = json.loads(json_str)
151
 
152
  # --- 6. Extraer frames ---
@@ -158,7 +174,9 @@ def serve_nitro_image(image):
158
  frames = json_data["spritesheet"]["frames"]
159
 
160
  if not frames:
161
- abort(400, f"❌ JSON no contiene 'frames'. Claves: {list(json_data.keys())}")
 
 
162
 
163
  frame_names = list(frames.keys())
164
  total_sprites = len(frame_names)
@@ -176,7 +194,10 @@ def serve_nitro_image(image):
176
  abort(400, "❌ El parámetro 'sprite' debe ser un número entero")
177
 
178
  if sprite_index < 0 or sprite_index >= total_sprites:
179
- abort(400, f"❌ Índice de sprite fuera de rango. Debe estar entre 0 y {total_sprites - 1}")
 
 
 
180
 
181
  selected_name = frame_names[sprite_index]
182
  frame_info = frames[selected_name]
@@ -185,7 +206,10 @@ def serve_nitro_image(image):
185
  coords = frame_info.get("frame", frame_info)
186
  for key in ["x", "y", "w", "h"]:
187
  if key not in coords:
188
- abort(400, f"❌ Coordenada '{key}' faltante en el sprite '{selected_name}'")
 
 
 
189
 
190
  x = int(coords["x"])
191
  y = int(coords["y"])
@@ -199,7 +223,7 @@ def serve_nitro_image(image):
199
  output = BytesIO()
200
  sprite.save(output, format="PNG")
201
  output.seek(0)
202
- return Response(output.getvalue(), mimetype='image/png')
203
 
204
  except requests.RequestException as e:
205
  abort(502, f"❌ Error al descargar: {str(e)}")
@@ -212,31 +236,32 @@ def serve_nitro_image(image):
212
  except Exception as e:
213
  abort(500, f"❌ Error inesperado: {str(e)}")
214
 
 
215
  def parse_nitro(data: bytes):
216
  """Parsea un archivo .nitro y devuelve [(nombre, datos_descomprimidos), ...]"""
217
  files = []
218
  offset = 0
219
  if len(data) < 2:
220
  return files
221
- file_count = struct.unpack('>H', data[offset:offset+2])[0]
222
  offset += 2
223
 
224
  for _ in range(file_count):
225
  if offset + 2 > len(data):
226
  break
227
- name_len = struct.unpack('>H', data[offset:offset+2])[0]
228
  offset += 2
229
  if offset + name_len > len(data):
230
  break
231
- name = data[offset:offset+name_len].decode('utf-8', errors='replace')
232
  offset += name_len
233
  if offset + 4 > len(data):
234
  break
235
- compressed_size = struct.unpack('>I', data[offset:offset+4])[0]
236
  offset += 4
237
  if offset + compressed_size > len(data):
238
  break
239
- compressed_data = data[offset:offset+compressed_size]
240
  offset += compressed_size
241
 
242
  try:
@@ -252,24 +277,187 @@ def parse_nitro(data: bytes):
252
  return files
253
 
254
 
255
- @app.route('/api/findfurni')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  def api_findfurni():
257
- config_url = request.args.get('config_url', DEFAULT_CONFIG_URL)
258
  extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
259
- query = request.args.get('q', '').strip().lower()
260
- part_type = request.args.get('type', '').strip()
261
- page = request.args.get('page', default=1, type=int)
262
- per_page = request.args.get('per_page', default=10, type=int)
263
  per_page = max(1, min(per_page, MAX_PER_PAGE))
264
  page = max(1, page)
265
 
266
  try:
267
- data = get_or_load_data(config_url, extra_vars)
268
  if not part_type or part_type not in data["types"]:
269
  return jsonify({"error": f"Tipo '{part_type}' no válido."}), 400
270
 
271
  # Filtrar ítems por lib_id
 
272
  all_items = data["_category_index"].get(part_type, [])
 
273
  # En /api/findfurni
274
  if query:
275
  filtered = []
@@ -300,29 +488,47 @@ def api_findfurni():
300
  total_pages = (total + per_page - 1) // per_page
301
  offset = (page - 1) * per_page
302
  items_slice = filtered[offset : offset + per_page]
303
-
304
  items = [
305
  {
306
  "lib_id": f.lib_id,
307
  "id": f.id,
308
- "type": part_type,
309
- "image_url": f"/api/images/{f.lib_id}.nitro?{request.query_string.decode()}"
 
 
 
 
 
 
 
310
  }
311
  for f in items_slice
312
  ]
313
-
314
- return jsonify({
315
- "items": items,
316
- "pagination": {
317
- "page": page,
318
- "per_page": per_page,
319
- "total": total,
320
- "total_pages": total_pages
321
- }
322
- })
 
 
 
 
 
 
323
 
324
  except Exception as e:
 
 
 
325
  return jsonify({"error": str(e)}), 500
326
- if __name__ == '__main__':
 
 
327
  print("🚀 Iniciando Armario Virtual...")
328
- app.run(debug=True, host='0.0.0.0', port=5000)
 
1
+ import gzip
 
2
  import hashlib
3
+ import json
4
+ import struct
5
+ import zlib
6
  from io import BytesIO
7
+ from itertools import chain
8
+
9
+ import requests
10
+ from flask import (
11
+ Flask,
12
+ Response,
13
+ abort,
14
+ jsonify,
15
+ make_response,
16
+ redirect,
17
+ render_template,
18
+ request,
19
+ )
20
  from PIL import Image
21
+
22
+ import cloth_loader
23
+
24
  app = Flask(__name__)
25
 
26
  # Configuración
 
32
  CACHE = {}
33
 
34
 
35
+ def get_or_load_data_by_category(
36
+ config_url: str, extra_vars: dict, category: str = None
37
+ ):
38
+ """Carga datos cacheados, opcionalmente por categoría."""
39
+ url = config_url.strip() or DEFAULT_CONFIG_URL
40
+ cache_key_input = f"{url}|{sorted(extra_vars.items())}"
41
+ base_cache_key = hashlib.md5(cache_key_input.encode("utf-8")).hexdigest()
42
+
43
+ # Si no se especifica categoría, cargar todo
44
+ if not category:
45
+ return get_or_load_data(config_url, extra_vars)
46
+
47
+ # Clave específica para la categoría
48
+ category_cache_key = f"{base_cache_key}_cat_{category}"
49
+
50
+ if category_cache_key in CACHE:
51
+ return CACHE[category_cache_key]
52
+
53
+ # Cargar datos completos
54
+ full_data = get_or_load_data(config_url, extra_vars)
55
+
56
+ # Extraer solo la categoría solicitada
57
+ category_data = {
58
+ "types": full_data["types"],
59
+ "_category_index": {category: full_data["_category_index"].get(category, [])},
60
+ "_palettes": full_data["_palettes"],
61
+ "config": full_data["config"],
62
+ }
63
+
64
+ CACHE[category_cache_key] = category_data
65
+ return category_data
66
+
67
+
68
  def get_or_load_data(config_url: str, extra_vars: dict):
69
  """Carga o devuelve datos cacheados (incluyendo config)."""
70
  url = config_url.strip() or DEFAULT_CONFIG_URL
71
  cache_key_input = f"{url}|{sorted(extra_vars.items())}"
72
+ cache_key = hashlib.md5(cache_key_input.encode("utf-8")).hexdigest()
73
 
74
  if cache_key in CACHE:
75
  return CACHE[cache_key]
 
77
  print(f"🔄 Cargando datos desde: {url} con vars {extra_vars}")
78
  try:
79
  # ¡cloth_loader.load_game_data ahora devuelve config!
80
+ figuremap, figuredata_flat, palettes, config = cloth_loader.load_game_data(
81
+ url, extra_vars
82
+ )
83
+
84
+ # En load_game_data, después de resolver el config
85
+
86
+ types, category_index = cloth_loader.get_all_part_types_from_data(
87
+ figuremap, figuredata_flat
88
+ )
89
+ # types, category_index = cloth_loader.get_all_part_types_from_data(figuremap, figuredata_flat)
90
 
91
  data = {
92
  "types": types,
 
102
  raise
103
 
104
 
105
+ @app.route("/")
106
  def index():
107
+ config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
108
  extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
109
  try:
110
+ data = get_or_load_data_by_category(config_url, extra_vars)
111
+ return render_template(
112
+ "armario.html", part_types=data["types"], max_per_page=MAX_PER_PAGE
113
+ )
114
  except Exception as e:
115
+ import traceback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
+ traceback.print_exc()
118
+ return f"<h2>Error al cargar la configuración</h2><pre>{str(e)}</pre>", 500
 
 
 
 
 
 
 
119
 
 
 
120
 
121
+ @app.route("/api/images/<image>.nitro")
122
  def serve_nitro_image(image):
123
  try:
124
  # --- 1. Obtener parámetros ---
125
+ config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
126
+ sprite_index_param = request.args.get("sprite")
127
+ get_total = request.args.get("get_total") == "1"
128
  extra_vars = {
129
+ k: v
130
+ for k, v in request.args.items()
131
+ if k not in RESERVED_PARAMS and k not in ("sprite", "get_total")
132
  }
133
 
134
  # --- 2. Cargar configuración ---
 
153
  # --- 4. Buscar JSON y PNG ---
154
  fly = {}
155
  for name, content in files:
156
+ if content.startswith(b"\x89PNG\r\n\x1a\n"):
157
  fly["png"] = {"content": content, "name": name}
158
+ elif name.lower().endswith(".json"):
159
  fly["json"] = {"content": content, "name": name}
160
 
161
  if "json" not in fly or "png" not in fly:
162
  abort(404, "❌ No se encontró JSON o PNG en el bundle")
163
 
164
  # --- 5. Parsear JSON ---
165
+ json_str = fly["json"]["content"].decode("utf-8")
166
  json_data = json.loads(json_str)
167
 
168
  # --- 6. Extraer frames ---
 
174
  frames = json_data["spritesheet"]["frames"]
175
 
176
  if not frames:
177
+ abort(
178
+ 400, f"❌ JSON no contiene 'frames'. Claves: {list(json_data.keys())}"
179
+ )
180
 
181
  frame_names = list(frames.keys())
182
  total_sprites = len(frame_names)
 
194
  abort(400, "❌ El parámetro 'sprite' debe ser un número entero")
195
 
196
  if sprite_index < 0 or sprite_index >= total_sprites:
197
+ abort(
198
+ 400,
199
+ f"❌ Índice de sprite fuera de rango. Debe estar entre 0 y {total_sprites - 1}",
200
+ )
201
 
202
  selected_name = frame_names[sprite_index]
203
  frame_info = frames[selected_name]
 
206
  coords = frame_info.get("frame", frame_info)
207
  for key in ["x", "y", "w", "h"]:
208
  if key not in coords:
209
+ abort(
210
+ 400,
211
+ f"❌ Coordenada '{key}' faltante en el sprite '{selected_name}'",
212
+ )
213
 
214
  x = int(coords["x"])
215
  y = int(coords["y"])
 
223
  output = BytesIO()
224
  sprite.save(output, format="PNG")
225
  output.seek(0)
226
+ return Response(output.getvalue(), mimetype="image/png")
227
 
228
  except requests.RequestException as e:
229
  abort(502, f"❌ Error al descargar: {str(e)}")
 
236
  except Exception as e:
237
  abort(500, f"❌ Error inesperado: {str(e)}")
238
 
239
+
240
  def parse_nitro(data: bytes):
241
  """Parsea un archivo .nitro y devuelve [(nombre, datos_descomprimidos), ...]"""
242
  files = []
243
  offset = 0
244
  if len(data) < 2:
245
  return files
246
+ file_count = struct.unpack(">H", data[offset : offset + 2])[0]
247
  offset += 2
248
 
249
  for _ in range(file_count):
250
  if offset + 2 > len(data):
251
  break
252
+ name_len = struct.unpack(">H", data[offset : offset + 2])[0]
253
  offset += 2
254
  if offset + name_len > len(data):
255
  break
256
+ name = data[offset : offset + name_len].decode("utf-8", errors="replace")
257
  offset += name_len
258
  if offset + 4 > len(data):
259
  break
260
+ compressed_size = struct.unpack(">I", data[offset : offset + 4])[0]
261
  offset += 4
262
  if offset + compressed_size > len(data):
263
  break
264
+ compressed_data = data[offset : offset + compressed_size]
265
  offset += compressed_size
266
 
267
  try:
 
277
  return files
278
 
279
 
280
+ def organizar_paletas(paletas: list[cloth_loader.Palette], paleta_id: int):
281
+ colores: list[dict] = []
282
+ for paleta in paletas:
283
+ if paleta.id != paleta_id:
284
+ continue
285
+ for color in paleta.colors:
286
+ if not color.selectable:
287
+ continue
288
+ colores.append(
289
+ {
290
+ "index": color.index,
291
+ "color": color.id,
292
+ "hexColor": "#" + color.hex_code,
293
+ }
294
+ )
295
+ return colores
296
+
297
+
298
+ @app.route("/api/effects")
299
+ def api_effects():
300
+ config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
301
+ extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
302
+ data = get_or_load_data_by_category(config_url, extra_vars, "ca")
303
+ config = data["config"]
304
+ effect_map = config["avatar.effectmap.url"]
305
+ map_effect = requests.get(effect_map).json()["effects"]
306
+
307
+ def guardar_efecto(efecto):
308
+ return {
309
+ "id": efecto["id"],
310
+ "tipo": efecto["type"],
311
+ "file": f"/api/gifs/{efecto['lib']}.nitro?{request.query_string.decode()}",
312
+ }
313
+
314
+ datos = list(map(guardar_efecto, map_effect))
315
+ return render_template("efectos.html", efectos=datos)
316
+
317
+
318
+ @app.route("/api/gifs/<image>.nitro")
319
+ def serve_nitro_gif(image):
320
+ try:
321
+ # --- 1. Obtener parámetros ---
322
+ config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
323
+ get_total = request.args.get("get_total") == "1"
324
+ extra_vars = {
325
+ k: v
326
+ for k, v in request.args.items()
327
+ if k not in RESERVED_PARAMS and k not in ("sprite", "get_total")
328
+ }
329
+
330
+ # --- 2. Cargar configuración ---
331
+ data = get_or_load_data_by_category(config_url, extra_vars, "ca")
332
+ config = data["config"]
333
+ asset_url_template = config.get("avatar.asset.effect.url")
334
+ if not asset_url_template:
335
+ abort(500, "❌ avatar.asset.url no encontrado en la configuración")
336
+
337
+ nitro_url = asset_url_template.format(libname=image)
338
+ print(f"📥 Descargando: {nitro_url}")
339
+
340
+ # --- 3. Descargar y parsear .nitro ---
341
+ resp = requests.get(nitro_url)
342
+ resp.raise_for_status()
343
+ nitro_data = resp.content
344
+
345
+ files = parse_nitro(nitro_data)
346
+ if not files:
347
+ abort(404, "📦 Bundle vacío")
348
+
349
+ # --- 4. Buscar JSON y PNG ---
350
+ fly = {}
351
+ for name, content in files:
352
+ # If gzip compressed, decompress first
353
+ if content[:2] == b"\x1f\x8b":
354
+ content = gzip.decompress(content)
355
+
356
+ if name.lower().endswith(".png"):
357
+ fly["png"] = {"content": content, "name": name}
358
+
359
+ elif name.lower().endswith(".json"):
360
+ fly["json"] = {"content": content, "name": name}
361
+ # --- 5. Parsear JSON ---
362
+
363
+ json_str = fly["json"]["content"].decode("utf-8")
364
+ json_data = json.loads(json_str)
365
+
366
+ # --- 6. Extraer frames ---
367
+ frames = {}
368
+ if isinstance(json_data, dict):
369
+ if "frames" in json_data:
370
+ frames = json_data["frames"]
371
+ elif "spritesheet" in json_data and "frames" in json_data["spritesheet"]:
372
+ frames = json_data["spritesheet"]["frames"]
373
+
374
+ if not frames:
375
+ abort(
376
+ 400, f"❌ JSON no contiene 'frames'. Claves: {list(json_data.keys())}"
377
+ )
378
+
379
+ frame_names = list(frames.keys())
380
+ total_sprites = len(frame_names)
381
+
382
+ # --- 7. Modo: obtener total de sprites ---
383
+ if get_total:
384
+ return jsonify({"total_sprites": total_sprites})
385
+ sprites = []
386
+ # --- 8. Seleccionar sprite por índice ---
387
+ for frame_name, frame_info in frames.items():
388
+ # --- 9. Extraer coordenadas ---
389
+ coords = frame_info.get("frame", frame_info)
390
+ for key in ["x", "y", "w", "h"]:
391
+ if key not in coords:
392
+ abort(
393
+ 400,
394
+ f"❌ Coordenada '{key}' faltante en el sprite '{selected_name}'",
395
+ )
396
+
397
+ x = int(coords["x"])
398
+ y = int(coords["y"])
399
+ w = int(coords["w"])
400
+ h = int(coords["h"])
401
+
402
+ # --- 10. Recortar y devolver PNG ---
403
+ png_image = Image.open(BytesIO(fly["png"]["content"]))
404
+ sprite = png_image.crop((x, y, x + w, y + h))
405
+ sprites.append(sprite)
406
+
407
+ output = BytesIO()
408
+ sprites[0].save(
409
+ output,
410
+ save_all=True,
411
+ append_images=sprites[1:],
412
+ format="GIF",
413
+ duration=1000, # milisegundos por frame (opcional pero recomendado)
414
+ loop=0, # 0 = bucle infinito (opcional pero recomendado)
415
+ disposal=2, # Índice de color transparente (ajusta según tu paleta)
416
+ optimize=True, # reduce tamaño (opcional)
417
+ )
418
+ output.seek(0)
419
+
420
+ return Response(output.getvalue(), mimetype="image/gif")
421
+
422
+ except requests.RequestException as e:
423
+ __import__("traceback").print_exc()
424
+ abort(502, f"❌ Error al descargar: {str(e)}")
425
+ except json.JSONDecodeError as e:
426
+ __import__("traceback").print_exc()
427
+ abort(500, f"❌ JSON inválido: {str(e)}")
428
+ except (KeyError, ValueError) as e:
429
+ import traceback
430
+
431
+ traceback.print_exc()
432
+ abort(400, f"❌ Estructura inválida: {str(e)}")
433
+ except zlib.error as e:
434
+ __import__("traceback").print_exc()
435
+ abort(500, f"❌ Error al descomprimir: {str(e)}")
436
+ except Exception as e:
437
+ __import__("traceback").print_exc()
438
+ abort(500, f"❌ Error inesperado: {str(e)}")
439
+
440
+
441
+ @app.route("/api/findfurni")
442
  def api_findfurni():
443
+ config_url = request.args.get("config_url", DEFAULT_CONFIG_URL)
444
  extra_vars = {k: v for k, v in request.args.items() if k not in RESERVED_PARAMS}
445
+ query = request.args.get("q", "").strip().lower()
446
+ part_type = request.args.get("type", "").strip()
447
+ page = request.args.get("page", default=1, type=int)
448
+ per_page = request.args.get("per_page", default=10, type=int)
449
  per_page = max(1, min(per_page, MAX_PER_PAGE))
450
  page = max(1, page)
451
 
452
  try:
453
+ data = get_or_load_data_by_category(config_url, extra_vars, part_type)
454
  if not part_type or part_type not in data["types"]:
455
  return jsonify({"error": f"Tipo '{part_type}' no válido."}), 400
456
 
457
  # Filtrar ítems por lib_id
458
+
459
  all_items = data["_category_index"].get(part_type, [])
460
+
461
  # En /api/findfurni
462
  if query:
463
  filtered = []
 
488
  total_pages = (total + per_page - 1) // per_page
489
  offset = (page - 1) * per_page
490
  items_slice = filtered[offset : offset + per_page]
491
+ # En /api/furnis/<part_type>
492
  items = [
493
  {
494
  "lib_id": f.lib_id,
495
  "id": f.id,
496
+ "type": f.type,
497
+ "colors": len(
498
+ set([part.colorindex for part in f.parts if f.colorable])
499
+ ),
500
+ "colorable": f.colorable,
501
+ "palette": organizar_paletas(
502
+ data["_palettes"], f.paleta
503
+ ), # ← Paleta completa
504
+ "image_url": f"/api/images/{f.lib_id}.nitro?{request.query_string.decode()}",
505
  }
506
  for f in items_slice
507
  ]
508
+ response = make_response(
509
+ jsonify(
510
+ {
511
+ "items": items,
512
+ "pagination": {
513
+ "page": page,
514
+ "per_page": per_page,
515
+ "total": total,
516
+ "total_pages": total_pages,
517
+ },
518
+ }
519
+ )
520
+ )
521
+ response.headers["Cache-Control"] = "public, max-age=3600" # 1 hora
522
+ return response
523
+ return jsonify()
524
 
525
  except Exception as e:
526
+ import traceback
527
+
528
+ traceback.print_exc()
529
  return jsonify({"error": str(e)}), 500
530
+
531
+
532
+ if __name__ == "__main__":
533
  print("🚀 Iniciando Armario Virtual...")
534
+ app.run(debug=True, host="0.0.0.0", port=5000)
cloth_loader.py CHANGED
@@ -1,15 +1,20 @@
1
- import requests
2
- from itertools import chain
3
- from typing import Any, Optional, List, Dict
4
- from concurrent.futures import ThreadPoolExecutor
5
  import re
 
 
 
6
 
 
7
 
8
  # =============== CLASES ===============
9
 
 
10
  class Color:
11
  __slots__ = ("id", "index", "club", "selectable", "hex_code")
12
- def __init__(self, id: int, index: int, club: int, selectable: bool, hex_code: str) -> None:
 
 
 
13
  self.id = id
14
  self.index = index
15
  self.club = club
@@ -28,6 +33,7 @@ class Color:
28
 
29
  class Palette:
30
  __slots__ = ("id", "colors")
 
31
  def __init__(self, id: int, colors: List[Color]) -> None:
32
  self.id = id
33
  self.colors = colors
@@ -47,26 +53,51 @@ class Palette:
47
 
48
  class Part:
49
  __slots__ = ("id", "type", "colorable", "index", "colorindex")
50
- def __init__(self, id: int, type: str, colorable: bool = False, index: int = 0, colorindex: int = 0) -> None:
 
 
 
 
 
 
 
 
51
  self.id = id
52
  self.type = type
53
  self.colorable = colorable
54
  self.index = index
55
  self.colorindex = colorindex
 
56
  def __repr__(self) -> str:
57
  return f"<<<parteId:{self.id},tipo:{self.type},colorindex:{self.colorindex}>>>"
58
 
59
  def __eq__(self, other) -> bool:
60
  if not isinstance(other, Part):
61
  return False
62
- return self.id == other.id and self.type == other.type and self.colorindex == other.colorindex
 
 
 
 
63
 
64
  def __hash__(self) -> int:
65
  return hash((self.id, self.type, self.colorindex))
66
 
67
 
68
  class Lib:
69
- __slots__ = ("id", "parts", "gender", "club", "colorable", "selectable", "preselectable", "sellable")
 
 
 
 
 
 
 
 
 
 
 
 
70
  def __init__(
71
  self,
72
  id: int,
@@ -77,6 +108,8 @@ class Lib:
77
  selectable: bool = False,
78
  preselectable: bool = False,
79
  sellable: bool = False,
 
 
80
  ) -> None:
81
  self.id = id
82
  self.parts = parts
@@ -86,13 +119,18 @@ class Lib:
86
  self.selectable = selectable
87
  self.preselectable = preselectable
88
  self.sellable = sellable
 
 
89
 
90
  def __repr__(self) -> str:
91
- return f"ID:{self.id},partes:{len(self.parts)},gender:{self.gender}"
92
 
93
  def __eq__(self, other) -> bool:
94
  return isinstance(other, Lib) and self.parts == other.parts
95
 
 
 
 
96
  def copy(self):
97
  return Lib(
98
  id=self.id,
@@ -103,11 +141,14 @@ class Lib:
103
  selectable=self.selectable,
104
  preselectable=self.preselectable,
105
  sellable=self.sellable,
 
 
106
  )
107
 
108
 
109
  class Full(Lib):
110
  __slots__ = ("lib_id",)
 
111
  def __init__(self, obj: Lib, lib_id: str) -> None:
112
  super().__init__(
113
  id=obj.id,
@@ -118,23 +159,26 @@ class Full(Lib):
118
  selectable=obj.selectable,
119
  preselectable=obj.preselectable,
120
  sellable=obj.sellable,
 
 
121
  )
122
  self.lib_id = lib_id
123
 
124
  def __repr__(self) -> str:
125
- return f"ID:{self.id},partes:{len(self.parts)},Lib:{self.lib_id}"
126
 
127
 
128
  # =============== FUNCIONES AUXILIARES ===============
129
 
 
130
  def link_parts(partes: list[dict]) -> list[Part]:
131
  return [
132
  Part(
133
  id=p["id"],
134
- type=p["type"],
135
  colorable=p.get("colorable", False),
136
  index=p.get("index", 0),
137
- colorindex=p.get("colorindex", 0)
138
  )
139
  for p in partes
140
  ]
@@ -147,7 +191,7 @@ def link_colors(colors_data: list[dict]) -> list[Color]:
147
  index=c["index"],
148
  club=c["club"],
149
  selectable=c["selectable"],
150
- hex_code=c["hexCode"]
151
  )
152
  for c in colors_data
153
  ]
@@ -157,7 +201,7 @@ def link_palettes(palettes_data: list[dict]) -> list[Palette]:
157
  return [Palette(p["id"], link_colors(p["colors"])) for p in palettes_data]
158
 
159
 
160
- def hook( info:dict) -> Any:
161
  if "parts" in info:
162
  parts = link_parts(info["parts"])
163
  return Lib(
@@ -181,7 +225,10 @@ def fetch_json(url: str) -> Any:
181
 
182
  # =============== CARGA DINÁMICA ===============
183
 
184
- def load_game_data(config_url: str, extra_vars: Dict[str, str] = None) -> tuple[list[Lib], list[Lib], list[Palette], dict]:
 
 
 
185
  """
186
  Carga datos desde renderer-config.json.
187
  - extra_vars sobrescribe cualquier clave del JSON.
@@ -235,9 +282,11 @@ def load_game_data(config_url: str, extra_vars: Dict[str, str] = None) -> tuple[
235
  for key in all_keys:
236
  value = config[key]
237
  if isinstance(value, str) and "${" in value:
 
238
  def replace_var(match):
239
  var_name = match.group(1)
240
  return context.get(var_name, match.group(0))
 
241
  resolved = re.sub(r"\$\{([^}]+)\}", replace_var, value).strip()
242
  if "${" not in resolved and context.get(key) != resolved:
243
  context[key] = resolved
@@ -245,19 +294,22 @@ def load_game_data(config_url: str, extra_vars: Dict[str, str] = None) -> tuple[
245
 
246
  # 5. Detectar variables raíz faltantes
247
  def escape_html(text: str) -> str:
248
- return (text
249
- .replace("&", "&amp;")
250
- .replace("<", "&lt;")
251
- .replace(">", "&gt;")
252
- .replace('"', "&quot;")
253
- .replace("'", "&#x27;"))
 
254
 
255
  def debug_config(original_config: dict, ctx: dict) -> str:
256
  def resolve_value(value):
257
  if isinstance(value, str):
 
258
  def repl(m):
259
  var = m.group(1)
260
  return ctx.get(var, f"${{{var}}}")
 
261
  return re.sub(r"\$\{([^}]+)\}", repl, value)
262
  elif isinstance(value, list):
263
  return [resolve_value(v) for v in value]
@@ -265,8 +317,10 @@ def load_game_data(config_url: str, extra_vars: Dict[str, str] = None) -> tuple[
265
  return {k: resolve_value(v) for k, v in value.items()}
266
  else:
267
  return value
 
268
  resolved = resolve_value(original_config)
269
  import json
 
270
  return json.dumps(resolved, indent=2, ensure_ascii=False)
271
 
272
  all_missing_vars = set()
@@ -321,18 +375,44 @@ def load_game_data(config_url: str, extra_vars: Dict[str, str] = None) -> tuple[
321
  figuredata_dict = future_data.result()
322
 
323
  # 8. Procesar
324
- figuremap: List[Lib] = figuremap_dict["libraries"]
 
 
 
 
 
 
 
 
325
  figuredata_flat: List[Lib] = list(
326
- chain.from_iterable(set_["sets"] for set_ in figuredata_dict["setTypes"])
327
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  palettes: List[Palette] = link_palettes(figuredata_dict["palettes"])
 
329
  def resolve_config_variables(config_dict: dict, context: dict) -> dict:
330
  """Resuelve todas las ${var} en todo el config usando el contexto."""
 
331
  def resolve_value(value):
332
  if isinstance(value, str):
 
333
  def repl(match):
334
  var = match.group(1)
335
  return context.get(var, match.group(0))
 
336
  return re.sub(r"\$\{([^}]+)\}", repl, value)
337
  elif isinstance(value, list):
338
  return [resolve_value(v) for v in value]
@@ -340,35 +420,101 @@ def load_game_data(config_url: str, extra_vars: Dict[str, str] = None) -> tuple[
340
  return {k: resolve_value(v) for k, v in value.items()}
341
  else:
342
  return value
 
343
  return resolve_value(config_dict)
344
- resolved_config = resolve_config_variables(config,context)
 
 
345
  return figuremap, figuredata_flat, palettes, resolved_config
346
 
 
 
 
 
347
 
348
- # =============== FUNCIONES PÚBLICAS ===============
349
 
350
- def get_all_part_types_from_data(figuremap, figuredata_flat) -> tuple[List[str], dict]:
 
 
 
 
 
 
 
 
 
351
  _category_index = {}
 
 
 
352
  def parts_base_key(lib):
353
  return tuple((p.id, p.type) for p in lib.parts)
354
 
 
 
 
 
 
 
 
 
355
  figuredata_by_base_key = {}
356
  for lib in figuredata_flat:
357
  key = parts_base_key(lib)
358
  if key not in figuredata_by_base_key:
359
  figuredata_by_base_key[key] = lib
360
 
361
- _furnis_all = []
362
  for item in figuremap:
363
  key = parts_base_key(item)
364
  if key in figuredata_by_base_key:
365
  matched_lib = figuredata_by_base_key[key]
 
366
  full = Full(matched_lib.copy(), str(item.id))
367
- _furnis_all.append(full)
368
- for part in full.parts:
369
- ptype = part.type
370
- if ptype not in _category_index:
371
- _category_index[ptype] = []
372
- _category_index[ptype].append(full)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
 
374
  return sorted(_category_index.keys()), _category_index
 
1
+ import hashlib
 
 
 
2
  import re
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from itertools import chain
5
+ from typing import Any, Dict, List, Optional, Tuple
6
 
7
+ import requests
8
 
9
  # =============== CLASES ===============
10
 
11
+
12
  class Color:
13
  __slots__ = ("id", "index", "club", "selectable", "hex_code")
14
+
15
+ def __init__(
16
+ self, id: int, index: int, club: int, selectable: bool, hex_code: str
17
+ ) -> None:
18
  self.id = id
19
  self.index = index
20
  self.club = club
 
33
 
34
  class Palette:
35
  __slots__ = ("id", "colors")
36
+
37
  def __init__(self, id: int, colors: List[Color]) -> None:
38
  self.id = id
39
  self.colors = colors
 
53
 
54
  class Part:
55
  __slots__ = ("id", "type", "colorable", "index", "colorindex")
56
+
57
+ def __init__(
58
+ self,
59
+ id: int,
60
+ type: str,
61
+ colorable: bool = False,
62
+ index: int = 0,
63
+ colorindex: int = 0,
64
+ ) -> None:
65
  self.id = id
66
  self.type = type
67
  self.colorable = colorable
68
  self.index = index
69
  self.colorindex = colorindex
70
+
71
  def __repr__(self) -> str:
72
  return f"<<<parteId:{self.id},tipo:{self.type},colorindex:{self.colorindex}>>>"
73
 
74
  def __eq__(self, other) -> bool:
75
  if not isinstance(other, Part):
76
  return False
77
+ return (
78
+ self.id == other.id
79
+ and self.type == other.type
80
+ and self.colorindex == other.colorindex
81
+ )
82
 
83
  def __hash__(self) -> int:
84
  return hash((self.id, self.type, self.colorindex))
85
 
86
 
87
  class Lib:
88
+ __slots__ = (
89
+ "id",
90
+ "parts",
91
+ "gender",
92
+ "club",
93
+ "colorable",
94
+ "selectable",
95
+ "preselectable",
96
+ "sellable",
97
+ "paleta",
98
+ "type",
99
+ )
100
+
101
  def __init__(
102
  self,
103
  id: int,
 
108
  selectable: bool = False,
109
  preselectable: bool = False,
110
  sellable: bool = False,
111
+ paleta: int = -1,
112
+ type: str = "Desconocido",
113
  ) -> None:
114
  self.id = id
115
  self.parts = parts
 
119
  self.selectable = selectable
120
  self.preselectable = preselectable
121
  self.sellable = sellable
122
+ self.paleta = paleta
123
+ self.type = type
124
 
125
  def __repr__(self) -> str:
126
+ return f"ID:{self.id},partes:{len(self.parts)},gender:{self.gender}, paleta:{self.paleta},Tipo:{self.type}"
127
 
128
  def __eq__(self, other) -> bool:
129
  return isinstance(other, Lib) and self.parts == other.parts
130
 
131
+ def set_paleta(self, pid):
132
+ self.paleta = pid
133
+
134
  def copy(self):
135
  return Lib(
136
  id=self.id,
 
141
  selectable=self.selectable,
142
  preselectable=self.preselectable,
143
  sellable=self.sellable,
144
+ paleta=self.paleta,
145
+ type=self.type,
146
  )
147
 
148
 
149
  class Full(Lib):
150
  __slots__ = ("lib_id",)
151
+
152
  def __init__(self, obj: Lib, lib_id: str) -> None:
153
  super().__init__(
154
  id=obj.id,
 
159
  selectable=obj.selectable,
160
  preselectable=obj.preselectable,
161
  sellable=obj.sellable,
162
+ paleta=obj.paleta,
163
+ type=obj.type,
164
  )
165
  self.lib_id = lib_id
166
 
167
  def __repr__(self) -> str:
168
+ return f"ID:{self.id},partes:{len(self.parts)},Lib:{self.lib_id},Paleta:{self.paleta}"
169
 
170
 
171
  # =============== FUNCIONES AUXILIARES ===============
172
 
173
+
174
  def link_parts(partes: list[dict]) -> list[Part]:
175
  return [
176
  Part(
177
  id=p["id"],
178
+ type="hr" if p["type"] == "hrb" else p["type"],
179
  colorable=p.get("colorable", False),
180
  index=p.get("index", 0),
181
+ colorindex=p.get("colorindex", 0),
182
  )
183
  for p in partes
184
  ]
 
191
  index=c["index"],
192
  club=c["club"],
193
  selectable=c["selectable"],
194
+ hex_code=c["hexCode"],
195
  )
196
  for c in colors_data
197
  ]
 
201
  return [Palette(p["id"], link_colors(p["colors"])) for p in palettes_data]
202
 
203
 
204
+ def hook(info: dict) -> Any:
205
  if "parts" in info:
206
  parts = link_parts(info["parts"])
207
  return Lib(
 
225
 
226
  # =============== CARGA DINÁMICA ===============
227
 
228
+
229
+ def load_game_data(
230
+ config_url: str, extra_vars: Dict[str, str] = None
231
+ ) -> tuple[list[Lib], list[Lib], list[Palette], dict]:
232
  """
233
  Carga datos desde renderer-config.json.
234
  - extra_vars sobrescribe cualquier clave del JSON.
 
282
  for key in all_keys:
283
  value = config[key]
284
  if isinstance(value, str) and "${" in value:
285
+
286
  def replace_var(match):
287
  var_name = match.group(1)
288
  return context.get(var_name, match.group(0))
289
+
290
  resolved = re.sub(r"\$\{([^}]+)\}", replace_var, value).strip()
291
  if "${" not in resolved and context.get(key) != resolved:
292
  context[key] = resolved
 
294
 
295
  # 5. Detectar variables raíz faltantes
296
  def escape_html(text: str) -> str:
297
+ return (
298
+ text.replace("&", "&amp;")
299
+ .replace("<", "&lt;")
300
+ .replace(">", "&gt;")
301
+ .replace('"', "&quot;")
302
+ .replace("'", "&#x27;")
303
+ )
304
 
305
  def debug_config(original_config: dict, ctx: dict) -> str:
306
  def resolve_value(value):
307
  if isinstance(value, str):
308
+
309
  def repl(m):
310
  var = m.group(1)
311
  return ctx.get(var, f"${{{var}}}")
312
+
313
  return re.sub(r"\$\{([^}]+)\}", repl, value)
314
  elif isinstance(value, list):
315
  return [resolve_value(v) for v in value]
 
317
  return {k: resolve_value(v) for k, v in value.items()}
318
  else:
319
  return value
320
+
321
  resolved = resolve_value(original_config)
322
  import json
323
+
324
  return json.dumps(resolved, indent=2, ensure_ascii=False)
325
 
326
  all_missing_vars = set()
 
375
  figuredata_dict = future_data.result()
376
 
377
  # 8. Procesar
378
+ def setPaleta(set_) -> List[Lib]:
379
+ v: List[Lib] = set_["sets"]
380
+ for i in range(len(v)):
381
+ v[i].set_paleta(set_["paletteId"])
382
+ v[i].type = set_["type"]
383
+ return v
384
+
385
+ figuremap_raw: List[Lib] = figuremap_dict["libraries"]
386
+ figuremap_by_id = {lib.id: lib for lib in figuremap_raw}
387
  figuredata_flat: List[Lib] = list(
388
+ chain.from_iterable(setPaleta(set_) for set_ in figuredata_dict["setTypes"])
389
  )
390
+ figuremap = []
391
+ for lib in figuremap_raw:
392
+ # Buscar en figuredata_flat el mismo ID
393
+ matching_data = None
394
+ for data_lib in figuredata_flat:
395
+ if data_lib.id == lib.id:
396
+ matching_data = data_lib
397
+ break
398
+
399
+ if matching_data and matching_data.paleta != -1:
400
+ lib.set_paleta(matching_data.paleta)
401
+ if matching_data and matching_data.type != "Desconocido":
402
+ lib.type = matching_data.type
403
+ figuremap.append(lib)
404
  palettes: List[Palette] = link_palettes(figuredata_dict["palettes"])
405
+
406
  def resolve_config_variables(config_dict: dict, context: dict) -> dict:
407
  """Resuelve todas las ${var} en todo el config usando el contexto."""
408
+
409
  def resolve_value(value):
410
  if isinstance(value, str):
411
+
412
  def repl(match):
413
  var = match.group(1)
414
  return context.get(var, match.group(0))
415
+
416
  return re.sub(r"\$\{([^}]+)\}", repl, value)
417
  elif isinstance(value, list):
418
  return [resolve_value(v) for v in value]
 
420
  return {k: resolve_value(v) for k, v in value.items()}
421
  else:
422
  return value
423
+
424
  return resolve_value(config_dict)
425
+
426
+ resolved_config = resolve_config_variables(config, context)
427
+
428
  return figuremap, figuredata_flat, palettes, resolved_config
429
 
430
+ # =============== FUNCIONES PÚBLICAS ===============
431
+
432
+
433
+ pruebas = {"hair": "hr", "trousers": "lg", "hat": "ha"}
434
 
 
435
 
436
+ def return_correct(name):
437
+ for started in pruebas.keys():
438
+ if name.startswith(started):
439
+ return pruebas[started]
440
+ return "Desconocido"
441
+
442
+
443
+ def get_all_part_types_from_data(
444
+ figuremap, figuredata_flat, include_all=True
445
+ ) -> tuple[list[str], dict]:
446
  _category_index = {}
447
+ if include_all:
448
+ _category_index["Todos"] = []
449
+
450
  def parts_base_key(lib):
451
  return tuple((p.id, p.type) for p in lib.parts)
452
 
453
+ # Indexar ambos conjuntos
454
+ figuremap_keys = set()
455
+ figuremap_dict = {}
456
+ for item in figuremap:
457
+ key = parts_base_key(item)
458
+ figuremap_keys.add(key)
459
+ figuremap_dict[key] = item
460
+
461
  figuredata_by_base_key = {}
462
  for lib in figuredata_flat:
463
  key = parts_base_key(lib)
464
  if key not in figuredata_by_base_key:
465
  figuredata_by_base_key[key] = lib
466
 
467
+ # 1. Ítems normales (en ambos)
468
  for item in figuremap:
469
  key = parts_base_key(item)
470
  if key in figuredata_by_base_key:
471
  matched_lib = figuredata_by_base_key[key]
472
+
473
  full = Full(matched_lib.copy(), str(item.id))
474
+ # full.set_paleta(matched_lib.paleta)
475
+ full.set_paleta(matched_lib.paleta)
476
+ full.type = matched_lib.type
477
+ if full.type == "Desconocido":
478
+ if len(set([t.type for t in full.parts])) == 1:
479
+ full.type = full.parts[0].type
480
+ else:
481
+ full.type = return_correct(full.lib_id)
482
+ if full.type == "Desconocido":
483
+ full.type = full.parts[0].type
484
+ print(full.lib_id, "->", full.type)
485
+
486
+ # Encontrar la parte con mayor prioridad
487
+ if full.type not in _category_index:
488
+ _category_index[full.type] = []
489
+
490
+ _category_index[full.type].append(full)
491
+ if include_all:
492
+ _category_index["Todos"].append(full)
493
+ # 2. Ítems EXTRA (solo en figuredata)
494
+ extra_items = []
495
+ for lib in figuredata_flat:
496
+ key = parts_base_key(lib)
497
+ if key not in figuremap_keys:
498
+ full = Full(lib.copy(), str(lib.id)) # ID 0 para ítems extra
499
+ extra_items.append(full)
500
+
501
+ if extra_items:
502
+ _category_index["extra"] = extra_items
503
+
504
+ # 3. Ítems MISSING (solo en figuremap)
505
+ missing_items = []
506
+ for item in figuremap:
507
+ key = parts_base_key(item)
508
+ if key not in figuredata_by_base_key:
509
+ # Crear un objeto Full "vacío" con datos mínimos
510
+ dummy_lib = Lib(
511
+ 0,
512
+ item.parts,
513
+ )
514
+ full = Full(dummy_lib, str(item.id))
515
+ missing_items.append(full)
516
+
517
+ if missing_items:
518
+ _category_index["missing"] = missing_items
519
 
520
  return sorted(_category_index.keys()), _category_index
templates/armario.html CHANGED
@@ -108,11 +108,17 @@
108
  display: flex;
109
  flex-direction: column;
110
  align-items: center;
 
111
  }
112
  .item:hover {
113
  transform: translateY(-3px);
114
  box-shadow: 0 5px 12px rgba(0, 0, 0, 0.15);
115
  }
 
 
 
 
 
116
 
117
  /* Sprites */
118
  .sprite-container {
@@ -121,12 +127,51 @@
121
  height: 80px;
122
  margin-bottom: 24px;
123
  }
124
- .sprite-image {
 
 
 
 
 
 
 
 
125
  width: 100%;
126
  height: 100%;
127
  object-fit: contain;
128
  image-rendering: pixelated;
 
129
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  .sprite-controls {
131
  position: absolute;
132
  bottom: -24px;
@@ -164,49 +209,7 @@
164
  margin-top: 4px;
165
  }
166
 
167
- /* Paginación */
168
- .pagination {
169
- display: flex;
170
- justify-content: center;
171
- align-items: center;
172
- gap: 12px;
173
- margin-top: 25px;
174
- padding: 15px;
175
- background: white;
176
- border-radius: 12px;
177
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
178
- }
179
- .pagination button {
180
- padding: 8px 16px;
181
- background: #4299e1;
182
- color: white;
183
- border: none;
184
- border-radius: 6px;
185
- cursor: pointer;
186
- font-weight: bold;
187
- }
188
- .pagination button:disabled {
189
- background: #cbd5e0;
190
- cursor: not-allowed;
191
- }
192
- .pagination span {
193
- font-weight: bold;
194
- color: #2d3748;
195
- }
196
- .loading {
197
- grid-column: 1 / -1;
198
- text-align: center;
199
- padding: 30px;
200
- color: #a0aec0;
201
- }
202
- .error {
203
- grid-column: 1 / -1;
204
- text-align: center;
205
- padding: 20px;
206
- color: #e53e3e;
207
- background: #fed7d7;
208
- border-radius: 8px;
209
- }
210
  .selection-box {
211
  background: white;
212
  padding: 15px;
@@ -221,6 +224,21 @@
221
  color: #2d3748;
222
  word-break: break-all;
223
  min-height: 24px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
225
  .copy-button {
226
  margin-top: 10px;
@@ -240,6 +258,8 @@
240
  background: #cbd5e0;
241
  cursor: not-allowed;
242
  }
 
 
243
  .avatar-preview {
244
  background: white;
245
  padding: 15px;
@@ -252,6 +272,8 @@
252
  color: #a0aec0;
253
  font-style: italic;
254
  }
 
 
255
  .selected-items-preview {
256
  background: white;
257
  padding: 15px;
@@ -260,25 +282,20 @@
260
  margin: 20px 0;
261
  }
262
  .selected-items-grid {
263
- display: flex;
264
- gap: 12px;
265
- flex-wrap: wrap;
266
- justify-content: center;
267
- min-height: 60px;
268
  }
269
  .selected-item-card {
 
 
270
  background: #f8fafc;
271
  border-radius: 8px;
272
- padding: 8px;
273
  text-align: center;
274
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
275
- width: 80px;
276
- }
277
- .selected-item-card img {
278
- width: 60px;
279
- height: 60px;
280
- object-fit: contain;
281
- image-rendering: pixelated;
282
  }
283
  .selected-item-card .item-id {
284
  font-size: 10px;
@@ -286,6 +303,128 @@
286
  margin-top: 4px;
287
  word-break: break-all;
288
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  </style>
290
  </head>
291
  <body>
@@ -321,7 +460,7 @@
321
  <input
322
  type="text"
323
  id="searchInput"
324
- placeholder="Buscar por ID (ej: hr-100)"
325
  />
326
  </div>
327
 
@@ -331,35 +470,60 @@
331
  Selecciona una categoría para comenzar
332
  </div>
333
  </div>
334
-
335
- <!-- Paginación -->
336
  <div
337
- class="pagination"
338
- id="paginationControls"
339
- style="display: none"
 
 
 
 
 
340
  >
341
- <button id="prevBtn"> Anterior</button>
342
- <span
343
- >Página <span id="currentPage">1</span> de
344
- <span id="totalPages">1</span></span
345
- >
346
- <button id="nextBtn">Siguiente ▶</button>
347
- </div>
348
- </div>
349
- <!-- Lista de selección -->
350
- <div class="selection-box">
351
- <h3>Selección actual:</h3>
352
- <div id="selectionOutput" class="selection-output">
353
- Ningún ítem seleccionado
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
  </div>
355
- <!-- Miniaturas de ítems seleccionados -->
356
- <div class="selected-items-preview">
357
- <h3>Ítems seleccionados:</h3>
358
- <div id="selectedItemsGrid" class="selected-items-grid">
359
- <!-- Las miniaturas se generarán aquí -->
360
  </div>
 
 
 
361
  </div>
362
- <button id="copyButton" class="copy-button" disabled>Copiar</button>
363
  <!-- Visor de avatar -->
364
  <div class="avatar-preview">
365
  <h3>Vista previa del avatar:</h3>
@@ -378,67 +542,331 @@
378
  Selecciona ítems para ver el avatar
379
  </div>
380
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  </div>
382
 
383
  <script>
384
  // Estado global
385
- const selectedItems = []; // [{ type, id }, ...]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
 
387
- // Obtener parámetros de la URL
388
- const urlParams = new URLSearchParams(window.location.search);
389
- let baseUrlParams = "";
390
- for (const [key, value] of urlParams) {
391
- if (!['page', 'per_page', 'q', 'type'].includes(key)) {
392
- baseUrlParams += `&${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
393
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  }
395
- if (baseUrlParams) baseUrlParams = "?" + baseUrlParams.substring(1);
396
 
397
- let currentType = null;
398
- let currentPage = 1;
399
- let perPage = 10;
400
- const maxPerPage = {{ max_per_page }};
 
401
 
402
- // Elementos del DOM
403
- const typesContainer = document.getElementById('typesContainer');
404
- const grid = document.getElementById('furnisGrid');
405
- const paginationEl = document.getElementById('paginationControls');
406
- const prevBtn = document.getElementById('prevBtn');
407
- const nextBtn = document.getElementById('nextBtn');
408
- const currentPageEl = document.getElementById('currentPage');
409
- const totalPagesEl = document.getElementById('totalPages');
410
- const perPageInput = document.getElementById('perPageInput');
411
- const selectionOutput = document.getElementById('selectionOutput');
412
 
413
- // Formatear selección
414
- function formatSelection() {
415
- if (selectedItems.length === 0) {
416
- return "Ningún ítem seleccionado";
417
- }
418
- return selectedItems.map(item =>
419
- `${item.type}-${item.id}-0-0`
420
- ).join('.');
421
  }
422
 
423
  // Generar URL del avatar
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  function generateAvatarUrl() {
425
  if (selectedItems.length === 0) return null;
426
 
427
- // Obtener código de figura: hr-100-0-0.ch-200-0-0
428
- const figureCode = selectedItems.map(item =>
429
- `${item.type}-${item.id}-0-0`
430
- ).join('.');
 
 
 
 
 
 
431
 
432
- // Obtener URL base desde parámetros o usar predeterminada
433
  const urlParams = new URLSearchParams(window.location.search);
434
  let avatarUrlTemplate = urlParams.get('avatar_url') ||
435
- 'https://www.hartico.tv/HabboFigure/%figure%&action=wav&gesture=std&direction=2&head_direction=3&headonly=0&dance=0&effect=0&size=l&frame_num=0&img_format=png';
436
 
437
- return avatarUrlTemplate.replace('%figure%', figureCode);
 
 
 
 
 
 
 
 
438
  }
439
- // Actualizar vista de selección con "etiquetas" y botón de copiar
440
- // Actualizar vista de selección y avatar
441
- // Actualizar todo: selección, avatar y miniaturas
442
  function updateSelectionDisplay() {
443
  const copyButton = document.getElementById('copyButton');
444
  const avatarImage = document.getElementById('avatarImage');
@@ -454,65 +882,150 @@
454
  return;
455
  }
456
 
457
- // Generar texto plano para copiar
458
- const plainText = selectedItems.map(item =>
459
- `${item.type}-${item.id}-0-0`
460
- ).join('.');
461
 
462
- // Mostrar etiquetas clickeables
463
  const tags = selectedItems.map((item, index) =>
464
- `<span class="selection-tag" data-index="${index}">${item.type}-${item.id}-0-0</span>`
465
  ).join('.');
466
 
467
  selectionOutput.innerHTML = tags;
468
  copyButton.disabled = false;
469
 
470
- // Actualizar avatar
471
  const avatarUrl = generateAvatarUrl();
472
  if (avatarUrl) {
473
- avatarImage.src = avatarUrl;
 
474
  avatarImage.style.display = 'inline-block';
475
  avatarPlaceholder.style.display = 'none';
 
 
 
476
  }
477
 
478
- // Generar miniaturas
479
- selectedItemsGrid.innerHTML = selectedItems.map((item, index) => {
480
- // Construir URL de la imagen del ítem
481
- const params = new URLSearchParams(window.location.search);
482
- let baseUrlParams = "";
483
- for (const [key, value] of params) {
484
- if (!['page', 'per_page', 'q', 'type'].includes(key)) {
485
- baseUrlParams += `&${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  }
487
  }
488
- const imageUrl = `/api/images/${item.lib_id}.nitro?sprite=0${baseUrlParams}`;
489
 
 
 
 
 
 
 
490
  return `
491
  <div class="selected-item-card" data-index="${index}">
492
- <img src="${imageUrl}" alt="${item.lib_id}" loading="lazy">
 
 
 
 
 
493
  <div class="item-id">${item.type}-${item.id}</div>
 
 
494
  </div>
495
  `;
496
  }).join('');
497
 
498
- // Asignar eventos a las "etiquetas"
499
- document.querySelectorAll('.selection-tag').forEach(tag => {
500
- tag.addEventListener('click', (e) => {
501
- e.preventDefault();
502
- const index = parseInt(tag.dataset.index);
503
- selectedItems.splice(index, 1);
504
- updateSelectionDisplay();
505
- });
506
- });
 
 
 
 
 
 
 
507
 
508
- // Asignar eventos a las miniaturas
509
- document.querySelectorAll('.selected-item-card').forEach(card => {
510
- card.addEventListener('click', () => {
511
- const index = parseInt(card.dataset.index);
512
- selectedItems.splice(index, 1);
513
- updateSelectionDisplay();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  });
515
- });
516
 
517
  // Evento de copiar
518
  copyButton.onclick = async () => {
@@ -530,24 +1043,36 @@
530
  };
531
  }
532
 
533
- // FUNCIÓN changeSprite
534
- function changeSprite(img, direction, counter, itemEl) {
535
- const currentIndex = parseInt(img.dataset.spriteIndex);
536
- const total = parseInt(itemEl.dataset.totalSprites);
537
- let newIndex = currentIndex + direction;
 
 
 
 
 
538
 
539
- if (newIndex < 0) newIndex = total - 1;
540
- if (newIndex >= total) newIndex = 0;
 
 
541
 
542
- const url = new URL(img.src);
543
- url.searchParams.set('sprite', newIndex);
544
- img.src = url.toString();
545
- img.dataset.spriteIndex = newIndex;
546
- counter.textContent = `${newIndex + 1} / ${total}`;
547
- }
 
 
 
 
548
 
549
  // Cargar contenido
550
  function loadContent(page, searchQuery = '') {
 
551
  if (!currentType) return;
552
 
553
  const params = new URLSearchParams();
@@ -563,7 +1088,12 @@
563
  }
564
  }
565
 
566
- const apiUrl = `/api/findfurni?${params.toString()}`;
 
 
 
 
 
567
  fetch(apiUrl)
568
  .then(response => response.json())
569
  .then(data => {
@@ -572,51 +1102,74 @@
572
  return;
573
  }
574
 
 
 
 
 
575
  if (data.items.length === 0) {
576
  grid.innerHTML = '<div class="loading">No se encontraron resultados.</div>';
577
  } else {
578
- grid.innerHTML = data.items.map(item => `
579
- <div class="item" data-lib-id="${item.lib_id}" data-id="${item.id}" data-type="${item.type}">
580
- <div class="sprite-container">
581
- <img src="${item.image_url}&sprite=0" alt="${item.lib_id}" class="sprite-image" data-sprite-index="0">
582
- <div class="sprite-controls">
583
- <button class="sprite-btn prev" data-action="prev">◀</button>
584
- <span class="sprite-counter">1 / ?</span>
585
- <button class="sprite-btn next" data-action="next">▶</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
  </div>
 
587
  </div>
588
- <div class="id">${item.lib_id}</div>
589
- </div>
590
- `).join('');
591
 
592
- // Inicializar controles y eventos de selección
593
  document.querySelectorAll('.item').forEach(itemEl => {
594
  const img = itemEl.querySelector('.sprite-image');
 
595
  const counter = itemEl.querySelector('.sprite-counter');
596
  const prevBtn = itemEl.querySelector('.prev');
597
  const nextBtn = itemEl.querySelector('.next');
598
 
599
- // Evento de selección (único por categoría)
600
- itemEl.addEventListener('click', () => {
 
 
 
 
601
  const type = itemEl.dataset.type;
602
  const id = itemEl.dataset.id;
603
- const lib_id = itemEl.dataset.libId; // ← ¡Nuevo!
604
-
605
- // Buscar si ya existe un ítem en esta categoría
 
 
606
  const existingIndex = selectedItems.findIndex(item => item.type === type);
607
 
608
  if (existingIndex !== -1) {
609
- // Reemplazar el ítem existente
610
- selectedItems[existingIndex] = { type, id, lib_id }; // ← Incluye lib_id
611
  } else {
612
- // Agregar nuevo ítem
613
- selectedItems.push({ type, id, lib_id }); // ← Incluye lib_id
614
  }
615
 
616
  updateSelectionDisplay();
617
  });
618
 
619
- // Controles de sprite
620
  fetch(`${img.src}&get_total=1`)
621
  .then(res => res.json())
622
  .then(data => {
@@ -629,12 +1182,14 @@
629
  itemEl.dataset.totalSprites = "1";
630
  });
631
 
 
632
  prevBtn.addEventListener('click', (e) => {
633
- e.stopPropagation(); // Evitar que se dispare el click del ítem
634
  changeSprite(img, -1, counter, itemEl);
635
  });
 
636
  nextBtn.addEventListener('click', (e) => {
637
- e.stopPropagation(); // Evitar que se dispare el click del ítem
638
  changeSprite(img, 1, counter, itemEl);
639
  });
640
  });
@@ -652,7 +1207,7 @@
652
  });
653
  }
654
 
655
- // Evento de búsqueda
656
  let searchTimeout;
657
  function getSearchQuery() {
658
  return document.getElementById('searchInput').value.trim();
@@ -665,7 +1220,7 @@
665
  }, 300);
666
  });
667
 
668
- // Cambiar elementos por página
669
  perPageInput.addEventListener('change', () => {
670
  let val = parseInt(perPageInput.value) || 10;
671
  val = Math.max(1, Math.min(val, maxPerPage));
@@ -674,7 +1229,6 @@
674
  if (currentType) loadContent(currentPage, getSearchQuery());
675
  });
676
 
677
- // Seleccionar tipo
678
  typesContainer.addEventListener('click', (e) => {
679
  if (e.target.classList.contains('type-btn')) {
680
  document.querySelectorAll('.type-btn').forEach(btn => btn.classList.remove('active'));
@@ -700,7 +1254,128 @@
700
  loadContent(currentPage, getSearchQuery());
701
  }
702
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
 
 
 
 
 
 
 
704
  // Iniciar
705
  document.querySelector('.type-btn')?.click();
706
  </script>
 
108
  display: flex;
109
  flex-direction: column;
110
  align-items: center;
111
+ cursor: pointer;
112
  }
113
  .item:hover {
114
  transform: translateY(-3px);
115
  box-shadow: 0 5px 12px rgba(0, 0, 0, 0.15);
116
  }
117
+ .item.selected {
118
+ box-shadow:
119
+ 0 0 0 3px #4299e1,
120
+ 0 3px 8px rgba(0, 0, 0, 0.1);
121
+ }
122
 
123
  /* Sprites */
124
  .sprite-container {
 
127
  height: 80px;
128
  margin-bottom: 24px;
129
  }
130
+ .image-container {
131
+ width: auto;
132
+ height: 80px;
133
+ position: relative;
134
+ margin: 0 auto;
135
+ }
136
+
137
+ .sprite-image,
138
+ .item-image {
139
  width: 100%;
140
  height: 100%;
141
  object-fit: contain;
142
  image-rendering: pixelated;
143
+ display: block; /* evita espacios extra */
144
  }
145
+ .image-loader {
146
+ position: absolute;
147
+ top: 0;
148
+ left: 0;
149
+ width: 100%;
150
+ height: 100%;
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ background: rgba(255, 255, 255, 0.7);
155
+ border-radius: 4px;
156
+ z-index: 1;
157
+ }
158
+ .spinner {
159
+ width: 24px;
160
+ height: 24px;
161
+ border: 3px solid #e2e8f0;
162
+ border-top: 3px solid #4299e1;
163
+ border-radius: 50%;
164
+ animation: spin 1s linear infinite;
165
+ }
166
+ @keyframes spin {
167
+ 0% {
168
+ transform: rotate(0deg);
169
+ }
170
+ 100% {
171
+ transform: rotate(360deg);
172
+ }
173
+ }
174
+
175
  .sprite-controls {
176
  position: absolute;
177
  bottom: -24px;
 
209
  margin-top: 4px;
210
  }
211
 
212
+ /* Selección */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  .selection-box {
214
  background: white;
215
  padding: 15px;
 
224
  color: #2d3748;
225
  word-break: break-all;
226
  min-height: 24px;
227
+ margin: 10px 0;
228
+ }
229
+ .selection-tag {
230
+ display: inline-block;
231
+ background: #e2e8f0;
232
+ color: #2d3748;
233
+ padding: 2px 6px;
234
+ border-radius: 4px;
235
+ font-family: monospace;
236
+ font-size: 13px;
237
+ cursor: pointer;
238
+ transition: background 0.2s;
239
+ }
240
+ .selection-tag:hover {
241
+ background: #cbd5e0;
242
  }
243
  .copy-button {
244
  margin-top: 10px;
 
258
  background: #cbd5e0;
259
  cursor: not-allowed;
260
  }
261
+
262
+ /* Avatar preview */
263
  .avatar-preview {
264
  background: white;
265
  padding: 15px;
 
272
  color: #a0aec0;
273
  font-style: italic;
274
  }
275
+
276
+ /* Miniaturas seleccionadas - EN COLUMNA VERTICAL */
277
  .selected-items-preview {
278
  background: white;
279
  padding: 15px;
 
282
  margin: 20px 0;
283
  }
284
  .selected-items-grid {
285
+ display: block;
286
+ margin: 0 auto;
287
+ max-width: 600px;
 
 
288
  }
289
  .selected-item-card {
290
+ display: block;
291
+ width: 100%;
292
  background: #f8fafc;
293
  border-radius: 8px;
294
+ padding: 12px;
295
  text-align: center;
296
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
297
+ margin-bottom: 20px;
298
+ cursor: pointer;
 
 
 
 
 
299
  }
300
  .selected-item-card .item-id {
301
  font-size: 10px;
 
303
  margin-top: 4px;
304
  word-break: break-all;
305
  }
306
+ .selected-item-card .itemlib_id {
307
+ font-size: 10px;
308
+ color: #4a5568;
309
+ margin-top: 4px;
310
+ word-break: break-all;
311
+ }
312
+ /* Series de paletas con scroll horizontal por fila */
313
+ .color-slots-series {
314
+ margin: 12px 0;
315
+ display: flex;
316
+ flex-direction: column;
317
+ gap: 8px;
318
+ align-items: center;
319
+ }
320
+ /* Contenedor de un slot de color con scroll vertical (10 filas máx) */
321
+ .color-slot-container {
322
+ /* 10 filas * (20px altura + 4px gap) - 4px último gap */
323
+ max-height: 236px;
324
+ overflow-y: auto;
325
+ padding-right: 6px;
326
+ scrollbar-width: thin;
327
+ scrollbar-color: #cbd5e0 transparent;
328
+ }
329
+
330
+ .color-slot-container::-webkit-scrollbar {
331
+ width: 6px;
332
+ }
333
+
334
+ .color-slot-container::-webkit-scrollbar-track {
335
+ background: transparent;
336
+ }
337
+
338
+ .color-slot-container::-webkit-scrollbar-thumb {
339
+ background: #cbd5e0;
340
+ border-radius: 3px;
341
+ }
342
+
343
+ /* Filas dentro del slot */
344
+ .color-slot-rows {
345
+ display: flex;
346
+ flex-direction: column;
347
+ gap: 4px;
348
+ }
349
+
350
+ .color-slot-row {
351
+ display: flex;
352
+ flex-wrap: wrap;
353
+ gap: 4px;
354
+ justify-content: center;
355
+ }
356
+
357
+ .color-separator {
358
+ width: 100%;
359
+ height: 1px;
360
+ background: #e2e8f0;
361
+ margin: 6px 0;
362
+ }
363
+
364
+ .color-swatch {
365
+ width: 20px;
366
+ height: 20px;
367
+ border-radius: 50%;
368
+ border: 2px solid #cbd5e0;
369
+ cursor: pointer;
370
+ transition:
371
+ transform 0.1s,
372
+ border-color 0.2s;
373
+ flex-shrink: 0;
374
+ }
375
+
376
+ .color-swatch:hover {
377
+ transform: scale(1.2);
378
+ }
379
+
380
+ .color-swatch.active {
381
+ border-color: #4299e1;
382
+ box-shadow: 0 0 0 2px #4299e1;
383
+ }
384
+
385
+ /* Paginación */
386
+ .pagination {
387
+ display: flex;
388
+ justify-content: center;
389
+ align-items: center;
390
+ gap: 12px;
391
+ margin-top: 25px;
392
+ padding: 15px;
393
+ background: white;
394
+ border-radius: 12px;
395
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
396
+ }
397
+ .pagination button {
398
+ padding: 8px 16px;
399
+ background: #4299e1;
400
+ color: white;
401
+ border: none;
402
+ border-radius: 6px;
403
+ cursor: pointer;
404
+ font-weight: bold;
405
+ }
406
+ .pagination button:disabled {
407
+ background: #cbd5e0;
408
+ cursor: not-allowed;
409
+ }
410
+ .pagination span {
411
+ font-weight: bold;
412
+ color: #2d3748;
413
+ }
414
+ .loading {
415
+ grid-column: 1 / -1;
416
+ text-align: center;
417
+ padding: 30px;
418
+ color: #a0aec0;
419
+ }
420
+ .error {
421
+ grid-column: 1 / -1;
422
+ text-align: center;
423
+ padding: 20px;
424
+ color: #e53e3e;
425
+ background: #fed7d7;
426
+ border-radius: 8px;
427
+ }
428
  </style>
429
  </head>
430
  <body>
 
460
  <input
461
  type="text"
462
  id="searchInput"
463
+ placeholder="Buscar por ID, lib_id o parte (ej: hr, 100)"
464
  />
465
  </div>
466
 
 
470
  Selecciona una categoría para comenzar
471
  </div>
472
  </div>
473
+ <!-- Cargador de figurestring -->
 
474
  <div
475
+ class="load-box"
476
+ style="
477
+ background: white;
478
+ padding: 15px;
479
+ border-radius: 12px;
480
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
481
+ margin: 20px 0;
482
+ "
483
  >
484
+ <h3>Cargar figura</h3>
485
+ <div style="display: flex; gap: 10px; margin-top: 10px">
486
+ <input
487
+ type="text"
488
+ id="figurestringLoad"
489
+ placeholder="Ej: hr-100-0.ch-200-1"
490
+ style="
491
+ flex: 1;
492
+ padding: 8px;
493
+ border: 1px solid #cbd5e0;
494
+ border-radius: 6px;
495
+ "
496
+ />
497
+ <button
498
+ id="loadButton"
499
+ style="
500
+ padding: 8px 16px;
501
+ background: #4299e1;
502
+ color: white;
503
+ border: none;
504
+ border-radius: 6px;
505
+ cursor: pointer;
506
+ "
507
+ >
508
+ Cargar
509
+ </button>
510
+ </div>
511
+ <div
512
+ id="loadMessage"
513
+ style="margin-top: 10px; min-height: 20px; font-size: 12px"
514
+ ></div>
515
  </div>
516
+ <!-- Selección actual -->
517
+ <div class="selection-box">
518
+ <h3>Selección actual:</h3>
519
+ <div id="selectionOutput" class="selection-output">
520
+ Ningún ítem seleccionado
521
  </div>
522
+ <button id="copyButton" class="copy-button" disabled>
523
+ Copiar
524
+ </button>
525
  </div>
526
+
527
  <!-- Visor de avatar -->
528
  <div class="avatar-preview">
529
  <h3>Vista previa del avatar:</h3>
 
542
  Selecciona ítems para ver el avatar
543
  </div>
544
  </div>
545
+ <!-- Controles avanzados del avatar -->
546
+ <div
547
+ class="avatar-controls"
548
+ style="
549
+ background: white;
550
+ padding: 15px;
551
+ border-radius: 12px;
552
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
553
+ margin: 20px 0;
554
+ "
555
+ >
556
+ <h3>Controles del avatar</h3>
557
+
558
+ <!-- Dirección -->
559
+ <div style="margin: 10px 0">
560
+ <label>Dirección:</label>
561
+ <select
562
+ id="directionSelect"
563
+ style="
564
+ margin-left: 10px;
565
+ padding: 4px;
566
+ border: 1px solid #cbd5e0;
567
+ border-radius: 4px;
568
+ "
569
+ >
570
+ <option value="0">0 - Espalda</option>
571
+ <option value="1">1</option>
572
+ <option value="2" selected>2 - Frente</option>
573
+ <option value="3">3</option>
574
+ <option value="4">4 - Izquierda</option>
575
+ <option value="5">5</option>
576
+ <option value="6">6 - Derecha</option>
577
+ <option value="7">7</option>
578
+ </select>
579
+
580
+ <label style="margin-left: 15px">Cabeza:</label>
581
+ <select
582
+ id="headDirectionSelect"
583
+ style="
584
+ margin-left: 10px;
585
+ padding: 4px;
586
+ border: 1px solid #cbd5e0;
587
+ border-radius: 4px;
588
+ "
589
+ >
590
+ <option value="0">0 - Espalda</option>
591
+ <option value="1">1</option>
592
+ <option value="2" selected>2 - Frente</option>
593
+ <option value="3">3</option>
594
+ <option value="4">4 - Izquierda</option>
595
+ <option value="5">5</option>
596
+ <option value="6">6 - Derecha</option>
597
+ <option value="7">7</option>
598
+ </select>
599
+ </div>
600
+
601
+ <!-- Postura y acción -->
602
+ <div style="margin: 10px 0">
603
+ <label>Postura:</label>
604
+ <select
605
+ id="postureSelect"
606
+ style="
607
+ margin-left: 10px;
608
+ padding: 4px;
609
+ border: 1px solid #cbd5e0;
610
+ border-radius: 4px;
611
+ "
612
+ >
613
+ <option value="std">Estándar</option>
614
+ <option value="wlk">Caminando</option>
615
+ <option value="sit">Sentado</option>
616
+ <option value="lay">Acostado</option>
617
+ </select>
618
+
619
+ <label style="margin-left: 15px">Expresión:</label>
620
+ <select
621
+ id="expressionSelect"
622
+ style="
623
+ margin-left: 10px;
624
+ padding: 4px;
625
+ border: 1px solid #cbd5e0;
626
+ border-radius: 4px;
627
+ "
628
+ >
629
+ <option value="">Ninguna</option>
630
+ <option value="wav">Saludando</option>
631
+ <option value="blow">Beso</option>
632
+ <option value="laugh">Risa</option>
633
+ <option value="respect">Respeto</option>
634
+ </select>
635
+ </div>
636
+
637
+ <!-- Gestos y baile -->
638
+ <div style="margin: 10px 0">
639
+ <label>Gesto:</label>
640
+ <select
641
+ id="gestureSelect"
642
+ style="
643
+ margin-left: 10px;
644
+ padding: 4px;
645
+ border: 1px solid #cbd5e0;
646
+ border-radius: 4px;
647
+ "
648
+ >
649
+ <option value="std">Estándar</option>
650
+ <option value="agr">Enojado</option>
651
+ <option value="sad">Triste</option>
652
+ <option value="sml">Sonriendo</option>
653
+ <option value="srp">Sorprendido</option>
654
+ </select>
655
+
656
+ <label style="margin-left: 15px">Baile:</label>
657
+ <select
658
+ id="danceSelect"
659
+ style="
660
+ margin-left: 10px;
661
+ padding: 4px;
662
+ border: 1px solid #cbd5e0;
663
+ border-radius: 4px;
664
+ "
665
+ >
666
+ <option value="0">No bailar</option>
667
+ <option value="1">Baile 1</option>
668
+ <option value="2">Baile 2</option>
669
+ <option value="3">Baile 3</option>
670
+ <option value="4">Baile 4</option>
671
+ </select>
672
+ </div>
673
+
674
+ <!-- Tamaño y efectos -->
675
+ <div style="margin: 10px 0">
676
+ <label>Tamaño:</label>
677
+ <select
678
+ id="sizeSelect"
679
+ style="
680
+ margin-left: 10px;
681
+ padding: 4px;
682
+ border: 1px solid #cbd5e0;
683
+ border-radius: 4px;
684
+ "
685
+ >
686
+ <option value="s">Pequeño</option>
687
+ <option value="n" selected>Normal</option>
688
+ <option value="l">Grande</option>
689
+ </select>
690
+
691
+ <label style="margin-left: 15px">Efecto:</label>
692
+ <input
693
+ type="number"
694
+ id="effectInput"
695
+ min="0"
696
+ max="999"
697
+ value="0"
698
+ style="
699
+ margin-left: 10px;
700
+ width: 60px;
701
+ padding: 4px;
702
+ border: 1px solid #cbd5e0;
703
+ border-radius: 4px;
704
+ "
705
+ />
706
+ </div>
707
+
708
+ <button
709
+ id="applyAvatarBtn"
710
+ style="
711
+ margin-top: 10px;
712
+ padding: 6px 12px;
713
+ background: #4299e1;
714
+ color: white;
715
+ border: none;
716
+ border-radius: 6px;
717
+ cursor: pointer;
718
+ "
719
+ >
720
+ Aplicar cambios
721
+ </button>
722
+ </div>
723
+
724
+ <!-- Miniaturas seleccionadas -->
725
+ <div class="selected-items-preview">
726
+ <h3>Ítems seleccionados:</h3>
727
+ <div id="selectedItemsGrid" class="selected-items-grid">
728
+ <div
729
+ style="color: #a0aec0; width: 100%; text-align: center"
730
+ >
731
+ No hay ítems seleccionados
732
+ </div>
733
+ </div>
734
+ </div>
735
+
736
+ <!-- Paginación -->
737
+ <div
738
+ class="pagination"
739
+ id="paginationControls"
740
+ style="display: none"
741
+ >
742
+ <button id="prevBtn">◀ Anterior</button>
743
+ <span
744
+ >Página <span id="currentPage">1</span> de
745
+ <span id="totalPages">1</span></span
746
+ >
747
+ <button id="nextBtn">Siguiente ▶</button>
748
+ </div>
749
  </div>
750
 
751
  <script>
752
  // Estado global
753
+ const selectedItems = []; // [{ type, id, lib_id, colors, colorValues }, ...]
754
+ dataCache = {}; // { tipo: [items] }
755
+ const avatarState = {
756
+ direction: 2,
757
+ headDirection: 2,
758
+ posture: 'std',
759
+ expression: '',
760
+ gesture: 'std',
761
+ dance: 0,
762
+ size: 'n',
763
+ effect: 0
764
+ };
765
+ // Reemplazar setupImageLoader con Intersection Observer
766
+ function setupLazyImage(imgElement, loaderElement) {
767
+ const observer = new IntersectionObserver((entries) => {
768
+ entries.forEach(entry => {
769
+ if (entry.isIntersecting) {
770
+ // Cargar imagen normalmente
771
+ setupImageLoader(imgElement, loaderElement);
772
+ observer.unobserve(imgElement);
773
+ }
774
+ });
775
+ });
776
+ observer.observe(imgElement);
777
+ }
778
+ // Función reutilizable para imágenes con loader
779
+ function setupImageLoader(imgElement, loaderElement) {
780
 
781
+ if (imgElement.complete && imgElement.naturalHeight !== 0) {
782
+ if (loaderElement) loaderElement.style.display = 'none';
783
+ imgElement.style.opacity = '1';
784
+ return;
 
 
785
  }
786
+
787
+ if (loaderElement) loaderElement.style.display = 'flex';
788
+ imgElement.style.opacity = '0';
789
+
790
+ imgElement.onload = () => {
791
+ if (loaderElement) loaderElement.style.display = 'none';
792
+ imgElement.style.opacity = '1';
793
+ imgElement.style.transition = 'opacity 0.3s';
794
+ };
795
+
796
+ imgElement.onerror = () => {
797
+ if (loaderElement) {
798
+ loaderElement.innerHTML = '<span style="color:#e53e3e; font-size:20px">❌</span>';
799
+ }
800
+ };
801
  }
 
802
 
803
+ // Cambiar sprite
804
+ function changeSprite(img, direction, counter, itemEl) {
805
+ const currentIndex = parseInt(img.dataset.spriteIndex);
806
+ const total = parseInt(itemEl.dataset.totalSprites);
807
+ let newIndex = currentIndex + direction;
808
 
809
+ if (newIndex < 0) newIndex = total - 1;
810
+ if (newIndex >= total) newIndex = 0;
 
 
 
 
 
 
 
 
811
 
812
+ const url = new URL(img.src);
813
+ url.searchParams.set('sprite', newIndex);
814
+ img.src = url.toString();
815
+ img.dataset.spriteIndex = newIndex;
816
+ counter.textContent = `${newIndex + 1} / ${total}`;
 
 
 
817
  }
818
 
819
  // Generar URL del avatar
820
+ // Estado del avatar
821
+ // Función para construir URLs correctamente
822
+ function buildApiUrl(basePath, params) {
823
+ const urlParams = new URLSearchParams();
824
+
825
+ // Parámetros específicos del endpoint
826
+ for (const [key, value] of Object.entries(params)) {
827
+ urlParams.append(key, value);
828
+ }
829
+
830
+ // Parámetros globales de la URL actual
831
+ const currentParams = new URLSearchParams(window.location.search);
832
+ for (const [key, value] of currentParams) {
833
+ if (!['page', 'per_page', 'q', 'type'].includes(key)) {
834
+ urlParams.append(key, value);
835
+ }
836
+ }
837
+
838
+ return `${basePath}?${urlParams.toString()}`;
839
+ }
840
+
841
  function generateAvatarUrl() {
842
  if (selectedItems.length === 0) return null;
843
 
844
+ const figureParts = selectedItems.map(item => {
845
+ const colorStr = item.colorValues ? item.colorValues.join('-') : '0';
846
+ return `${item.type}-${item.id}-${colorStr}`;
847
+ }).join('.');
848
+
849
+ // Construir acción combinada
850
+ let action = avatarState.posture;
851
+ if (avatarState.expression) {
852
+ action += `,${avatarState.expression}`;
853
+ }
854
 
 
855
  const urlParams = new URLSearchParams(window.location.search);
856
  let avatarUrlTemplate = urlParams.get('avatar_url') ||
857
+ 'https://www.hartico.tv/HabboFigure/%figure%&action={action}&gesture={gesture}&direction={direction}&head_direction={head_direction}&headonly=0&dance={dance}&effect={effect}&size={size}&frame_num=0&img_format=png';
858
 
859
+ return avatarUrlTemplate
860
+ .replace('%figure%', figureParts)
861
+ .replace('{action}', action)
862
+ .replace('{gesture}', avatarState.gesture)
863
+ .replace('{direction}', avatarState.direction)
864
+ .replace('{head_direction}', avatarState.headDirection)
865
+ .replace('{dance}', avatarState.dance)
866
+ .replace('{effect}', avatarState.effect)
867
+ .replace('{size}', avatarState.size);
868
  }
869
+ // Actualizar todo
 
 
870
  function updateSelectionDisplay() {
871
  const copyButton = document.getElementById('copyButton');
872
  const avatarImage = document.getElementById('avatarImage');
 
882
  return;
883
  }
884
 
885
+ const plainText = selectedItems.map(item => {
886
+ const colorStr = item.colorValues ? item.colorValues.join('-') : '0';
887
+ return `${item.type}-${item.id}-${colorStr}`;
888
+ }).join('.');
889
 
 
890
  const tags = selectedItems.map((item, index) =>
891
+ `<span class="selection-tag" data-index="${index}">${item.type}-${item.id}-${item.colorValues?.join('-') || '62'}</span>`
892
  ).join('.');
893
 
894
  selectionOutput.innerHTML = tags;
895
  copyButton.disabled = false;
896
 
897
+ // En updateSelectionDisplay
898
  const avatarUrl = generateAvatarUrl();
899
  if (avatarUrl) {
900
+ // Forzar recarga incluso si la URL es la misma
901
+ avatarImage.src = avatarUrl + '&t=' + Date.now();
902
  avatarImage.style.display = 'inline-block';
903
  avatarPlaceholder.style.display = 'none';
904
+ } else {
905
+ avatarImage.style.display = 'none';
906
+ avatarPlaceholder.style.display = 'block';
907
  }
908
 
909
+ // Generar miniaturas con selectores en serie (10 por fila)
910
+ const miniaturasHTML = selectedItems.map((item, index) => {
911
+ const categoryItems = dataCache[item.type]|| dataCache["Todos"] || [];
912
+ const matchingItem = categoryItems.find(i => i.id == item.id);
913
+ const palette = matchingItem?.palette || [];
914
+ const colors = item.colors || 0;
915
+
916
+ // Generar slots de color en serie
917
+ let colorSlotsHTML = '';
918
+ if (colors > 0) {
919
+ for (let slot = 0; slot < colors; slot++) {
920
+ // Swatches para este slot
921
+ // Generar todos los swatches
922
+ const allSwatches = [
923
+ `<button
924
+ class="color-swatch ${(item.colorValues?.[slot] === '0' || !item.colorValues) ? 'active' : ''}"
925
+ data-color="0"
926
+ style="background: #e2e8f0;"
927
+ title="Color base">
928
+ </button>`
929
+ ];
930
+
931
+ allSwatches.push(...palette.map(color => `
932
+ <button
933
+ class="color-swatch ${(item.colorValues?.[slot] == color.color) ? 'active' : ''}"
934
+ data-color="${color.color}"
935
+ data-indice="${color.index}"
936
+ style="background: ${color.hexColor};"
937
+ title="Color ${color.color}, index:${color.index}">
938
+ </button>
939
+ `));
940
+
941
+ // Agrupar en filas de 10
942
+ const rowsHTML = [];
943
+ for (let i = 0; i < allSwatches.length; i += 10) {
944
+ const rowSwatches = allSwatches.slice(i, i + 10).join('');
945
+ rowsHTML.push(`<div class="color-slot-row">${rowSwatches}</div>`);
946
+ }
947
+
948
+ // Envolver en contenedor con scroll
949
+ colorSlotsHTML += `
950
+ <div class="color-slot-container">
951
+ <div class="color-slot-rows">
952
+ ${rowsHTML.join('')}
953
+ </div>
954
+ </div>
955
+ `;
956
+
957
+ // Agregar separador (excepto después del último slot)
958
+ if (slot < colors - 1) {
959
+ colorSlotsHTML += '<div class="color-separator"></div>';
960
+ }
961
  }
962
  }
 
963
 
964
+ // Usar lib_id original para la imagen base
965
+ // DESPUÉS:
966
+ const spriteParams = `sprite=0`;
967
+ const imageUrl = buildApiUrl(`/api/images/${item.lib_id}.nitro`, {
968
+ sprite: 0
969
+ });
970
  return `
971
  <div class="selected-item-card" data-index="${index}">
972
+ <div class="image-container">
973
+ <div class="image-loader">
974
+ <div class="spinner"></div>
975
+ </div>
976
+ <img src="${imageUrl}" alt="${item.lib_id}" class="item-image">
977
+ </div>
978
  <div class="item-id">${item.type}-${item.id}</div>
979
+ <div class="itemlib_id">${item.lib_id}</div>
980
+ ${colorSlotsHTML ? `<div class="color-slots-series">${colorSlotsHTML}</div>` : ''}
981
  </div>
982
  `;
983
  }).join('');
984
 
985
+ selectedItemsGrid.innerHTML = miniaturasHTML;
986
+
987
+ // Inicializar loaders y eventos de miniaturas
988
+ setTimeout(() => {
989
+ document.querySelectorAll('.selected-item-card').forEach(card => {
990
+ const img = card.querySelector('.item-image');
991
+ const loader = card.querySelector('.image-loader');
992
+ setupLazyImage(img, loader);
993
+
994
+ // Evento de clic para eliminar (excepto en swatches)
995
+ card.addEventListener('click', (e) => {
996
+ if (e.target.closest('.color-swatch')) return;
997
+ const index = parseInt(card.dataset.index);
998
+ selectedItems.splice(index, 1);
999
+ updateSelectionDisplay();
1000
+ });
1001
 
1002
+ // Eventos de color corregidos
1003
+ const colorSlotContainers = card.querySelectorAll('.color-slot-container');
1004
+ colorSlotContainers.forEach((container, slotIndex) => {
1005
+ const swatches = container.querySelectorAll('.color-swatch');
1006
+ swatches.forEach(swatch => {
1007
+ swatch.addEventListener('click', (e) => {
1008
+ e.stopPropagation();
1009
+
1010
+ // Actualizar valor de color
1011
+ const colorValue = swatch.dataset.color;
1012
+ const index = parseInt(card.dataset.index);
1013
+
1014
+ // Asegurar que exista el array de valores
1015
+ if (!selectedItems[index].colorValues) {
1016
+ selectedItems[index].colorValues = Array(selectedItems[index].colors).fill('0');
1017
+ }
1018
+
1019
+ // Actualizar SOLO el slot correspondiente
1020
+ selectedItems[index].colorValues[slotIndex] = colorValue;
1021
+
1022
+ // Actualizar vista
1023
+ updateSelectionDisplay();
1024
+ });
1025
+ });
1026
+ });
1027
  });
1028
+ }, 0);
1029
 
1030
  // Evento de copiar
1031
  copyButton.onclick = async () => {
 
1043
  };
1044
  }
1045
 
1046
+ // Parámetros de URL
1047
+ const urlParams = new URLSearchParams(window.location.search);
1048
+ // Generar baseUrlParams SIN el primer "?"
1049
+ let baseUrlParams = "";
1050
+ for (const [key, value] of urlParams) {
1051
+ if (!['page', 'per_page', 'q', 'type'].includes(key)) {
1052
+ baseUrlParams += `${baseUrlParams ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
1053
+ }
1054
+ }
1055
+ if (baseUrlParams) baseUrlParams = "?" + baseUrlParams.substring(1);
1056
 
1057
+ let currentType = null;
1058
+ let currentPage = 1;
1059
+ let perPage = 10;
1060
+ const maxPerPage = {{ max_per_page }};
1061
 
1062
+ // Elementos del DOM
1063
+ const typesContainer = document.getElementById('typesContainer');
1064
+ const grid = document.getElementById('furnisGrid');
1065
+ const paginationEl = document.getElementById('paginationControls');
1066
+ const prevBtn = document.getElementById('prevBtn');
1067
+ const nextBtn = document.getElementById('nextBtn');
1068
+ const currentPageEl = document.getElementById('currentPage');
1069
+ const totalPagesEl = document.getElementById('totalPages');
1070
+ const perPageInput = document.getElementById('perPageInput');
1071
+ const selectionOutput = document.getElementById('selectionOutput');
1072
 
1073
  // Cargar contenido
1074
  function loadContent(page, searchQuery = '') {
1075
+
1076
  if (!currentType) return;
1077
 
1078
  const params = new URLSearchParams();
 
1088
  }
1089
  }
1090
 
1091
+ const apiUrl = buildApiUrl('/api/findfurni', {
1092
+ type: currentType,
1093
+ page: page,
1094
+ per_page: perPageInput.value || 10,
1095
+ q: searchQuery
1096
+ });
1097
  fetch(apiUrl)
1098
  .then(response => response.json())
1099
  .then(data => {
 
1102
  return;
1103
  }
1104
 
1105
+ // Almacenar en caché para acceder a la paleta
1106
+ dataCache[currentType] = data.items;
1107
+ console.log(dataCache)
1108
+
1109
  if (data.items.length === 0) {
1110
  grid.innerHTML = '<div class="loading">No se encontraron resultados.</div>';
1111
  } else {
1112
+ grid.innerHTML = data.items.map(item => {
1113
+ const isSelected = selectedItems.some(
1114
+ sel => sel.type === item.type && sel.id == item.id
1115
+ );
1116
+
1117
+ return `
1118
+ <div class="item ${isSelected ? 'selected' : ''}"
1119
+ data-lib-id="${item.lib_id}"
1120
+ data-id="${item.id}"
1121
+ data-type="${item.type}">
1122
+ <div class="sprite-container">
1123
+ <div class="image-container">
1124
+ <div class="image-loader">
1125
+ <div class="spinner"></div>
1126
+ </div>
1127
+ <img src="${item.image_url}&sprite=0" alt="${item.lib_id}" class="sprite-image" data-sprite-index="0">
1128
+ </div>
1129
+ <div class="sprite-controls">
1130
+ <button class="sprite-btn prev" data-action="prev">◀</button>
1131
+ <span class="sprite-counter">1 / ?</span>
1132
+ <button class="sprite-btn next" data-action="next">▶</button>
1133
+ </div>
1134
  </div>
1135
+ <div class="id">${item.lib_id}</div>
1136
  </div>
1137
+ `;
1138
+ }).join('');
 
1139
 
1140
+ // Inicializar loaders e interacciones
1141
  document.querySelectorAll('.item').forEach(itemEl => {
1142
  const img = itemEl.querySelector('.sprite-image');
1143
+ const loader = itemEl.querySelector('.image-loader');
1144
  const counter = itemEl.querySelector('.sprite-counter');
1145
  const prevBtn = itemEl.querySelector('.prev');
1146
  const nextBtn = itemEl.querySelector('.next');
1147
 
1148
+ setupLazyImage(img, loader);
1149
+
1150
+ // Evento de selección
1151
+ itemEl.addEventListener('click', (e) => {
1152
+ if (e.target.closest('.sprite-controls')) return;
1153
+
1154
  const type = itemEl.dataset.type;
1155
  const id = itemEl.dataset.id;
1156
+ const lib_id = itemEl.dataset.libId;
1157
+ const categoryItems = dataCache[type] || dataCache["Todos"] || [];
1158
+ const matchingItem = categoryItems.find(i => i.id == id);
1159
+ const colors = matchingItem?.colors || 0;
1160
+ console.log(matchingItem)
1161
  const existingIndex = selectedItems.findIndex(item => item.type === type);
1162
 
1163
  if (existingIndex !== -1) {
1164
+ selectedItems[existingIndex] = { type, id, lib_id, colors };
 
1165
  } else {
1166
+ selectedItems.push({ type, id, lib_id, colors });
 
1167
  }
1168
 
1169
  updateSelectionDisplay();
1170
  });
1171
 
1172
+ // Obtener total de sprites
1173
  fetch(`${img.src}&get_total=1`)
1174
  .then(res => res.json())
1175
  .then(data => {
 
1182
  itemEl.dataset.totalSprites = "1";
1183
  });
1184
 
1185
+ // Eventos de navegación
1186
  prevBtn.addEventListener('click', (e) => {
1187
+ e.stopPropagation();
1188
  changeSprite(img, -1, counter, itemEl);
1189
  });
1190
+
1191
  nextBtn.addEventListener('click', (e) => {
1192
+ e.stopPropagation();
1193
  changeSprite(img, 1, counter, itemEl);
1194
  });
1195
  });
 
1207
  });
1208
  }
1209
 
1210
+ // Búsqueda
1211
  let searchTimeout;
1212
  function getSearchQuery() {
1213
  return document.getElementById('searchInput').value.trim();
 
1220
  }, 300);
1221
  });
1222
 
1223
+ // Controles
1224
  perPageInput.addEventListener('change', () => {
1225
  let val = parseInt(perPageInput.value) || 10;
1226
  val = Math.max(1, Math.min(val, maxPerPage));
 
1229
  if (currentType) loadContent(currentPage, getSearchQuery());
1230
  });
1231
 
 
1232
  typesContainer.addEventListener('click', (e) => {
1233
  if (e.target.classList.contains('type-btn')) {
1234
  document.querySelectorAll('.type-btn').forEach(btn => btn.classList.remove('active'));
 
1254
  loadContent(currentPage, getSearchQuery());
1255
  }
1256
  });
1257
+ // Cargador de figurestring inteligente
1258
+ document.getElementById('loadButton').addEventListener('click', async () => {
1259
+ const input = document.getElementById('figurestringLoad');
1260
+ const message = document.getElementById('loadMessage');
1261
+ const figurestring = input.value.trim();
1262
+
1263
+ if (!figurestring) {
1264
+ message.innerHTML = '<span style="color:#e53e3e;">Ingresa una figurestring válida</span>';
1265
+ return;
1266
+ }
1267
+
1268
+ try {
1269
+ message.innerHTML = 'Cargando...';
1270
+ selectedItems.length = 0;
1271
+
1272
+ // Parsear figurestring
1273
+ const parts = figurestring.split('.');
1274
+ const loadPromises = [];
1275
+
1276
+ for (const part of parts) {
1277
+ const tokens = part.split('-');
1278
+ if (tokens.length < 2) continue;
1279
+
1280
+ const ptype = tokens[0];
1281
+ const pid = tokens[1];
1282
+ const colors = tokens.slice(2).length > 0 ? tokens.slice(2) : ['0'];
1283
+
1284
+ // Hacer llamada a findfurni para obtener datos reales
1285
+ const promise = fetch(`/api/findfurni?type=${ptype}&q=${pid}${baseUrlParams}`)
1286
+ .then(response => response.json())
1287
+ .then(data => {
1288
+ if (data.items && data.items.length > 0) {
1289
+ // Encontrado - usar primer resultado
1290
+ const item = data.items[0];
1291
+ selectedItems.push({
1292
+ type: ptype,
1293
+ id: pid,
1294
+ lib_id: item.lib_id,
1295
+ colors: colors.length,
1296
+ colorValues: colors,
1297
+ palette: item.palette || []
1298
+ });
1299
+ } else {
1300
+ // No encontrado - placeholder
1301
+ selectedItems.push({
1302
+ type: ptype,
1303
+ id: pid,
1304
+ lib_id: `${ptype}-${pid}`,
1305
+ colors: colors.length,
1306
+ colorValues: colors,
1307
+ palette: [],
1308
+ isPlaceholder: true
1309
+ });
1310
+ }
1311
+ })
1312
+ .catch(err => {
1313
+ // Error - placeholder
1314
+ selectedItems.push({
1315
+ type: ptype,
1316
+ id: pid,
1317
+ lib_id: `${ptype}-${pid}`,
1318
+ colors: colors.length,
1319
+ colorValues: colors,
1320
+ palette: [],
1321
+ isPlaceholder: true
1322
+ });
1323
+ });
1324
+
1325
+ loadPromises.push(promise);
1326
+ }
1327
+
1328
+ // Esperar todas las llamadas
1329
+ await Promise.all(loadPromises);
1330
+
1331
+ message.innerHTML = `<span style="color:#48bb78;">¡Figura cargada!</span>`;
1332
+ input.value = '';
1333
+ updateSelectionDisplay();
1334
+
1335
+ setTimeout(() => {
1336
+ message.innerHTML = '';
1337
+ }, 3000);
1338
+
1339
+ } catch (err) {
1340
+ console.error('Error al cargar:', err);
1341
+ message.innerHTML = '<span style="color:#e53e3e;">Error al cargar la figura</span>';
1342
+ }
1343
+ });
1344
+
1345
+ // Permitir cargar con Enter
1346
+ document.getElementById('figurestringLoad').addEventListener('keypress', (e) => {
1347
+ if (e.key === 'Enter') {
1348
+ document.getElementById('loadButton').click();
1349
+ }
1350
+ });
1351
+ // Inicializar controles del avatar
1352
+ document.addEventListener('DOMContentLoaded', () => {
1353
+ // Aplicar cambios al cambiar cualquier control
1354
+ const controls = [
1355
+ 'directionSelect', 'headDirectionSelect', 'postureSelect',
1356
+ 'expressionSelect', 'gestureSelect', 'danceSelect', 'sizeSelect', 'effectInput'
1357
+ ];
1358
+
1359
+ controls.forEach(id => {
1360
+ const element = document.getElementById(id);
1361
+ if (element) {
1362
+ element.addEventListener('change', () => {
1363
+ // Actualizar avatarState
1364
+ avatarState.direction = parseInt(document.getElementById('directionSelect').value) || 2;
1365
+ avatarState.headDirection = parseInt(document.getElementById('headDirectionSelect').value) || 2;
1366
+ avatarState.posture = document.getElementById('postureSelect').value || 'std';
1367
+ avatarState.expression = document.getElementById('expressionSelect').value || '';
1368
+ avatarState.gesture = document.getElementById('gestureSelect').value || 'std';
1369
+ avatarState.dance = parseInt(document.getElementById('danceSelect').value) || 0;
1370
+ avatarState.size = document.getElementById('sizeSelect').value || 'n';
1371
+ avatarState.effect = parseInt(document.getElementById('effectInput').value) || 0;
1372
 
1373
+ // Actualizar vista
1374
+ updateSelectionDisplay();
1375
+ });
1376
+ }
1377
+ });
1378
+ });
1379
  // Iniciar
1380
  document.querySelector('.type-btn')?.click();
1381
  </script>
templates/efectos.html ADDED
@@ -0,0 +1,708 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>✨ Efectos Nitro - Avatar Studio</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ :root {
16
+ --primary: #ff00ff;
17
+ --secondary: #00ffff;
18
+ --dark: #0a0a1a;
19
+ --light: #ffffff;
20
+ --gradient-angle: 135deg;
21
+ }
22
+
23
+ body {
24
+ background: linear-gradient(var(--gradient-angle), #0f0c29, #302b63, #24243e);
25
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
26
+ min-height: 100vh;
27
+ padding: 20px;
28
+ color: var(--light);
29
+ overflow-x: hidden;
30
+ }
31
+
32
+ .container {
33
+ max-width: 1400px;
34
+ margin: 0 auto;
35
+ }
36
+
37
+ /* ===== HEADER ===== */
38
+ header {
39
+ text-align: center;
40
+ padding: 30px 0;
41
+ margin-bottom: 30px;
42
+ position: relative;
43
+ }
44
+
45
+ header::before {
46
+ content: '';
47
+ position: absolute;
48
+ top: 0;
49
+ left: 0;
50
+ right: 0;
51
+ height: 4px;
52
+ background: linear-gradient(90deg, transparent, var(--primary), var(--secondary), transparent);
53
+ animation: glow 2s ease-in-out infinite;
54
+ }
55
+
56
+ @keyframes glow {
57
+ 0%, 100% { opacity: 0.7; }
58
+ 50% { opacity: 1; }
59
+ }
60
+
61
+ h1 {
62
+ font-size: 3.5rem;
63
+ margin-bottom: 10px;
64
+ background: linear-gradient(90deg, var(--primary), var(--secondary), #ff9966, var(--primary));
65
+ -webkit-background-clip: text;
66
+ -webkit-text-fill-color: transparent;
67
+ background-size: 300% 300%;
68
+ animation: gradientShift 8s ease infinite;
69
+ text-shadow: 0 0 20px rgba(255, 0, 255, 0.5);
70
+ letter-spacing: 2px;
71
+ }
72
+
73
+ @keyframes gradientShift {
74
+ 0% { background-position: 0% 50%; }
75
+ 50% { background-position: 100% 50%; }
76
+ 100% { background-position: 0% 50%; }
77
+ }
78
+
79
+ .subtitle {
80
+ font-size: 1.2rem;
81
+ color: #aaa;
82
+ margin-top: 10px;
83
+ font-weight: 300;
84
+ }
85
+
86
+ /* ===== SEARCH & FILTERS ===== */
87
+ .controls {
88
+ background: rgba(20, 20, 40, 0.8);
89
+ backdrop-filter: blur(10px);
90
+ border-radius: 15px;
91
+ padding: 20px;
92
+ margin-bottom: 30px;
93
+ border: 1px solid rgba(255, 0, 255, 0.2);
94
+ box-shadow: 0 0 30px rgba(255, 0, 255, 0.1);
95
+ }
96
+
97
+ .search-container {
98
+ display: flex;
99
+ gap: 15px;
100
+ flex-wrap: wrap;
101
+ align-items: center;
102
+ }
103
+
104
+ .search-box {
105
+ flex: 1;
106
+ min-width: 300px;
107
+ }
108
+
109
+ .search-box input {
110
+ width: 100%;
111
+ padding: 12px 20px;
112
+ border: 2px solid var(--primary);
113
+ border-radius: 50px;
114
+ background: rgba(30, 30, 60, 0.8);
115
+ color: white;
116
+ font-size: 1rem;
117
+ transition: all 0.3s ease;
118
+ }
119
+
120
+ .search-box input:focus {
121
+ outline: none;
122
+ border-color: var(--secondary);
123
+ box-shadow: 0 0 20px rgba(0, 255, 255, 0.3);
124
+ }
125
+
126
+ .search-box input::placeholder {
127
+ color: #888;
128
+ }
129
+
130
+ .filter-btn {
131
+ padding: 12px 25px;
132
+ background: linear-gradient(90deg, var(--primary), var(--secondary));
133
+ border: none;
134
+ border-radius: 50px;
135
+ color: white;
136
+ font-weight: bold;
137
+ cursor: pointer;
138
+ transition: all 0.3s ease;
139
+ box-shadow: 0 5px 15px rgba(255, 0, 255, 0.4);
140
+ }
141
+
142
+ .filter-btn:hover {
143
+ transform: translateY(-2px);
144
+ box-shadow: 0 8px 25px rgba(255, 0, 255, 0.6);
145
+ }
146
+
147
+ .filter-btn:active {
148
+ transform: translateY(1px);
149
+ }
150
+
151
+ /* ===== STATS BAR ===== */
152
+ .stats-bar {
153
+ display: flex;
154
+ justify-content: space-between;
155
+ align-items: center;
156
+ background: rgba(30, 30, 60, 0.8);
157
+ padding: 15px 25px;
158
+ border-radius: 10px;
159
+ margin-bottom: 25px;
160
+ border-left: 4px solid var(--secondary);
161
+ }
162
+
163
+ .total-count {
164
+ font-size: 1.3rem;
165
+ font-weight: bold;
166
+ color: var(--secondary);
167
+ }
168
+
169
+ .view-toggle {
170
+ display: flex;
171
+ gap: 10px;
172
+ }
173
+
174
+ .view-btn {
175
+ padding: 8px 15px;
176
+ background: rgba(50, 50, 100, 0.7);
177
+ border: 2px solid var(--primary);
178
+ border-radius: 8px;
179
+ color: white;
180
+ cursor: pointer;
181
+ transition: all 0.3s ease;
182
+ }
183
+
184
+ .view-btn.active {
185
+ background: var(--primary);
186
+ border-color: white;
187
+ box-shadow: 0 0 15px rgba(255, 0, 255, 0.5);
188
+ }
189
+
190
+ .view-btn:hover:not(.active) {
191
+ background: rgba(255, 0, 255, 0.3);
192
+ }
193
+
194
+ /* ===== EFFECTS GRID ===== */
195
+ .effects-grid {
196
+ display: grid;
197
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
198
+ gap: 25px;
199
+ padding: 10px;
200
+ }
201
+
202
+ .effect-card {
203
+ background: rgba(30, 30, 60, 0.7);
204
+ border-radius: 15px;
205
+ overflow: hidden;
206
+ border: 2px solid transparent;
207
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
208
+ position: relative;
209
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
210
+ }
211
+
212
+ .effect-card::before {
213
+ content: '';
214
+ position: absolute;
215
+ top: 0;
216
+ left: 0;
217
+ right: 0;
218
+ bottom: 0;
219
+ background: linear-gradient(45deg, transparent, rgba(255, 0, 255, 0.1), transparent);
220
+ z-index: 1;
221
+ opacity: 0;
222
+ transition: opacity 0.3s ease;
223
+ }
224
+
225
+ .effect-card:hover {
226
+ transform: translateY(-10px) scale(1.03);
227
+ box-shadow: 0 15px 40px rgba(255, 0, 255, 0.4);
228
+ border-color: var(--primary);
229
+ }
230
+
231
+ .effect-card:hover::before {
232
+ opacity: 1;
233
+ }
234
+
235
+ .effect-card:hover .effect-img {
236
+ transform: scale(1.1);
237
+ filter: brightness(1.2) saturate(1.3);
238
+ }
239
+
240
+ .effect-card:hover .effect-info {
241
+ background: rgba(40, 40, 80, 0.95);
242
+ }
243
+
244
+ .effect-img-container {
245
+ position: relative;
246
+ aspect-ratio: 1;
247
+ overflow: hidden;
248
+ background: linear-gradient(135deg, #1a1a2e, #16213e);
249
+ display: flex;
250
+ align-items: center;
251
+ justify-content: center;
252
+ }
253
+
254
+ .effect-img {
255
+ width: 80%;
256
+ height: 80%;
257
+ object-fit: contain;
258
+ transition: all 0.4s ease;
259
+ cursor: pointer;
260
+ animation: pulse 3s ease-in-out infinite;
261
+ }
262
+
263
+ @keyframes pulse {
264
+ 0%, 100% { transform: scale(1); }
265
+ 50% { transform: scale(1.05); }
266
+ }
267
+
268
+ .effect-img.loading {
269
+ opacity: 0.3;
270
+ }
271
+
272
+ .effect-img.error {
273
+ display: none;
274
+ }
275
+
276
+ .loading-spinner {
277
+ position: absolute;
278
+ width: 40px;
279
+ height: 40px;
280
+ border: 4px solid rgba(255, 255, 255, 0.3);
281
+ border-top: 4px solid var(--primary);
282
+ border-radius: 50%;
283
+ animation: spin 1s linear infinite;
284
+ }
285
+
286
+ @keyframes spin {
287
+ 0% { transform: rotate(0deg); }
288
+ 100% { transform: rotate(360deg); }
289
+ }
290
+
291
+ .effect-info {
292
+ padding: 15px;
293
+ background: rgba(25, 25, 50, 0.9);
294
+ transition: all 0.3s ease;
295
+ }
296
+
297
+ .effect-id {
298
+ font-size: 0.9rem;
299
+ color: var(--secondary);
300
+ font-weight: bold;
301
+ margin-bottom: 5px;
302
+ text-transform: uppercase;
303
+ letter-spacing: 1px;
304
+ }
305
+
306
+ .effect-type {
307
+ font-size: 0.85rem;
308
+ color: #aaa;
309
+ background: rgba(0, 255, 255, 0.1);
310
+ padding: 3px 10px;
311
+ border-radius: 15px;
312
+ display: inline-block;
313
+ }
314
+
315
+ /* ===== EMPTY STATE ===== */
316
+ .empty-state {
317
+ grid-column: 1 / -1;
318
+ text-align: center;
319
+ padding: 60px 20px;
320
+ background: rgba(30, 30, 60, 0.5);
321
+ border-radius: 15px;
322
+ border: 2px dashed rgba(255, 0, 255, 0.3);
323
+ }
324
+
325
+ .empty-state i {
326
+ font-size: 4rem;
327
+ color: var(--secondary);
328
+ margin-bottom: 20px;
329
+ animation: bounce 2s ease-in-out infinite;
330
+ }
331
+
332
+ @keyframes bounce {
333
+ 0%, 100% { transform: translateY(0); }
334
+ 50% { transform: translateY(-20px); }
335
+ }
336
+
337
+ .empty-state h3 {
338
+ font-size: 1.8rem;
339
+ margin-bottom: 10px;
340
+ background: linear-gradient(90deg, var(--primary), var(--secondary));
341
+ -webkit-background-clip: text;
342
+ -webkit-text-fill-color: transparent;
343
+ }
344
+
345
+ .empty-state p {
346
+ color: #888;
347
+ max-width: 500px;
348
+ margin: 0 auto;
349
+ }
350
+
351
+ /* ===== LOADING SKELETON ===== */
352
+ .skeleton {
353
+ background: linear-gradient(90deg, #2a2a4a, #3a3a5a, #2a2a4a);
354
+ background-size: 200% 100%;
355
+ animation: shimmer 1.5s ease-in-out infinite;
356
+ border-radius: 15px;
357
+ overflow: hidden;
358
+ }
359
+
360
+ @keyframes shimmer {
361
+ 0% { background-position: 200% 0; }
362
+ 100% { background-position: -200% 0; }
363
+ }
364
+
365
+ /* ===== FOOTER ===== */
366
+ footer {
367
+ text-align: center;
368
+ padding: 30px;
369
+ margin-top: 40px;
370
+ color: #888;
371
+ font-size: 0.9rem;
372
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
373
+ }
374
+
375
+ /* ===== NOTIFICATION ===== */
376
+ .notification {
377
+ position: fixed;
378
+ top: 20px;
379
+ right: 20px;
380
+ padding: 15px 25px;
381
+ background: rgba(30, 30, 60, 0.95);
382
+ border-left: 4px solid var(--primary);
383
+ color: white;
384
+ border-radius: 10px;
385
+ box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5);
386
+ transform: translateX(400px);
387
+ transition: transform 0.3s ease;
388
+ z-index: 1000;
389
+ }
390
+
391
+ .notification.show {
392
+ transform: translateX(0);
393
+ }
394
+
395
+ .notification.success {
396
+ border-left-color: #00ff9d;
397
+ }
398
+
399
+ .notification.error {
400
+ border-left-color: #ff4d4d;
401
+ }
402
+
403
+ /* ===== MODAL ===== */
404
+ .modal {
405
+ display: none;
406
+ position: fixed;
407
+ top: 0;
408
+ left: 0;
409
+ right: 0;
410
+ bottom: 0;
411
+ background: rgba(0, 0, 0, 0.9);
412
+ z-index: 2000;
413
+ justify-content: center;
414
+ align-items: center;
415
+ opacity: 0;
416
+ transition: opacity 0.3s ease;
417
+ }
418
+
419
+ .modal.show {
420
+ display: flex;
421
+ opacity: 1;
422
+ }
423
+
424
+ .modal-content {
425
+ background: rgba(30, 30, 60, 0.95);
426
+ border: 3px solid var(--secondary);
427
+ border-radius: 20px;
428
+ padding: 30px;
429
+ max-width: 90%;
430
+ max-height: 90%;
431
+ position: relative;
432
+ }
433
+
434
+ .modal-close {
435
+ position: absolute;
436
+ top: 15px;
437
+ right: 15px;
438
+ background: rgba(255, 0, 255, 0.2);
439
+ border: none;
440
+ color: white;
441
+ width: 35px;
442
+ height: 35px;
443
+ border-radius: 50%;
444
+ cursor: pointer;
445
+ font-size: 1.2rem;
446
+ transition: all 0.3s ease;
447
+ }
448
+
449
+ .modal-close:hover {
450
+ background: var(--primary);
451
+ transform: rotate(90deg);
452
+ }
453
+
454
+ .modal-gif {
455
+ max-width: 80vw;
456
+ max-height: 70vh;
457
+ border: 2px solid var(--primary);
458
+ border-radius: 10px;
459
+ box-shadow: 0 0 50px rgba(255, 0, 255, 0.5);
460
+ }
461
+
462
+ /* ===== RESPONSIVE ===== */
463
+ @media (max-width: 768px) {
464
+ h1 {
465
+ font-size: 2.5rem;
466
+ }
467
+
468
+ .search-box {
469
+ min-width: 100%;
470
+ }
471
+
472
+ .effects-grid {
473
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
474
+ }
475
+
476
+ .effect-img {
477
+ width: 70%;
478
+ height: 70%;
479
+ }
480
+ }
481
+
482
+ @media (max-width: 480px) {
483
+ h1 {
484
+ font-size: 2rem;
485
+ }
486
+
487
+ .controls {
488
+ padding: 15px;
489
+ }
490
+
491
+ .stats-bar {
492
+ flex-direction: column;
493
+ gap: 15px;
494
+ }
495
+
496
+ .effects-grid {
497
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
498
+ }
499
+ }
500
+ </style>
501
+ </head>
502
+ <body>
503
+ <div class="container">
504
+ <!-- Header -->
505
+ <header>
506
+ <h1><i class="fas fa-magic"></i> NITRO EFFECTS STUDIO</h1>
507
+ <p class="subtitle">Colección de efectos animados para tu avatar</p>
508
+ </header>
509
+
510
+ <!-- Controls -->
511
+ <div class="controls">
512
+ <div class="search-container">
513
+ <div class="search-box">
514
+ <input type="text" id="searchInput" placeholder="🔍 Buscar efecto por ID o tipo..." onkeyup="filterEffects()">
515
+ </div>
516
+ <button class="filter-btn" onclick="clearFilters()">
517
+ <i class="fas fa-sync-alt"></i> Limpiar
518
+ </button>
519
+ </div>
520
+ </div>
521
+
522
+ <!-- Stats -->
523
+ <div class="stats-bar">
524
+ <div class="total-count">
525
+ <i class="fas fa-fire"></i> <span id="totalCount">{{ efectos|length }}</span> efectos disponibles
526
+ </div>
527
+ <div class="view-toggle">
528
+ <button class="view-btn active" onclick="setView('grid')">
529
+ <i class="fas fa-th-large"></i> Cuadrícula
530
+ </button>
531
+ <button class="view-btn" onclick="setView('list')">
532
+ <i class="fas fa-list"></i> Lista
533
+ </button>
534
+ </div>
535
+ </div>
536
+
537
+ <!-- Effects Grid -->
538
+ <div class="effects-grid" id="effectsContainer">
539
+ {% if efectos|length == 0 %}
540
+ <div class="empty-state">
541
+ <i class="fas fa-ghost"></i>
542
+ <h3>¡No hay efectos!</h3>
543
+ <p>Parece que no se encontraron efectos con los parámetros actuales. Intenta ajustar la configuración o verifica la URL.</p>
544
+ </div>
545
+ {% else %}
546
+ {% for efecto in efectos %}
547
+ <div class="effect-card" data-id="{{ efecto.id }}" data-type="{{ efecto.tipo }}">
548
+ <div class="effect-img-container">
549
+ <div class="loading-spinner"></div>
550
+ <img
551
+ src="{{ efecto.file }}"
552
+ alt="{{ efecto.id }}"
553
+ class="effect-img loading"
554
+ onerror="handleImageError(this)"
555
+ onload="handleImageLoad(this)"
556
+ onclick="showGifModal('{{ efecto.file }}', '{{ efecto.id }}')"
557
+ />
558
+ </div>
559
+ <div class="effect-info">
560
+ <div class="effect-id">{{ efecto.id }}</div>
561
+ <div class="effect-type"><i class="fas fa-tag"></i> {{ efecto.tipo }}</div>
562
+ </div>
563
+ </div>
564
+ {% endfor %}
565
+ {% endif %}
566
+ </div>
567
+ </div>
568
+
569
+ <!-- Modal for GIF preview -->
570
+ <div class="modal" id="gifModal">
571
+ <div class="modal-content">
572
+ <button class="modal-close" onclick="closeModal()">&times;</button>
573
+ <img class="modal-gif" id="modalGif" src="" alt="Preview">
574
+ </div>
575
+ </div>
576
+
577
+ <!-- Notification -->
578
+ <div class="notification" id="notification"></div>
579
+
580
+ <script>
581
+ // ========== IMAGE HANDLING ==========
582
+ function handleImageLoad(img) {
583
+ img.classList.remove('loading');
584
+ }
585
+
586
+ function handleImageError(img) {
587
+ img.classList.add('error');
588
+ img.parentElement.querySelector('.loading-spinner').style.display = 'none';
589
+ img.parentElement.innerHTML = '<i class="fas fa-times-circle" style="font-size: 3rem; color: #ff4d4d;"></i>';
590
+ }
591
+
592
+ // ========== SEARCH & FILTER ==========
593
+ function filterEffects() {
594
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
595
+ const cards = document.querySelectorAll('.effect-card');
596
+ let visibleCount = 0;
597
+
598
+ cards.forEach(card => {
599
+ const id = card.dataset.id.toLowerCase();
600
+ const type = card.dataset.type.toLowerCase();
601
+
602
+ if (id.includes(searchTerm) || type.includes(searchTerm)) {
603
+ card.style.display = '';
604
+ visibleCount++;
605
+ } else {
606
+ card.style.display = 'none';
607
+ }
608
+ });
609
+
610
+ document.getElementById('totalCount').textContent = visibleCount;
611
+
612
+ // Show empty state if needed
613
+ const grid = document.getElementById('effectsContainer');
614
+ if (visibleCount === 0 && cards.length > 0) {
615
+ if (!grid.querySelector('.empty-state')) {
616
+ const emptyState = document.createElement('div');
617
+ emptyState.className = 'empty-state';
618
+ emptyState.innerHTML = `
619
+ <i class="fas fa-search"></i>
620
+ <h3>¡No se encontraron resultados!</h3>
621
+ <p>Intenta con otro término de búsqueda</p>
622
+ `;
623
+ emptyState.style.gridColumn = '1 / -1';
624
+ grid.appendChild(emptyState);
625
+ }
626
+ } else {
627
+ const emptyState = grid.querySelector('.empty-state');
628
+ if (emptyState) emptyState.remove();
629
+ }
630
+ }
631
+
632
+ function clearFilters() {
633
+ document.getElementById('searchInput').value = '';
634
+ filterEffects();
635
+ showNotification('Filtros limpiados', 'success');
636
+ }
637
+
638
+ // ========== VIEW MODE ==========
639
+ function setView(mode) {
640
+ const grid = document.getElementById('effectsContainer');
641
+ const buttons = document.querySelectorAll('.view-btn');
642
+
643
+ buttons.forEach(btn => btn.classList.remove('active'));
644
+ event.target.classList.add('active');
645
+
646
+ if (mode === 'list') {
647
+ grid.style.gridTemplateColumns = '1fr';
648
+ } else {
649
+ grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(220px, 1fr))';
650
+ }
651
+
652
+ showNotification(`Vista: ${mode === 'list' ? 'Lista' : 'Cuadrícula'}`, 'success');
653
+ }
654
+
655
+ // ========== MODAL ==========
656
+ function showGifModal(src, id) {
657
+ const modal = document.getElementById('gifModal');
658
+ const gif = document.getElementById('modalGif');
659
+
660
+ gif.src = src;
661
+ modal.classList.add('show');
662
+
663
+ // Log for analytics
664
+ console.log(`Previewing effect: ${id}`);
665
+ }
666
+
667
+ function closeModal() {
668
+ const modal = document.getElementById('gifModal');
669
+ modal.classList.remove('show');
670
+ document.getElementById('modalGif').src = '';
671
+ }
672
+
673
+ // Close modal on click outside
674
+ window.onclick = function(event) {
675
+ const modal = document.getElementById('gifModal');
676
+ if (event.target === modal) {
677
+ closeModal();
678
+ }
679
+ }
680
+
681
+ // Close modal on Escape key
682
+ document.addEventListener('keydown', function(event) {
683
+ if (event.key === 'Escape') {
684
+ closeModal();
685
+ }
686
+ });
687
+
688
+ // ========== NOTIFICATIONS ==========
689
+ function showNotification(message, type = 'info') {
690
+ const notification = document.getElementById('notification');
691
+ notification.textContent = message;
692
+ notification.className = `notification ${type} show`;
693
+
694
+ setTimeout(() => {
695
+ notification.classList.remove('show');
696
+ }, 3000);
697
+ }
698
+
699
+ // ========== PAGE LOAD ==========
700
+ window.addEventListener('load', function() {
701
+ // Show welcome message
702
+ setTimeout(() => {
703
+ showNotification(`✨ ${document.getElementById('totalCount').textContent} efectos cargados`, 'success');
704
+ }, 500);
705
+ });
706
+ </script>
707
+ </body>
708
+ </html>