mirror of
https://github.com/aljazceru/awesome-nostr.git
synced 2026-10-05 11:28:22 +00:00
The nav link's textContent includes the whitespace around the chevron icon, so it never matched the trimmed section keys in window.parsedResources. displaySection() cleared the container and then threw, leaving the page with only a section heading and no resources. Trim the value so clearing the search restores the active section.
1236 lines
42 KiB
JavaScript
1236 lines
42 KiB
JavaScript
// Move these declarations to the very top of the file
|
|
const darkModeToggle = document.getElementById('darkModeToggle');
|
|
const menuToggle = document.getElementById('menuToggle');
|
|
const sidebar = document.querySelector('.sidebar');
|
|
const body = document.body;
|
|
let touchStartX = 0;
|
|
let touchEndX = 0;
|
|
let touchStartY = 0;
|
|
let touchEndY = 0;
|
|
let isSwiping = false;
|
|
|
|
// Color theme definitions
|
|
const colorThemes = {
|
|
default: {
|
|
light: {
|
|
primary: '#e05297',
|
|
background: '#fdf8f3',
|
|
text: '#2d1b3d',
|
|
cardBackground: '#ffffff',
|
|
sidebarBackground: '#f6f0f8',
|
|
hoverColor: '#fef4fb',
|
|
linkColor: '#7c5cbf'
|
|
},
|
|
dark: {
|
|
primary: '#ff7eb3',
|
|
background: '#191020',
|
|
text: '#ede8f5',
|
|
cardBackground: '#241b2e',
|
|
sidebarBackground: '#1e1528',
|
|
hoverColor: 'rgba(255, 126, 179, 0.06)',
|
|
linkColor: '#c4b5fd'
|
|
}
|
|
},
|
|
purple: {
|
|
light: {
|
|
primary: '#9c528b',
|
|
background: '#ffffff',
|
|
text: '#2f0147',
|
|
cardBackground: '#f7f3f7',
|
|
sidebarBackground: '#b9929f',
|
|
hoverColor: '#c470a4'
|
|
},
|
|
dark: {
|
|
background: '#1a0d1a',
|
|
text: '#e6d6e6',
|
|
cardBackground: '#301a30',
|
|
sidebarBackground: '#4a2d4a',
|
|
linkColor: '#cc88cc'
|
|
}
|
|
},
|
|
nature: {
|
|
light: {
|
|
primary: '#2c5530',
|
|
background: '#ffffff',
|
|
text: '#1a2f1c',
|
|
cardBackground: '#f8faf8',
|
|
sidebarBackground: '#718355',
|
|
hoverColor: '#90a955'
|
|
},
|
|
dark: {
|
|
background: '#0f1a0f',
|
|
text: '#d4e6d4',
|
|
cardBackground: '#1a331a',
|
|
sidebarBackground: '#264d26',
|
|
linkColor: '#99dd99'
|
|
}
|
|
},
|
|
sunset: {
|
|
light: {
|
|
primary: '#cf5c36',
|
|
background: '#ffffff',
|
|
text: '#1f1f1f',
|
|
cardBackground: '#fdf8f3',
|
|
sidebarBackground: '#c8963e',
|
|
hoverColor: '#f3a953'
|
|
},
|
|
dark: {
|
|
background: '#1a0f0a',
|
|
text: '#ffd4a3',
|
|
cardBackground: '#2d1a0d',
|
|
sidebarBackground: '#4d2d1a',
|
|
linkColor: '#ffaa55'
|
|
}
|
|
},
|
|
grape: {
|
|
light: {
|
|
primary: '#642ca9',
|
|
background: '#ffffff',
|
|
text: '#2d1a47',
|
|
cardBackground: '#faf7fd',
|
|
sidebarBackground: '#9775cc',
|
|
hoverColor: '#8855cc'
|
|
},
|
|
dark: {
|
|
background: '#0d0a1a',
|
|
text: '#e6d4ff',
|
|
cardBackground: '#1a0d33',
|
|
sidebarBackground: '#33204d',
|
|
linkColor: '#bb88ff'
|
|
}
|
|
},
|
|
autumn: {
|
|
light: {
|
|
primary: '#d95d39',
|
|
background: '#ffffff',
|
|
text: '#2d1a0f',
|
|
cardBackground: '#fdf7f0',
|
|
sidebarBackground: '#cc8866',
|
|
hoverColor: '#f18805'
|
|
},
|
|
dark: {
|
|
background: '#1a0f0a',
|
|
text: '#ffcc99',
|
|
cardBackground: '#331a0d',
|
|
sidebarBackground: '#4d2d1a',
|
|
linkColor: '#ffaa77'
|
|
}
|
|
},
|
|
midnight: {
|
|
light: {
|
|
primary: '#b91372',
|
|
background: '#ffffff',
|
|
text: '#31081f',
|
|
cardBackground: '#fdf3f7',
|
|
sidebarBackground: '#d988aa',
|
|
hoverColor: '#e6558c'
|
|
},
|
|
dark: {
|
|
background: '#0a0005',
|
|
text: '#ff99cc',
|
|
cardBackground: '#260d1a',
|
|
sidebarBackground: '#4d1a33',
|
|
linkColor: '#ff77bb'
|
|
}
|
|
},
|
|
slate: {
|
|
light: {
|
|
primary: '#475569',
|
|
background: '#ffffff',
|
|
text: '#1e293b',
|
|
cardBackground: '#f8fafc',
|
|
sidebarBackground: '#94a3b8',
|
|
hoverColor: '#64748b'
|
|
},
|
|
dark: {
|
|
background: '#0f172a',
|
|
text: '#cbd5e1',
|
|
cardBackground: '#1e293b',
|
|
sidebarBackground: '#334155',
|
|
linkColor: '#94a3b8'
|
|
}
|
|
},
|
|
forest: {
|
|
light: {
|
|
primary: '#16a085',
|
|
background: '#ffffff',
|
|
text: '#2c3e50',
|
|
cardBackground: '#f0fdf4',
|
|
sidebarBackground: '#52d4a6',
|
|
hoverColor: '#1abc9c'
|
|
},
|
|
dark: {
|
|
background: '#0a1a12',
|
|
text: '#a7f3d0',
|
|
cardBackground: '#064e3b',
|
|
sidebarBackground: '#047857',
|
|
linkColor: '#6ee7b7'
|
|
}
|
|
},
|
|
ocean: {
|
|
light: {
|
|
primary: '#2563eb',
|
|
background: '#ffffff',
|
|
text: '#1e40af',
|
|
cardBackground: '#eff6ff',
|
|
sidebarBackground: '#60a5fa',
|
|
hoverColor: '#3b82f6'
|
|
},
|
|
dark: {
|
|
background: '#0c1429',
|
|
text: '#bfdbfe',
|
|
cardBackground: '#1e3a8a',
|
|
sidebarBackground: '#1d4ed8',
|
|
linkColor: '#93c5fd'
|
|
}
|
|
},
|
|
cherry: {
|
|
light: {
|
|
primary: '#dc2626',
|
|
background: '#ffffff',
|
|
text: '#991b1b',
|
|
cardBackground: '#fef2f2',
|
|
sidebarBackground: '#f87171',
|
|
hoverColor: '#ef4444'
|
|
},
|
|
dark: {
|
|
background: '#1a0606',
|
|
text: '#fecaca',
|
|
cardBackground: '#7f1d1d',
|
|
sidebarBackground: '#991b1b',
|
|
linkColor: '#fb7185'
|
|
}
|
|
},
|
|
cyberpunk: {
|
|
light: {
|
|
primary: '#8b5cf6',
|
|
background: '#fafafa',
|
|
text: '#171717',
|
|
cardBackground: '#f5f5f5',
|
|
sidebarBackground: '#a78bfa',
|
|
hoverColor: '#7c3aed'
|
|
},
|
|
dark: {
|
|
background: '#0a0a0a',
|
|
text: '#00ffff',
|
|
cardBackground: '#1a0a1a',
|
|
sidebarBackground: '#2a1a2a',
|
|
linkColor: '#ff0099'
|
|
}
|
|
},
|
|
nord: {
|
|
light: {
|
|
primary: '#5e81ac',
|
|
background: '#eceff4',
|
|
text: '#2e3440',
|
|
cardBackground: '#e5e9f0',
|
|
sidebarBackground: '#81a1c1',
|
|
hoverColor: '#88c0d0'
|
|
},
|
|
dark: {
|
|
background: '#2e3440',
|
|
text: '#d8dee9',
|
|
cardBackground: '#3b4252',
|
|
sidebarBackground: '#434c5e',
|
|
linkColor: '#88c0d0'
|
|
}
|
|
},
|
|
warm: {
|
|
light: {
|
|
primary: '#f59e0b',
|
|
background: '#ffffff',
|
|
text: '#92400e',
|
|
cardBackground: '#fffbeb',
|
|
sidebarBackground: '#fbbf24',
|
|
hoverColor: '#f97316'
|
|
},
|
|
dark: {
|
|
background: '#1c1917',
|
|
text: '#fed7aa',
|
|
cardBackground: '#451a03',
|
|
sidebarBackground: '#78350f',
|
|
linkColor: '#fb923c'
|
|
}
|
|
},
|
|
dracula: {
|
|
light: {
|
|
primary: '#bd93f9',
|
|
background: '#ffffff',
|
|
text: '#282a36',
|
|
cardBackground: '#f8f8f2',
|
|
sidebarBackground: '#cfc9e3',
|
|
hoverColor: '#ff79c6'
|
|
},
|
|
dark: {
|
|
background: '#282a36',
|
|
text: '#f8f8f2',
|
|
cardBackground: '#44475a',
|
|
sidebarBackground: '#6272a4',
|
|
linkColor: '#bd93f9'
|
|
}
|
|
}
|
|
|
|
};
|
|
|
|
// Initialize all UI controls
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Dark mode initialization
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme) {
|
|
body.dataset.theme = savedTheme;
|
|
} else {
|
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
body.dataset.theme = prefersDark ? 'dark' : 'light';
|
|
localStorage.setItem('theme', body.dataset.theme);
|
|
}
|
|
|
|
// Update dark mode toggle button icon
|
|
updateDarkModeIcon();
|
|
|
|
// Dark mode toggle event listener
|
|
darkModeToggle.addEventListener('click', () => {
|
|
body.dataset.theme = body.dataset.theme === 'dark' ? 'light' : 'dark';
|
|
localStorage.setItem('theme', body.dataset.theme);
|
|
updateDarkModeIcon();
|
|
|
|
// Reapply color theme when switching dark/light mode
|
|
const currentColorTheme = localStorage.getItem('colorTheme') || 'default';
|
|
applyColorTheme(currentColorTheme);
|
|
});
|
|
|
|
// Color theme initialization - color theme select is commented out in HTML
|
|
const savedColorTheme = localStorage.getItem('colorTheme');
|
|
const defaultTheme = 'default';
|
|
|
|
// Validate saved theme exists and apply it
|
|
const initialTheme = colorThemes[savedColorTheme] ? savedColorTheme : defaultTheme;
|
|
applyColorTheme(initialTheme);
|
|
|
|
// Test if marked is loaded
|
|
if (typeof marked === 'undefined') {
|
|
console.error('marked.js is not loaded!');
|
|
document.getElementById('resources-container').innerHTML = `
|
|
<div class="error-message">
|
|
Error: marked.js library is not loaded properly.
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
// If everything is working, proceed with main functionality
|
|
parseAndDisplayContent()
|
|
.then(() => {
|
|
console.log("Content successfully parsed and displayed");
|
|
|
|
// Show section if page fragment exists
|
|
if (location.hash) {
|
|
document.querySelector(`a[href='${location.hash}']`).click();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error in main content processing:', error);
|
|
document.getElementById('resources-container').innerHTML = `
|
|
<div class="error-message">
|
|
Error loading content: ${error.message}
|
|
</div>`;
|
|
});
|
|
|
|
// grantless: banner jumps to the Zappable projects section
|
|
const fundBanner = document.querySelector('.fund-banner');
|
|
if (fundBanner) {
|
|
fundBanner.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
const link = document.querySelector(`.nav-links a[data-section="${ZAPPABLE_SECTION}"]`);
|
|
if (link) link.click();
|
|
});
|
|
}
|
|
|
|
// Ensure we're working with valid elements
|
|
if (!sidebar || !menuToggle) {
|
|
console.error('Required elements not found');
|
|
return;
|
|
}
|
|
|
|
// Single function to handle sidebar state
|
|
const toggleSidebar = (show) => {
|
|
if (show === undefined) {
|
|
sidebar.classList.toggle('active');
|
|
} else {
|
|
sidebar.classList[show ? 'add' : 'remove']('active');
|
|
}
|
|
// Update aria-expanded state
|
|
menuToggle.setAttribute('aria-expanded', sidebar.classList.contains('active'));
|
|
};
|
|
|
|
// Menu toggle click handler
|
|
menuToggle.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleSidebar();
|
|
});
|
|
|
|
// Delegate sidebar link clicks using event delegation
|
|
document.querySelector('.nav-links').addEventListener('click', (e) => {
|
|
const link = e.target.closest('a');
|
|
if (!link) return;
|
|
|
|
e.preventDefault();
|
|
|
|
// Remove active class from all links
|
|
document.querySelectorAll('.nav-links a').forEach(l =>
|
|
l.classList.remove('active')
|
|
);
|
|
|
|
// Add active class to clicked link
|
|
link.classList.add('active');
|
|
|
|
// Get section name and display it
|
|
const section = link.getAttribute('data-section');
|
|
displaySection(section, window.parsedResources);
|
|
|
|
// Close sidebar on mobile
|
|
if (window.innerWidth <= 768) {
|
|
toggleSidebar(false);
|
|
}
|
|
});
|
|
|
|
// Touch handling
|
|
let touchStartX = 0;
|
|
let touchStartY = 0;
|
|
let touchEndX = 0;
|
|
let touchEndY = 0;
|
|
let isSwiping = false;
|
|
const EDGE_ZONE = 30; // px from left edge to trigger swipe
|
|
|
|
const handleTouchStart = (e) => {
|
|
touchStartX = e.touches[0].clientX;
|
|
touchStartY = e.touches[0].clientY;
|
|
// Only allow swipe gesture from the left edge or when sidebar is open
|
|
isSwiping = touchStartX < EDGE_ZONE || sidebar.classList.contains('active');
|
|
};
|
|
|
|
const handleTouchMove = (e) => {
|
|
if (!isSwiping) return;
|
|
|
|
touchEndX = e.touches[0].clientX;
|
|
touchEndY = e.touches[0].clientY;
|
|
|
|
const deltaX = touchStartX - touchEndX;
|
|
const deltaY = Math.abs(touchStartY - touchEndY);
|
|
|
|
// If vertical movement dominates, this is a scroll, not a swipe
|
|
if (deltaY > Math.abs(deltaX)) {
|
|
isSwiping = false;
|
|
return;
|
|
}
|
|
|
|
// Only prevent default once we're confident this is a horizontal swipe
|
|
if (Math.abs(deltaX) > 30 && Math.abs(deltaX) > deltaY * 2) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
if (!isSwiping) return;
|
|
|
|
const deltaX = touchStartX - touchEndX;
|
|
const deltaY = Math.abs(touchStartY - touchEndY);
|
|
const swipeThreshold = 50;
|
|
|
|
if (Math.abs(deltaX) > swipeThreshold && deltaY < 100) {
|
|
toggleSidebar(deltaX < 0);
|
|
}
|
|
|
|
isSwiping = false;
|
|
};
|
|
|
|
// Add touch event listeners
|
|
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
|
|
// Close sidebar when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (window.innerWidth <= 768 &&
|
|
!sidebar.contains(e.target) &&
|
|
!menuToggle.contains(e.target) &&
|
|
sidebar.classList.contains('active')) {
|
|
toggleSidebar(false);
|
|
}
|
|
});
|
|
|
|
// Handle window resize
|
|
let resizeTimer;
|
|
window.addEventListener('resize', () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => {
|
|
if (window.innerWidth > 768) {
|
|
toggleSidebar(true);
|
|
} else {
|
|
toggleSidebar(false);
|
|
}
|
|
}, 250);
|
|
});
|
|
});
|
|
|
|
// Helper function to update dark mode icon
|
|
function updateDarkModeIcon() {
|
|
darkModeToggle.innerHTML = body.dataset.theme === 'dark'
|
|
? '<i class="fas fa-sun"></i>'
|
|
: '<i class="fas fa-moon"></i>';
|
|
}
|
|
|
|
function handleSwipe() {
|
|
const swipeThreshold = 100;
|
|
const swipeDistance = touchStartX - touchEndX;
|
|
|
|
if (swipeDistance > swipeThreshold && sidebar.classList.contains('active')) {
|
|
sidebar.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
// Search functionality
|
|
const searchInput = document.getElementById('search');
|
|
|
|
searchInput.addEventListener('input', (e) => {
|
|
const searchTerm = e.target.value.toLowerCase();
|
|
const container = document.getElementById('resources-container');
|
|
|
|
if (!searchTerm) {
|
|
// If search is empty, restore current category view
|
|
// (the nav link's textContent carries the icon markup's whitespace,
|
|
// so it must be trimmed to match the keys in window.parsedResources)
|
|
const currentCategory = document.querySelector('.nav-links a.active')?.textContent.trim();
|
|
if (currentCategory) {
|
|
displaySection(currentCategory, window.parsedResources);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Clear current container
|
|
container.innerHTML = '';
|
|
|
|
// Search through all sections
|
|
Object.entries(window.parsedResources).forEach(([sectionName, sectionContent]) => {
|
|
sectionContent.forEach(item => {
|
|
if (item.type === 'resources') {
|
|
// Process both top-level and nested items for search
|
|
searchResourceList(item.element, container, searchTerm, sectionName);
|
|
} else if (item.type === 'content') {
|
|
// Search through regular content
|
|
const contentText = item.element.textContent.toLowerCase();
|
|
if (contentText.includes(searchTerm)) {
|
|
const contentDiv = document.createElement('div');
|
|
contentDiv.className = 'markdown-content';
|
|
contentDiv.innerHTML = item.element.outerHTML;
|
|
|
|
// Add section label
|
|
const sectionLabel = document.createElement('div');
|
|
sectionLabel.className = 'category-label';
|
|
sectionLabel.textContent = sectionName;
|
|
|
|
container.appendChild(sectionLabel);
|
|
container.appendChild(contentDiv);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// Show "no results" message if nothing found
|
|
if (!container.children.length) {
|
|
container.innerHTML = `
|
|
<div class="no-results">
|
|
No resources found matching "${searchTerm}"
|
|
</div>
|
|
`;
|
|
}
|
|
});
|
|
|
|
// New helper function to search through resource lists recursively
|
|
function searchResourceList(ulElement, container, searchTerm, sectionName) {
|
|
Array.from(ulElement.children).forEach(li => {
|
|
// Search in the main item if it has a link
|
|
if (li.querySelector(':scope > a')) {
|
|
const resource = parseListItem(li);
|
|
const searchableText = [resource.name, resource.description, resource.link]
|
|
.join(' ')
|
|
.toLowerCase();
|
|
|
|
if (searchableText.includes(searchTerm)) {
|
|
const card = createResourceCard(resource);
|
|
|
|
// Add section label to card
|
|
const sectionLabel = document.createElement('div');
|
|
sectionLabel.className = 'category-label';
|
|
sectionLabel.textContent = sectionName;
|
|
card.insertBefore(sectionLabel, card.firstChild);
|
|
|
|
container.appendChild(card);
|
|
}
|
|
}
|
|
|
|
// Search in nested items if they exist
|
|
const nestedUl = li.querySelector(':scope > ul');
|
|
if (nestedUl) {
|
|
searchResourceList(nestedUl, container, searchTerm, sectionName);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add active class handling for navigation
|
|
document.querySelectorAll('.nav-links a').forEach(link => {
|
|
link.addEventListener('click', () => {
|
|
document.querySelectorAll('.nav-links a').forEach(l => l.classList.remove('active'));
|
|
link.classList.add('active');
|
|
searchInput.value = ''; // Clear search when changing categories
|
|
});
|
|
});
|
|
|
|
// Add this function to fetch contributors from GitHub API
|
|
async function fetchContributors(owner, repo) {
|
|
try {
|
|
let page = 1;
|
|
let allContributors = [];
|
|
|
|
while (true) {
|
|
const response = await fetch(
|
|
`https://api.github.com/repos/${owner}/${repo}/contributors?per_page=100&page=${page}`
|
|
);
|
|
|
|
if (!response.ok) throw new Error('Failed to fetch contributors');
|
|
|
|
const contributors = await response.json();
|
|
if (contributors.length === 0) break; // No more contributors
|
|
|
|
allContributors = [...allContributors, ...contributors];
|
|
page++;
|
|
|
|
// Check if we've reached the last page
|
|
const linkHeader = response.headers.get('Link');
|
|
if (!linkHeader || !linkHeader.includes('rel="next"')) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
console.log(`Total contributors fetched: ${allContributors.length}`);
|
|
return allContributors;
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching contributors:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Modify the parseResources function to handle contributors differently
|
|
function parseResources(content) {
|
|
const resources = {
|
|
'most-popular': [],
|
|
'protocol': [],
|
|
'relays': [],
|
|
'clients': [],
|
|
'libraries': [],
|
|
'bridges-and-gateways': [],
|
|
'cache-services': [],
|
|
'tools': [],
|
|
'nip-05-identity-services': [],
|
|
'offline-signers': [],
|
|
'vanity-pubkey-mining': [],
|
|
'peer-to-peer-markets': [],
|
|
'nip-07-browser-extensions': [],
|
|
'nip-47-nostr-wallet-connect-nwc-implementations': [],
|
|
'nip-57-zaps-compatible-wallets-and-solutions': [],
|
|
'nip-90-data-vending-machines': [],
|
|
'nip-96-file-storage-servers': [],
|
|
'nostr-web-services-nws': [],
|
|
'adjacent-protocols': [],
|
|
'games-on-nostr': [],
|
|
'communities': [],
|
|
'tutorials': [],
|
|
'recommended-reading-watching': [],
|
|
'podcasts': [],
|
|
'other-links': [],
|
|
'deprecated': [],
|
|
'related-resources': [],
|
|
'contributing': [],
|
|
'contributors': []
|
|
};
|
|
|
|
const lines = content.split('\n');
|
|
let currentMainSection = '';
|
|
let currentSubSection = '';
|
|
let contributingContent = '';
|
|
|
|
// Extract repo info from contributors section
|
|
let repoInfo = null;
|
|
const repoRegex = /github\.com\/([\w-]+)\/([\w-]+)\/graphs\/contributors/;
|
|
|
|
lines.forEach(line => {
|
|
if (line.startsWith('## ')) {
|
|
currentMainSection = line.slice(3).trim();
|
|
currentSubSection = '';
|
|
console.log('Processing section:', currentMainSection); // Debug log
|
|
} else if (currentMainSection.toLowerCase() === 'contributors') {
|
|
const match = line.match(repoRegex);
|
|
if (match) {
|
|
repoInfo = {
|
|
owner: match[1],
|
|
repo: match[2]
|
|
};
|
|
}
|
|
} else if (currentMainSection.toLowerCase() === 'contributing') {
|
|
if (line.trim() && !line.startsWith('##')) {
|
|
contributingContent += line + '\n';
|
|
}
|
|
}
|
|
// Detect subsection headers (###)
|
|
else if (line.startsWith('### ')) {
|
|
currentSubSection = line.slice(4).trim();
|
|
}
|
|
// Parse regular resource lines (starting with '- [')
|
|
else if (line.trim().startsWith('- [')) {
|
|
const resource = parseResourceLine(line);
|
|
if (resource) {
|
|
// Convert section header to category ID format
|
|
const categoryId = currentMainSection
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '');
|
|
|
|
console.log('Parsed resource:', categoryId, resource); // Debug log
|
|
|
|
// Add resource to appropriate category if it exists
|
|
if (resources[categoryId]) {
|
|
resources[categoryId].push(resource);
|
|
} else {
|
|
console.warn('Category not found:', categoryId); // Debug log
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Add special handling for contributors and contributing sections
|
|
resources['contributors'] = [{
|
|
type: 'github-contributors',
|
|
repoInfo: repoInfo
|
|
}];
|
|
|
|
resources['contributing'] = [{
|
|
type: 'markdown',
|
|
content: contributingContent.trim()
|
|
}];
|
|
|
|
// Log the parsed data for debugging
|
|
console.log('Parsed resources:', resources);
|
|
return resources;
|
|
}
|
|
|
|
// Function to parse a single resource line
|
|
function parseResourceLine(line) {
|
|
// Modified regex patterns to be more lenient
|
|
const nameRegex = /\[(.*?)\]/;
|
|
const linkRegex = /\((.*?)\)/;
|
|
const starsRegex = /!\[stars\].*?stars\/(.*?)\/.*?style=social/;
|
|
|
|
// Modified description regex to be optional
|
|
const descriptionRegex = /(?:\) - )(.*?)(?=(?:\[|\n|$))|(?:style=social\) - )(.*?)(?=(?:\[|\n|$))/;
|
|
|
|
try {
|
|
const nameMatch = nameRegex.exec(line);
|
|
const linkMatch = linkRegex.exec(line);
|
|
|
|
// Only require name and link to be present
|
|
if (nameMatch?.[1] && linkMatch?.[1]) {
|
|
const name = nameMatch[1].trim();
|
|
const link = linkMatch[1].trim();
|
|
const stars = starsRegex.exec(line)?.[1];
|
|
|
|
// Make description optional
|
|
const descMatch = descriptionRegex.exec(line);
|
|
const description = (descMatch?.[1] || descMatch?.[2] || '').trim();
|
|
|
|
return {
|
|
name,
|
|
link,
|
|
stars: stars ? parseInt(stars) : 0,
|
|
description, // This might be an empty string now
|
|
raw: line.trim()
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Error parsing resource line:', error, line);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ===== grantless: zap/donate chip support =====
|
|
// Reads a rendered markdown <li> and extracts the resource link plus the
|
|
// optional chips from the README convention:
|
|
// - [Name](https://...) - description [⚡ zap](https://nostr.net/grant/?zap=addr@host) [donate](https://...)
|
|
// GitHub's sanitizer strips lightning: links entirely (verified via the
|
|
// GitHub markdown API), so chips carry an https link to our own grant page,
|
|
// which opens the zap dialog (QR / open-wallet / in-page zap). Plain
|
|
// lightning: chips are still recognized for back-compat.
|
|
const LIGHTNING_SCHEME_RE = /^lightning:/i;
|
|
const ZAP_LABEL_RE = /^\s*⚡\s*zap\s*$/i;
|
|
// chip host: our own grant page carries the address in the query string
|
|
const GRANT_CHIP_RE = /^https:\/\/nostr\.net\/grant\/\?zap=([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+)(&|$)/i;
|
|
|
|
// chip href + anchor label → lightning address, or null
|
|
function lnAddressFromChip(href, label) {
|
|
href = href || '';
|
|
if (LIGHTNING_SCHEME_RE.test(href)) return href.replace(LIGHTNING_SCHEME_RE, '');
|
|
if (ZAP_LABEL_RE.test(label || '')) {
|
|
const m = href.match(GRANT_CHIP_RE);
|
|
if (m) return decodeURIComponent(m[1]);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseListItem(li) {
|
|
let name = '';
|
|
let link = '';
|
|
let lnAddress = null;
|
|
let donateUrl = null;
|
|
|
|
Array.from(li.querySelectorAll('a')).forEach(a => {
|
|
const href = a.getAttribute('href') || '';
|
|
const label = a.textContent.trim();
|
|
const chipAddr = lnAddressFromChip(href, label);
|
|
if (chipAddr) {
|
|
if (lnAddress === null) lnAddress = chipAddr;
|
|
} else if (label.toLowerCase() === 'donate' && /^https?:/i.test(href)) {
|
|
if (donateUrl === null) donateUrl = href;
|
|
} else if (!name) {
|
|
name = label;
|
|
link = href;
|
|
}
|
|
});
|
|
|
|
// Description = li text minus the name, chips, stars badge and nested lists
|
|
const clone = li.cloneNode(true);
|
|
clone.querySelectorAll('ul').forEach(u => u.remove());
|
|
clone.querySelectorAll('img').forEach(i => i.remove());
|
|
clone.querySelectorAll('a').forEach(a => {
|
|
const href = a.getAttribute('href') || '';
|
|
const label = a.textContent.trim();
|
|
if (lnAddressFromChip(href, label) || label.toLowerCase() === 'donate') a.remove();
|
|
});
|
|
let description = clone.textContent.replace(/\s+/g, ' ').trim();
|
|
if (name && description.toLowerCase().startsWith(name.toLowerCase())) {
|
|
description = description.slice(name.length).trim();
|
|
}
|
|
description = description.replace(/^[-–—:]\s*/, '').trim();
|
|
|
|
const starsImg = li.querySelector('img[alt="stars"]');
|
|
return {
|
|
name,
|
|
link,
|
|
description,
|
|
stars: starsImg ? parseInt(starsImg.src.match(/stars\/(\d+)/)?.[1]) || 0 : 0,
|
|
lnAddress,
|
|
donateUrl
|
|
};
|
|
}
|
|
|
|
// ===== grantless: aggregated "Zappable projects" section =====
|
|
const CURATOR_LN_ADDRESS = 'aljaz@minibits.cash';
|
|
const ZAPPABLE_SECTION = 'Zappable projects';
|
|
|
|
// Builds a synthetic resource list containing nostr.net (curator, pinned)
|
|
// plus every entry from any section that carries a lightning: chip.
|
|
function buildZappableSection(sections) {
|
|
const ul = document.createElement('ul');
|
|
|
|
// nostr.net itself — curator & host, always first
|
|
const curator = document.createElement('li');
|
|
curator.innerHTML = `<a href="https://nostr.net">nostr.net</a> - Curator & host — this list and relay.nostr.net <a href="lightning:${CURATOR_LN_ADDRESS}">⚡ zap</a>`;
|
|
ul.appendChild(curator);
|
|
|
|
// collect zappable entries from all sections, nested lists included
|
|
const collect = (list) => {
|
|
Array.from(list.children).forEach(li => {
|
|
if (parseListItem(li).lnAddress) ul.appendChild(li.cloneNode(true));
|
|
const nested = li.querySelector(':scope > ul');
|
|
if (nested) collect(nested);
|
|
});
|
|
};
|
|
Object.values(sections).forEach(content =>
|
|
content.forEach(item => { if (item.type === 'resources') collect(item.element); })
|
|
);
|
|
|
|
return [{ type: 'resources', element: ul }];
|
|
}
|
|
|
|
// Modified createResourceCard function to display markdown links in a cleaner format
|
|
function createResourceCard(resource) {
|
|
const card = document.createElement('div');
|
|
card.className = 'resource-card';
|
|
|
|
// Add schema.org structured data
|
|
card.setAttribute('itemscope', '');
|
|
card.setAttribute('itemtype', 'https://schema.org/SoftwareApplication');
|
|
|
|
// Extract domain and build multiple fallback favicon URLs
|
|
let faviconUrl = '';
|
|
try {
|
|
const url = new URL(resource.link);
|
|
const domain = url.hostname;
|
|
|
|
// Try multiple favicon sources
|
|
const faviconSources = [
|
|
`https://www.google.com/s2/favicons?domain=https://${domain}&sz=64`,
|
|
`https://${domain}/favicon.ico`,
|
|
`https://${domain}/favicon.png`,
|
|
`https://${domain}/apple-touch-icon.png`,
|
|
`https://${domain}/apple-touch-icon-precomposed.png`
|
|
];
|
|
|
|
// Create image element with fallback chain
|
|
const img = document.createElement('img');
|
|
img.className = 'resource-favicon';
|
|
img.alt = '';
|
|
|
|
// Set first source as initial
|
|
img.src = faviconSources[0];
|
|
|
|
// Add error handling to try next source
|
|
let sourceIndex = 0;
|
|
img.onerror = () => {
|
|
sourceIndex++;
|
|
if (sourceIndex < faviconSources.length) {
|
|
img.src = faviconSources[sourceIndex];
|
|
} else {
|
|
// If all sources fail, use a default icon
|
|
img.src = 'data:image/svg+xml,' + encodeURIComponent(`
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="32" height="32">
|
|
<circle cx="50" cy="50" r="40" fill="#ccc"/>
|
|
<text x="50" y="65" font-size="40" text-anchor="middle" fill="#fff">
|
|
${resource.name.charAt(0).toUpperCase()}
|
|
</text>
|
|
</svg>
|
|
`);
|
|
img.onerror = null; // Remove error handler once default is shown
|
|
}
|
|
};
|
|
|
|
faviconUrl = img.outerHTML;
|
|
} catch (e) {
|
|
console.warn('Invalid URL:', resource.link);
|
|
// Use default icon for invalid URLs
|
|
faviconUrl = `<div class="resource-favicon-fallback">${resource.name.charAt(0).toUpperCase()}</div>`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="resource-header">
|
|
${faviconUrl}
|
|
<div class="resource-content">
|
|
<h3 class="resource-title">
|
|
<a href="${resource.link}"
|
|
target="_blank"
|
|
rel="noopener"
|
|
itemprop="name">
|
|
${resource.name}
|
|
</a>
|
|
${resource.stars ? `
|
|
<div class="resource-stars" itemprop="aggregateRating" itemscope itemtype="https://schema.org/AggregateRating">
|
|
<i class="fas fa-star" aria-hidden="true"></i>
|
|
<span itemprop="ratingValue">${resource.stars}</span>
|
|
<meta itemprop="ratingCount" content="${resource.stars}">
|
|
</div>
|
|
` : ''}
|
|
</h3>
|
|
${resource.description ? `
|
|
<div class="resource-description" itemprop="description">
|
|
${resource.description}
|
|
</div>
|
|
` : ''}
|
|
${(resource.lnAddress || resource.donateUrl) ? `
|
|
<div class="resource-chips">
|
|
${resource.lnAddress ? `
|
|
<a class="zap-chip" href="lightning:${resource.lnAddress}"
|
|
title="Zap ${resource.name} · ${resource.lnAddress}"
|
|
data-ln-address="${resource.lnAddress}">
|
|
<i class="fas fa-bolt" aria-hidden="true"></i> Zap
|
|
</a>` : ''}
|
|
${resource.donateUrl ? `
|
|
<a class="donate-chip" href="${resource.donateUrl}" target="_blank" rel="noopener">
|
|
<i class="fas fa-heart" aria-hidden="true"></i> Donate
|
|
</a>` : ''}
|
|
</div>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return card;
|
|
}
|
|
|
|
// Update createSpecialSectionCard to handle GitHub contributors
|
|
async function createSpecialSectionCard(resource) {
|
|
const card = document.createElement('div');
|
|
card.className = 'resource-card';
|
|
|
|
if (resource.type === 'github-contributors') {
|
|
card.className += ' contributors-card';
|
|
card.innerHTML = `
|
|
<div class="resource-title">Contributors</div>
|
|
<div class="contributors-grid">
|
|
<div class="loading">Loading contributors...</div>
|
|
</div>
|
|
`;
|
|
|
|
if (resource.repoInfo) {
|
|
console.log('Fetching contributors for:', resource.repoInfo); // Debug log
|
|
const contributors = await fetchContributors(resource.repoInfo.owner, resource.repoInfo.repo);
|
|
console.log('Number of contributors:', contributors.length); // Debug log
|
|
const contributorsHtml = contributors.map(contributor => `
|
|
<a href="${contributor.html_url}" target="_blank" title="${contributor.login}">
|
|
<img src="${contributor.avatar_url}" alt="${contributor.login}" />
|
|
<span>${contributor.login}</span>
|
|
</a>
|
|
`).join('');
|
|
|
|
const grid = card.querySelector('.contributors-grid');
|
|
grid.innerHTML = contributorsHtml || 'No contributors found';
|
|
}
|
|
} else if (resource.type === 'markdown') {
|
|
// For contributing section
|
|
const formattedContent = resource.content
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
|
|
|
card.innerHTML = `
|
|
<div class="resource-title">How to Contribute</div>
|
|
<div class="resource-description">
|
|
${formattedContent}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
// Update populateResources to handle both special and regular resources
|
|
async function populateResources(categoryId, resources) {
|
|
const container = document.getElementById('resources-container');
|
|
container.innerHTML = '';
|
|
|
|
const categoryResources = resources[categoryId];
|
|
if (!categoryResources) return;
|
|
|
|
for (const resource of categoryResources) {
|
|
let card;
|
|
if (resource.type) {
|
|
// Handle special sections (contributors and contributing)
|
|
card = await createSpecialSectionCard(resource);
|
|
} else {
|
|
// Handle regular resource cards
|
|
card = createResourceCard(resource);
|
|
}
|
|
container.appendChild(card);
|
|
}
|
|
}
|
|
|
|
// Function to get icon for category
|
|
function getCategoryIcon(category) {
|
|
const iconMap = {
|
|
'most-popular': 'fa-star',
|
|
'protocol': 'fa-book',
|
|
'relays': 'fa-server',
|
|
'clients': 'fa-mobile-alt',
|
|
'libraries': 'fa-code',
|
|
'bridges-and-gateways': 'fa-bridge',
|
|
'cache-services': 'fa-database',
|
|
'tools': 'fa-wrench',
|
|
'nip-05-identity-services': 'fa-id-card',
|
|
'offline-signers': 'fa-signature',
|
|
'vanity-pubkey-mining': 'fa-hammer',
|
|
'peer-to-peer-markets': 'fa-store',
|
|
'nip-07-browser-extensions': 'fa-puzzle-piece',
|
|
'nip-47-nostr-wallet-connect-nwc-implementations': 'fa-wallet',
|
|
'nip-57-zaps-compatible-wallets-and-solutions': 'fa-bolt',
|
|
'nip-90-data-vending-machines': 'fa-store-alt',
|
|
'nip-96-file-storage-servers': 'fa-folder',
|
|
'nostr-web-services-nws': 'fa-globe',
|
|
'adjacent-protocols': 'fa-link',
|
|
'games-on-nostr': 'fa-gamepad',
|
|
'communities': 'fa-users',
|
|
'tutorials': 'fa-graduation-cap',
|
|
'recommended-reading-watching': 'fa-book-reader',
|
|
'podcasts': 'fa-podcast',
|
|
'other-links': 'fa-external-link-alt',
|
|
'deprecated': 'fa-archive',
|
|
'related-resources': 'fa-project-diagram',
|
|
'contributing': 'fa-hands-helping',
|
|
'contributors': 'fa-users-cog',
|
|
// Default icon if category not found
|
|
'default': 'fa-circle'
|
|
};
|
|
|
|
const key = category.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
return iconMap[key] || iconMap.default;
|
|
}
|
|
|
|
// Function to generate navigation from content
|
|
function generateNavigation(sectionNames) {
|
|
const navList = document.querySelector('.nav-links');
|
|
navList.innerHTML = ''; // Clear existing navigation
|
|
|
|
sectionNames.forEach(section => {
|
|
// Create URL-friendly ID
|
|
const sectionId = section
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/(^-|-$)/g, '');
|
|
|
|
const li = document.createElement('li');
|
|
li.innerHTML = `
|
|
<a href="#${sectionId}" data-section="${section}">
|
|
<i class="fas fa-chevron-right"></i>
|
|
${section}
|
|
</a>
|
|
`;
|
|
|
|
// Add click handler
|
|
li.querySelector('a').addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
// Remove active class from all links
|
|
document.querySelectorAll('.nav-links a').forEach(a =>
|
|
a.classList.remove('active')
|
|
);
|
|
// Add active class to clicked link
|
|
e.target.classList.add('active');
|
|
// Display the section
|
|
displaySection(section, window.parsedResources);
|
|
});
|
|
|
|
navList.appendChild(li);
|
|
});
|
|
|
|
// Set first item as active
|
|
const firstLink = navList.querySelector('a');
|
|
if (firstLink) {
|
|
firstLink.classList.add('active');
|
|
}
|
|
}
|
|
|
|
async function parseAndDisplayContent() {
|
|
try {
|
|
const response = await fetch('./README.md');
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
const content = await response.text();
|
|
|
|
// Parse the markdown into HTML
|
|
const htmlContent = marked.parse(content);
|
|
|
|
// Create a temporary element to parse the HTML
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = htmlContent;
|
|
|
|
// Get all sections (h2 headers and their content)
|
|
const sections = {};
|
|
let currentSection = null;
|
|
let currentSectionContent = [];
|
|
|
|
Array.from(tempDiv.children).forEach(element => {
|
|
if (element.tagName === 'H2') {
|
|
// If we have a previous section, save it
|
|
if (currentSection) {
|
|
sections[currentSection] = currentSectionContent;
|
|
}
|
|
// Start new section
|
|
currentSection = element.textContent.trim();
|
|
currentSectionContent = [];
|
|
} else if (currentSection) {
|
|
currentSectionContent.push({
|
|
type: element.tagName === 'UL' ? 'resources' : 'content',
|
|
element: element
|
|
});
|
|
}
|
|
});
|
|
|
|
// Save the last section
|
|
if (currentSection) {
|
|
sections[currentSection] = currentSectionContent;
|
|
}
|
|
|
|
// grantless: insert the aggregated zappable section after the first section
|
|
const withZappable = {};
|
|
const originalEntries = Object.entries(sections);
|
|
if (originalEntries.length) {
|
|
withZappable[originalEntries[0][0]] = originalEntries[0][1];
|
|
}
|
|
withZappable[ZAPPABLE_SECTION] = buildZappableSection(sections);
|
|
originalEntries.slice(1).forEach(([name, content]) => { withZappable[name] = content; });
|
|
|
|
// Store sections globally
|
|
window.parsedResources = withZappable;
|
|
|
|
// Generate navigation
|
|
const sectionNames = Object.keys(withZappable);
|
|
generateNavigation(sectionNames);
|
|
|
|
// Display initial section
|
|
if (sectionNames.length > 0) {
|
|
displaySection(sectionNames[0], withZappable);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error processing markdown:', error);
|
|
throw error; // Re-throw to be caught by the main error handler
|
|
}
|
|
}
|
|
|
|
function displaySection(sectionName, sections) {
|
|
const container = document.getElementById('resources-container');
|
|
container.innerHTML = '';
|
|
|
|
// Add section header
|
|
const header = document.createElement('h2');
|
|
header.textContent = sectionName;
|
|
container.appendChild(header);
|
|
|
|
// Display section content
|
|
sections[sectionName].forEach(item => {
|
|
if (item.type === 'content') {
|
|
// Regular markdown content
|
|
const contentDiv = document.createElement('div');
|
|
contentDiv.className = 'markdown-content';
|
|
contentDiv.innerHTML = item.element.outerHTML;
|
|
container.appendChild(contentDiv);
|
|
} else if (item.type === 'resources') {
|
|
// Resource list
|
|
Array.from(item.element.children).forEach(li => {
|
|
const card = createResourceCard(parseListItem(li));
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
});
|
|
|
|
window.scrollTo(0, 0);
|
|
}
|
|
|
|
function applyColorTheme(themeName) {
|
|
// Check if theme exists, otherwise fallback to default
|
|
const themeExists = colorThemes[themeName];
|
|
if (!themeExists) {
|
|
console.warn(`Theme "${themeName}" not found, falling back to default theme`);
|
|
themeName = 'default';
|
|
}
|
|
|
|
const isDark = document.body.dataset.theme === 'dark';
|
|
const theme = colorThemes[themeName][isDark ? 'dark' : 'light'];
|
|
|
|
if (!theme) {
|
|
console.error(`Could not find ${isDark ? 'dark' : 'light'} mode for theme "${themeName}"`);
|
|
return;
|
|
}
|
|
|
|
// Apply theme colors to CSS variables
|
|
const root = document.documentElement;
|
|
Object.entries(theme).forEach(([key, value]) => {
|
|
const cssVar = `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
|
root.style.setProperty(cssVar, value);
|
|
});
|
|
|
|
// Store the successfully applied theme
|
|
localStorage.setItem('colorTheme', themeName);
|
|
} |