@mtldev514/retro-portfolio-engine 1.0.0

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.
Files changed (43) hide show
  1. package/README.md +408 -0
  2. package/bin/cli.js +103 -0
  3. package/engine/admin/admin.css +720 -0
  4. package/engine/admin/admin.html +801 -0
  5. package/engine/admin/admin_api.py +230 -0
  6. package/engine/admin/scripts/backup.sh +116 -0
  7. package/engine/admin/scripts/config_loader.py +180 -0
  8. package/engine/admin/scripts/init.sh +141 -0
  9. package/engine/admin/scripts/manager.py +308 -0
  10. package/engine/admin/scripts/restore.sh +121 -0
  11. package/engine/admin/scripts/server.py +41 -0
  12. package/engine/admin/scripts/update.sh +321 -0
  13. package/engine/admin/scripts/validate_json.py +62 -0
  14. package/engine/fonts.css +37 -0
  15. package/engine/index.html +190 -0
  16. package/engine/js/config-loader.js +370 -0
  17. package/engine/js/config.js +173 -0
  18. package/engine/js/counter.js +17 -0
  19. package/engine/js/effects.js +97 -0
  20. package/engine/js/i18n.js +68 -0
  21. package/engine/js/init.js +107 -0
  22. package/engine/js/media.js +264 -0
  23. package/engine/js/render.js +282 -0
  24. package/engine/js/router.js +133 -0
  25. package/engine/js/sparkle.js +123 -0
  26. package/engine/js/themes.js +607 -0
  27. package/engine/style.css +2037 -0
  28. package/index.js +35 -0
  29. package/package.json +48 -0
  30. package/scripts/admin.js +67 -0
  31. package/scripts/build.js +142 -0
  32. package/scripts/init.js +237 -0
  33. package/scripts/post-install.js +16 -0
  34. package/scripts/serve.js +54 -0
  35. package/templates/user-portfolio/.github/workflows/deploy.yml +57 -0
  36. package/templates/user-portfolio/config/app.json +36 -0
  37. package/templates/user-portfolio/config/categories.json +241 -0
  38. package/templates/user-portfolio/config/languages.json +15 -0
  39. package/templates/user-portfolio/config/media-types.json +59 -0
  40. package/templates/user-portfolio/data/painting.json +3 -0
  41. package/templates/user-portfolio/data/projects.json +3 -0
  42. package/templates/user-portfolio/lang/en.json +114 -0
  43. package/templates/user-portfolio/lang/fr.json +114 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Advanced Configuration Loader
3
+ * Supports loading from local directories OR remote GitHub repository
4
+ */
5
+
6
+ const ConfigLoader = {
7
+ source: null,
8
+ cache: {},
9
+
10
+ /**
11
+ * Initialize and load configuration source
12
+ */
13
+ async init() {
14
+ try {
15
+ // Check for override version from global config or query params
16
+ const urlParams = new URLSearchParams(window.location.search);
17
+ const overrideMode = urlParams.get('mode') || window.RETRO_CONFIG?.mode;
18
+ const overrideData = urlParams.get('dataDir') || window.RETRO_CONFIG?.dataDir;
19
+
20
+ // Load config-source.json to determine where to load from
21
+ let response;
22
+ try {
23
+ response = await fetch('config-source.json');
24
+ } catch (e) {
25
+ console.warn('⚠️ No config-source.json found, using defaults');
26
+ }
27
+
28
+ this.source = response ? await response.json() : {
29
+ mode: overrideMode || 'local',
30
+ local: { configDir: 'config', dataDir: overrideData || 'data', langDir: 'lang' }
31
+ };
32
+
33
+ if (overrideMode) this.source.mode = overrideMode;
34
+ if (overrideData) {
35
+ if (!this.source.local) this.source.local = {};
36
+ this.source.local.dataDir = overrideData;
37
+ }
38
+
39
+ console.log(`📦 Config mode: ${this.source.mode}`);
40
+
41
+ if (this.source.mode === 'remote' && this.source.remote.enabled) {
42
+ console.log(`🌐 Loading from remote: ${this.source.remote.repo}`);
43
+ return this.loadRemote();
44
+ } else if (this.source.mode === 'hybrid') {
45
+ console.log('🔄 Trying remote, fallback to local');
46
+ try {
47
+ return await this.loadRemote();
48
+ } catch (error) {
49
+ console.warn('⚠️ Remote failed, using local:', error.message);
50
+ return this.loadLocal();
51
+ }
52
+ } else {
53
+ console.log('💾 Loading from local directories');
54
+ return this.loadLocal();
55
+ }
56
+ } catch (error) {
57
+ console.error('❌ Error loading config-source.json:', error);
58
+ console.log('🔄 Falling back to local mode');
59
+ return this.loadLocal();
60
+ }
61
+ },
62
+
63
+ /**
64
+ * Load configuration from local directories
65
+ */
66
+ async loadLocal() {
67
+ const configDir = this.source?.local?.configDir || 'config';
68
+ const dataDir = this.source?.local?.dataDir || 'data';
69
+ const langDir = this.source?.local?.langDir || 'lang';
70
+
71
+ try {
72
+ const [app, languages, categories, mediaTypes] = await Promise.all([
73
+ fetch(`${configDir}/app.json`).then(r => r.json()),
74
+ fetch(`${configDir}/languages.json`).then(r => r.json()),
75
+ fetch(`${configDir}/categories.json`).then(r => r.json()),
76
+ fetch(`${configDir}/media-types.json`).then(r => r.json())
77
+ ]);
78
+
79
+ return {
80
+ app,
81
+ languages,
82
+ categories,
83
+ mediaTypes,
84
+ source: 'local',
85
+ paths: { configDir, dataDir, langDir }
86
+ };
87
+ } catch (error) {
88
+ console.error('❌ Failed to load local config:', error);
89
+ throw error;
90
+ }
91
+ },
92
+
93
+ /**
94
+ * Load configuration from remote GitHub repository
95
+ */
96
+ async loadRemote() {
97
+ if (!this.source?.remote?.enabled) {
98
+ throw new Error('Remote config not enabled');
99
+ }
100
+
101
+ const baseUrl = this.source.remote.baseUrl;
102
+
103
+ // Check cache first
104
+ if (this.source.cache?.enabled && this.isCacheValid()) {
105
+ console.log('💨 Using cached remote config');
106
+ return this.cache.data;
107
+ }
108
+
109
+ try {
110
+ const [app, languages, categories, mediaTypes] = await Promise.all([
111
+ this.fetchRemote(`${baseUrl}config/app.json`),
112
+ this.fetchRemote(`${baseUrl}config/languages.json`),
113
+ this.fetchRemote(`${baseUrl}config/categories.json`),
114
+ this.fetchRemote(`${baseUrl}config/media-types.json`)
115
+ ]);
116
+
117
+ const data = {
118
+ app,
119
+ languages,
120
+ categories,
121
+ mediaTypes,
122
+ source: 'remote',
123
+ paths: {
124
+ configDir: baseUrl + 'config',
125
+ dataDir: baseUrl + 'data',
126
+ langDir: baseUrl + 'lang'
127
+ }
128
+ };
129
+
130
+ // Cache the result
131
+ if (this.source.cache?.enabled) {
132
+ this.cache = {
133
+ data,
134
+ timestamp: Date.now(),
135
+ duration: this.source.cache.duration * 1000
136
+ };
137
+ }
138
+
139
+ return data;
140
+ } catch (error) {
141
+ console.error('❌ Failed to load remote config:', error);
142
+ throw error;
143
+ }
144
+ },
145
+
146
+ /**
147
+ * Fetch from remote with error handling
148
+ */
149
+ async fetchRemote(url) {
150
+ const response = await fetch(url);
151
+ if (!response.ok) {
152
+ throw new Error(`HTTP ${response.status}: ${url}`);
153
+ }
154
+ return response.json();
155
+ },
156
+
157
+ /**
158
+ * Check if cache is still valid
159
+ */
160
+ isCacheValid() {
161
+ if (!this.cache.timestamp) return false;
162
+ const age = Date.now() - this.cache.timestamp;
163
+ return age < this.cache.duration;
164
+ },
165
+
166
+ /**
167
+ * Clear cache
168
+ */
169
+ clearCache() {
170
+ this.cache = {};
171
+ },
172
+
173
+ /**
174
+ * Get data file path (adjusts for local vs remote)
175
+ */
176
+ getDataPath(filename) {
177
+ if (!this.cache.data) return `data/${filename}`;
178
+ return `${this.cache.data.paths.dataDir}/${filename}`;
179
+ },
180
+
181
+ /**
182
+ * Get language file path
183
+ */
184
+ getLangPath(langCode) {
185
+ if (!this.cache.data) return `lang/${langCode}.json`;
186
+ return `${this.cache.data.paths.langDir}/${langCode}.json`;
187
+ },
188
+
189
+ /**
190
+ * Get engine asset path
191
+ */
192
+ getEnginePath(path) {
193
+ const baseUrl = window.RETRO_ENGINE_URL || '';
194
+ return baseUrl + path.replace(/^\//, '');
195
+ }
196
+ };
197
+
198
+ // Enhanced AppConfig that uses ConfigLoader
199
+ const AppConfig = {
200
+ app: null,
201
+ languages: null,
202
+ categories: null,
203
+ mediaTypes: null,
204
+ loaded: false,
205
+ source: 'local',
206
+ paths: null,
207
+
208
+ /**
209
+ * Load all configuration files
210
+ */
211
+ async load() {
212
+ try {
213
+ const config = await ConfigLoader.init();
214
+
215
+ this.app = config.app;
216
+ this.languages = config.languages;
217
+ this.categories = config.categories;
218
+ this.mediaTypes = config.mediaTypes;
219
+ this.source = config.source;
220
+ this.paths = config.paths;
221
+ this.loaded = true;
222
+
223
+ console.log(`✅ Configuration loaded from ${config.source}`);
224
+ return true;
225
+ } catch (error) {
226
+ console.error('❌ Failed to load configuration:', error);
227
+ return false;
228
+ }
229
+ },
230
+
231
+ /**
232
+ * Get API base URL
233
+ */
234
+ getApiUrl() {
235
+ return this.app?.api?.baseUrl || 'http://127.0.0.1:5001';
236
+ },
237
+
238
+ /**
239
+ * Get supported language codes
240
+ */
241
+ getLanguageCodes() {
242
+ return this.languages?.supportedLanguages.map(l => l.code) || ['en'];
243
+ },
244
+
245
+ /**
246
+ * Get default language
247
+ */
248
+ getDefaultLanguage() {
249
+ return this.languages?.defaultLanguage || 'en';
250
+ },
251
+
252
+ /**
253
+ * Get all content types
254
+ */
255
+ getAllContentTypes() {
256
+ return this.categories?.contentTypes || this.categories?.categories || [];
257
+ },
258
+
259
+ /**
260
+ * Get content type configuration by ID
261
+ */
262
+ getContentType(contentTypeId) {
263
+ return this.getAllContentTypes().find(c => c.id === contentTypeId);
264
+ },
265
+
266
+ /**
267
+ * Get category configuration by ID (legacy, returns content type)
268
+ */
269
+ getCategory(categoryId) {
270
+ return this.getContentType(categoryId);
271
+ },
272
+
273
+ /**
274
+ * Get all categories (legacy, returns content types)
275
+ */
276
+ getAllCategories() {
277
+ return this.getAllContentTypes();
278
+ },
279
+
280
+ /**
281
+ * Get all media types
282
+ */
283
+ getAllMediaTypes() {
284
+ return this.mediaTypes?.mediaTypes || [];
285
+ },
286
+
287
+ /**
288
+ * Get media type configuration by ID
289
+ */
290
+ getMediaType(mediaTypeId) {
291
+ return this.getAllMediaTypes().find(m => m.id === mediaTypeId);
292
+ },
293
+
294
+ /**
295
+ * Get content types by media type
296
+ */
297
+ getContentTypesByMedia(mediaTypeId) {
298
+ return this.getAllContentTypes().filter(ct => ct.mediaType === mediaTypeId);
299
+ },
300
+
301
+ /**
302
+ * Get categories that support galleries (based on media type)
303
+ */
304
+ getGalleryCategories() {
305
+ const galleryTypes = [];
306
+ for (const ct of this.getAllContentTypes()) {
307
+ const mediaType = this.getMediaType(ct.mediaType);
308
+ if (mediaType && mediaType.supportsGallery) {
309
+ galleryTypes.push(ct.id);
310
+ }
311
+ }
312
+ return galleryTypes;
313
+ },
314
+
315
+ /**
316
+ * Get data file path for a category
317
+ */
318
+ getCategoryDataFile(categoryId) {
319
+ const category = this.getCategory(categoryId);
320
+ if (!category) return `data/${categoryId}.json`;
321
+
322
+ // If remote, return full URL, otherwise relative path
323
+ const fileName = category.dataFile.split('/').pop();
324
+ return ConfigLoader.getDataPath(fileName);
325
+ },
326
+
327
+ /**
328
+ * Get optional fields for a category
329
+ */
330
+ getCategoryFields(categoryId) {
331
+ const category = this.getCategory(categoryId);
332
+ return category?.fields?.optional || [];
333
+ },
334
+
335
+ /**
336
+ * Check if a field exists for a category
337
+ */
338
+ categoryHasField(categoryId, fieldName) {
339
+ const fields = this.getCategoryFields(categoryId);
340
+ return fields.some(f => f.name === fieldName);
341
+ },
342
+
343
+ /**
344
+ * Create multilingual object with all supported languages
345
+ */
346
+ createMultilingualObject(value) {
347
+ const obj = {};
348
+ this.getLanguageCodes().forEach(code => {
349
+ obj[code] = value;
350
+ });
351
+ return obj;
352
+ },
353
+
354
+ /**
355
+ * Get app setting
356
+ */
357
+ getSetting(path) {
358
+ const parts = path.split('.');
359
+ let value = this.app;
360
+ for (const part of parts) {
361
+ value = value?.[part];
362
+ if (value === undefined) return null;
363
+ }
364
+ return value;
365
+ }
366
+ };
367
+
368
+ // Export for use in other modules
369
+ window.AppConfig = AppConfig;
370
+ window.ConfigLoader = ConfigLoader;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Configuration Loader
3
+ * Loads all configuration files and makes them available globally
4
+ */
5
+
6
+ const AppConfig = {
7
+ app: null,
8
+ languages: null,
9
+ categories: null,
10
+ mediaTypes: null,
11
+ loaded: false,
12
+
13
+ /**
14
+ * Load all configuration files
15
+ */
16
+ async load() {
17
+ try {
18
+ const [appData, languagesData, categoriesData, mediaTypesData] = await Promise.all([
19
+ fetch('config/app.json').then(r => r.json()),
20
+ fetch('config/languages.json').then(r => r.json()),
21
+ fetch('config/categories.json').then(r => r.json()),
22
+ fetch('config/media-types.json').then(r => r.json())
23
+ ]);
24
+
25
+ this.app = appData;
26
+ this.languages = languagesData;
27
+ this.categories = categoriesData;
28
+ this.mediaTypes = mediaTypesData;
29
+ this.loaded = true;
30
+
31
+ console.log('✅ Configuration loaded successfully');
32
+ return true;
33
+ } catch (error) {
34
+ console.error('❌ Failed to load configuration:', error);
35
+ return false;
36
+ }
37
+ },
38
+
39
+ /**
40
+ * Get API base URL
41
+ */
42
+ getApiUrl() {
43
+ return this.app?.api?.baseUrl || 'http://127.0.0.1:5001';
44
+ },
45
+
46
+ /**
47
+ * Get supported language codes
48
+ */
49
+ getLanguageCodes() {
50
+ return this.languages?.supportedLanguages.map(l => l.code) || ['en'];
51
+ },
52
+
53
+ /**
54
+ * Get default language
55
+ */
56
+ getDefaultLanguage() {
57
+ return this.languages?.defaultLanguage || 'en';
58
+ },
59
+
60
+ /**
61
+ * Get all content types
62
+ */
63
+ getAllContentTypes() {
64
+ return this.categories?.contentTypes || this.categories?.categories || [];
65
+ },
66
+
67
+ /**
68
+ * Get content type configuration by ID
69
+ */
70
+ getContentType(contentTypeId) {
71
+ return this.getAllContentTypes().find(c => c.id === contentTypeId);
72
+ },
73
+
74
+ /**
75
+ * Get category configuration by ID (legacy, returns content type)
76
+ */
77
+ getCategory(categoryId) {
78
+ return this.getContentType(categoryId);
79
+ },
80
+
81
+ /**
82
+ * Get all categories (legacy, returns content types)
83
+ */
84
+ getAllCategories() {
85
+ return this.getAllContentTypes();
86
+ },
87
+
88
+ /**
89
+ * Get all media types
90
+ */
91
+ getAllMediaTypes() {
92
+ return this.mediaTypes?.mediaTypes || [];
93
+ },
94
+
95
+ /**
96
+ * Get media type configuration by ID
97
+ */
98
+ getMediaType(mediaTypeId) {
99
+ return this.getAllMediaTypes().find(m => m.id === mediaTypeId);
100
+ },
101
+
102
+ /**
103
+ * Get content types by media type
104
+ */
105
+ getContentTypesByMedia(mediaTypeId) {
106
+ return this.getAllContentTypes().filter(ct => ct.mediaType === mediaTypeId);
107
+ },
108
+
109
+ /**
110
+ * Get categories that support galleries (based on media type)
111
+ */
112
+ getGalleryCategories() {
113
+ const galleryTypes = [];
114
+ for (const ct of this.getAllContentTypes()) {
115
+ const mediaType = this.getMediaType(ct.mediaType);
116
+ if (mediaType && mediaType.supportsGallery) {
117
+ galleryTypes.push(ct.id);
118
+ }
119
+ }
120
+ return galleryTypes;
121
+ },
122
+
123
+ /**
124
+ * Get data file path for a category
125
+ */
126
+ getCategoryDataFile(categoryId) {
127
+ const category = this.getCategory(categoryId);
128
+ return category?.dataFile || `data/${categoryId}.json`;
129
+ },
130
+
131
+ /**
132
+ * Get optional fields for a category
133
+ */
134
+ getCategoryFields(categoryId) {
135
+ const category = this.getCategory(categoryId);
136
+ return category?.fields?.optional || [];
137
+ },
138
+
139
+ /**
140
+ * Check if a field exists for a category
141
+ */
142
+ categoryHasField(categoryId, fieldName) {
143
+ const fields = this.getCategoryFields(categoryId);
144
+ return fields.some(f => f.name === fieldName);
145
+ },
146
+
147
+ /**
148
+ * Create multilingual object with all supported languages
149
+ */
150
+ createMultilingualObject(value) {
151
+ const obj = {};
152
+ this.getLanguageCodes().forEach(code => {
153
+ obj[code] = value;
154
+ });
155
+ return obj;
156
+ },
157
+
158
+ /**
159
+ * Get app setting
160
+ */
161
+ getSetting(path) {
162
+ const parts = path.split('.');
163
+ let value = this.app;
164
+ for (const part of parts) {
165
+ value = value?.[part];
166
+ if (value === undefined) return null;
167
+ }
168
+ return value;
169
+ }
170
+ };
171
+
172
+ // Export for use in other modules
173
+ window.AppConfig = AppConfig;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Real visitor counter using counterapi.dev
3
+ */
4
+ (function() {
5
+ const el = document.querySelector('.visitor-counter');
6
+ if (!el) return;
7
+
8
+ fetch('https://api.counterapi.dev/v1/retro-portfolio/visits/up')
9
+ .then(r => r.json())
10
+ .then(data => {
11
+ const count = data.count || 0;
12
+ el.textContent = String(count).padStart(6, '0');
13
+ })
14
+ .catch(() => {
15
+ el.textContent = '------';
16
+ });
17
+ })();
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Party Mode Manager
3
+ * Combines Glitter Text and Sparkles
4
+ */
5
+
6
+ const effects = {
7
+ partyModeEnabled: false,
8
+
9
+ init() {
10
+ // Load saved preferences
11
+ this.partyModeEnabled = localStorage.getItem('party-mode-enabled') === 'true';
12
+
13
+ if (this.partyModeEnabled) {
14
+ this.enablePartyMode();
15
+ }
16
+
17
+ this.updateIndicators();
18
+ },
19
+
20
+ enablePartyMode() {
21
+ document.body.classList.add('party-mode');
22
+
23
+ // 1. Enable Glitter Text
24
+ const title = document.getElementById('page-title');
25
+ if (title && !title.classList.contains('glitter-text')) {
26
+ title.classList.add('glitter-text');
27
+ }
28
+ document.querySelectorAll('.gallery-item h3').forEach(h3 => {
29
+ if (!h3.classList.contains('glitter-text-alt')) {
30
+ h3.classList.add('glitter-text-alt');
31
+ }
32
+ });
33
+
34
+ // 2. Enable Sparkles
35
+ if (window.sparkle && window.sparkle.enable) {
36
+ window.sparkle.enable();
37
+ }
38
+ },
39
+
40
+ disablePartyMode() {
41
+ document.body.classList.remove('party-mode');
42
+
43
+ // 1. Disable Glitter Text
44
+ const title = document.getElementById('page-title');
45
+ if (title) {
46
+ title.classList.remove('glitter-text');
47
+ }
48
+ document.querySelectorAll('.gallery-item h3').forEach(h3 => {
49
+ h3.classList.remove('glitter-text-alt');
50
+ });
51
+
52
+ // 2. Disable Sparkles
53
+ if (window.sparkle && window.sparkle.disable) {
54
+ window.sparkle.disable();
55
+ }
56
+ },
57
+
58
+ updateIndicators() {
59
+ const indicator = document.getElementById('party-mode-indicator');
60
+ if (indicator) {
61
+ indicator.textContent = this.partyModeEnabled ? '✨' : '○';
62
+ }
63
+ }
64
+ };
65
+
66
+ // Global toggle function
67
+ window.togglePartyMode = function () {
68
+ effects.partyModeEnabled = !effects.partyModeEnabled;
69
+ localStorage.setItem('party-mode-enabled', effects.partyModeEnabled);
70
+
71
+ if (effects.partyModeEnabled) {
72
+ effects.enablePartyMode();
73
+ } else {
74
+ effects.disablePartyMode();
75
+ }
76
+ effects.updateIndicators();
77
+ };
78
+
79
+ // Initialize when DOM is ready
80
+ if (document.readyState === 'loading') {
81
+ document.addEventListener('DOMContentLoaded', () => effects.init());
82
+ } else {
83
+ effects.init();
84
+ }
85
+
86
+ // Re-apply glitter to new gallery items when they're rendered
87
+ const observer = new MutationObserver(() => {
88
+ if (effects.partyModeEnabled) {
89
+ document.querySelectorAll('.gallery-item h3:not(.glitter-text-alt)').forEach(h3 => {
90
+ h3.classList.add('glitter-text-alt');
91
+ });
92
+ }
93
+ });
94
+
95
+ observer.observe(document.body, { childList: true, subtree: true });
96
+
97
+ window.effects = effects;