@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.
- package/README.md +408 -0
- package/bin/cli.js +103 -0
- package/engine/admin/admin.css +720 -0
- package/engine/admin/admin.html +801 -0
- package/engine/admin/admin_api.py +230 -0
- package/engine/admin/scripts/backup.sh +116 -0
- package/engine/admin/scripts/config_loader.py +180 -0
- package/engine/admin/scripts/init.sh +141 -0
- package/engine/admin/scripts/manager.py +308 -0
- package/engine/admin/scripts/restore.sh +121 -0
- package/engine/admin/scripts/server.py +41 -0
- package/engine/admin/scripts/update.sh +321 -0
- package/engine/admin/scripts/validate_json.py +62 -0
- package/engine/fonts.css +37 -0
- package/engine/index.html +190 -0
- package/engine/js/config-loader.js +370 -0
- package/engine/js/config.js +173 -0
- package/engine/js/counter.js +17 -0
- package/engine/js/effects.js +97 -0
- package/engine/js/i18n.js +68 -0
- package/engine/js/init.js +107 -0
- package/engine/js/media.js +264 -0
- package/engine/js/render.js +282 -0
- package/engine/js/router.js +133 -0
- package/engine/js/sparkle.js +123 -0
- package/engine/js/themes.js +607 -0
- package/engine/style.css +2037 -0
- package/index.js +35 -0
- package/package.json +48 -0
- package/scripts/admin.js +67 -0
- package/scripts/build.js +142 -0
- package/scripts/init.js +237 -0
- package/scripts/post-install.js +16 -0
- package/scripts/serve.js +54 -0
- package/templates/user-portfolio/.github/workflows/deploy.yml +57 -0
- package/templates/user-portfolio/config/app.json +36 -0
- package/templates/user-portfolio/config/categories.json +241 -0
- package/templates/user-portfolio/config/languages.json +15 -0
- package/templates/user-portfolio/config/media-types.json +59 -0
- package/templates/user-portfolio/data/painting.json +3 -0
- package/templates/user-portfolio/data/projects.json +3 -0
- package/templates/user-portfolio/lang/en.json +114 -0
- 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;
|