PageSourceSearch

https://www.ethtimecapsule.com/gallery.js?v=2026073001

js ethtimecapsule.com collected 2026-10-05 08:46:05 UTC 17,161 bytes, 500 lines download raw bytes

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, '&lt;')
41    .replace(/>/g, '&gt;')
42    .replace(/"/g, '&quot;')
43    .replace(/'/g, '&#39;');
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.