@mtldev514/retro-portfolio-maker 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/engine/admin/admin_api.py +129 -0
- package/engine/admin/scripts/manager.py +177 -0
- package/engine/admin.html +2 -2
- package/engine/config-manager.html +703 -0
- package/package.json +1 -1
|
@@ -234,6 +234,135 @@ def save_config(config_name):
|
|
|
234
234
|
except Exception as e:
|
|
235
235
|
return jsonify({"error": str(e)}), 500
|
|
236
236
|
|
|
237
|
+
@app.route('/api/content/delete', methods=['POST'])
|
|
238
|
+
def delete_content():
|
|
239
|
+
"""Delete content item from both cloud storage and JSON"""
|
|
240
|
+
try:
|
|
241
|
+
data = request.json
|
|
242
|
+
category = data.get('category')
|
|
243
|
+
item_id = data.get('id')
|
|
244
|
+
|
|
245
|
+
if not category or not item_id:
|
|
246
|
+
return jsonify({"error": "Category and ID are required"}), 400
|
|
247
|
+
|
|
248
|
+
# Use manager's delete function
|
|
249
|
+
result = manager.delete_item(category, item_id)
|
|
250
|
+
return jsonify(result)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
return jsonify({"success": False, "error": str(e)}), 500
|
|
253
|
+
|
|
254
|
+
@app.route('/api/content/update', methods=['POST'])
|
|
255
|
+
def update_content():
|
|
256
|
+
"""Update content item fields"""
|
|
257
|
+
try:
|
|
258
|
+
data = request.json
|
|
259
|
+
category = data.get('category')
|
|
260
|
+
item_id = data.get('id')
|
|
261
|
+
updates = data.get('updates', {})
|
|
262
|
+
|
|
263
|
+
if not category or not item_id:
|
|
264
|
+
return jsonify({"error": "Category and ID are required"}), 400
|
|
265
|
+
|
|
266
|
+
data_file = os.path.join(USER_DATA_DIR, f'{category}.json')
|
|
267
|
+
if not os.path.exists(data_file):
|
|
268
|
+
return jsonify({"error": f"Data file not found for category '{category}'"}), 404
|
|
269
|
+
|
|
270
|
+
# Load existing data
|
|
271
|
+
with open(data_file, 'r', encoding='utf-8') as f:
|
|
272
|
+
content = f.read().strip()
|
|
273
|
+
items = json.loads(content) if content else []
|
|
274
|
+
|
|
275
|
+
# Find and update the item
|
|
276
|
+
item_found = False
|
|
277
|
+
for item in items:
|
|
278
|
+
item_title = item.get("title")
|
|
279
|
+
if isinstance(item_title, dict):
|
|
280
|
+
item_title = item_title.get("en", "")
|
|
281
|
+
|
|
282
|
+
if item.get("id") == item_id or item_title == item_id:
|
|
283
|
+
# Update fields
|
|
284
|
+
for key, value in updates.items():
|
|
285
|
+
item[key] = value
|
|
286
|
+
item_found = True
|
|
287
|
+
break
|
|
288
|
+
|
|
289
|
+
if not item_found:
|
|
290
|
+
return jsonify({"error": f"Item '{item_id}' not found"}), 404
|
|
291
|
+
|
|
292
|
+
# Save updated data
|
|
293
|
+
with open(data_file, 'w', encoding='utf-8') as f:
|
|
294
|
+
json.dump(items, f, indent=2, ensure_ascii=False)
|
|
295
|
+
|
|
296
|
+
return jsonify({"success": True})
|
|
297
|
+
except Exception as e:
|
|
298
|
+
return jsonify({"error": str(e)}), 500
|
|
299
|
+
|
|
300
|
+
@app.route('/api/content/move-to-pile', methods=['POST'])
|
|
301
|
+
def move_to_pile():
|
|
302
|
+
"""Move an item's images into another item's gallery (pile feature)"""
|
|
303
|
+
try:
|
|
304
|
+
data = request.json
|
|
305
|
+
category = data.get('category')
|
|
306
|
+
source_id = data.get('sourceId')
|
|
307
|
+
target_id = data.get('targetId')
|
|
308
|
+
|
|
309
|
+
if not all([category, source_id, target_id]):
|
|
310
|
+
return jsonify({"error": "Category, sourceId, and targetId are required"}), 400
|
|
311
|
+
|
|
312
|
+
data_file = os.path.join(USER_DATA_DIR, f'{category}.json')
|
|
313
|
+
if not os.path.exists(data_file):
|
|
314
|
+
return jsonify({"error": f"Data file not found for category '{category}'"}), 404
|
|
315
|
+
|
|
316
|
+
# Load existing data
|
|
317
|
+
with open(data_file, 'r', encoding='utf-8') as f:
|
|
318
|
+
content = f.read().strip()
|
|
319
|
+
items = json.loads(content) if content else []
|
|
320
|
+
|
|
321
|
+
source_item = None
|
|
322
|
+
target_item = None
|
|
323
|
+
remaining_items = []
|
|
324
|
+
|
|
325
|
+
# Find source and target items
|
|
326
|
+
for item in items:
|
|
327
|
+
item_title = item.get("title")
|
|
328
|
+
if isinstance(item_title, dict):
|
|
329
|
+
item_title = item_title.get("en", "")
|
|
330
|
+
|
|
331
|
+
item_identifier = item.get("id") or item_title
|
|
332
|
+
|
|
333
|
+
if item_identifier == source_id:
|
|
334
|
+
source_item = item
|
|
335
|
+
elif item_identifier == target_id:
|
|
336
|
+
target_item = item
|
|
337
|
+
remaining_items.append(item)
|
|
338
|
+
else:
|
|
339
|
+
remaining_items.append(item)
|
|
340
|
+
|
|
341
|
+
if not source_item or not target_item:
|
|
342
|
+
return jsonify({"error": "Source or target item not found"}), 404
|
|
343
|
+
|
|
344
|
+
# Move images from source to target's gallery
|
|
345
|
+
if 'gallery' not in target_item:
|
|
346
|
+
target_item['gallery'] = []
|
|
347
|
+
|
|
348
|
+
# Add source's main URL
|
|
349
|
+
target_item['gallery'].append(source_item['url'])
|
|
350
|
+
|
|
351
|
+
# Add source's gallery images
|
|
352
|
+
if 'gallery' in source_item:
|
|
353
|
+
target_item['gallery'].extend(source_item['gallery'])
|
|
354
|
+
|
|
355
|
+
# Save updated data (without source item)
|
|
356
|
+
with open(data_file, 'w', encoding='utf-8') as f:
|
|
357
|
+
json.dump(remaining_items, f, indent=2, ensure_ascii=False)
|
|
358
|
+
|
|
359
|
+
return jsonify({
|
|
360
|
+
"success": True,
|
|
361
|
+
"targetGalleryCount": len(target_item['gallery'])
|
|
362
|
+
})
|
|
363
|
+
except Exception as e:
|
|
364
|
+
return jsonify({"error": str(e)}), 500
|
|
365
|
+
|
|
237
366
|
if __name__ == '__main__':
|
|
238
367
|
print(f"🔧 Admin API starting...")
|
|
239
368
|
print(f" Data dir: {os.path.abspath(USER_DATA_DIR)}")
|
|
@@ -291,6 +291,183 @@ def save_from_url(url, title, category, medium=None, genre=None, description=Non
|
|
|
291
291
|
update_site_timestamp()
|
|
292
292
|
return new_entry
|
|
293
293
|
|
|
294
|
+
|
|
295
|
+
def extract_cloudinary_public_id(url):
|
|
296
|
+
"""Extract the public_id from a Cloudinary URL.
|
|
297
|
+
Example: https://res.cloudinary.com/demo/image/upload/v1234567890/portfolio/painting/sample.jpg
|
|
298
|
+
Returns: portfolio/painting/sample"""
|
|
299
|
+
if "cloudinary.com" not in url:
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
# Split by /upload/ and take the part after it
|
|
303
|
+
parts = url.split("/upload/")
|
|
304
|
+
if len(parts) < 2:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
# Remove version (v1234567890) and file extension
|
|
308
|
+
path = parts[1]
|
|
309
|
+
path = re.sub(r'^v\d+/', '', path) # Remove version prefix
|
|
310
|
+
path = re.sub(r'\.[^.]+$', '', path) # Remove extension
|
|
311
|
+
|
|
312
|
+
return path
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def delete_from_cloudinary(url):
|
|
316
|
+
"""Delete a media file from Cloudinary using its URL."""
|
|
317
|
+
public_id = extract_cloudinary_public_id(url)
|
|
318
|
+
if not public_id:
|
|
319
|
+
print(f"Skipping Cloudinary delete: URL is not a Cloudinary URL ({url})")
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
try:
|
|
323
|
+
print(f"Deleting from Cloudinary: {public_id}")
|
|
324
|
+
result = cloudinary.uploader.destroy(public_id, resource_type="image")
|
|
325
|
+
|
|
326
|
+
# Also try as video if image deletion failed
|
|
327
|
+
if result.get("result") != "ok":
|
|
328
|
+
result = cloudinary.uploader.destroy(public_id, resource_type="video")
|
|
329
|
+
|
|
330
|
+
if result.get("result") == "ok":
|
|
331
|
+
print(f"Successfully deleted from Cloudinary: {public_id}")
|
|
332
|
+
return True
|
|
333
|
+
else:
|
|
334
|
+
print(f"Cloudinary deletion returned: {result.get('result')} for {public_id}")
|
|
335
|
+
return False
|
|
336
|
+
except Exception as e:
|
|
337
|
+
print(f"Error deleting from Cloudinary: {e}")
|
|
338
|
+
return False
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def delete_from_github_release(url):
|
|
342
|
+
"""Delete a media file from GitHub Release using its URL."""
|
|
343
|
+
if "github.com" not in url and "githubusercontent.com" not in url:
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
# Extract filename from URL
|
|
347
|
+
filename = url.split("/")[-1]
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
release = get_or_create_release()
|
|
351
|
+
assets = release.get("assets", [])
|
|
352
|
+
|
|
353
|
+
# Find the asset with matching name
|
|
354
|
+
for asset in assets:
|
|
355
|
+
if asset["name"] == filename:
|
|
356
|
+
headers = {
|
|
357
|
+
"Authorization": f"token {GITHUB_TOKEN}",
|
|
358
|
+
"Accept": "application/vnd.github.v3+json",
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
print(f"Deleting from GitHub Release: {filename}")
|
|
362
|
+
r = requests.delete(
|
|
363
|
+
f"https://api.github.com/repos/{GITHUB_REPO}/releases/assets/{asset['id']}",
|
|
364
|
+
headers=headers
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
if r.status_code == 204:
|
|
368
|
+
print(f"Successfully deleted from GitHub Release: {filename}")
|
|
369
|
+
return True
|
|
370
|
+
else:
|
|
371
|
+
print(f"GitHub deletion failed with status {r.status_code}")
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
print(f"Asset not found in GitHub Release: {filename}")
|
|
375
|
+
return False
|
|
376
|
+
except Exception as e:
|
|
377
|
+
print(f"Error deleting from GitHub Release: {e}")
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def delete_item(category, item_id):
|
|
382
|
+
"""Delete an item from both cloud storage and JSON database.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
category: The category (painting, music, etc.)
|
|
386
|
+
item_id: The item ID or title to delete
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
dict: Result with success status and message
|
|
390
|
+
"""
|
|
391
|
+
print(f"--- Deleting: {item_id} from {category} ---")
|
|
392
|
+
|
|
393
|
+
json_path = JSON_MAP.get(category)
|
|
394
|
+
if not json_path:
|
|
395
|
+
return {"success": False, "error": f"Category '{category}' is invalid"}
|
|
396
|
+
|
|
397
|
+
if not os.path.exists(json_path):
|
|
398
|
+
return {"success": False, "error": f"Data file not found for category '{category}'"}
|
|
399
|
+
|
|
400
|
+
# Load existing data
|
|
401
|
+
try:
|
|
402
|
+
with open(json_path, "r", encoding="utf-8") as f:
|
|
403
|
+
content = f.read().strip()
|
|
404
|
+
data = json.loads(content) if content else []
|
|
405
|
+
except json.JSONDecodeError:
|
|
406
|
+
return {"success": False, "error": "Invalid JSON in data file"}
|
|
407
|
+
|
|
408
|
+
# Find the item to delete
|
|
409
|
+
item_to_delete = None
|
|
410
|
+
new_data = []
|
|
411
|
+
|
|
412
|
+
for item in data:
|
|
413
|
+
# Match by ID or by title (for backward compatibility)
|
|
414
|
+
item_title = item.get("title")
|
|
415
|
+
if isinstance(item_title, dict):
|
|
416
|
+
item_title = item_title.get("en", "")
|
|
417
|
+
|
|
418
|
+
if item.get("id") == item_id or item_title == item_id:
|
|
419
|
+
item_to_delete = item
|
|
420
|
+
else:
|
|
421
|
+
new_data.append(item)
|
|
422
|
+
|
|
423
|
+
if not item_to_delete:
|
|
424
|
+
return {"success": False, "error": f"Item '{item_id}' not found in {category}"}
|
|
425
|
+
|
|
426
|
+
# Delete from cloud storage
|
|
427
|
+
deleted_urls = []
|
|
428
|
+
failed_urls = []
|
|
429
|
+
|
|
430
|
+
# Delete main URL
|
|
431
|
+
main_url = item_to_delete.get("url")
|
|
432
|
+
if main_url:
|
|
433
|
+
if delete_from_cloudinary(main_url):
|
|
434
|
+
deleted_urls.append(main_url)
|
|
435
|
+
elif delete_from_github_release(main_url):
|
|
436
|
+
deleted_urls.append(main_url)
|
|
437
|
+
else:
|
|
438
|
+
failed_urls.append(main_url)
|
|
439
|
+
|
|
440
|
+
# Delete gallery images if present
|
|
441
|
+
gallery = item_to_delete.get("gallery", [])
|
|
442
|
+
for gallery_url in gallery:
|
|
443
|
+
if delete_from_cloudinary(gallery_url):
|
|
444
|
+
deleted_urls.append(gallery_url)
|
|
445
|
+
else:
|
|
446
|
+
failed_urls.append(gallery_url)
|
|
447
|
+
|
|
448
|
+
# Save updated data back to JSON
|
|
449
|
+
try:
|
|
450
|
+
with open(json_path, "w", encoding="utf-8") as f:
|
|
451
|
+
json.dump(new_data, f, indent=4, ensure_ascii=False)
|
|
452
|
+
print(f"Removed item from {json_path}")
|
|
453
|
+
except Exception as e:
|
|
454
|
+
return {"success": False, "error": f"Failed to update JSON: {str(e)}"}
|
|
455
|
+
|
|
456
|
+
# Update site timestamp
|
|
457
|
+
update_site_timestamp()
|
|
458
|
+
|
|
459
|
+
result = {
|
|
460
|
+
"success": True,
|
|
461
|
+
"message": f"Deleted '{item_id}' from {category}",
|
|
462
|
+
"deleted_urls": len(deleted_urls),
|
|
463
|
+
"failed_urls": len(failed_urls)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if failed_urls:
|
|
467
|
+
result["warning"] = f"Some cloud files could not be deleted: {failed_urls}"
|
|
468
|
+
|
|
469
|
+
return result
|
|
470
|
+
|
|
294
471
|
if __name__ == "__main__":
|
|
295
472
|
parser = argparse.ArgumentParser(description="Alex's Portfolio Content Manager")
|
|
296
473
|
parser.add_argument("--file", required=True, help="Path to the media file or directory (with --pile)")
|
package/engine/admin.html
CHANGED
|
@@ -127,11 +127,11 @@
|
|
|
127
127
|
<p style="font-size: 12px;">Need high-res uploads? Make sure your .env is active.</p>
|
|
128
128
|
|
|
129
129
|
<div class="field-row" style="margin-top: 20px;">
|
|
130
|
-
<button onclick="
|
|
130
|
+
<button onclick="window.location.href='config-manager.html';" style="width: 100%; height: 50px; font-weight: bold; cursor: pointer; background: linear-gradient(to bottom, #0080ff, #0060c0); color: white; border: 2px outset #fff;">
|
|
131
131
|
⚙️ CONFIGURATION MANAGER
|
|
132
132
|
</button>
|
|
133
133
|
<p class="admin-muted" style="margin-top: 5px;">
|
|
134
|
-
*
|
|
134
|
+
* Edit JSON configuration files (app, languages, categories, media-types)
|
|
135
135
|
</p>
|
|
136
136
|
</div>
|
|
137
137
|
|
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>CONFIG MANAGER - PORTFOLIO V2.0</title>
|
|
6
|
+
<link rel="stylesheet" href="style.css">
|
|
7
|
+
<link rel="stylesheet" href="admin.css">
|
|
8
|
+
<link rel="stylesheet" href="fonts.css">
|
|
9
|
+
<script src="js/config.js"></script>
|
|
10
|
+
<style>
|
|
11
|
+
.config-section {
|
|
12
|
+
background: var(--admin-win-bg);
|
|
13
|
+
border: 2px outset var(--admin-win-border-light);
|
|
14
|
+
padding: 15px;
|
|
15
|
+
margin-bottom: 20px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.config-section-header {
|
|
19
|
+
font-weight: bold;
|
|
20
|
+
font-size: 13px;
|
|
21
|
+
margin-bottom: 10px;
|
|
22
|
+
padding-bottom: 5px;
|
|
23
|
+
border-bottom: 2px solid var(--admin-win-border-dark);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.config-grid {
|
|
27
|
+
display: grid;
|
|
28
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
29
|
+
gap: 15px;
|
|
30
|
+
margin-top: 15px;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.config-card {
|
|
34
|
+
background: white;
|
|
35
|
+
border: 2px inset var(--admin-win-border-dark);
|
|
36
|
+
padding: 12px;
|
|
37
|
+
position: relative;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.config-card-header {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 8px;
|
|
44
|
+
margin-bottom: 10px;
|
|
45
|
+
padding-bottom: 8px;
|
|
46
|
+
border-bottom: 1px solid #ccc;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.config-card-icon {
|
|
50
|
+
font-size: 24px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.config-card-title {
|
|
54
|
+
font-weight: bold;
|
|
55
|
+
font-size: 12px;
|
|
56
|
+
flex: 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.config-card-body {
|
|
60
|
+
font-size: 11px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.config-field {
|
|
64
|
+
margin-bottom: 8px;
|
|
65
|
+
padding: 4px;
|
|
66
|
+
background: #f0f0f0;
|
|
67
|
+
border-radius: 2px;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.config-field-label {
|
|
71
|
+
font-weight: bold;
|
|
72
|
+
color: #000080;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.config-badge {
|
|
76
|
+
display: inline-block;
|
|
77
|
+
padding: 2px 6px;
|
|
78
|
+
background: #000080;
|
|
79
|
+
color: white;
|
|
80
|
+
border-radius: 2px;
|
|
81
|
+
font-size: 10px;
|
|
82
|
+
margin-right: 4px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.config-actions {
|
|
86
|
+
margin-top: 10px;
|
|
87
|
+
display: flex;
|
|
88
|
+
gap: 5px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.btn-small {
|
|
92
|
+
padding: 4px 10px;
|
|
93
|
+
font-size: 11px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.add-new-section {
|
|
97
|
+
text-align: center;
|
|
98
|
+
padding: 30px;
|
|
99
|
+
background: #f0f0f0;
|
|
100
|
+
border: 2px dashed var(--admin-win-border-dark);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.add-new-section button {
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
padding: 10px 20px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.modal {
|
|
109
|
+
display: none;
|
|
110
|
+
position: fixed;
|
|
111
|
+
top: 0;
|
|
112
|
+
left: 0;
|
|
113
|
+
width: 100%;
|
|
114
|
+
height: 100%;
|
|
115
|
+
background: rgba(0, 0, 0, 0.5);
|
|
116
|
+
z-index: 1000;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.modal.active {
|
|
122
|
+
display: flex;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.modal-content {
|
|
126
|
+
background: var(--admin-win-bg);
|
|
127
|
+
border: 3px outset var(--admin-win-border-light);
|
|
128
|
+
min-width: 500px;
|
|
129
|
+
max-width: 700px;
|
|
130
|
+
max-height: 80vh;
|
|
131
|
+
overflow-y: auto;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.modal-header {
|
|
135
|
+
background: var(--admin-win-title-bg);
|
|
136
|
+
color: white;
|
|
137
|
+
padding: 4px 8px;
|
|
138
|
+
font-weight: bold;
|
|
139
|
+
display: flex;
|
|
140
|
+
justify-content: space-between;
|
|
141
|
+
align-items: center;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.modal-body {
|
|
145
|
+
padding: 20px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.form-row {
|
|
149
|
+
margin-bottom: 15px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.form-row label {
|
|
153
|
+
display: block;
|
|
154
|
+
font-weight: bold;
|
|
155
|
+
margin-bottom: 4px;
|
|
156
|
+
font-size: 11px;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.form-row input,
|
|
160
|
+
.form-row select,
|
|
161
|
+
.form-row textarea {
|
|
162
|
+
width: 100%;
|
|
163
|
+
padding: 4px;
|
|
164
|
+
font-family: 'MS Sans Serif', sans-serif;
|
|
165
|
+
font-size: 11px;
|
|
166
|
+
border: 2px inset var(--admin-win-border-dark);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.field-list {
|
|
170
|
+
border: 2px inset var(--admin-win-border-dark);
|
|
171
|
+
padding: 10px;
|
|
172
|
+
background: white;
|
|
173
|
+
max-height: 200px;
|
|
174
|
+
overflow-y: auto;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.field-item {
|
|
178
|
+
background: #f0f0f0;
|
|
179
|
+
padding: 8px;
|
|
180
|
+
margin-bottom: 5px;
|
|
181
|
+
border: 1px solid #ccc;
|
|
182
|
+
display: flex;
|
|
183
|
+
justify-content: space-between;
|
|
184
|
+
align-items: center;
|
|
185
|
+
}
|
|
186
|
+
</style>
|
|
187
|
+
</head>
|
|
188
|
+
|
|
189
|
+
<body class="admin-page">
|
|
190
|
+
<div class="window">
|
|
191
|
+
<div class="title-bar">
|
|
192
|
+
<span>CONFIGURATION MANAGER V2.0</span>
|
|
193
|
+
<div class="title-bar-controls">
|
|
194
|
+
<button>_</button>
|
|
195
|
+
<button>[]</button>
|
|
196
|
+
<button onclick="window.location.href='admin.html'">X</button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="menu-bar">
|
|
201
|
+
<button onclick="location.href='admin.html'">« Back to Admin</button>
|
|
202
|
+
<button onclick="saveAllConfigurations()">💾 Save All</button>
|
|
203
|
+
<button onclick="reloadConfigurations()">🔄 Reload</button>
|
|
204
|
+
<button onclick="exportConfigurations()">📤 Export</button>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<div class="admin-body">
|
|
208
|
+
<!-- Media Types Section -->
|
|
209
|
+
<div class="config-section">
|
|
210
|
+
<div class="config-section-header">
|
|
211
|
+
🎬 MEDIA TYPES (How content is displayed)
|
|
212
|
+
</div>
|
|
213
|
+
<p style="font-size: 11px; margin: 10px 0; color: #666;">
|
|
214
|
+
Media types define how different kinds of content are rendered (image viewer, audio player, video player, etc.)
|
|
215
|
+
</p>
|
|
216
|
+
<div id="mediaTypesGrid" class="config-grid"></div>
|
|
217
|
+
<div class="add-new-section">
|
|
218
|
+
<button onclick="openMediaTypeModal()">➕ Add New Media Type</button>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<!-- Content Types Section -->
|
|
223
|
+
<div class="config-section">
|
|
224
|
+
<div class="config-section-header">
|
|
225
|
+
📂 CONTENT TYPES (What you create)
|
|
226
|
+
</div>
|
|
227
|
+
<p style="font-size: 11px; margin: 10px 0; color: #666;">
|
|
228
|
+
Content types define categories of work (painting, photography, music, etc.) and their custom fields
|
|
229
|
+
</p>
|
|
230
|
+
<div id="contentTypesGrid" class="config-grid"></div>
|
|
231
|
+
<div class="add-new-section">
|
|
232
|
+
<button onclick="openContentTypeModal()">➕ Add New Content Type</button>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div class="status-bar">
|
|
237
|
+
<span id="statusText">Ready</span>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<!-- Media Type Modal -->
|
|
243
|
+
<div id="mediaTypeModal" class="modal">
|
|
244
|
+
<div class="modal-content">
|
|
245
|
+
<div class="modal-header">
|
|
246
|
+
<span id="mediaTypeModalTitle">Add Media Type</span>
|
|
247
|
+
<button onclick="closeMediaTypeModal()">×</button>
|
|
248
|
+
</div>
|
|
249
|
+
<div class="modal-body">
|
|
250
|
+
<form id="mediaTypeForm">
|
|
251
|
+
<div class="form-row">
|
|
252
|
+
<label>ID (unique identifier):</label>
|
|
253
|
+
<input type="text" id="mtId" required placeholder="e.g., image, audio, video">
|
|
254
|
+
</div>
|
|
255
|
+
<div class="form-row">
|
|
256
|
+
<label>Name:</label>
|
|
257
|
+
<input type="text" id="mtName" required placeholder="e.g., Image, Audio">
|
|
258
|
+
</div>
|
|
259
|
+
<div class="form-row">
|
|
260
|
+
<label>Icon (emoji):</label>
|
|
261
|
+
<input type="text" id="mtIcon" required placeholder="e.g., 🖼️, 🎵">
|
|
262
|
+
</div>
|
|
263
|
+
<div class="form-row">
|
|
264
|
+
<label>Viewer Component:</label>
|
|
265
|
+
<input type="text" id="mtViewer" required placeholder="e.g., ImageViewer, AudioPlayer">
|
|
266
|
+
</div>
|
|
267
|
+
<div class="form-row">
|
|
268
|
+
<label>
|
|
269
|
+
<input type="checkbox" id="mtSupportsGallery"> Supports Gallery
|
|
270
|
+
</label>
|
|
271
|
+
</div>
|
|
272
|
+
<div class="form-row">
|
|
273
|
+
<label>
|
|
274
|
+
<input type="checkbox" id="mtSupportsMetadata"> Supports Per-Item Metadata
|
|
275
|
+
</label>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="form-row">
|
|
278
|
+
<label>Accepted Formats (comma-separated):</label>
|
|
279
|
+
<input type="text" id="mtFormats" placeholder="e.g., .jpg, .png, .webp">
|
|
280
|
+
</div>
|
|
281
|
+
<div class="form-row">
|
|
282
|
+
<label>Upload Destination:</label>
|
|
283
|
+
<select id="mtDestination">
|
|
284
|
+
<option value="cloudinary">Cloudinary</option>
|
|
285
|
+
<option value="github">GitHub Releases</option>
|
|
286
|
+
<option value="none">None (URL only)</option>
|
|
287
|
+
</select>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="form-row">
|
|
290
|
+
<label>Description:</label>
|
|
291
|
+
<textarea id="mtDescription" rows="3"></textarea>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="form-row" style="text-align: right;">
|
|
294
|
+
<button type="button" onclick="closeMediaTypeModal()">Cancel</button>
|
|
295
|
+
<button type="submit">Save Media Type</button>
|
|
296
|
+
</div>
|
|
297
|
+
</form>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
|
|
302
|
+
<!-- Content Type Modal -->
|
|
303
|
+
<div id="contentTypeModal" class="modal">
|
|
304
|
+
<div class="modal-content">
|
|
305
|
+
<div class="modal-header">
|
|
306
|
+
<span id="contentTypeModalTitle">Add Content Type</span>
|
|
307
|
+
<button onclick="closeContentTypeModal()">×</button>
|
|
308
|
+
</div>
|
|
309
|
+
<div class="modal-body">
|
|
310
|
+
<form id="contentTypeForm">
|
|
311
|
+
<div class="form-row">
|
|
312
|
+
<label>ID (unique identifier):</label>
|
|
313
|
+
<input type="text" id="ctId" required placeholder="e.g., painting, photography">
|
|
314
|
+
</div>
|
|
315
|
+
<div class="form-row">
|
|
316
|
+
<label>Name:</label>
|
|
317
|
+
<input type="text" id="ctName" required placeholder="e.g., Painting, Photography">
|
|
318
|
+
</div>
|
|
319
|
+
<div class="form-row">
|
|
320
|
+
<label>Icon (emoji):</label>
|
|
321
|
+
<input type="text" id="ctIcon" required placeholder="e.g., 🎨, 📷">
|
|
322
|
+
</div>
|
|
323
|
+
<div class="form-row">
|
|
324
|
+
<label>Media Type:</label>
|
|
325
|
+
<select id="ctMediaType" required></select>
|
|
326
|
+
</div>
|
|
327
|
+
<div class="form-row">
|
|
328
|
+
<label>Data File Path:</label>
|
|
329
|
+
<input type="text" id="ctDataFile" required placeholder="e.g., data/painting.json">
|
|
330
|
+
</div>
|
|
331
|
+
<div class="form-row">
|
|
332
|
+
<label>Description:</label>
|
|
333
|
+
<textarea id="ctDescription" rows="2"></textarea>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<div class="form-row">
|
|
337
|
+
<label>Custom Fields:</label>
|
|
338
|
+
<div id="fieldsList" class="field-list"></div>
|
|
339
|
+
<button type="button" onclick="addField()" style="margin-top: 5px;">+ Add Field</button>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
<div class="form-row" style="text-align: right;">
|
|
343
|
+
<button type="button" onclick="closeContentTypeModal()">Cancel</button>
|
|
344
|
+
<button type="submit">Save Content Type</button>
|
|
345
|
+
</div>
|
|
346
|
+
</form>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
|
|
351
|
+
<script>
|
|
352
|
+
const API_URL = 'http://127.0.0.1:5001';
|
|
353
|
+
let currentMediaTypes = [];
|
|
354
|
+
let currentContentTypes = [];
|
|
355
|
+
let editingMediaTypeIndex = null;
|
|
356
|
+
let editingContentTypeIndex = null;
|
|
357
|
+
let currentFields = [];
|
|
358
|
+
|
|
359
|
+
// Load configuration on page load
|
|
360
|
+
async function init() {
|
|
361
|
+
await AppConfig.load();
|
|
362
|
+
await loadConfigurations();
|
|
363
|
+
renderMediaTypes();
|
|
364
|
+
renderContentTypes();
|
|
365
|
+
populateMediaTypeSelect();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function loadConfigurations() {
|
|
369
|
+
try {
|
|
370
|
+
// Load media-types.json
|
|
371
|
+
const mediaTypesResponse = await fetch(`${API_URL}/api/config/media-types`);
|
|
372
|
+
const mediaTypesData = await mediaTypesResponse.json();
|
|
373
|
+
currentMediaTypes = mediaTypesData.mediaTypes || [];
|
|
374
|
+
|
|
375
|
+
// Load categories.json
|
|
376
|
+
const categoriesResponse = await fetch(`${API_URL}/api/config/categories`);
|
|
377
|
+
const categoriesData = await categoriesResponse.json();
|
|
378
|
+
currentContentTypes = categoriesData.contentTypes || categoriesData.categories || [];
|
|
379
|
+
|
|
380
|
+
document.getElementById('statusText').textContent = 'Configuration loaded';
|
|
381
|
+
} catch (error) {
|
|
382
|
+
console.error('Error loading config:', error);
|
|
383
|
+
document.getElementById('statusText').textContent = 'Error loading configuration';
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function renderMediaTypes() {
|
|
388
|
+
const grid = document.getElementById('mediaTypesGrid');
|
|
389
|
+
grid.innerHTML = currentMediaTypes.map((mt, index) => `
|
|
390
|
+
<div class="config-card">
|
|
391
|
+
<div class="config-card-header">
|
|
392
|
+
<span class="config-card-icon">${mt.icon}</span>
|
|
393
|
+
<span class="config-card-title">${mt.name}</span>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="config-card-body">
|
|
396
|
+
<div class="config-field">
|
|
397
|
+
<span class="config-field-label">ID:</span> ${mt.id}
|
|
398
|
+
</div>
|
|
399
|
+
<div class="config-field">
|
|
400
|
+
<span class="config-field-label">Viewer:</span> ${mt.viewer}
|
|
401
|
+
</div>
|
|
402
|
+
<div class="config-field">
|
|
403
|
+
${mt.supportsGallery ? '<span class="config-badge">Gallery</span>' : ''}
|
|
404
|
+
${mt.supportsMetadata ? '<span class="config-badge">Metadata</span>' : ''}
|
|
405
|
+
</div>
|
|
406
|
+
<div class="config-field">
|
|
407
|
+
<span class="config-field-label">Formats:</span> ${mt.acceptedFormats.join(', ')}
|
|
408
|
+
</div>
|
|
409
|
+
<div class="config-actions">
|
|
410
|
+
<button class="btn-small" onclick="editMediaType(${index})">Edit</button>
|
|
411
|
+
<button class="btn-small" onclick="deleteMediaType(${index})">Delete</button>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
`).join('');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function renderContentTypes() {
|
|
419
|
+
const grid = document.getElementById('contentTypesGrid');
|
|
420
|
+
grid.innerHTML = currentContentTypes.map((ct, index) => {
|
|
421
|
+
const mediaType = AppConfig.getMediaType(ct.mediaType);
|
|
422
|
+
const fieldCount = (ct.fields?.optional || []).length;
|
|
423
|
+
|
|
424
|
+
return `
|
|
425
|
+
<div class="config-card">
|
|
426
|
+
<div class="config-card-header">
|
|
427
|
+
<span class="config-card-icon">${ct.icon}</span>
|
|
428
|
+
<span class="config-card-title">${ct.name}</span>
|
|
429
|
+
</div>
|
|
430
|
+
<div class="config-card-body">
|
|
431
|
+
<div class="config-field">
|
|
432
|
+
<span class="config-field-label">ID:</span> ${ct.id}
|
|
433
|
+
</div>
|
|
434
|
+
<div class="config-field">
|
|
435
|
+
<span class="config-field-label">Media Type:</span>
|
|
436
|
+
${mediaType?.icon || ''} ${ct.mediaType}
|
|
437
|
+
</div>
|
|
438
|
+
<div class="config-field">
|
|
439
|
+
<span class="config-field-label">Data File:</span> ${ct.dataFile}
|
|
440
|
+
</div>
|
|
441
|
+
<div class="config-field">
|
|
442
|
+
<span class="config-field-label">Custom Fields:</span> ${fieldCount} field(s)
|
|
443
|
+
</div>
|
|
444
|
+
<div class="config-actions">
|
|
445
|
+
<button class="btn-small" onclick="editContentType(${index})">Edit</button>
|
|
446
|
+
<button class="btn-small" onclick="deleteContentType(${index})">Delete</button>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
`;
|
|
451
|
+
}).join('');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function populateMediaTypeSelect() {
|
|
455
|
+
const select = document.getElementById('ctMediaType');
|
|
456
|
+
select.innerHTML = currentMediaTypes.map(mt =>
|
|
457
|
+
`<option value="${mt.id}">${mt.icon} ${mt.name}</option>`
|
|
458
|
+
).join('');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Modal handlers
|
|
462
|
+
function openMediaTypeModal(index = null) {
|
|
463
|
+
editingMediaTypeIndex = index;
|
|
464
|
+
const modal = document.getElementById('mediaTypeModal');
|
|
465
|
+
const form = document.getElementById('mediaTypeForm');
|
|
466
|
+
|
|
467
|
+
if (index !== null) {
|
|
468
|
+
const mt = currentMediaTypes[index];
|
|
469
|
+
document.getElementById('mediaTypeModalTitle').textContent = 'Edit Media Type';
|
|
470
|
+
document.getElementById('mtId').value = mt.id;
|
|
471
|
+
document.getElementById('mtName').value = mt.name;
|
|
472
|
+
document.getElementById('mtIcon').value = mt.icon;
|
|
473
|
+
document.getElementById('mtViewer').value = mt.viewer;
|
|
474
|
+
document.getElementById('mtSupportsGallery').checked = mt.supportsGallery;
|
|
475
|
+
document.getElementById('mtSupportsMetadata').checked = mt.supportsMetadata;
|
|
476
|
+
document.getElementById('mtFormats').value = mt.acceptedFormats.join(', ');
|
|
477
|
+
document.getElementById('mtDestination').value = mt.uploadDestination;
|
|
478
|
+
document.getElementById('mtDescription').value = mt.description || '';
|
|
479
|
+
document.getElementById('mtId').disabled = true;
|
|
480
|
+
} else {
|
|
481
|
+
document.getElementById('mediaTypeModalTitle').textContent = 'Add Media Type';
|
|
482
|
+
form.reset();
|
|
483
|
+
document.getElementById('mtId').disabled = false;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
modal.classList.add('active');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function closeMediaTypeModal() {
|
|
490
|
+
document.getElementById('mediaTypeModal').classList.remove('active');
|
|
491
|
+
editingMediaTypeIndex = null;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function openContentTypeModal(index = null) {
|
|
495
|
+
editingContentTypeIndex = index;
|
|
496
|
+
const modal = document.getElementById('contentTypeModal');
|
|
497
|
+
const form = document.getElementById('contentTypeForm');
|
|
498
|
+
|
|
499
|
+
if (index !== null) {
|
|
500
|
+
const ct = currentContentTypes[index];
|
|
501
|
+
document.getElementById('contentTypeModalTitle').textContent = 'Edit Content Type';
|
|
502
|
+
document.getElementById('ctId').value = ct.id;
|
|
503
|
+
document.getElementById('ctName').value = ct.name;
|
|
504
|
+
document.getElementById('ctIcon').value = ct.icon;
|
|
505
|
+
document.getElementById('ctMediaType').value = ct.mediaType;
|
|
506
|
+
document.getElementById('ctDataFile').value = ct.dataFile;
|
|
507
|
+
document.getElementById('ctDescription').value = ct.description || '';
|
|
508
|
+
currentFields = ct.fields?.optional || [];
|
|
509
|
+
document.getElementById('ctId').disabled = true;
|
|
510
|
+
} else {
|
|
511
|
+
document.getElementById('contentTypeModalTitle').textContent = 'Add Content Type';
|
|
512
|
+
form.reset();
|
|
513
|
+
currentFields = [];
|
|
514
|
+
document.getElementById('ctId').disabled = false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
renderFields();
|
|
518
|
+
modal.classList.add('active');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function closeContentTypeModal() {
|
|
522
|
+
document.getElementById('contentTypeModal').classList.remove('active');
|
|
523
|
+
editingContentTypeIndex = null;
|
|
524
|
+
currentFields = [];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Field management
|
|
528
|
+
function addField() {
|
|
529
|
+
currentFields.push({
|
|
530
|
+
name: '',
|
|
531
|
+
type: 'text',
|
|
532
|
+
label: '',
|
|
533
|
+
placeholder: ''
|
|
534
|
+
});
|
|
535
|
+
renderFields();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function removeField(index) {
|
|
539
|
+
currentFields.splice(index, 1);
|
|
540
|
+
renderFields();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function renderFields() {
|
|
544
|
+
const list = document.getElementById('fieldsList');
|
|
545
|
+
list.innerHTML = currentFields.map((field, index) => `
|
|
546
|
+
<div class="field-item">
|
|
547
|
+
<div>
|
|
548
|
+
<input type="text" placeholder="Field Name (e.g., medium)"
|
|
549
|
+
value="${field.name}"
|
|
550
|
+
onchange="currentFields[${index}].name = this.value"
|
|
551
|
+
style="width: 120px; margin-right: 5px;">
|
|
552
|
+
<select onchange="currentFields[${index}].type = this.value"
|
|
553
|
+
style="width: 100px; margin-right: 5px;">
|
|
554
|
+
<option value="text" ${field.type === 'text' ? 'selected' : ''}>Text</option>
|
|
555
|
+
<option value="textarea" ${field.type === 'textarea' ? 'selected' : ''}>Textarea</option>
|
|
556
|
+
</select>
|
|
557
|
+
<input type="text" placeholder="Label"
|
|
558
|
+
value="${field.label}"
|
|
559
|
+
onchange="currentFields[${index}].label = this.value"
|
|
560
|
+
style="width: 120px;">
|
|
561
|
+
</div>
|
|
562
|
+
<button type="button" onclick="removeField(${index})">×</button>
|
|
563
|
+
</div>
|
|
564
|
+
`).join('');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Form submissions
|
|
568
|
+
document.getElementById('mediaTypeForm').onsubmit = (e) => {
|
|
569
|
+
e.preventDefault();
|
|
570
|
+
|
|
571
|
+
const mediaType = {
|
|
572
|
+
id: document.getElementById('mtId').value,
|
|
573
|
+
name: document.getElementById('mtName').value,
|
|
574
|
+
icon: document.getElementById('mtIcon').value,
|
|
575
|
+
viewer: document.getElementById('mtViewer').value,
|
|
576
|
+
supportsGallery: document.getElementById('mtSupportsGallery').checked,
|
|
577
|
+
supportsMetadata: document.getElementById('mtSupportsMetadata').checked,
|
|
578
|
+
acceptedFormats: document.getElementById('mtFormats').value.split(',').map(f => f.trim()),
|
|
579
|
+
uploadDestination: document.getElementById('mtDestination').value,
|
|
580
|
+
description: document.getElementById('mtDescription').value
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
if (editingMediaTypeIndex !== null) {
|
|
584
|
+
currentMediaTypes[editingMediaTypeIndex] = mediaType;
|
|
585
|
+
} else {
|
|
586
|
+
currentMediaTypes.push(mediaType);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
renderMediaTypes();
|
|
590
|
+
populateMediaTypeSelect();
|
|
591
|
+
closeMediaTypeModal();
|
|
592
|
+
document.getElementById('statusText').textContent = 'Media type saved (click Save All to persist)';
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
document.getElementById('contentTypeForm').onsubmit = (e) => {
|
|
596
|
+
e.preventDefault();
|
|
597
|
+
|
|
598
|
+
const contentType = {
|
|
599
|
+
id: document.getElementById('ctId').value,
|
|
600
|
+
name: document.getElementById('ctName').value,
|
|
601
|
+
icon: document.getElementById('ctIcon').value,
|
|
602
|
+
mediaType: document.getElementById('ctMediaType').value,
|
|
603
|
+
dataFile: document.getElementById('ctDataFile').value,
|
|
604
|
+
description: document.getElementById('ctDescription').value,
|
|
605
|
+
fields: {
|
|
606
|
+
required: ['title', 'url'],
|
|
607
|
+
optional: currentFields
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
if (editingContentTypeIndex !== null) {
|
|
612
|
+
currentContentTypes[editingContentTypeIndex] = contentType;
|
|
613
|
+
} else {
|
|
614
|
+
currentContentTypes.push(contentType);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
renderContentTypes();
|
|
618
|
+
closeContentTypeModal();
|
|
619
|
+
document.getElementById('statusText').textContent = 'Content type saved (click Save All to persist)';
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// CRUD operations
|
|
623
|
+
function editMediaType(index) {
|
|
624
|
+
openMediaTypeModal(index);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function deleteMediaType(index) {
|
|
628
|
+
if (confirm(`Delete media type "${currentMediaTypes[index].name}"?`)) {
|
|
629
|
+
currentMediaTypes.splice(index, 1);
|
|
630
|
+
renderMediaTypes();
|
|
631
|
+
document.getElementById('statusText').textContent = 'Media type deleted (click Save All to persist)';
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function editContentType(index) {
|
|
636
|
+
openContentTypeModal(index);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function deleteContentType(index) {
|
|
640
|
+
if (confirm(`Delete content type "${currentContentTypes[index].name}"?`)) {
|
|
641
|
+
currentContentTypes.splice(index, 1);
|
|
642
|
+
renderContentTypes();
|
|
643
|
+
document.getElementById('statusText').textContent = 'Content type deleted (click Save All to persist)';
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Save/Export
|
|
648
|
+
async function saveAllConfigurations() {
|
|
649
|
+
if (!confirm('Save all configuration changes? This will update your config files.')) return;
|
|
650
|
+
|
|
651
|
+
try {
|
|
652
|
+
// Save media-types.json
|
|
653
|
+
const mtResponse = await fetch(`${API_URL}/api/config/media-types`, {
|
|
654
|
+
method: 'POST',
|
|
655
|
+
headers: { 'Content-Type': 'application/json' },
|
|
656
|
+
body: JSON.stringify({ mediaTypes: currentMediaTypes })
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Save categories.json
|
|
660
|
+
const ctResponse = await fetch(`${API_URL}/api/config/categories`, {
|
|
661
|
+
method: 'POST',
|
|
662
|
+
headers: { 'Content-Type': 'application/json' },
|
|
663
|
+
body: JSON.stringify({ contentTypes: currentContentTypes })
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
if (mtResponse.ok && ctResponse.ok) {
|
|
667
|
+
document.getElementById('statusText').textContent = 'Configuration saved successfully!';
|
|
668
|
+
alert('Configuration saved! Reload the page to see changes.');
|
|
669
|
+
} else {
|
|
670
|
+
throw new Error('Failed to save configuration');
|
|
671
|
+
}
|
|
672
|
+
} catch (error) {
|
|
673
|
+
console.error('Save error:', error);
|
|
674
|
+
alert('Error saving configuration: ' + error.message);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function reloadConfigurations() {
|
|
679
|
+
if (confirm('Reload configurations from server? Unsaved changes will be lost.')) {
|
|
680
|
+
location.reload();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function exportConfigurations() {
|
|
685
|
+
const config = {
|
|
686
|
+
mediaTypes: { mediaTypes: currentMediaTypes },
|
|
687
|
+
contentTypes: { contentTypes: currentContentTypes }
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
|
|
691
|
+
const url = URL.createObjectURL(blob);
|
|
692
|
+
const a = document.createElement('a');
|
|
693
|
+
a.href = url;
|
|
694
|
+
a.download = `portfolio-config-${new Date().toISOString().split('T')[0]}.json`;
|
|
695
|
+
a.click();
|
|
696
|
+
URL.revokeObjectURL(url);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Initialize on page load
|
|
700
|
+
init();
|
|
701
|
+
</script>
|
|
702
|
+
</body>
|
|
703
|
+
</html>
|
package/package.json
CHANGED