Today I decided to ask Claude to help me with this.
This is what we came up with, after lots of testing and fixing (it only loads OPEN topics, which makes more sense to me):
When clicking the “Load more random topics” it loads 5 more topics below the current ones. Clicking the button again, adds 5 more, until there’s no more topics:
To load the random topics, just visit:
yourwebsite.com/?random
Here’s how to implement it:
1 - Create a component
2 - Add this to the JS tab:
import { apiInitializer } from "discourse/lib/api";
// Hide content immediately if ?random is in URL
if (new URLSearchParams(window.location.search).has('random')) {
const style = document.createElement('style');
style.textContent = '#main-outlet { display: none; }';
document.head.appendChild(style);
}
let loadedTopicIds = new Set();
function addRandomTopics(listDiv, button) {
// Hide button and show loading
button.style.display = 'none';
const loadingDiv = document.createElement('div');
loadingDiv.className = 'loading-more';
loadingDiv.textContent = 'Loading...';
listDiv.appendChild(loadingDiv);
const query = `status:open`;
fetch(`/search.json?q=${encodeURIComponent(query)}`)
.then((response) => response.json())
.then((data) => {
loadingDiv.remove();
if (!data || !data.topics || data.topics.length === 0) {
button.remove();
const noMoreDiv = document.createElement('div');
noMoreDiv.className = 'no-more-topics';
noMoreDiv.textContent = 'No more topics to load';
listDiv.appendChild(noMoreDiv);
return;
}
// Filter out already loaded topics
const unloadedTopics = data.topics.filter(topic => !loadedTopicIds.has(topic.id));
if (unloadedTopics.length === 0) {
button.remove();
const noMoreDiv = document.createElement('div');
noMoreDiv.className = 'no-more-topics';
noMoreDiv.textContent = 'No more topics to load';
listDiv.appendChild(noMoreDiv);
return;
}
// Shuffle and pick up to 5 random topics
const shuffled = unloadedTopics.sort(() => 0.5 - Math.random());
const selected = shuffled.slice(0, Math.min(5, unloadedTopics.length));
// Get category from Discourse site data
const site = Discourse.__container__.lookup('site:main');
selected.forEach(topic => {
loadedTopicIds.add(topic.id);
const date = new Date(topic.created_at);
const formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
const itemDiv = document.createElement('div');
itemDiv.className = 'random-topic-item';
const linkDiv = document.createElement('div');
linkDiv.className = 'random-topic-link';
const link = document.createElement('a');
link.href = `/t/${topic.slug}/${topic.id}`;
link.textContent = topic.unicode_title || topic.title;
linkDiv.appendChild(link);
const metaDiv = document.createElement('div');
metaDiv.className = 'random-topic-meta';
const category = site.categories.find(cat => cat.id === topic.category_id);
const categoryName = category?.name || 'Uncategorized';
metaDiv.textContent = `${categoryName} • ${formattedDate}`;
itemDiv.appendChild(linkDiv);
itemDiv.appendChild(metaDiv);
// Insert before the button
listDiv.insertBefore(itemDiv, button);
});
// Check if we've loaded all available topics
if (selected.length < 5 || loadedTopicIds.size >= data.topics.length) {
button.remove();
const noMoreDiv = document.createElement('div');
noMoreDiv.className = 'no-more-topics';
noMoreDiv.textContent = 'No more topics to load';
listDiv.appendChild(noMoreDiv);
} else {
button.style.display = 'block';
}
})
.catch((err) => {
console.error("Fetch error:", err);
loadingDiv.remove();
button.style.display = 'block';
});
}
function loadRandomTopics(container) {
// Reset loaded topics
loadedTopicIds = new Set();
container.innerHTML = '<div class="spinner"></div>';
const query = `status:open`;
fetch(`/search.json?q=${encodeURIComponent(query)}`)
.then((response) => response.json())
.then((data) => {
if (!data || !data.topics || data.topics.length === 0) {
container.innerHTML = '<p>No topics found</p>';
return;
}
// Shuffle and pick 5 random topics
const shuffled = data.topics.sort(() => 0.5 - Math.random());
const selected = shuffled.slice(0, 5);
// Build the HTML
const listDiv = document.createElement('div');
listDiv.className = 'random-topics-list';
const heading = document.createElement('h2');
heading.textContent = 'Random Open Topics';
listDiv.appendChild(heading);
// Get category from Discourse site data
const site = Discourse.__container__.lookup('site:main');
selected.forEach(topic => {
loadedTopicIds.add(topic.id);
const date = new Date(topic.created_at);
const formattedDate = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
const itemDiv = document.createElement('div');
itemDiv.className = 'random-topic-item';
const linkDiv = document.createElement('div');
linkDiv.className = 'random-topic-link';
const link = document.createElement('a');
link.href = `/t/${topic.slug}/${topic.id}`;
link.textContent = topic.unicode_title || topic.title;
linkDiv.appendChild(link);
const metaDiv = document.createElement('div');
metaDiv.className = 'random-topic-meta';
const category = site.categories.find(cat => cat.id === topic.category_id);
const categoryName = category?.name || 'Uncategorized';
metaDiv.textContent = `${categoryName} • ${formattedDate}`;
itemDiv.appendChild(linkDiv);
itemDiv.appendChild(metaDiv);
listDiv.appendChild(itemDiv);
});
const button = document.createElement('button');
button.className = 'btn btn-primary load-more-random';
button.textContent = 'Load more random topics';
button.addEventListener('click', () => {
addRandomTopics(listDiv, button);
});
listDiv.appendChild(button);
container.innerHTML = '';
container.appendChild(listDiv);
})
.catch((err) => {
console.error("Fetch error:", err);
container.innerHTML = '<p>Error loading topics</p>';
});
}
export default apiInitializer("1.8.0", (api) => {
let wasOnRandomPage = new URLSearchParams(window.location.search).has('random');
api.onPageChange(() => {
const urlParams = new URLSearchParams(window.location.search);
const isOnRandomPage = urlParams.has('random');
// If we were on random page but now we're not, force reload
if (wasOnRandomPage && !isOnRandomPage) {
window.location.reload();
return;
}
wasOnRandomPage = isOnRandomPage;
if (!isOnRandomPage) {
return;
}
// Show and replace the content
const mainContent = document.querySelector('#main-outlet');
if (mainContent) {
mainContent.style.display = 'block';
mainContent.innerHTML = '<div id="random-topics-container"></div>';
const container = document.querySelector('#random-topics-container');
loadRandomTopics(container);
}
});
});
3 - Add this to the CSS tab:
.random-topics-list {
max-width: 800px;
margin: 40px auto;
padding: 20px;
}
.random-topics-list h2 {
margin-bottom: 30px;
font-size: 2em;
}
.random-topic-item {
margin-bottom: 25px;
}
.random-topic-link a {
color: #E9E9E9;
text-decoration: underline;
font-size: 1.1em;
}
.random-topic-link a:hover {
color: #229ED7;
}
.random-topic-meta {
color: #808080;
font-size: 0.85em;
margin-top: 4px;
}
.load-more-random {
margin-top: 30px;
}
.loading-more {
text-align: center;
margin-top: 20px;
color: #808080;
}
.no-more-topics {
text-align: center;
margin-top: 30px;
color: #808080;
font-style: italic;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 100px auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
If someone knows how to use the normal Discourse view instead of using an HTML custom page, that would be great! I’m happy with the feature itself, but having the default layout would be better, if someone can share how to?