@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,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified Render Engine for Alex's Portfolio
|
|
3
|
+
* Loads ALL categories, renders a single grid, supports filter buttons
|
|
4
|
+
*/
|
|
5
|
+
const renderer = {
|
|
6
|
+
categories: {},
|
|
7
|
+
categoryIcons: {},
|
|
8
|
+
|
|
9
|
+
allItems: [],
|
|
10
|
+
filteredItems: [],
|
|
11
|
+
activeFilter: 'all',
|
|
12
|
+
sortOrder: 'desc',
|
|
13
|
+
PAGE_SIZE: 24,
|
|
14
|
+
visibleCount: 0,
|
|
15
|
+
_loadMoreObserver: null,
|
|
16
|
+
|
|
17
|
+
// Load category configuration dynamically
|
|
18
|
+
async loadCategoryConfig() {
|
|
19
|
+
if (!window.AppConfig || !window.AppConfig.loaded) {
|
|
20
|
+
await window.AppConfig?.load();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const allCategories = window.AppConfig?.getAllCategories() || [];
|
|
24
|
+
const dataDir = window.AppConfig?.getSetting('paths.dataDir') || 'data';
|
|
25
|
+
|
|
26
|
+
this.categories = {};
|
|
27
|
+
this.categoryIcons = {};
|
|
28
|
+
|
|
29
|
+
allCategories.forEach(cat => {
|
|
30
|
+
const fileName = cat.dataFile.split('/').pop();
|
|
31
|
+
this.categories[cat.id] = {
|
|
32
|
+
file: fileName,
|
|
33
|
+
from: cat.id
|
|
34
|
+
};
|
|
35
|
+
this.categoryIcons[cat.id] = cat.icon;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Update PAGE_SIZE from config
|
|
39
|
+
this.PAGE_SIZE = window.AppConfig?.getSetting('pagination.pageSize') || 24;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
t(field) {
|
|
43
|
+
if (!field) return '';
|
|
44
|
+
if (typeof field === 'object' && !Array.isArray(field)) {
|
|
45
|
+
const lang = (window.i18n && i18n.currentLang) || 'en';
|
|
46
|
+
return field[lang] || field.en || '';
|
|
47
|
+
}
|
|
48
|
+
return field;
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async init() {
|
|
52
|
+
// Load category configuration first
|
|
53
|
+
await this.loadCategoryConfig();
|
|
54
|
+
|
|
55
|
+
// Skip re-fetch if data is already loaded (returning from detail view)
|
|
56
|
+
if (this.allItems.length === 0) {
|
|
57
|
+
const dataDir = window.AppConfig?.getSetting('paths.dataDir') || 'data';
|
|
58
|
+
const entries = Object.entries(this.categories);
|
|
59
|
+
const fetches = entries.map(async ([category, info]) => {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${dataDir}/${info.file}`);
|
|
62
|
+
const items = await res.json();
|
|
63
|
+
return items.map(item => ({
|
|
64
|
+
...item,
|
|
65
|
+
_category: category,
|
|
66
|
+
_from: info.from
|
|
67
|
+
}));
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const results = await Promise.all(fetches);
|
|
73
|
+
this.allItems = results.flat();
|
|
74
|
+
this.sortItems();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.renderGrid();
|
|
78
|
+
this.setupFilters();
|
|
79
|
+
if (window.i18n) window.i18n.updateDOM();
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
sortItems() {
|
|
83
|
+
const dir = this.sortOrder === 'desc' ? -1 : 1;
|
|
84
|
+
this.allItems.sort((a, b) => {
|
|
85
|
+
const da = a.created || a.date || '';
|
|
86
|
+
const db = b.created || b.date || '';
|
|
87
|
+
return dir * da.localeCompare(db);
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
renderGrid() {
|
|
92
|
+
const app = document.getElementById('app');
|
|
93
|
+
if (!app) return;
|
|
94
|
+
|
|
95
|
+
// Remove old grid but keep the filter bar
|
|
96
|
+
const oldGrid = document.getElementById('gallery-container');
|
|
97
|
+
if (oldGrid) oldGrid.remove();
|
|
98
|
+
|
|
99
|
+
// Build filtered list
|
|
100
|
+
this.filteredItems = this.activeFilter === 'all'
|
|
101
|
+
? [...this.allItems]
|
|
102
|
+
: this.allItems.filter(i => i._category === this.activeFilter);
|
|
103
|
+
|
|
104
|
+
this.visibleCount = 0;
|
|
105
|
+
|
|
106
|
+
const container = document.createElement('div');
|
|
107
|
+
container.id = 'gallery-container';
|
|
108
|
+
container.className = 'gallery-grid';
|
|
109
|
+
app.appendChild(container);
|
|
110
|
+
|
|
111
|
+
this.loadMoreItems();
|
|
112
|
+
|
|
113
|
+
// Empty message
|
|
114
|
+
if (this.filteredItems.length === 0) {
|
|
115
|
+
const emptyMsg = document.createElement('p');
|
|
116
|
+
emptyMsg.id = 'empty-filter-msg';
|
|
117
|
+
emptyMsg.className = 'empty-message';
|
|
118
|
+
emptyMsg.textContent = (window.i18n && i18n.translations.filter_empty) || 'Nothing here yet.';
|
|
119
|
+
container.appendChild(emptyMsg);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
loadMoreItems() {
|
|
124
|
+
const container = document.getElementById('gallery-container');
|
|
125
|
+
if (!container) return;
|
|
126
|
+
|
|
127
|
+
const batch = this.filteredItems.slice(this.visibleCount, this.visibleCount + this.PAGE_SIZE);
|
|
128
|
+
const frag = document.createDocumentFragment();
|
|
129
|
+
batch.forEach(item => frag.appendChild(this.createGalleryItem(item)));
|
|
130
|
+
|
|
131
|
+
// Remove old load-more button before appending
|
|
132
|
+
const oldBtn = document.getElementById('load-more-btn');
|
|
133
|
+
if (oldBtn) {
|
|
134
|
+
if (this._loadMoreObserver) this._loadMoreObserver.unobserve(oldBtn);
|
|
135
|
+
oldBtn.remove();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
container.appendChild(frag);
|
|
139
|
+
this.visibleCount += batch.length;
|
|
140
|
+
|
|
141
|
+
// Add "Load More" if more items remain
|
|
142
|
+
if (this.visibleCount < this.filteredItems.length) {
|
|
143
|
+
const remaining = this.filteredItems.length - this.visibleCount;
|
|
144
|
+
const btn = document.createElement('button');
|
|
145
|
+
btn.id = 'load-more-btn';
|
|
146
|
+
btn.className = 'load-more-btn';
|
|
147
|
+
btn.textContent = `Show More (${remaining} remaining)`;
|
|
148
|
+
btn.onclick = () => this.loadMoreItems();
|
|
149
|
+
container.appendChild(btn);
|
|
150
|
+
|
|
151
|
+
// Auto-load on scroll into view
|
|
152
|
+
if (!this._loadMoreObserver) {
|
|
153
|
+
this._loadMoreObserver = new IntersectionObserver((entries) => {
|
|
154
|
+
if (entries[0].isIntersecting) this.loadMoreItems();
|
|
155
|
+
}, { rootMargin: '200px' });
|
|
156
|
+
}
|
|
157
|
+
this._loadMoreObserver.observe(btn);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
createGalleryItem(item) {
|
|
162
|
+
const div = document.createElement('div');
|
|
163
|
+
div.className = 'gallery-item';
|
|
164
|
+
div.setAttribute('data-category', item._category);
|
|
165
|
+
|
|
166
|
+
const title = this.t(item.title);
|
|
167
|
+
const dateStr = item.created || item.date || 'N/A';
|
|
168
|
+
const dateLabel = item.created ? 'gallery_created_on' : 'gallery_added_on';
|
|
169
|
+
const dateFallback = item.created ? 'Created:' : 'Added:';
|
|
170
|
+
const itemId = item.id || (typeof item.title === 'string' ? item.title : (item.title && item.title.en) || '');
|
|
171
|
+
const from = item._from || 'gallery';
|
|
172
|
+
const detailHref = `detail.html?id=${encodeURIComponent(itemId)}&from=${from}`;
|
|
173
|
+
const icon = this.categoryIcons[item._category] || '';
|
|
174
|
+
|
|
175
|
+
if (item._category === 'music') {
|
|
176
|
+
const genre = this.t(item.genre);
|
|
177
|
+
const playMeLabel = (window.i18n && i18n.translations.music_play_me) || 'Play Me';
|
|
178
|
+
div.innerHTML = `
|
|
179
|
+
<a href="${detailHref}" class="gallery-link">
|
|
180
|
+
${item.url ? `<button class="music-card-play" data-track-url="${item.url.replace(/"/g, '"')}" title="${playMeLabel}">▶</button>` : `<div class="card-icon">🎵</div>`}
|
|
181
|
+
<h3 align="center">${title}</h3>
|
|
182
|
+
${genre ? `<p align="center" class="gallery-subtitle">${genre}</p>` : ''}
|
|
183
|
+
<p align="center" class="item-date">
|
|
184
|
+
<span data-i18n="${dateLabel}">${dateFallback}</span> ${dateStr}
|
|
185
|
+
</p>
|
|
186
|
+
</a>
|
|
187
|
+
`;
|
|
188
|
+
const playBtn = div.querySelector('.music-card-play');
|
|
189
|
+
if (playBtn) {
|
|
190
|
+
playBtn.addEventListener('click', (e) => {
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
e.stopPropagation();
|
|
193
|
+
const url = playBtn.dataset.trackUrl;
|
|
194
|
+
if (window.media && media.playlist) {
|
|
195
|
+
const idx = media.playlist.findIndex(p => p.src === url);
|
|
196
|
+
if (idx >= 0) media.switchTrack(idx);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
const medium = this.t(item.medium);
|
|
202
|
+
const description = this.t(item.description);
|
|
203
|
+
let visibilityEmoji = '';
|
|
204
|
+
const isProject = item.category === 'projects' || item._category === 'projects';
|
|
205
|
+
if (isProject) {
|
|
206
|
+
visibilityEmoji = item.visibility === 'private' ? '🔒' : '🌍';
|
|
207
|
+
}
|
|
208
|
+
const subTitle = isProject ? description : (medium ? `(${medium})` : '');
|
|
209
|
+
|
|
210
|
+
const hasImage = item.url && !isProject;
|
|
211
|
+
const githubLabel = (window.i18n && i18n.translations.card_github) || 'GitHub';
|
|
212
|
+
const websiteLabel = (window.i18n && i18n.translations.card_website) || 'Website';
|
|
213
|
+
const isPublic = isProject && item.visibility === 'public';
|
|
214
|
+
|
|
215
|
+
let cardActionHtml = '';
|
|
216
|
+
if (isPublic) {
|
|
217
|
+
if (item.website) {
|
|
218
|
+
cardActionHtml = `<div class="card-actions">
|
|
219
|
+
<a href="${item.website}" target="_blank" class="card-action-btn" onclick="event.stopPropagation()">
|
|
220
|
+
<svg class="icon" viewBox="0 0 24 24"><path d="M19 19H5V5h7V3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></svg> ${websiteLabel}
|
|
221
|
+
</a>
|
|
222
|
+
</div>`;
|
|
223
|
+
} else if (item.url) {
|
|
224
|
+
cardActionHtml = `<div class="card-actions">
|
|
225
|
+
<a href="${item.url}" target="_blank" class="card-action-btn" onclick="event.stopPropagation()">
|
|
226
|
+
<svg class="icon" viewBox="0 0 24 24"><path d="M12 .3a12 12 0 00-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1.1-.8.1-.7.1-.7 1.2.1 1.9 1.3 1.9 1.3 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2-.1-.3-.5-1.5.1-3.2 0 0 1-.3 3.4 1.2a11.5 11.5 0 016 0c2.3-1.5 3.3-1.2 3.3-1.2.7 1.7.3 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0012 .3z"/></svg> ${githubLabel}
|
|
227
|
+
</a>
|
|
228
|
+
</div>`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const pileCount = (item.gallery && item.gallery.length) ? item.gallery.length + 1 : 0;
|
|
233
|
+
const pileBadge = pileCount > 1 ? `<span class="pile-badge">📷 ${pileCount}</span>` : '';
|
|
234
|
+
const pileLabel = pileCount > 1 ? `<p align="center" class="gallery-subtitle pile-label">📷 ${pileCount} photos</p>` : '';
|
|
235
|
+
|
|
236
|
+
div.innerHTML = `
|
|
237
|
+
<a href="${detailHref}" class="gallery-link">
|
|
238
|
+
${hasImage ? `<div class="gallery-img-wrap"><img src="${item.url}" alt="${title}" loading="lazy">${pileBadge}</div>` : `<div class="card-icon">${icon}</div>`}
|
|
239
|
+
<h3 align="center">${title}</h3>
|
|
240
|
+
${subTitle ? `<p align="center" class="gallery-subtitle">${subTitle}</p>` : ''}
|
|
241
|
+
${pileLabel}
|
|
242
|
+
<p align="center" class="item-date">
|
|
243
|
+
<span data-i18n="${dateLabel}">${dateFallback}</span> ${dateStr}
|
|
244
|
+
</p>
|
|
245
|
+
</a>
|
|
246
|
+
${cardActionHtml}
|
|
247
|
+
`;
|
|
248
|
+
}
|
|
249
|
+
return div;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
setupFilters() {
|
|
253
|
+
const nav = document.getElementById('filter-nav');
|
|
254
|
+
if (!nav) return;
|
|
255
|
+
|
|
256
|
+
nav.addEventListener('click', (e) => {
|
|
257
|
+
// Filter buttons
|
|
258
|
+
const btn = e.target.closest('.filter-btn:not(.filter-btn-back)');
|
|
259
|
+
if (btn) {
|
|
260
|
+
nav.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
|
261
|
+
btn.classList.add('active');
|
|
262
|
+
this.activeFilter = btn.dataset.filter;
|
|
263
|
+
this.renderGrid();
|
|
264
|
+
if (window.i18n) window.i18n.updateDOM();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Sort buttons
|
|
269
|
+
const sortBtn = e.target.closest('.sort-btn');
|
|
270
|
+
if (sortBtn) {
|
|
271
|
+
nav.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active'));
|
|
272
|
+
sortBtn.classList.add('active');
|
|
273
|
+
this.sortOrder = sortBtn.dataset.sort;
|
|
274
|
+
this.sortItems();
|
|
275
|
+
this.renderGrid();
|
|
276
|
+
if (window.i18n) window.i18n.updateDOM();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
window.renderer = renderer;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPA Router for Alex's Portfolio
|
|
3
|
+
* Two states: grid view (unified gallery) and detail view (single item)
|
|
4
|
+
*/
|
|
5
|
+
const router = {
|
|
6
|
+
currentRoute: null,
|
|
7
|
+
|
|
8
|
+
async init() {
|
|
9
|
+
// 1. Apply theme immediately (synchronous, no flash)
|
|
10
|
+
if (window.themes) {
|
|
11
|
+
themes.init();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 2. Load translations
|
|
15
|
+
if (window.i18n) {
|
|
16
|
+
await i18n.init();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 2. Intercept internal link clicks (only <a> elements, not <button>)
|
|
20
|
+
document.addEventListener('click', (e) => {
|
|
21
|
+
const link = e.target.closest('a');
|
|
22
|
+
if (link && link.href && link.href.startsWith(window.location.origin) && !link.getAttribute('target')) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
this.navigate(link.href);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 3. Handle browser back/forward
|
|
29
|
+
window.addEventListener('popstate', () => {
|
|
30
|
+
this.loadPage(window.location.pathname + window.location.search);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// 4. GitHub Pages SPA redirect
|
|
34
|
+
const redirectPath = sessionStorage.getItem('spa-redirect');
|
|
35
|
+
if (redirectPath) {
|
|
36
|
+
sessionStorage.removeItem('spa-redirect');
|
|
37
|
+
window.history.replaceState({}, '', redirectPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 5. Load initial page
|
|
41
|
+
await this.loadPage(window.location.pathname + window.location.search);
|
|
42
|
+
|
|
43
|
+
// 6. Init media controller after DOM is populated
|
|
44
|
+
if (window.media) {
|
|
45
|
+
await media.init();
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
isDetailRoute(url) {
|
|
50
|
+
return (typeof url === 'string' ? url : '').split('#')[0].includes('detail.html');
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
async navigate(url) {
|
|
54
|
+
window.history.pushState({}, '', url);
|
|
55
|
+
await this.loadPage(url);
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async loadPage(url) {
|
|
59
|
+
const app = document.getElementById('app');
|
|
60
|
+
|
|
61
|
+
if (this.isDetailRoute(url)) {
|
|
62
|
+
// DETAIL VIEW: load detail.html fragment
|
|
63
|
+
this.currentRoute = 'detail';
|
|
64
|
+
|
|
65
|
+
// Save filter bar children, then swap in a Back button
|
|
66
|
+
let filterNav = document.getElementById('filter-nav');
|
|
67
|
+
if (filterNav) {
|
|
68
|
+
this._savedFilterNav = filterNav;
|
|
69
|
+
this._savedFilterChildren = Array.from(filterNav.children);
|
|
70
|
+
filterNav.innerHTML = '';
|
|
71
|
+
} else {
|
|
72
|
+
// Direct URL access: create filter bar dynamically
|
|
73
|
+
filterNav = document.createElement('div');
|
|
74
|
+
filterNav.id = 'filter-nav';
|
|
75
|
+
filterNav.className = 'filter-bar';
|
|
76
|
+
this._savedFilterNav = filterNav;
|
|
77
|
+
this._savedFilterChildren = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create full-width Back button
|
|
81
|
+
const backBtn = document.createElement('button');
|
|
82
|
+
backBtn.className = 'filter-btn filter-btn-back';
|
|
83
|
+
const t = (key, fallback) => (window.i18n && i18n.translations[key]) || fallback;
|
|
84
|
+
backBtn.textContent = '\u2B05 ' + t('detail_back', 'Back');
|
|
85
|
+
backBtn.addEventListener('click', () => router.navigate('index.html'));
|
|
86
|
+
filterNav.appendChild(backBtn);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch('pages/detail.html');
|
|
90
|
+
if (!response.ok) throw new Error('Could not load detail page');
|
|
91
|
+
const html = await response.text();
|
|
92
|
+
app.innerHTML = html;
|
|
93
|
+
|
|
94
|
+
// Prepend filter bar (with Back button) above detail content
|
|
95
|
+
app.prepend(this._savedFilterNav);
|
|
96
|
+
|
|
97
|
+
// Re-execute inline scripts
|
|
98
|
+
app.querySelectorAll('script').forEach(oldScript => {
|
|
99
|
+
const newScript = document.createElement('script');
|
|
100
|
+
newScript.textContent = oldScript.textContent;
|
|
101
|
+
oldScript.replaceWith(newScript);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (window.i18n) window.i18n.updateDOM();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error('Detail load error:', error);
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
// GRID VIEW: render unified gallery
|
|
110
|
+
this.currentRoute = 'grid';
|
|
111
|
+
|
|
112
|
+
// Restore filter bar with original children
|
|
113
|
+
if (this._savedFilterNav) {
|
|
114
|
+
if (this._savedFilterChildren) {
|
|
115
|
+
this._savedFilterNav.innerHTML = '';
|
|
116
|
+
this._savedFilterChildren.forEach(child => this._savedFilterNav.appendChild(child));
|
|
117
|
+
this._savedFilterChildren = null;
|
|
118
|
+
}
|
|
119
|
+
app.innerHTML = '';
|
|
120
|
+
app.appendChild(this._savedFilterNav);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (window.renderer) {
|
|
124
|
+
await renderer.init();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
window.scrollTo(0, 0);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
document.addEventListener('DOMContentLoaded', () => router.init());
|
|
133
|
+
window.router = router;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sparkle Cursor Trail — Geocities-era glitter effect
|
|
3
|
+
* Creates tiny stars that follow the mouse and fade away
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
// Read sparkle colors from CSS variables (centralised in style.css :root)
|
|
7
|
+
let colors = [];
|
|
8
|
+
let enabled = false; // Default to disabled, controlled by effects.js
|
|
9
|
+
|
|
10
|
+
function refreshColors() {
|
|
11
|
+
const s = getComputedStyle(document.documentElement);
|
|
12
|
+
colors = [
|
|
13
|
+
s.getPropertyValue('--sparkle-1').trim() || '#ffd700',
|
|
14
|
+
s.getPropertyValue('--sparkle-2').trim() || '#ff69b4',
|
|
15
|
+
s.getPropertyValue('--sparkle-3').trim() || '#00ffff',
|
|
16
|
+
s.getPropertyValue('--sparkle-4').trim() || '#ff00ff',
|
|
17
|
+
s.getPropertyValue('--sparkle-5').trim() || '#fff',
|
|
18
|
+
s.getPropertyValue('--sparkle-6').trim() || '#7fff00',
|
|
19
|
+
s.getPropertyValue('--sparkle-7').trim() || '#ff4500',
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
refreshColors();
|
|
23
|
+
|
|
24
|
+
const shapes = ['✦', '✧', '✶', '★', '·', '✸', '✹'];
|
|
25
|
+
const pool = [];
|
|
26
|
+
const POOL_SIZE = 50;
|
|
27
|
+
let mouseX = 0, mouseY = 0;
|
|
28
|
+
let ticking = false;
|
|
29
|
+
|
|
30
|
+
// Pre-create reusable sparkle elements
|
|
31
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
32
|
+
const el = document.createElement('div');
|
|
33
|
+
el.style.cssText = 'position:fixed;pointer-events:none;z-index:99998;font-size:12px;transition:none;will-change:transform,opacity;';
|
|
34
|
+
el.style.display = 'none';
|
|
35
|
+
document.body.appendChild(el);
|
|
36
|
+
pool.push({ el, active: false });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function spawnSparkle() {
|
|
40
|
+
if (!enabled) return;
|
|
41
|
+
|
|
42
|
+
// Find an inactive sparkle from the pool
|
|
43
|
+
const sparkle = pool.find(s => !s.active);
|
|
44
|
+
if (!sparkle) return;
|
|
45
|
+
|
|
46
|
+
sparkle.active = true;
|
|
47
|
+
const el = sparkle.el;
|
|
48
|
+
|
|
49
|
+
// Random offsets so sparkles spread around cursor
|
|
50
|
+
const offsetX = (Math.random() - 0.5) * 30;
|
|
51
|
+
const offsetY = (Math.random() - 0.5) * 30;
|
|
52
|
+
const x = mouseX + offsetX;
|
|
53
|
+
const y = mouseY + offsetY;
|
|
54
|
+
|
|
55
|
+
// Random appearance
|
|
56
|
+
const color = colors[Math.floor(Math.random() * colors.length)];
|
|
57
|
+
const shape = shapes[Math.floor(Math.random() * shapes.length)];
|
|
58
|
+
const size = 8 + Math.random() * 14;
|
|
59
|
+
const drift = (Math.random() - 0.5) * 40;
|
|
60
|
+
|
|
61
|
+
el.textContent = shape;
|
|
62
|
+
el.style.color = color;
|
|
63
|
+
el.style.fontSize = size + 'px';
|
|
64
|
+
el.style.left = x + 'px';
|
|
65
|
+
el.style.top = y + 'px';
|
|
66
|
+
el.style.opacity = '1';
|
|
67
|
+
el.style.transform = 'translate(0, 0) scale(1) rotate(0deg)';
|
|
68
|
+
el.style.display = 'block';
|
|
69
|
+
|
|
70
|
+
const duration = 600 + Math.random() * 500;
|
|
71
|
+
const start = performance.now();
|
|
72
|
+
|
|
73
|
+
function animate(now) {
|
|
74
|
+
const elapsed = now - start;
|
|
75
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
76
|
+
|
|
77
|
+
// Float up and drift sideways, shrink and fade
|
|
78
|
+
const moveY = -30 * progress;
|
|
79
|
+
const moveX = drift * progress;
|
|
80
|
+
const scale = 1 - progress * 0.7;
|
|
81
|
+
const rotation = progress * 180 * (drift > 0 ? 1 : -1);
|
|
82
|
+
const opacity = 1 - progress;
|
|
83
|
+
|
|
84
|
+
el.style.transform = `translate(${moveX}px, ${moveY}px) scale(${scale}) rotate(${rotation}deg)`;
|
|
85
|
+
el.style.opacity = opacity;
|
|
86
|
+
|
|
87
|
+
if (progress < 1) {
|
|
88
|
+
requestAnimationFrame(animate);
|
|
89
|
+
} else {
|
|
90
|
+
el.style.display = 'none';
|
|
91
|
+
sparkle.active = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
requestAnimationFrame(animate);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Throttled spawn on mousemove
|
|
99
|
+
document.addEventListener('mousemove', (e) => {
|
|
100
|
+
mouseX = e.clientX;
|
|
101
|
+
mouseY = e.clientY;
|
|
102
|
+
|
|
103
|
+
if (!ticking && enabled) {
|
|
104
|
+
ticking = true;
|
|
105
|
+
requestAnimationFrame(() => {
|
|
106
|
+
// Spawn 2-3 sparkles per frame for a nice density
|
|
107
|
+
const count = 2 + Math.floor(Math.random() * 2);
|
|
108
|
+
for (let i = 0; i < count; i++) {
|
|
109
|
+
spawnSparkle();
|
|
110
|
+
}
|
|
111
|
+
ticking = false;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Expose public API
|
|
117
|
+
window.sparkle = {
|
|
118
|
+
refreshColors,
|
|
119
|
+
enable: () => { enabled = true; },
|
|
120
|
+
disable: () => { enabled = false; }
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
})();
|