1/* gallery.js â Gallery Page Logic */ 2/* Displays the capsule archive from the static manifest (see capsule_data.js). 3 Every capsule is revealed: stories come from the manifest as plaintext and 4 images are served already decrypted, so there is no wallet, no IPFS lookup 5 and no in-browser Shutter decryption here. */ 6 7import { 8 loadCapsules as fetchAllCapsules, 9 getCapsule, 10 imageUrl, 11 storyText, 12 capsuleTags, 13 shortAddress, 14 enhanceCreatorNames 15} from "./capsule_data.js"; 16 17// ============= GLOBALS ============= 18 19// Configuration loaded from public_config.json 20let appConfig = null; 21 22// The full archive, newest first. Filtering and paging happen in memory. 23let allCapsules = []; 24 25// Gallery state 26let currentFilter = 'all'; // 'all' or specific tag name 27let currentSearch = ''; 28let shownCount = 0; 29const batchSize = 12; 30let isLoading = false; 31let availableTags = []; // Store available tags for filtering 32 33// ============= HELPER FUNCTIONS ============= 34 35// Escape text destined for innerHTML. Titles and creator addresses come from 36// user submissions and the chain, so they are never interpolated raw. 37function escapeHtml(value) { 38 return String(value == null ? '' : value) 39 .replace(/&/g, '&') 40 .replace(/</g, '<') 41 .replace(/>/g, '>') 42 .replace(/"/g, '"') 43 .replace(/'/g, '''); 44} 45 46function formatRevealDate(revealTime) { 47 const date = new Date(Number(revealTime) * 1000); 48 if (Number.isNaN(date.getTime())) return 'unknown date'; 49 return date.toLocaleString('en-US', { 50 year: 'numeric', 51 month: 'long', 52 day: 'numeric', 53 timeZone: 'UTC' 54 }); 55} 56 57// ============= INITIALIZATION ============= 58window.addEventListener("DOMContentLoaded", async () => { 59 const loadStatus = document.getElementById('load-status'); 60 try { 61 appConfig = await loadPublicConfig(); 62 console.log('ð Loaded app configuration:', appConfig); 63 64 // Setup event listeners 65 setupEventListeners(); 66 67 // Tags come from config and drive both the filter chips and which tags a 68 // card is allowed to display, so they are loaded before the first render. 69 await loadTagsFromConfig(); 70 71 if (loadStatus) loadStatus.textContent = 'Loading capsulesâ¦'; 72 allCapsules = await fetchAllCapsules(); 73 console.log(`â Loaded ${allCapsules.length} capsules from the manifest`); 74 75 // Check if we have a direct capsule link 76 const urlParams = new URLSearchParams(window.location.search); 77 const capsuleId = urlParams.get('capsule'); 78 79 if (capsuleId) { 80 console.log(`ð¯ Direct capsule link detected: ${capsuleId}`); 81 await loadDirectCapsule(capsuleId); 82 } else { 83 // Apply a tag filter from the URL (?tag=...) if present before loading 84 const tagParam = urlParams.get('tag'); 85 if (tagParam) { 86 console.log(`ð·ï¸ Tag filter from URL detected: ${tagParam}`); 87 currentFilter = tagParam; 88 highlightFilterButton(currentFilter); 89 } 90 console.log('ð Loading all capsules'); 91 loadCapsules({ reset: true }); 92 } 93 94 } catch (e) { 95 console.error("Initialization failed:", e); 96 if (loadStatus) { 97 loadStatus.textContent = 'Could not load the capsule archive. Please reload the page.'; 98 } 99 } 100}); 101 102// ============= EVENT LISTENERS ============= 103function setupEventListeners() { 104 // Filter buttons 105 document.getElementById('filter-all').onclick = () => setFilter('all'); 106 107 // Search 108 document.getElementById('search-btn').onclick = performSearch; 109 document.getElementById('search-input').addEventListener('keypress', (e) => { 110 if (e.key === 'Enter') { 111 performSearch(); 112 } 113 }); 114 115 // Load more 116 document.getElementById('load-more-btn').onclick = loadMoreCapsules; 117} 118 119// ============= FILTER AND SEARCH ============= 120async function loadTagsFromConfig() { 121 try { 122 console.log('ð·ï¸ Loading tags from config...'); 123 124 if (!appConfig || !appConfig.tag_sections) { 125 console.warn('No tag_sections in config'); 126 return; 127 } 128 129 // Extract all tags from all sections (flatten the sections) 130 const allTags = []; 131 appConfig.tag_sections.forEach(section => {
132 section.tags.forEach(tagObj => { 133 allTags.push(tagObj.name); 134 }); 135 }); 136 137 // Use tags from config instead of extracting from capsules 138 availableTags = allTags.map(tag => ({ 139 name: tag, 140 count: 0 // We'll show count as 0 or remove it since we're not extracting 141 })); 142 143 console.log('â Tags loaded from config:', availableTags); 144 145 // Render tag filter buttons 146 renderTagFilters(); 147 148 } catch (error) { 149 console.error('Failed to load tags from config:', error); 150 } 151} 152 153function renderTagFilters() { 154 const tagFiltersContainer = document.getElementById('tag-filters'); 155 if (!tagFiltersContainer) return; 156 157 tagFiltersContainer.innerHTML = ''; 158 159 // Create emoji mapping for tags from config 160 const tagEmojiMap = {}; 161 if (appConfig && appConfig.tag_sections) { 162 appConfig.tag_sections.forEach(section => { 163 section.tags.forEach(tagObj => { 164 tagEmojiMap[tagObj.name] = tagObj.emoji; 165 }); 166 }); 167 } 168 169 availableTags.forEach(({ name }) => { 170 const tagButton = document.createElement('button'); 171 tagButton.className = 'btn-tag-filter'; 172 173 const emoji = tagEmojiMap[name]; 174 if (emoji) { 175 tagButton.innerHTML = `${emoji} #${escapeHtml(name)}`; 176 } else { 177 tagButton.innerHTML = `#${escapeHtml(name)}`; 178 } 179 tagButton.onclick = () => setFilter(name); 180 tagFiltersContainer.appendChild(tagButton); 181 }); 182 183 // Re-apply the active state now that the buttons exist (e.g. a tag from the URL) 184 highlightFilterButton(currentFilter); 185} 186 187// Highlight the button matching the given filter ('all' or a tag name). 188// Safe to call before the tag buttons exist (they're rendered asynchronously). 189function highlightFilterButton(filter) { 190 document.querySelectorAll('.filter-controls button').forEach(btn => { 191 btn.classList.remove('active'); 192 }); 193 document.querySelectorAll('.btn-tag-filter').forEach(btn => { 194 btn.classList.remove('active'); 195 }); 196 197 if (filter === 'all') { 198 const allBtn = document.getElementById('filter-all'); 199 if (allBtn) allBtn.classList.add('active'); 200 } else { 201 // Find and activate the corresponding tag button 202 document.querySelectorAll('.btn-tag-filter').forEach(btn => { 203 if (btn.textContent.toLowerCase().includes(`#${filter.toLowerCase()}`)) { 204 btn.classList.add('active'); 205 } 206 }); 207 } 208} 209 210// Reflect the selected tag in the URL query string (?tag=...) without reloading, 211// so the selection is shareable and bookmarkable. 212function updateTagQueryParam(filter) { 213 const url = new URL(window.location); 214 if (filter === 'all') { 215 url.searchParams.delete('tag'); 216 } else { 217 url.searchParams.set('tag', filter); 218 } 219 // A tag selection and the single-capsule view are mutually exclusive, so drop 220 // any lingering ?capsule= param (e.g. when a tag chip is clicked inside a 221 // direct capsule view) to avoid producing a conflicting URL. 222 url.searchParams.delete('capsule'); 223 history.replaceState(null, '', url); 224} 225 226function setFilter(filter) { 227 currentFilter = filter; 228 229 // Update button states 230 highlightFilterButton(filter); 231 232 // Keep the URL in sync with the current selection 233 updateTagQueryParam(filter); 234 235 loadCapsules({ reset: true }); 236} 237 238function performSearch() { 239 const searchInput = document.getElementById('search-input'); 240 currentSearch = searchInput.value.trim(); 241 loadCapsules({ reset: true }); 242} 243 244// ============= LOAD CAPSULES ============= 245 246// The capsules matching the current tag filter and search term. Tags are matched 247// exactly against a capsule's tag list rather than as a substring, so a filter 248// like #Base no longer also matches a tag such as "basement". 249function getVisibleCapsules() { 250 let visible = allCapsules; 251 252 if (currentFilter !== 'all') { 253 const wanted = currentFilter.toLowerCase(); 254 visible = visible.filter(capsule => 255 capsuleTags(capsule).some(tag => tag.toLowerCase() === wanted) 256 ); 257 } 258 259 if (currentSearch) { 260 // Same fields the database API searched: title, tags and creator. 261 const needle = currentSearch.toLowerCase(); 262 visible = visible.filter(capsule => 263 String(capsule.title || '').toLowerCase().includes(needle) || 264 String(capsule.tags || '').toLowerCase().includes(needle) || 265 String(capsule.creator || '').toLowerCase().includes(needle) 266 ); 267 } 268 269 return visible; 270} 271 272async function loadCapsules({ reset = false } = {}) { 273 if (isLoading) return; 274 275 isLoading = true; 276 const loadingIndicator = document.getElementById('loading-indicator'); 277 const loadStatus = document.getElementById('load-status'); 278 const grid = document.getElementById('capsules-grid');
279 const loadMoreBtn = document.getElementById('load-more-btn'); 280 281 try { 282 if (loadingIndicator) loadingIndicator.style.display = 'block'; 283 284 if (reset) { 285 shownCount = 0; 286 if (grid) grid.innerHTML = ''; 287 if (loadMoreBtn) loadMoreBtn.style.display = ''; 288 } 289 290 const visible = getVisibleCapsules(); 291 const batch = visible.slice(shownCount, shownCount + batchSize); 292 293 await renderCapsules(batch); 294 shownCount += batch.length; 295 296 if (loadStatus) { 297 if (visible.length === 0) { 298 loadStatus.textContent = currentSearch 299 ? `No capsules match â${currentSearch}â` 300 : 'No capsules to show'; 301 } else { 302 loadStatus.textContent = `Showing ${shownCount} of ${visible.length} capsules`; 303 } 304 } 305 306 // Update load more button 307 if (loadMoreBtn) { 308 const hasMore = shownCount < visible.length; 309 loadMoreBtn.textContent = hasMore ? 'Load More Capsules' : 'No More Capsules'; 310 loadMoreBtn.disabled = !hasMore; 311 } 312 313 } catch (error) { 314 console.error('Failed to load capsules:', error); 315 if (loadStatus) loadStatus.textContent = 'Failed to load capsules: ' + error.message; 316 } finally { 317 isLoading = false; 318 if (loadingIndicator) loadingIndicator.style.display = 'none'; 319 } 320} 321 322function loadMoreCapsules() { 323 loadCapsules(); 324} 325 326// ============= RENDER CAPSULES ============= 327async function renderCapsules(capsules) { 328 const grid = document.getElementById('capsules-grid'); 329 if (!grid) return; 330 331 capsules.forEach(capsule => { 332 grid.appendChild(createCapsuleCard(capsule)); 333 }); 334 335 // Creator names resolve in the background; cards are already on screen. 336 enhanceCreatorNames(grid); 337} 338 339// Renders one capsule card. `single` marks the standalone view (?capsule=N), where 340// the card is the whole page rather than one tile in the grid. 341function createCapsuleCard(capsule, { single = false } = {}) { 342 const card = document.createElement('div'); 343 card.className = 'capsule-card-gallery'; 344 card.setAttribute('data-capsule-id', capsule.id); // Add unique identifier 345 346 // Only include tags that are defined in public_config.json 347 const validTagNames = availableTags.map(tag => tag.name.toLowerCase()); 348 const tags = capsuleTags(capsule).filter(tag => validTagNames.includes(tag.toLowerCase())); 349 350 const formattedDate = formatRevealDate(capsule.revealTime); 351 const story = storyText(capsule); 352 353 // In the standalone view the story is the point of the page, so show it right 354 // away. In the grid it stays collapsed so the tiles keep a uniform height. 355 const storyExpanded = single; 356 357 card.innerHTML = ` 358 <div class="preview-image-container"> 359 <img src="${escapeHtml(imageUrl(capsule))}" alt="Capsule image" class="preview-image" loading="lazy" 360 data-capsule-id="${escapeHtml(capsule.id)}" 361 onerror="markImageUnavailable(this)"> 362 <div class="issuer-tag">issued by <span data-creator-address="${escapeHtml(capsule.creator)}">${escapeHtml(shortAddress(capsule.creator))}</span></div> 363 </div> 364 365 <div class="preview-content"> 366 <h2 class="preview-title">${escapeHtml(capsule.title || 'Untitled Capsule')}</h2> 367 368 <div class="preview-meta"> 369 <div class="meta-item"> 370 <div class="meta-icon">ð</div> 371 <div class="meta-text"> 372 <div class="meta-label">unlocked on</div> 373 <div class="meta-value">${escapeHtml(formattedDate)}</div> 374 </div> 375 </div> 376 <div class="meta-item"> 377 <div class="meta-icon">ð</div> 378 <div class="meta-text"> 379 <div class="meta-label">story</div> 380 <div class="meta-value" id="story-toggle-${escapeHtml(capsule.id)}" style="cursor: pointer; color: #4F46E5;" 381 data-label-show="read story" data-label-hide="hide story" 382 onclick="toggleStory(${escapeHtml(capsule.id)})">${storyExpanded ? 'hide story' : 'read story'}</div> 383 </div> 384 </div> 385 </div> 386 387 <div class="preview-tags"> 388 ${tags.map(tag => `<span class="tag" onclick="filterByTag('${escapeHtml(tag.toLowerCase())}')">#${escapeHtml(tag)}</span>`).join('')} 389 </div> 390 391 <div id="story-${escapeHtml(capsule.id)}" style="margin-top: 16px; font-size: 14px; line-height: 1.4; color: #333; white-space: pre-wrap; display: ${storyExpanded ? 'block' : 'none'};"></div> 392 </div> 393 `; 394 395 // textContent, not innerHTML: the story is untrusted user content. 396 const storyElement = card.querySelector(`#story-${CSS.escape(String(capsule.id))}`); 397 if (storyElement) { 398 if (story) { 399 storyElement.textContent = story; 400 } else { 401 storyElement.innerHTML = '<em style="color: #999;">Story not available.</em>'; 402 } 403 } 404 405 // Only grid tiles link onwards: in the standalone view this is already the 406 // individual capsule, so a card-wide handler would just reload the same page --
407 // and would do so every time the reader clicks into the expanded story text. 408 if (!single) { 409 // Add click handler to make entire card clickable (but prevent clicks on interactive elements) 410 card.addEventListener('click', (e) => { 411 // Don't navigate if clicking on interactive elements 412 if (e.target.closest('.meta-value[onclick]') || e.target.closest('.tag[onclick]')) { 413 return; 414 } 415 416 // Navigate to individual capsule view 417 window.location.href = `/gallery.html?capsule=${capsule.id}`; 418 }); 419 420 // Add cursor pointer to indicate clickability 421 card.style.cursor = 'pointer'; 422 } 423 424 return card; 425} 426 427// ============= CAPSULE INTERACTIONS ============= 428 429function toggleStory(id) { 430 const storyElement = document.getElementById(`story-${id}`); 431 if (!storyElement) return; 432 433 const show = storyElement.style.display === 'none'; 434 storyElement.style.display = show ? 'block' : 'none'; 435 436 // Keep the link in step with what it now does, since the story can start out 437 // either collapsed (grid) or expanded (standalone view). 438 const toggle = document.getElementById(`story-toggle-${id}`); 439 if (toggle) { 440 toggle.textContent = show ? toggle.dataset.labelHide : toggle.dataset.labelShow; 441 } 442} 443 444// Every capsule has a published image, so a failure here means the archive host 445// is unreachable. Show a quiet placeholder rather than a broken image icon. 446function markImageUnavailable(imgElement) { 447 imgElement.onerror = null; 448 imgElement.style.display = 'none'; 449 const container = imgElement.closest('.preview-image-container'); 450 if (container && !container.querySelector('.image-unavailable')) { 451 const placeholder = document.createElement('div'); 452 placeholder.className = 'image-unavailable'; 453 placeholder.style.cssText = 'color:#999;font-size:13px;font-style:italic;'; 454 placeholder.textContent = 'Image unavailable'; 455 container.appendChild(placeholder); 456 } 457} 458 459// Expose functions globally for HTML onclick handlers 460window.toggleStory = toggleStory; 461window.markImageUnavailable = markImageUnavailable; 462window.filterByTag = function(tagName) { 463 console.log(`ð·ï¸ Filtering by tag: ${tagName}`); 464 setFilter(tagName); 465}; 466 467// Function to load a specific capsule directly 468async function loadDirectCapsule(capsuleId) { 469 const loadStatus = document.getElementById('load-status'); 470 const gallery = document.getElementById('capsules-grid'); 471 472 const capsule = await getCapsule(capsuleId); 473 if (!capsule) { 474 console.error(`â Capsule #${capsuleId} not found`); 475 // Fall back to showing the whole archive. The status is set afterwards 476 // because loadCapsules writes its own "Showing X of Y" message. 477 await loadCapsules({ reset: true }); 478 if (loadStatus) { 479 loadStatus.textContent = `Capsule #${capsuleId} not found, showing all capsules instead`; 480 } 481 return; 482 } 483 484 console.log('â Direct capsule loaded:', capsule); 485 486 if (gallery) { 487 gallery.innerHTML = ''; 488 gallery.appendChild(createCapsuleCard(capsule, { single: true })); 489 enhanceCreatorNames(gallery); 490 491 // Update page title to reflect the specific capsule 492 document.title = `Time Capsule: ${capsule.title} - Ethereum Time Capsule`; 493 494 if (loadStatus) loadStatus.textContent = `Showing specific capsule #${capsule.id}`; 495 496 // Hide load more button since we're only showing one capsule
497 const loadMoreBtn = document.getElementById('load-more-btn'); 498 if (loadMoreBtn) loadMoreBtn.style.display = 'none'; 499 } 500}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.