@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,68 @@
1
+ const i18n = {
2
+ currentLang: localStorage.getItem('selectedLang') || (window.AppConfig?.getDefaultLanguage() || 'en'),
3
+ translations: {},
4
+
5
+ async init() {
6
+ // Wait for config to load if not already loaded
7
+ if (window.AppConfig && !window.AppConfig.loaded) {
8
+ await window.AppConfig.load();
9
+ }
10
+ await this.loadTranslations(this.currentLang);
11
+ this.updateDOM();
12
+ this.updateSwitcherUI();
13
+ },
14
+
15
+ async loadTranslations(lang) {
16
+ try {
17
+ const langDir = window.AppConfig?.getSetting('paths.langDir') || 'lang';
18
+ const response = await fetch(`${langDir}/${lang}.json`);
19
+ if (!response.ok) throw new Error(`Could not load ${lang} translation`);
20
+ this.translations = await response.ok ? await response.json() : {};
21
+ this.currentLang = lang;
22
+ localStorage.setItem('selectedLang', lang);
23
+ } catch (error) {
24
+ console.error('i18n Error:', error);
25
+ // Fallback to default language
26
+ const defaultLang = window.AppConfig?.getDefaultLanguage() || 'en';
27
+ if (lang !== defaultLang) await this.loadTranslations(defaultLang);
28
+ }
29
+ },
30
+
31
+ updateDOM() {
32
+ const elements = document.querySelectorAll('[data-i18n]');
33
+ elements.forEach(el => {
34
+ const key = el.getAttribute('data-i18n');
35
+ if (this.translations[key]) {
36
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
37
+ el.placeholder = this.translations[key];
38
+ } else {
39
+ el.innerText = this.translations[key];
40
+ }
41
+ }
42
+ });
43
+ },
44
+
45
+ updateSwitcherUI() {
46
+ // Gear button is static — no UI update needed
47
+ },
48
+
49
+ async changeLang(lang) {
50
+ await this.loadTranslations(lang);
51
+ this.updateSwitcherUI();
52
+ // Rebuild media playlist names for new language
53
+ if (window.media && media.rawTracks.length > 0) {
54
+ media.buildPlaylist();
55
+ media.updateTrackDisplay();
56
+ media.populateTrackSelector();
57
+ }
58
+ // Re-load current page to re-render everything in the new language
59
+ if (window.router && router.currentRoute) {
60
+ await router.loadPage(window.location.pathname + window.location.search);
61
+ } else {
62
+ this.updateDOM();
63
+ }
64
+ }
65
+ };
66
+
67
+ // i18n.init() is called by the router during boot — no auto-run needed
68
+ window.i18n = i18n;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Application Initializer
3
+ * Loads configuration and initializes all modules
4
+ */
5
+
6
+ (async function() {
7
+ console.log('🚀 Initializing application...');
8
+
9
+ // Load configuration first
10
+ const configLoaded = await AppConfig.load();
11
+ if (!configLoaded) {
12
+ console.error('Failed to load configuration. Using defaults.');
13
+ }
14
+
15
+ // Initialize language selector dynamically
16
+ initLanguageSelector();
17
+
18
+ // Initialize theme selector dynamically
19
+ initThemeSelector();
20
+
21
+ // Initialize other modules
22
+ if (typeof i18n !== 'undefined') {
23
+ i18n.init();
24
+ }
25
+
26
+ if (typeof themes !== 'undefined') {
27
+ themes.init();
28
+ }
29
+
30
+ console.log('✅ Application initialized');
31
+ })();
32
+
33
+ /**
34
+ * Dynamically build language selector from config
35
+ */
36
+ function initLanguageSelector() {
37
+ const langSection = document.querySelector('.settings-dropdown .settings-section-label');
38
+ if (!langSection || langSection.textContent !== 'Language') {
39
+ return; // Not on a page with language selector
40
+ }
41
+
42
+ // Find the language options container
43
+ const settingsDropdown = langSection.parentElement;
44
+ const langDividerIndex = Array.from(settingsDropdown.children).indexOf(langSection) - 1;
45
+
46
+ // Remove existing language options
47
+ const existingLangOptions = [];
48
+ let sibling = langSection.nextElementSibling;
49
+ while (sibling && !sibling.classList.contains('settings-divider') && !sibling.classList.contains('settings-section-label')) {
50
+ existingLangOptions.push(sibling);
51
+ sibling = sibling.nextElementSibling;
52
+ }
53
+ existingLangOptions.forEach(el => el.remove());
54
+
55
+ // Add new language options from config
56
+ if (AppConfig.languages && AppConfig.languages.supportedLanguages) {
57
+ AppConfig.languages.supportedLanguages.forEach(lang => {
58
+ const option = document.createElement('div');
59
+ option.className = 'settings-option';
60
+ option.onclick = () => i18n.changeLang(lang.code);
61
+ option.innerHTML = `<span class="lang-flag">${lang.flag}</span> ${lang.name}`;
62
+
63
+ // Insert after language label
64
+ langSection.parentNode.insertBefore(option, langSection.nextSibling);
65
+ });
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Dynamically build theme selector from config
71
+ */
72
+ function initThemeSelector() {
73
+ // This will be implemented when we externalize themes.js
74
+ // For now, themes are still in themes.js
75
+ }
76
+
77
+ /**
78
+ * Dynamically build category filters from config
79
+ */
80
+ function initCategoryFilters() {
81
+ const filterButtons = document.querySelector('.filter-buttons');
82
+ if (!filterButtons || !AppConfig.categories) return;
83
+
84
+ // Clear existing filters except "All"
85
+ const allButton = filterButtons.querySelector('[data-filter="all"]');
86
+ filterButtons.innerHTML = '';
87
+ if (allButton) {
88
+ filterButtons.appendChild(allButton);
89
+ }
90
+
91
+ // Add category filters from config
92
+ AppConfig.getAllCategories().forEach(category => {
93
+ const button = document.createElement('button');
94
+ button.className = 'filter-btn';
95
+ button.setAttribute('data-filter', category.id);
96
+ button.textContent = `${category.icon} ${category.name}`;
97
+ button.onclick = () => {
98
+ if (typeof render !== 'undefined' && render.filterCategory) {
99
+ render.filterCategory(category.id);
100
+ }
101
+ };
102
+ filterButtons.appendChild(button);
103
+ });
104
+ }
105
+
106
+ // Export for use in other modules
107
+ window.initCategoryFilters = initCategoryFilters;
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Winamp-style Media Controller for Alex's Portfolio
3
+ * Manages audio playlist with seek, time display, and mini visualizer
4
+ * Loads tracks dynamically from data/music.json
5
+ */
6
+ const media = {
7
+ audio: new Audio(),
8
+ currentTrackIndex: 0,
9
+ playlist: [],
10
+ rawTracks: [],
11
+ vizInterval: null,
12
+
13
+ tf(field) {
14
+ if (!field) return '';
15
+ if (typeof field === 'object' && !Array.isArray(field)) {
16
+ const lang = (window.i18n && i18n.currentLang) || 'en';
17
+ return field[lang] || field.en || '';
18
+ }
19
+ return field;
20
+ },
21
+
22
+ async init() {
23
+ await this.loadPlaylist();
24
+ this.setupEventListeners();
25
+ this.updateTrackDisplay();
26
+ this.populateTrackSelector();
27
+ this.startViz();
28
+ console.log(`Winamp initialized with ${this.playlist.length} tracks`);
29
+ },
30
+
31
+ buildPlaylist() {
32
+ this.playlist = this.rawTracks.map(t => {
33
+ const title = this.tf(t.title);
34
+ const genre = this.tf(t.genre);
35
+ return {
36
+ name: title + (genre ? ` [${genre}]` : ''),
37
+ src: t.url
38
+ };
39
+ });
40
+ },
41
+
42
+ async loadPlaylist() {
43
+ try {
44
+ const res = await fetch('data/music.json');
45
+ const tracks = await res.json();
46
+ if (tracks && tracks.length > 0) {
47
+ this.rawTracks = tracks;
48
+ this.buildPlaylist();
49
+ }
50
+ } catch (e) {
51
+ console.log('Could not load music.json');
52
+ }
53
+ },
54
+
55
+ populateTrackSelector() {
56
+ const list = document.getElementById('radio-tracklist');
57
+ if (!list) return;
58
+ list.innerHTML = '';
59
+ if (this.playlist.length === 0) {
60
+ const empty = document.createElement('div');
61
+ empty.className = 'winamp-pl-item winamp-pl-empty';
62
+ empty.textContent = (window.i18n && i18n.translations.sidebar_radio_no_tracks) || 'No tracks available';
63
+ list.appendChild(empty);
64
+ return;
65
+ }
66
+ this.playlist.forEach((track, i) => {
67
+ const item = document.createElement('div');
68
+ item.className = 'winamp-pl-item';
69
+ if (i === this.currentTrackIndex) item.classList.add('active');
70
+ item.dataset.index = i;
71
+ item.innerHTML = `<span class="winamp-pl-num">${i + 1}.</span> ${track.name}`;
72
+ item.ondblclick = () => this.switchTrack(i);
73
+ item.onclick = () => {
74
+ // Single click = select, highlight
75
+ list.querySelectorAll('.winamp-pl-item.selected').forEach(el => el.classList.remove('selected'));
76
+ item.classList.add('selected');
77
+ };
78
+ list.appendChild(item);
79
+ });
80
+ },
81
+
82
+ setupEventListeners() {
83
+ const playPauseBtn = document.querySelector('.radio-playpause');
84
+ const prevBtn = document.querySelector('.radio-prev');
85
+ const nextBtn = document.querySelector('.radio-next');
86
+ const volumeSlider = document.querySelector('.radio-volume');
87
+ const seekBar = document.getElementById('winamp-seek');
88
+
89
+ if (playPauseBtn) playPauseBtn.onclick = () => this.togglePlayPause();
90
+ if (prevBtn) prevBtn.onclick = () => this.prev();
91
+ if (nextBtn) nextBtn.onclick = () => this.next();
92
+
93
+ if (volumeSlider) {
94
+ volumeSlider.oninput = (e) => {
95
+ this.audio.volume = e.target.value / 100;
96
+ };
97
+ }
98
+
99
+ // Seek bar
100
+ if (seekBar) {
101
+ seekBar.oninput = (e) => {
102
+ if (this.audio.duration) {
103
+ this.audio.currentTime = (e.target.value / 100) * this.audio.duration;
104
+ }
105
+ };
106
+ }
107
+
108
+ // Update time, duration, and seek position
109
+ this.audio.ontimeupdate = () => {
110
+ const timeEl = document.getElementById('winamp-time');
111
+ if (timeEl) {
112
+ const m = Math.floor(this.audio.currentTime / 60);
113
+ const s = Math.floor(this.audio.currentTime % 60);
114
+ timeEl.textContent = String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
115
+ }
116
+ const durEl = document.getElementById('winamp-duration');
117
+ if (durEl && this.audio.duration) {
118
+ const dm = Math.floor(this.audio.duration / 60);
119
+ const ds = Math.floor(this.audio.duration % 60);
120
+ durEl.textContent = String(dm).padStart(2, '0') + ':' + String(ds).padStart(2, '0');
121
+ }
122
+ const seek = document.getElementById('winamp-seek');
123
+ if (seek && this.audio.duration) {
124
+ seek.value = (this.audio.currentTime / this.audio.duration) * 100;
125
+ }
126
+ };
127
+
128
+ // Sync playing class and button icon with actual audio state
129
+ this.audio.onplay = () => {
130
+ this.setPlayingState(true);
131
+ this.updatePlayPauseIcon(true);
132
+ };
133
+ this.audio.onpause = () => {
134
+ this.setPlayingState(false);
135
+ this.updatePlayPauseIcon(false);
136
+ };
137
+
138
+ // Auto-advance to next track
139
+ this.audio.onended = () => this.next();
140
+ },
141
+
142
+ setPlayingState(isPlaying) {
143
+ const winamp = document.querySelector('.winamp');
144
+ if (winamp) winamp.classList.toggle('playing', isPlaying);
145
+ },
146
+
147
+ togglePlayPause() {
148
+ if (this.audio.paused) {
149
+ this.play();
150
+ } else {
151
+ this.audio.pause();
152
+ }
153
+ },
154
+
155
+ play() {
156
+ if (!this.audio.src && this.playlist.length > 0) this.switchTrack(0);
157
+ const p = this.audio.play();
158
+ if (p && p.catch) p.catch(e => console.warn('Play blocked:', e));
159
+ this.setPlayingState(true);
160
+ },
161
+
162
+ updatePlayPauseIcon(isPlaying) {
163
+ const btn = document.querySelector('.radio-playpause');
164
+ if (!btn) return;
165
+ const svg = btn.querySelector('svg');
166
+ if (!svg) return;
167
+ const playPath = 'M8 5v14l11-7z';
168
+ const pausePath = 'M6 19h4V5H6v14zm8-14v14h4V5h-4z';
169
+ svg.querySelector('path').setAttribute('d', isPlaying ? pausePath : playPath);
170
+ btn.title = isPlaying ? 'Pause' : 'Play';
171
+ },
172
+
173
+ prev() {
174
+ if (this.playlist.length === 0) return;
175
+ const prev = (this.currentTrackIndex - 1 + this.playlist.length) % this.playlist.length;
176
+ this.switchTrack(prev);
177
+ },
178
+
179
+ next() {
180
+ if (this.playlist.length === 0) return;
181
+ const next = (this.currentTrackIndex + 1) % this.playlist.length;
182
+ this.switchTrack(next);
183
+ },
184
+
185
+ switchTrack(index) {
186
+ this.currentTrackIndex = index;
187
+ const track = this.playlist[index];
188
+ if (!track) return;
189
+ this.audio.src = track.src;
190
+ this.updateTrackDisplay();
191
+ this.play();
192
+ // Highlight active track in playlist
193
+ const list = document.getElementById('radio-tracklist');
194
+ if (list) {
195
+ list.querySelectorAll('.winamp-pl-item').forEach(el => {
196
+ el.classList.toggle('active', parseInt(el.dataset.index) === index);
197
+ });
198
+ // Scroll active into view
199
+ const activeEl = list.querySelector('.winamp-pl-item.active');
200
+ if (activeEl) activeEl.scrollIntoView({ block: 'nearest' });
201
+ }
202
+ },
203
+
204
+ updateTrackDisplay() {
205
+ const el = document.querySelector('.radio-track-name');
206
+ if (!el) return;
207
+ if (this.playlist.length > 0) {
208
+ const idx = this.currentTrackIndex + 1;
209
+ el.innerText = `${idx}. ${this.playlist[this.currentTrackIndex].name}`;
210
+ } else {
211
+ el.innerText = (window.i18n && i18n.translations.sidebar_radio_no_tracks) || 'No tracks available';
212
+ }
213
+ this.setupTickerScroll(el);
214
+ },
215
+
216
+ setupTickerScroll(el) {
217
+ if (!el) el = document.querySelector('.radio-track-name');
218
+ if (!el) return;
219
+ const container = el.parentElement;
220
+ const apply = () => {
221
+ if (!container.clientWidth) return;
222
+ const totalDist = container.clientWidth + el.scrollWidth;
223
+ const speed = Math.max(6, totalDist / 40);
224
+ el.style.setProperty('--ticker-speed', `${speed}s`);
225
+ el.style.setProperty('--ticker-end', `-${el.scrollWidth}px`);
226
+ };
227
+ apply();
228
+ setTimeout(apply, 200);
229
+ },
230
+
231
+ // Mini fake visualizer (random bars, Winamp style)
232
+ startViz() {
233
+ const viz = document.getElementById('winamp-viz');
234
+ if (!viz) return;
235
+
236
+ // Create 12 bars
237
+ viz.innerHTML = '';
238
+ for (let i = 0; i < 12; i++) {
239
+ const bar = document.createElement('div');
240
+ bar.className = 'winamp-viz-bar';
241
+ viz.appendChild(bar);
242
+ }
243
+
244
+ const bars = viz.querySelectorAll('.winamp-viz-bar');
245
+
246
+ // Animate based on play state
247
+ const animate = () => {
248
+ bars.forEach(bar => {
249
+ let h;
250
+ if (!this.audio.paused && this.audio.src) {
251
+ h = Math.random() * 100;
252
+ } else {
253
+ h = 3;
254
+ }
255
+ bar.style.height = h + '%';
256
+ });
257
+ };
258
+
259
+ this.vizInterval = setInterval(animate, 120);
260
+ }
261
+ };
262
+
263
+ // media.init() is called by the router during boot — no auto-run needed
264
+ window.media = media;