@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.
@@ -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="const configTab = document.querySelectorAll('.tab')[4]; if(configTab) showTab('config', configTab);" style="width: 100%; height: 50px; font-weight: bold; cursor: pointer; background: linear-gradient(to bottom, #0080ff, #0060c0); color: white; border: 2px outset #fff;">
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
- * Manage site settings, media types, and content categories through the Configuration tab
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtldev514/retro-portfolio-maker",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Retro portfolio maker - Site-as-a-Package. Install the engine, provide your data, build your 90s-aesthetic portfolio.",
5
5
  "main": "index.js",
6
6
  "bin": {