mirror of
https://github.com/Zie619/n8n-workflows.git
synced 2025-11-25 03:15:25 +08:00
## 🌐 GitHub Pages Public Search Interface - Complete client-side search application solving Issue #84 - Responsive HTML/CSS/JavaScript with mobile optimization - Real-time search across 2,057+ workflows with instant results - Category filtering across 15 workflow categories - Dark/light theme support with system preference detection - Direct workflow JSON download functionality ## 🤖 GitHub Actions Automation - deploy-pages.yml: Automated deployment to GitHub Pages - update-readme.yml: Weekly automated README statistics updates - Comprehensive workflow indexing and category generation ## 🔍 Enhanced Search & Categorization - Static search index generation for GitHub Pages - Developer-chosen category prioritization system - CalcsLive custom node integration and categorization - Enhanced workflow database with better custom node detection - Fixed README corruption with live database statistics ## 📚 Documentation & Infrastructure - Comprehensive CHANGELOG.md with proper versioning - Enhanced README with accurate statistics and public interface links - Professional documentation solving repository infrastructure needs ## Technical Improvements - Fixed Unicode encoding issues in Python scripts - Enhanced CalcsLive detection with false positive prevention - Improved JSON description preservation and indexing - Mobile-optimized responsive design for all devices 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
439 lines
15 KiB
JavaScript
439 lines
15 KiB
JavaScript
/**
|
||
* Client-side search functionality for N8N Workflow Collection
|
||
* Handles searching, filtering, and displaying workflow results
|
||
*/
|
||
|
||
class WorkflowSearch {
|
||
constructor() {
|
||
this.searchIndex = null;
|
||
this.currentResults = [];
|
||
this.displayedCount = 0;
|
||
this.resultsPerPage = 20;
|
||
this.isLoading = false;
|
||
|
||
// DOM elements
|
||
this.searchInput = document.getElementById('search-input');
|
||
this.categoryFilter = document.getElementById('category-filter');
|
||
this.complexityFilter = document.getElementById('complexity-filter');
|
||
this.triggerFilter = document.getElementById('trigger-filter');
|
||
this.resultsGrid = document.getElementById('results-grid');
|
||
this.resultsTitle = document.getElementById('results-title');
|
||
this.resultsCount = document.getElementById('results-count');
|
||
this.loadingEl = document.getElementById('loading');
|
||
this.noResultsEl = document.getElementById('no-results');
|
||
this.loadMoreBtn = document.getElementById('load-more');
|
||
|
||
this.init();
|
||
}
|
||
|
||
async init() {
|
||
try {
|
||
await this.loadSearchIndex();
|
||
this.setupEventListeners();
|
||
this.populateFilters();
|
||
this.updateStats();
|
||
this.showFeaturedWorkflows();
|
||
} catch (error) {
|
||
console.error('Failed to initialize search:', error);
|
||
this.showError('Failed to load workflow data. Please try again later.');
|
||
}
|
||
}
|
||
|
||
async loadSearchIndex() {
|
||
this.showLoading(true);
|
||
try {
|
||
const response = await fetch('api/search-index.json');
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load search index');
|
||
}
|
||
this.searchIndex = await response.json();
|
||
} finally {
|
||
this.showLoading(false);
|
||
}
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// Search input
|
||
this.searchInput.addEventListener('input', this.debounce(this.handleSearch.bind(this), 300));
|
||
this.searchInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') {
|
||
this.handleSearch();
|
||
}
|
||
});
|
||
|
||
// Filters
|
||
this.categoryFilter.addEventListener('change', this.handleSearch.bind(this));
|
||
this.complexityFilter.addEventListener('change', this.handleSearch.bind(this));
|
||
this.triggerFilter.addEventListener('change', this.handleSearch.bind(this));
|
||
|
||
// Load more button
|
||
this.loadMoreBtn.addEventListener('click', this.loadMoreResults.bind(this));
|
||
|
||
// Search button
|
||
document.getElementById('search-btn').addEventListener('click', this.handleSearch.bind(this));
|
||
}
|
||
|
||
populateFilters() {
|
||
// Populate category filter
|
||
this.searchIndex.categories.forEach(category => {
|
||
const option = document.createElement('option');
|
||
option.value = category;
|
||
option.textContent = category;
|
||
this.categoryFilter.appendChild(option);
|
||
});
|
||
}
|
||
|
||
updateStats() {
|
||
const stats = this.searchIndex.stats;
|
||
|
||
document.getElementById('total-count').textContent = stats.total_workflows.toLocaleString();
|
||
document.getElementById('workflows-count').textContent = stats.total_workflows.toLocaleString();
|
||
document.getElementById('active-count').textContent = stats.active_workflows.toLocaleString();
|
||
document.getElementById('integrations-count').textContent = stats.unique_integrations.toLocaleString();
|
||
document.getElementById('categories-count').textContent = stats.categories.toLocaleString();
|
||
}
|
||
|
||
handleSearch() {
|
||
const query = this.searchInput.value.trim().toLowerCase();
|
||
const category = this.categoryFilter.value;
|
||
const complexity = this.complexityFilter.value;
|
||
const trigger = this.triggerFilter.value;
|
||
|
||
this.currentResults = this.searchWorkflows(query, { category, complexity, trigger });
|
||
this.displayedCount = 0;
|
||
this.displayResults(true);
|
||
this.updateResultsHeader(query, { category, complexity, trigger });
|
||
}
|
||
|
||
searchWorkflows(query, filters = {}) {
|
||
let results = [...this.searchIndex.workflows];
|
||
|
||
// Text search
|
||
if (query) {
|
||
results = results.filter(workflow =>
|
||
workflow.searchable_text.includes(query)
|
||
);
|
||
|
||
// Sort by relevance (name matches first, then description)
|
||
results.sort((a, b) => {
|
||
const aNameMatch = a.name.toLowerCase().includes(query);
|
||
const bNameMatch = b.name.toLowerCase().includes(query);
|
||
|
||
if (aNameMatch && !bNameMatch) return -1;
|
||
if (!aNameMatch && bNameMatch) return 1;
|
||
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
// Apply filters
|
||
if (filters.category) {
|
||
results = results.filter(workflow => workflow.category === filters.category);
|
||
}
|
||
|
||
if (filters.complexity) {
|
||
results = results.filter(workflow => workflow.complexity === filters.complexity);
|
||
}
|
||
|
||
if (filters.trigger) {
|
||
results = results.filter(workflow => workflow.trigger_type === filters.trigger);
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
showFeaturedWorkflows() {
|
||
// Show recent workflows or popular ones when no search
|
||
const featured = this.searchIndex.workflows
|
||
.filter(w => w.integrations.length > 0)
|
||
.slice(0, this.resultsPerPage);
|
||
|
||
this.currentResults = featured;
|
||
this.displayedCount = 0;
|
||
this.displayResults(true);
|
||
this.resultsTitle.textContent = 'Featured Workflows';
|
||
this.resultsCount.textContent = '';
|
||
}
|
||
|
||
displayResults(reset = false) {
|
||
if (reset) {
|
||
this.resultsGrid.innerHTML = '';
|
||
this.displayedCount = 0;
|
||
}
|
||
|
||
if (this.currentResults.length === 0) {
|
||
this.showNoResults();
|
||
return;
|
||
}
|
||
|
||
this.hideNoResults();
|
||
|
||
const startIndex = this.displayedCount;
|
||
const endIndex = Math.min(startIndex + this.resultsPerPage, this.currentResults.length);
|
||
const resultsToShow = this.currentResults.slice(startIndex, endIndex);
|
||
|
||
resultsToShow.forEach(workflow => {
|
||
const card = this.createWorkflowCard(workflow);
|
||
this.resultsGrid.appendChild(card);
|
||
});
|
||
|
||
this.displayedCount = endIndex;
|
||
|
||
// Update load more button
|
||
if (this.displayedCount < this.currentResults.length) {
|
||
this.loadMoreBtn.classList.remove('hidden');
|
||
} else {
|
||
this.loadMoreBtn.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
createWorkflowCard(workflow) {
|
||
const card = document.createElement('div');
|
||
card.className = 'workflow-card';
|
||
card.onclick = () => this.openWorkflowDetails(workflow);
|
||
|
||
const integrationTags = workflow.integrations
|
||
.slice(0, 3)
|
||
.map(integration => `<span class="integration-tag">${integration}</span>`)
|
||
.join('');
|
||
|
||
const moreIntegrations = workflow.integrations.length > 3
|
||
? `<span class="integration-tag">+${workflow.integrations.length - 3} more</span>`
|
||
: '';
|
||
|
||
card.innerHTML = `
|
||
<h3 class="workflow-title">${this.escapeHtml(workflow.name)}</h3>
|
||
<p class="workflow-description">${this.escapeHtml(workflow.description)}</p>
|
||
|
||
<div class="workflow-meta">
|
||
<span class="meta-tag category">${workflow.category}</span>
|
||
<span class="meta-tag trigger">${workflow.trigger_type}</span>
|
||
<span class="meta-tag">${workflow.complexity} complexity</span>
|
||
<span class="meta-tag">${workflow.node_count} nodes</span>
|
||
</div>
|
||
|
||
<div class="workflow-integrations">
|
||
${integrationTags}
|
||
${moreIntegrations}
|
||
</div>
|
||
|
||
<div class="workflow-actions">
|
||
<a href="${workflow.download_url}" class="btn btn-primary" target="_blank" onclick="event.stopPropagation()">
|
||
📥 Download JSON
|
||
</a>
|
||
<button class="btn btn-secondary" onclick="event.stopPropagation(); window.copyWorkflowId('${workflow.filename}')">
|
||
📋 Copy ID
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
return card;
|
||
}
|
||
|
||
openWorkflowDetails(workflow) {
|
||
// Create modal or expand card with more details
|
||
const modal = this.createDetailsModal(workflow);
|
||
document.body.appendChild(modal);
|
||
|
||
// Add event listener to close modal
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) {
|
||
document.body.removeChild(modal);
|
||
}
|
||
});
|
||
}
|
||
|
||
createDetailsModal(workflow) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay';
|
||
modal.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
padding: 1rem;
|
||
`;
|
||
|
||
const modalContent = document.createElement('div');
|
||
modalContent.style.cssText = `
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 2rem;
|
||
max-width: 600px;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
position: relative;
|
||
`;
|
||
|
||
const allIntegrations = workflow.integrations
|
||
.map(integration => `<span class="integration-tag">${integration}</span>`)
|
||
.join('');
|
||
|
||
const allTags = workflow.tags
|
||
.map(tag => `<span class="meta-tag">${tag}</span>`)
|
||
.join('');
|
||
|
||
modalContent.innerHTML = `
|
||
<button onclick="this.parentElement.parentElement.remove()" style="position: absolute; top: 1rem; right: 1rem; background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
||
|
||
<h2 style="margin-bottom: 1rem;">${this.escapeHtml(workflow.name)}</h2>
|
||
|
||
<div style="margin-bottom: 1.5rem;">
|
||
<strong>Description:</strong>
|
||
<p style="margin-top: 0.5rem;">${this.escapeHtml(workflow.description)}</p>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 1.5rem;">
|
||
<strong>Details:</strong>
|
||
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem; margin-top: 0.5rem;">
|
||
<div><strong>Category:</strong> ${workflow.category}</div>
|
||
<div><strong>Trigger:</strong> ${workflow.trigger_type}</div>
|
||
<div><strong>Complexity:</strong> ${workflow.complexity}</div>
|
||
<div><strong>Nodes:</strong> ${workflow.node_count}</div>
|
||
<div><strong>Status:</strong> ${workflow.active ? 'Active' : 'Inactive'}</div>
|
||
<div><strong>File:</strong> ${workflow.filename}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-bottom: 1.5rem;">
|
||
<strong>Integrations:</strong>
|
||
<div style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.25rem;">
|
||
${allIntegrations}
|
||
</div>
|
||
</div>
|
||
|
||
${workflow.tags.length > 0 ? `
|
||
<div style="margin-bottom: 1.5rem;">
|
||
<strong>Tags:</strong>
|
||
<div style="margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.25rem;">
|
||
${allTags}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div style="display: flex; gap: 1rem;">
|
||
<a href="${workflow.download_url}" class="btn btn-primary" target="_blank">
|
||
📥 Download JSON
|
||
</a>
|
||
<button class="btn btn-secondary" onclick="window.copyWorkflowId('${workflow.filename}')">
|
||
📋 Copy Filename
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
modal.appendChild(modalContent);
|
||
return modal;
|
||
}
|
||
|
||
updateResultsHeader(query, filters) {
|
||
let title = 'Search Results';
|
||
let filterDesc = [];
|
||
|
||
if (query) {
|
||
title = `Search: "${query}"`;
|
||
}
|
||
|
||
if (filters.category) filterDesc.push(`Category: ${filters.category}`);
|
||
if (filters.complexity) filterDesc.push(`Complexity: ${filters.complexity}`);
|
||
if (filters.trigger) filterDesc.push(`Trigger: ${filters.trigger}`);
|
||
|
||
if (filterDesc.length > 0) {
|
||
title += ` (${filterDesc.join(', ')})`;
|
||
}
|
||
|
||
this.resultsTitle.textContent = title;
|
||
this.resultsCount.textContent = `${this.currentResults.length} workflows found`;
|
||
}
|
||
|
||
loadMoreResults() {
|
||
this.displayResults(false);
|
||
}
|
||
|
||
showLoading(show) {
|
||
this.isLoading = show;
|
||
this.loadingEl.classList.toggle('hidden', !show);
|
||
}
|
||
|
||
showNoResults() {
|
||
this.noResultsEl.classList.remove('hidden');
|
||
this.loadMoreBtn.classList.add('hidden');
|
||
}
|
||
|
||
hideNoResults() {
|
||
this.noResultsEl.classList.add('hidden');
|
||
}
|
||
|
||
showError(message) {
|
||
const errorEl = document.createElement('div');
|
||
errorEl.className = 'error-message';
|
||
errorEl.style.cssText = `
|
||
background: #fed7d7;
|
||
color: #c53030;
|
||
padding: 1rem;
|
||
border-radius: 8px;
|
||
margin: 1rem 0;
|
||
text-align: center;
|
||
`;
|
||
errorEl.textContent = message;
|
||
|
||
this.resultsGrid.innerHTML = '';
|
||
this.resultsGrid.appendChild(errorEl);
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
debounce(func, wait) {
|
||
let timeout;
|
||
return function executedFunction(...args) {
|
||
const later = () => {
|
||
clearTimeout(timeout);
|
||
func(...args);
|
||
};
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(later, wait);
|
||
};
|
||
}
|
||
}
|
||
|
||
// Global functions
|
||
window.copyWorkflowId = function(filename) {
|
||
navigator.clipboard.writeText(filename).then(() => {
|
||
// Show temporary success message
|
||
const btn = event.target;
|
||
const originalText = btn.textContent;
|
||
btn.textContent = '✅ Copied!';
|
||
setTimeout(() => {
|
||
btn.textContent = originalText;
|
||
}, 2000);
|
||
}).catch(() => {
|
||
// Fallback for older browsers
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = filename;
|
||
document.body.appendChild(textArea);
|
||
textArea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textArea);
|
||
|
||
const btn = event.target;
|
||
const originalText = btn.textContent;
|
||
btn.textContent = '✅ Copied!';
|
||
setTimeout(() => {
|
||
btn.textContent = originalText;
|
||
}, 2000);
|
||
});
|
||
};
|
||
|
||
// Initialize search when page loads
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
new WorkflowSearch();
|
||
}); |