Created
December 29, 2025 10:46
-
-
Save msenturk/d81f97f2daf33b85006599e974166106 to your computer and use it in GitHub Desktop.
hn.html
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>monospace.news</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| :root { | |
| --color-black: #000000; | |
| --color-dark-gray: #333333; | |
| --color-medium-gray: #666666; | |
| --color-light-gray: #999999; | |
| --color-white: #ffffff; | |
| --color-off-white: #f8f8f8; | |
| --color-accent: #3366cc; | |
| --color-border: #cccccc; | |
| --color-hover: #f5f5f5; | |
| --color-error: #cc0000; | |
| --comment-indent: 20px; | |
| } | |
| body { | |
| font-family: monospace; | |
| font-size: 16px; | |
| line-height: 1.5; | |
| color: var(--color-black); | |
| background-color: var(--color-white); | |
| margin: 0; | |
| padding: 0; | |
| } | |
| .container { | |
| max-width: 1000px; | |
| margin: 0 auto; | |
| padding: 0 20px; | |
| } | |
| /* Header */ | |
| header { | |
| border-bottom: 1px solid var(--color-border); | |
| padding: 20px 0; | |
| margin-bottom: 20px; | |
| background-color: var(--color-off-white); | |
| } | |
| .header-content { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .logo { | |
| font-size: 24px; | |
| font-weight: bold; | |
| text-decoration: none; | |
| color: var(--color-black); | |
| display: flex; | |
| align-items: center; | |
| } | |
| .logo::before { | |
| content: "$ "; | |
| color: var(--color-accent); | |
| } | |
| nav { | |
| display: flex; | |
| gap: 20px; | |
| } | |
| nav a { | |
| color: var(--color-dark-gray); | |
| text-decoration: none; | |
| padding: 5px 10px; | |
| transition: color 0.1s; | |
| } | |
| nav a:hover { | |
| color: var(--color-accent); | |
| } | |
| /* Main Content */ | |
| main { | |
| min-height: calc(100vh - 200px); | |
| } | |
| .controls { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 20px; | |
| border-bottom: 1px solid var(--color-border); | |
| padding-bottom: 10px; | |
| } | |
| .tabs { | |
| display: flex; | |
| gap: 15px; | |
| } | |
| .tab { | |
| background: none; | |
| border: none; | |
| color: var(--color-medium-gray); | |
| cursor: pointer; | |
| font-family: inherit; | |
| font-size: 16px; | |
| padding: 5px 10px; | |
| transition: color 0.1s; | |
| } | |
| .tab:hover { | |
| color: var(--color-dark-gray); | |
| } | |
| .tab.active { | |
| color: var(--color-black); | |
| border-bottom: 1px solid var(--color-black); | |
| font-weight: bold; | |
| } | |
| .search-box { | |
| background: var(--color-white); | |
| border: 1px solid var(--color-border); | |
| color: var(--color-black); | |
| padding: 5px 10px; | |
| font-family: inherit; | |
| width: 200px; | |
| } | |
| .search-box:focus { | |
| outline: none; | |
| border-color: var(--color-accent); | |
| } | |
| /* Content Area */ | |
| .content { | |
| border: 1px solid var(--color-border); | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| background-color: var(--color-white); | |
| } | |
| /* Loading State */ | |
| .loading { | |
| text-align: center; | |
| padding: 20px 0; | |
| color: var(--color-medium-gray); | |
| } | |
| .error { | |
| text-align: center; | |
| padding: 20px 0; | |
| color: var(--color-error); | |
| } | |
| .retry-button { | |
| background: var(--color-white); | |
| border: 1px solid var(--color-error); | |
| color: var(--color-error); | |
| padding: 8px 20px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| margin-top: 10px; | |
| } | |
| .retry-button:hover { | |
| background: var(--color-error); | |
| color: var(--color-white); | |
| } | |
| /* Story List */ | |
| .story-list { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .story-item { | |
| display: flex; | |
| gap: 15px; | |
| padding: 10px 0; | |
| border-bottom: 1px solid var(--color-border); | |
| } | |
| .story-item:last-child { | |
| border-bottom: none; | |
| } | |
| .story-number { | |
| color: var(--color-medium-gray); | |
| min-width: 30px; | |
| text-align: right; | |
| } | |
| .story-content { | |
| flex: 1; | |
| } | |
| .story-title { | |
| color: var(--color-black); | |
| text-decoration: none; | |
| display: block; | |
| margin-bottom: 5px; | |
| } | |
| .story-title:hover { | |
| color: var(--color-accent); | |
| text-decoration: underline; | |
| } | |
| .story-domain { | |
| color: var(--color-medium-gray); | |
| font-size: 14px; | |
| } | |
| .story-meta { | |
| display: flex; | |
| gap: 10px; | |
| margin-top: 5px; | |
| font-size: 14px; | |
| color: var(--color-medium-gray); | |
| } | |
| .story-meta a { | |
| color: var(--color-medium-gray); | |
| text-decoration: none; | |
| } | |
| .story-meta a:hover { | |
| color: var(--color-accent); | |
| text-decoration: underline; | |
| } | |
| /* Comments Section */ | |
| .comments-section { | |
| margin-top: 15px; | |
| display: none; | |
| } | |
| .comments-section.active { | |
| display: block; | |
| } | |
| .comment-toggle { | |
| cursor: pointer; | |
| color: var(--color-medium-gray); | |
| } | |
| .comment-toggle:hover { | |
| color: var(--color-accent); | |
| } | |
| .comment-container { | |
| margin-top: 10px; | |
| border-left: 1px solid var(--color-border); | |
| padding-left: var(--comment-indent); | |
| } | |
| .comment { | |
| margin-bottom: 15px; | |
| padding-bottom: 15px; | |
| border-bottom: 1px solid var(--color-border); | |
| } | |
| .comment:last-child { | |
| border-bottom: none; | |
| margin-bottom: 0; | |
| padding-bottom: 0; | |
| } | |
| .comment-header { | |
| color: var(--color-medium-gray); | |
| font-size: 14px; | |
| margin-bottom: 5px; | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| .comment-text { | |
| color: var(--color-dark-gray); | |
| font-size: 14px; | |
| white-space: pre-wrap; | |
| } | |
| .comment-text a { | |
| color: var(--color-accent); | |
| } | |
| .comment-replies { | |
| margin-top: 10px; | |
| } | |
| .reply-toggle { | |
| font-size: 12px; | |
| color: var(--color-medium-gray); | |
| cursor: pointer; | |
| margin-top: 5px; | |
| } | |
| .reply-toggle:hover { | |
| color: var(--color-accent); | |
| } | |
| .reply-loading { | |
| font-size: 12px; | |
| color: var(--color-medium-gray); | |
| font-style: italic; | |
| margin-top: 5px; | |
| } | |
| /* Pagination */ | |
| .pagination { | |
| text-align: center; | |
| padding: 20px 0; | |
| border-top: 1px solid var(--color-border); | |
| margin-top: 20px; | |
| } | |
| .more-button { | |
| background: var(--color-white); | |
| border: 1px solid var(--color-border); | |
| color: var(--color-dark-gray); | |
| padding: 10px 30px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| transition: all 0.1s; | |
| } | |
| .more-button:hover { | |
| background: var(--color-hover); | |
| border-color: var(--color-accent); | |
| color: var(--color-accent); | |
| } | |
| .more-button:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| /* Footer */ | |
| footer { | |
| border-top: 1px solid var(--color-border); | |
| padding: 20px 0; | |
| margin-top: 40px; | |
| text-align: center; | |
| color: var(--color-medium-gray); | |
| font-size: 14px; | |
| background-color: var(--color-off-white); | |
| } | |
| /* Status Indicator */ | |
| .status { | |
| position: fixed; | |
| bottom: 20px; | |
| right: 20px; | |
| background: var(--color-off-white); | |
| border: 1px solid var(--color-border); | |
| padding: 10px 15px; | |
| font-size: 12px; | |
| color: var(--color-medium-gray); | |
| display: none; | |
| } | |
| .status.show { | |
| display: block; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .header-content { | |
| flex-direction: column; | |
| gap: 15px; | |
| } | |
| .controls { | |
| flex-direction: column; | |
| gap: 15px; | |
| } | |
| .tabs { | |
| flex-wrap: wrap; | |
| } | |
| .search-box { | |
| width: 100%; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="container"> | |
| <div class="header-content"> | |
| <a href="#" class="logo">monospace.news</a> | |
| <nav> | |
| <a href="#" data-tab="top">top</a> | |
| <a href="#" data-tab="new">new</a> | |
| <a href="#" data-tab="best">best</a> | |
| <a href="#" data-tab="ask">ask</a> | |
| <a href="#" data-tab="show">show</a> | |
| <a href="#" data-tab="jobs">jobs</a> | |
| </nav> | |
| </div> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="container"> | |
| <div class="controls"> | |
| <div class="tabs"> | |
| <button class="tab active" data-category="top">top</button> | |
| <button class="tab" data-category="new">new</button> | |
| <button class="tab" data-category="best">best</button> | |
| <button class="tab" data-category="ask">ask</button> | |
| <button class="tab" data-category="show">show</button> | |
| <button class="tab" data-category="jobs">jobs</button> | |
| </div> | |
| <input type="text" class="search-box" placeholder="search..." id="searchInput"> | |
| </div> | |
| <div class="content"> | |
| <div id="storiesContainer"> | |
| <div class="loading">Loading stories...</div> | |
| </div> | |
| <div class="pagination" id="pagination" style="display: none;"> | |
| <button class="more-button" id="moreButton">More</button> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <footer> | |
| <div class="container"> | |
| <p>powered by HackerNews API | cached data | lazy loading</p> | |
| </div> | |
| </footer> | |
| <div class="status" id="status"></div> | |
| <script> | |
| // HackerNews API Base URL | |
| const API_BASE = 'https://hacker-news.firebaseio.com/v0'; | |
| // Application State | |
| const state = { | |
| currentCategory: 'top', | |
| stories: [], | |
| storyIds: [], | |
| currentPage: 0, | |
| storiesPerPage: 30, | |
| searchTerm: '', | |
| isLoading: false, | |
| cache: { | |
| stories: new Map(), | |
| comments: new Map(), | |
| storyIds: new Map(), | |
| timestamps: new Map() | |
| }, | |
| expandedComments: new Set(), | |
| expandedReplies: new Set(), | |
| loadingComments: new Set() | |
| }; | |
| // Cache expiration time (5 minutes) | |
| const CACHE_EXPIRY = 5 * 60 * 1000; | |
| // DOM Elements | |
| const storiesContainer = document.getElementById('storiesContainer'); | |
| const pagination = document.getElementById('pagination'); | |
| const moreButton = document.getElementById('moreButton'); | |
| const tabs = document.querySelectorAll('.tab'); | |
| const navLinks = document.querySelectorAll('nav a'); | |
| const searchInput = document.getElementById('searchInput'); | |
| const status = document.getElementById('status'); | |
| // Initialize | |
| document.addEventListener('DOMContentLoaded', () => { | |
| console.log('Application initialized'); | |
| attachEventListeners(); | |
| loadStories('top'); | |
| }); | |
| // Event Listeners | |
| function attachEventListeners() { | |
| tabs.forEach(tab => { | |
| tab.addEventListener('click', (e) => { | |
| const category = e.target.dataset.category; | |
| switchCategory(category); | |
| }); | |
| }); | |
| navLinks.forEach(link => { | |
| link.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| const category = e.target.dataset.tab; | |
| if (category) { | |
| switchCategory(category); | |
| } | |
| }); | |
| }); | |
| searchInput.addEventListener('input', (e) => { | |
| state.searchTerm = e.target.value.toLowerCase(); | |
| filterAndRenderStories(); | |
| }); | |
| moreButton.addEventListener('click', loadMoreStories); | |
| } | |
| // Utility function for fetch with timeout | |
| async function fetchWithTimeout(url, timeout = 10000) { | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), timeout); | |
| try { | |
| const response = await fetch(url, { signal: controller.signal }); | |
| clearTimeout(timeoutId); | |
| return response; | |
| } catch (error) { | |
| clearTimeout(timeoutId); | |
| throw error; | |
| } | |
| } | |
| // Category Switching | |
| function switchCategory(category) { | |
| if (state.currentCategory === category) return; | |
| console.log(`Switching to category: ${category}`); | |
| state.currentCategory = category; | |
| state.currentPage = 0; | |
| state.stories = []; | |
| state.expandedComments.clear(); | |
| state.expandedReplies.clear(); | |
| state.loadingComments.clear(); | |
| // Update active tab | |
| tabs.forEach(tab => { | |
| tab.classList.toggle('active', tab.dataset.category === category); | |
| }); | |
| // Load stories for new category | |
| loadStories(category); | |
| } | |
| // Cache Management | |
| function isCacheValid(key) { | |
| const timestamp = state.cache.timestamps.get(key); | |
| return timestamp && (Date.now() - timestamp < CACHE_EXPIRY); | |
| } | |
| function setCache(key, data) { | |
| state.cache.stories.set(key, data); | |
| state.cache.timestamps.set(key, Date.now()); | |
| } | |
| // API Functions with better error handling | |
| async function fetchStoryIds(category) { | |
| const cacheKey = `ids_${category}`; | |
| if (isCacheValid(cacheKey)) { | |
| console.log(`Using cached IDs for ${category}`); | |
| return state.cache.storyIds.get(cacheKey); | |
| } | |
| const endpoints = { | |
| 'top': '/topstories.json', | |
| 'new': '/newstories.json', | |
| 'best': '/beststories.json', | |
| 'ask': '/askstories.json', | |
| 'show': '/showstories.json', | |
| 'jobs': '/jobstories.json' | |
| }; | |
| const endpoint = endpoints[category] || endpoints['top']; | |
| const url = `${API_BASE}${endpoint}`; | |
| console.log(`Fetching story IDs from: ${url}`); | |
| try { | |
| const response = await fetchWithTimeout(url); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const ids = await response.json(); | |
| console.log(`Fetched ${ids.length} story IDs for ${category}`); | |
| state.cache.storyIds.set(cacheKey, ids); | |
| state.cache.timestamps.set(cacheKey, Date.now()); | |
| return ids; | |
| } catch (error) { | |
| console.error(`Failed to fetch story IDs for ${category}:`, error); | |
| throw new Error(`Failed to fetch ${category} stories: ${error.message}`); | |
| } | |
| } | |
| async function fetchStoryDetails(storyId) { | |
| const cacheKey = `story_${storyId}`; | |
| if (isCacheValid(cacheKey)) { | |
| return state.cache.stories.get(cacheKey); | |
| } | |
| const url = `${API_BASE}/item/${storyId}.json`; | |
| try { | |
| const response = await fetchWithTimeout(url); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const story = await response.json(); | |
| setCache(cacheKey, story); | |
| return story; | |
| } catch (error) { | |
| console.error(`Failed to fetch story ${storyId}:`, error); | |
| throw error; | |
| } | |
| } | |
| async function fetchComment(commentId) { | |
| const cacheKey = `comment_${commentId}`; | |
| if (isCacheValid(cacheKey)) { | |
| return state.cache.comments.get(cacheKey); | |
| } | |
| const url = `${API_BASE}/item/${commentId}.json`; | |
| try { | |
| const response = await fetchWithTimeout(url); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const comment = await response.json(); | |
| state.cache.comments.set(cacheKey, comment); | |
| state.cache.timestamps.set(cacheKey, Date.now()); | |
| return comment; | |
| } catch (error) { | |
| console.error(`Failed to fetch comment ${commentId}:`, error); | |
| throw error; | |
| } | |
| } | |
| // Story Loading | |
| async function loadStories(category) { | |
| console.log(`Loading stories for category: ${category}`); | |
| state.isLoading = true; | |
| storiesContainer.innerHTML = '<div class="loading">Loading stories...</div>'; | |
| try { | |
| const storyIds = await fetchStoryIds(category); | |
| if (!storyIds || storyIds.length === 0) { | |
| throw new Error('No stories found'); | |
| } | |
| state.storyIds = storyIds; | |
| state.hasMore = storyIds.length > state.storiesPerPage; | |
| await loadPage(0); | |
| } catch (error) { | |
| console.error('Error loading stories:', error); | |
| storiesContainer.innerHTML = ` | |
| <div class="error"> | |
| <div>Failed to load stories: ${error.message}</div> | |
| <button class="retry-button" onclick="loadStories('${category}')">Retry</button> | |
| </div> | |
| `; | |
| } finally { | |
| state.isLoading = false; | |
| } | |
| } | |
| async function loadPage(page) { | |
| const startIndex = page * state.storiesPerPage; | |
| const endIndex = startIndex + state.storiesPerPage; | |
| const pageIds = state.storyIds.slice(startIndex, endIndex); | |
| if (pageIds.length === 0) { | |
| state.hasMore = false; | |
| return; | |
| } | |
| console.log(`Loading page ${page} with ${pageIds.length} stories`); | |
| try { | |
| const storyPromises = pageIds.map(id => | |
| fetchStoryDetails(id).catch(err => { | |
| console.error(`Failed to fetch story ${id}:`, err); | |
| return null; | |
| }) | |
| ); | |
| const pageStories = await Promise.all(storyPromises); | |
| const validStories = pageStories.filter(story => story !== null); | |
| console.log(`Successfully loaded ${validStories.length} stories`); | |
| if (page === 0) { | |
| state.stories = validStories; | |
| } else { | |
| state.stories = [...state.stories, ...validStories]; | |
| } | |
| state.currentPage = page; | |
| state.hasMore = endIndex < state.storyIds.length; | |
| filterAndRenderStories(); | |
| updatePagination(); | |
| } catch (error) { | |
| console.error('Error loading page:', error); | |
| storiesContainer.innerHTML = ` | |
| <div class="error"> | |
| <div>Failed to load stories: ${error.message}</div> | |
| <button class="retry-button" onclick="loadPage(${page})">Retry</button> | |
| </div> | |
| `; | |
| } | |
| } | |
| async function loadMoreStories() { | |
| if (state.isLoading || !state.hasMore) return; | |
| state.isLoading = true; | |
| moreButton.disabled = true; | |
| moreButton.textContent = 'Loading...'; | |
| try { | |
| await loadPage(state.currentPage + 1); | |
| } finally { | |
| state.isLoading = false; | |
| moreButton.disabled = false; | |
| moreButton.textContent = 'More'; | |
| } | |
| } | |
| function showStatus(message) { | |
| status.textContent = message; | |
| status.classList.add('show'); | |
| setTimeout(() => { | |
| status.classList.remove('show'); | |
| }, 2000); | |
| } | |
| // Story Rendering | |
| function filterAndRenderStories() { | |
| let filteredStories = state.stories; | |
| if (state.searchTerm) { | |
| filteredStories = state.stories.filter(story => | |
| story.title.toLowerCase().includes(state.searchTerm) || | |
| (story.text && story.text.toLowerCase().includes(state.searchTerm)) | |
| ); | |
| } | |
| renderStories(filteredStories); | |
| } | |
| function renderStories(storiesToRender) { | |
| if (storiesToRender.length === 0) { | |
| storiesContainer.innerHTML = '<div class="loading">No stories found</div>'; | |
| return; | |
| } | |
| const storyListHTML = storiesToRender.map((story, index) => { | |
| const domain = getDomain(story.url); | |
| const timeAgo = getTimeAgo(story.time); | |
| const isExpanded = state.expandedComments.has(story.id); | |
| const isLoading = state.loadingComments.has(story.id); | |
| return ` | |
| <div class="story-item"> | |
| <span class="story-number">${index + 1}.</span> | |
| <div class="story-content"> | |
| <a href="${story.url || '#'}" target="_blank" class="story-title"> | |
| ${escapeHtml(story.title)} | |
| </a> | |
| ${domain ? `<span class="story-domain">(${domain})</span>` : ''} | |
| <div class="story-meta"> | |
| <span>${story.score || 0} points</span> | |
| <span>by <a href="#">${escapeHtml(story.by || 'unknown')}</a></span> | |
| <span>${timeAgo}</span> | |
| <span>|</span> | |
| <span class="comment-toggle" onclick="toggleComments(${story.id}); return false;"> | |
| ${isLoading ? 'Loading...' : (isExpanded ? '[-]' : '[+]')} ${story.descendants || 0} comments | |
| </span> | |
| </div> | |
| <div class="comments-section ${isExpanded ? 'active' : ''}" id="comments-${story.id}"> | |
| ${isExpanded ? renderCommentsContainer(story.id) : ''} | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| storiesContainer.innerHTML = `<div class="story-list">${storyListHTML}</div>`; | |
| // Re-attach loading states for comments that were being loaded | |
| state.loadingComments.forEach(storyId => { | |
| const commentsSection = document.getElementById(`comments-${storyId}`); | |
| if (commentsSection && !commentsSection.innerHTML) { | |
| commentsSection.innerHTML = renderCommentsContainer(storyId); | |
| } | |
| }); | |
| } | |
| function renderCommentsContainer(storyId) { | |
| return ` | |
| <div class="comment-container"> | |
| <div id="comment-loading-${storyId}" class="loading">Loading comments...</div> | |
| <div id="comment-list-${storyId}" style="display: none;"></div> | |
| </div> | |
| `; | |
| } | |
| function updatePagination() { | |
| if (state.hasMore) { | |
| pagination.style.display = 'block'; | |
| } else { | |
| pagination.style.display = 'none'; | |
| } | |
| } | |
| // Helper Functions | |
| function getDomain(url) { | |
| if (!url) return null; | |
| try { | |
| const domain = new URL(url).hostname; | |
| return domain.replace('www.', ''); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function getTimeAgo(timestamp) { | |
| const seconds = Math.floor(Date.now() / 1000 - timestamp); | |
| const intervals = { | |
| year: 31536000, | |
| month: 2592000, | |
| week: 604800, | |
| day: 86400, | |
| hour: 3600, | |
| minute: 60 | |
| }; | |
| for (const [unit, secondsInUnit] of Object.entries(intervals)) { | |
| const interval = Math.floor(seconds / secondsInUnit); | |
| if (interval >= 1) { | |
| return `${interval} ${unit}${interval === 1 ? '' : 's'} ago`; | |
| } | |
| } | |
| return 'just now'; | |
| } | |
| function escapeHtml(text) { | |
| if (!text) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| // Interactions | |
| async function toggleComments(storyId) { | |
| // Prevent multiple simultaneous loads | |
| if (state.loadingComments.has(storyId)) { | |
| return; | |
| } | |
| const isExpanded = state.expandedComments.has(storyId); | |
| if (isExpanded) { | |
| // Collapse comments | |
| state.expandedComments.delete(storyId); | |
| state.loadingComments.delete(storyId); | |
| const commentsSection = document.getElementById(`comments-${storyId}`); | |
| if (commentsSection) { | |
| commentsSection.classList.remove('active'); | |
| } | |
| // Re-render to update toggle state | |
| filterAndRenderStories(); | |
| } else { | |
| // Expand and load comments | |
| state.expandedComments.add(storyId); | |
| state.loadingComments.add(storyId); | |
| // Update UI immediately to show loading state | |
| filterAndRenderStories(); | |
| // Use requestAnimationFrame to ensure DOM is updated | |
| requestAnimationFrame(() => { | |
| // Ensure comments section exists | |
| const commentsSection = document.getElementById(`comments-${storyId}`); | |
| if (commentsSection) { | |
| commentsSection.classList.add('active'); | |
| // Create container if it doesn't exist | |
| if (!commentsSection.innerHTML) { | |
| commentsSection.innerHTML = renderCommentsContainer(storyId); | |
| } | |
| // Load comments | |
| loadTopLevelComments(storyId); | |
| } | |
| }); | |
| } | |
| } | |
| async function loadTopLevelComments(storyId) { | |
| let loadingEl = document.getElementById(`comment-loading-${storyId}`); | |
| let listEl = document.getElementById(`comment-list-${storyId}`); | |
| // Wait for elements to be created if they don't exist | |
| let attempts = 0; | |
| while ((!loadingEl || !listEl) && attempts < 10) { | |
| await new Promise(resolve => setTimeout(resolve, 100)); | |
| loadingEl = document.getElementById(`comment-loading-${storyId}`); | |
| listEl = document.getElementById(`comment-list-${storyId}`); | |
| attempts++; | |
| } | |
| // If still not found, abort | |
| if (!loadingEl || !listEl) { | |
| console.error(`Comment elements not found for story ${storyId} after 10 attempts`); | |
| state.loadingComments.delete(storyId); | |
| filterAndRenderStories(); | |
| return; | |
| } | |
| try { | |
| const story = await fetchStoryDetails(storyId); | |
| if (!story.kids || story.kids.length === 0) { | |
| loadingEl.textContent = 'No comments yet.'; | |
| state.loadingComments.delete(storyId); | |
| return; | |
| } | |
| loadingEl.style.display = 'none'; | |
| listEl.style.display = 'block'; | |
| const commentPromises = story.kids.slice(0, 10).map(id => | |
| fetchComment(id).catch(err => { | |
| console.error(`Failed to fetch comment ${id}:`, err); | |
| return null; | |
| }) | |
| ); | |
| const comments = await Promise.all(commentPromises); | |
| const validComments = comments.filter(comment => comment && !comment.deleted); | |
| const commentsHTML = validComments.map(comment => | |
| renderComment(comment, 0) | |
| ).join(''); | |
| listEl.innerHTML = commentsHTML; | |
| state.loadingComments.delete(storyId); | |
| } catch (error) { | |
| loadingEl.textContent = 'Failed to load comments.'; | |
| state.loadingComments.delete(storyId); | |
| console.error('Error loading comments:', error); | |
| } | |
| } | |
| function renderComment(comment, depth) { | |
| const timeAgo = getTimeAgo(comment.time); | |
| const hasReplies = comment.kids && comment.kids.length > 0; | |
| const isExpanded = state.expandedReplies.has(comment.id); | |
| return ` | |
| <div class="comment" style="margin-left: ${depth * 20}px;" data-comment-id="${comment.id}"> | |
| <div class="comment-header"> | |
| <span>${escapeHtml(comment.by || 'unknown')} ${timeAgo}</span> | |
| ${hasReplies ? | |
| `<span class="reply-toggle" onclick="toggleReplies(${comment.id}); return false;"> | |
| ${isExpanded ? '[-]' : '[+]'} ${comment.kids.length} ${comment.kids.length === 1 ? 'reply' : 'replies'} | |
| </span>` : | |
| ''} | |
| </div> | |
| <div class="comment-text">${parseCommentText(comment.text || '')}</div> | |
| ${hasReplies && isExpanded ? | |
| `<div class="comment-replies" id="replies-${comment.id}"> | |
| <div class="reply-loading">Loading replies...</div> | |
| </div>` : | |
| ''} | |
| </div> | |
| `; | |
| } | |
| async function toggleReplies(commentId) { | |
| const repliesEl = document.getElementById(`replies-${commentId}`); | |
| const isExpanded = state.expandedReplies.has(commentId); | |
| if (isExpanded) { | |
| state.expandedReplies.delete(commentId); | |
| if (repliesEl) { | |
| repliesEl.remove(); | |
| } | |
| } else { | |
| state.expandedReplies.add(commentId); | |
| await loadReplies(commentId); | |
| } | |
| } | |
| async function loadReplies(commentId) { | |
| let repliesEl = document.getElementById(`replies-${commentId}`); | |
| // Create replies container if it doesn't exist | |
| if (!repliesEl) { | |
| const commentEl = document.querySelector(`[data-comment-id="${commentId}"]`); | |
| if (!commentEl) { | |
| console.error(`Comment element not found for comment ${commentId}`); | |
| return; | |
| } | |
| repliesEl = document.createElement('div'); | |
| repliesEl.className = 'comment-replies'; | |
| repliesEl.id = `replies-${commentId}`; | |
| repliesEl.innerHTML = '<div class="reply-loading">Loading replies...</div>'; | |
| commentEl.appendChild(repliesEl); | |
| } | |
| try { | |
| const comment = await fetchComment(commentId); | |
| if (!comment.kids || comment.kids.length === 0) { | |
| repliesEl.innerHTML = '<div class="comment">No replies.</div>'; | |
| return; | |
| } | |
| const replyPromises = comment.kids.slice(0, 5).map(id => | |
| fetchComment(id).catch(err => { | |
| console.error(`Failed to fetch reply ${id}:`, err); | |
| return null; | |
| }) | |
| ); | |
| const replies = await Promise.all(replyPromises); | |
| const validReplies = replies.filter(reply => reply && !reply.deleted); | |
| const parentComment = document.querySelector(`[data-comment-id="${commentId}"]`); | |
| const depth = parentComment ? parseInt(parentComment.style.marginLeft) / 20 : 0; | |
| const repliesHTML = validReplies.map(reply => | |
| renderComment(reply, depth + 1) | |
| ).join(''); | |
| repliesEl.innerHTML = repliesHTML; | |
| } catch (error) { | |
| repliesEl.innerHTML = '<div class="comment">Failed to load replies.</div>'; | |
| console.error('Error loading replies:', error); | |
| } | |
| } | |
| function parseCommentText(text) { | |
| if (!text) return ''; | |
| return text | |
| .replace(/<p>/g, '</p><p>') | |
| .replace(/<\/?a[^>]*>/g, '') | |
| .replace(/<i>/g, '<em>') | |
| .replace(/<\/i>/g, '</em>') | |
| .replace(/<code>/g, '') | |
| .replace(/<\/code>/g, '') | |
| .replace(///g, '/') | |
| .replace(/'/g, "'") | |
| .replace(/"/g, '"') | |
| .replace(/>/g, '>') | |
| .replace(/</g, '<') | |
| .replace(/&/g, '&'); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment