mirror of
https://github.com/github/awesome-copilot.git
synced 2026-02-20 18:35:14 +00:00
chore: rename website-astro to website, update gitignore
- Rename website-astro/ to website/ - Add website/dist/ and website/.astro/ to gitignore - Update generate-website-data.mjs output path
This commit is contained in:
34
website/src/scripts/choices.ts
Normal file
34
website/src/scripts/choices.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Choices.js wrapper with sensible defaults
|
||||
*/
|
||||
import Choices from 'choices.js';
|
||||
import 'choices.js/public/assets/styles/choices.min.css';
|
||||
|
||||
export type { Choices };
|
||||
|
||||
/**
|
||||
* Get selected values from a Choices instance
|
||||
*/
|
||||
export function getChoicesValues(choices: Choices): string[] {
|
||||
const val = choices.getValue(true);
|
||||
return Array.isArray(val) ? val : (val ? [val] : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Choices instance with sensible defaults
|
||||
*/
|
||||
export function createChoices(selector: string | HTMLSelectElement, options: Partial<Choices['config']> = {}): Choices {
|
||||
return new Choices(selector, {
|
||||
removeItemButton: true,
|
||||
searchPlaceholderValue: 'Search...',
|
||||
noResultsText: 'No results found',
|
||||
noChoicesText: 'No options available',
|
||||
itemSelectText: '',
|
||||
shouldSort: false,
|
||||
searchResultLimit: 100,
|
||||
resetScrollPosition: false,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export { Choices };
|
||||
7
website/src/scripts/jszip.ts
Normal file
7
website/src/scripts/jszip.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* JSZip entry point for bundling
|
||||
*/
|
||||
import JSZip from 'jszip';
|
||||
|
||||
export { JSZip };
|
||||
export default JSZip;
|
||||
106
website/src/scripts/modal.ts
Normal file
106
website/src/scripts/modal.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Modal functionality for file viewing
|
||||
*/
|
||||
|
||||
import { fetchFileContent, getVSCodeInstallUrl, copyToClipboard, showToast } from './utils';
|
||||
|
||||
// Modal state
|
||||
let currentFilePath: string | null = null;
|
||||
let currentFileContent: string | null = null;
|
||||
let currentFileType: string | null = null;
|
||||
|
||||
/**
|
||||
* Setup modal functionality
|
||||
*/
|
||||
export function setupModal(): void {
|
||||
const modal = document.getElementById('file-modal');
|
||||
const closeBtn = document.getElementById('close-modal');
|
||||
const copyBtn = document.getElementById('copy-btn');
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
copyBtn?.addEventListener('click', async () => {
|
||||
if (currentFileContent) {
|
||||
const success = await copyToClipboard(currentFileContent);
|
||||
showToast(success ? 'Copied to clipboard!' : 'Failed to copy', success ? 'success' : 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open file viewer modal
|
||||
*/
|
||||
export async function openFileModal(filePath: string, type: string): Promise<void> {
|
||||
const modal = document.getElementById('file-modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const contentEl = document.getElementById('modal-content')?.querySelector('code');
|
||||
const installBtn = document.getElementById('install-vscode-btn') as HTMLAnchorElement | null;
|
||||
|
||||
if (!modal || !title || !contentEl) return;
|
||||
|
||||
currentFilePath = filePath;
|
||||
currentFileType = type;
|
||||
|
||||
// Show modal with loading state
|
||||
title.textContent = filePath.split('/').pop() || filePath;
|
||||
contentEl.textContent = 'Loading...';
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Setup install button
|
||||
const installUrl = getVSCodeInstallUrl(type, filePath);
|
||||
if (installUrl && installBtn) {
|
||||
installBtn.href = installUrl;
|
||||
installBtn.style.display = 'inline-flex';
|
||||
} else if (installBtn) {
|
||||
installBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Fetch and display content
|
||||
const fileContent = await fetchFileContent(filePath);
|
||||
currentFileContent = fileContent;
|
||||
|
||||
if (fileContent) {
|
||||
contentEl.textContent = fileContent;
|
||||
} else {
|
||||
contentEl.textContent = 'Failed to load file content. Click the button below to view on GitHub.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close modal
|
||||
*/
|
||||
export function closeModal(): void {
|
||||
const modal = document.getElementById('file-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
currentFilePath = null;
|
||||
currentFileContent = null;
|
||||
currentFileType = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current file path (for external use)
|
||||
*/
|
||||
export function getCurrentFilePath(): string | null {
|
||||
return currentFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current file content (for external use)
|
||||
*/
|
||||
export function getCurrentFileContent(): string | null {
|
||||
return currentFileContent;
|
||||
}
|
||||
174
website/src/scripts/pages/agents.ts
Normal file
174
website/src/scripts/pages/agents.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Agents page functionality
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
|
||||
interface Agent {
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
hasHandoffs?: boolean;
|
||||
}
|
||||
|
||||
interface AgentsData {
|
||||
items: Agent[];
|
||||
filters: {
|
||||
models: string[];
|
||||
tools: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const resourceType = 'agent';
|
||||
let allItems: Agent[] = [];
|
||||
let search = new FuzzySearch();
|
||||
let modelSelect: Choices;
|
||||
let toolSelect: Choices;
|
||||
|
||||
let currentFilters = {
|
||||
models: [] as string[],
|
||||
tools: [] as string[],
|
||||
hasHandoffs: false,
|
||||
};
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const countEl = document.getElementById('results-count');
|
||||
const query = searchInput?.value || '';
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
|
||||
if (currentFilters.models.length > 0) {
|
||||
results = results.filter(item => {
|
||||
if (currentFilters.models.includes('(none)') && !item.model) {
|
||||
return true;
|
||||
}
|
||||
return item.model && currentFilters.models.includes(item.model);
|
||||
});
|
||||
}
|
||||
|
||||
if (currentFilters.tools.length > 0) {
|
||||
results = results.filter(item =>
|
||||
item.tools?.some(tool => currentFilters.tools.includes(tool))
|
||||
);
|
||||
}
|
||||
|
||||
if (currentFilters.hasHandoffs) {
|
||||
results = results.filter(item => item.hasHandoffs);
|
||||
}
|
||||
|
||||
renderItems(results, query);
|
||||
|
||||
const activeFilters: string[] = [];
|
||||
if (currentFilters.models.length > 0) activeFilters.push(`models: ${currentFilters.models.length}`);
|
||||
if (currentFilters.tools.length > 0) activeFilters.push(`tools: ${currentFilters.tools.length}`);
|
||||
if (currentFilters.hasHandoffs) activeFilters.push('has handoffs');
|
||||
|
||||
let countText = `${results.length} of ${allItems.length} agents`;
|
||||
if (activeFilters.length > 0) {
|
||||
countText += ` (filtered by ${activeFilters.join(', ')})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Agent[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No agents found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${item.model ? `<span class="resource-tag tag-model">${escapeHtml(item.model)}</span>` : ''}
|
||||
${item.tools?.slice(0, 3).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
|
||||
${item.tools && item.tools.length > 3 ? `<span class="resource-tag">+${item.tools.length - 3} more</span>` : ''}
|
||||
${item.hasHandoffs ? `<span class="resource-tag tag-handoffs">handoffs</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()">
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initAgentsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const handoffsCheckbox = document.getElementById('filter-handoffs') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
|
||||
const data = await fetchData<AgentsData>('agents.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
search.setItems(allItems);
|
||||
|
||||
// Initialize Choices.js for model filter
|
||||
modelSelect = createChoices('#filter-model', { placeholderValue: 'All Models' });
|
||||
modelSelect.setChoices(data.filters.models.map(m => ({ value: m, label: m })), 'value', 'label', true);
|
||||
document.getElementById('filter-model')?.addEventListener('change', () => {
|
||||
currentFilters.models = getChoicesValues(modelSelect);
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
// Initialize Choices.js for tool filter
|
||||
toolSelect = createChoices('#filter-tool', { placeholderValue: 'All Tools' });
|
||||
toolSelect.setChoices(data.filters.tools.map(t => ({ value: t, label: t })), 'value', 'label', true);
|
||||
document.getElementById('filter-tool')?.addEventListener('change', () => {
|
||||
currentFilters.tools = getChoicesValues(toolSelect);
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
handoffsCheckbox?.addEventListener('change', () => {
|
||||
currentFilters.hasHandoffs = handoffsCheckbox.checked;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { models: [], tools: [], hasHandoffs: false };
|
||||
modelSelect.removeActiveItems();
|
||||
toolSelect.removeActiveItems();
|
||||
if (handoffsCheckbox) handoffsCheckbox.checked = false;
|
||||
if (searchInput) searchInput.value = '';
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
setupModal();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initAgentsPage);
|
||||
144
website/src/scripts/pages/collections.ts
Normal file
144
website/src/scripts/pages/collections.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Collections page functionality
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
tags?: string[];
|
||||
featured?: boolean;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
interface CollectionsData {
|
||||
items: Collection[];
|
||||
filters: {
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const resourceType = 'collection';
|
||||
let allItems: Collection[] = [];
|
||||
let search = new FuzzySearch();
|
||||
let tagSelect: Choices;
|
||||
let currentFilters = {
|
||||
tags: [] as string[],
|
||||
featured: false
|
||||
};
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const countEl = document.getElementById('results-count');
|
||||
const query = searchInput?.value || '';
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
|
||||
if (currentFilters.tags.length > 0) {
|
||||
results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag)));
|
||||
}
|
||||
if (currentFilters.featured) {
|
||||
results = results.filter(item => item.featured);
|
||||
}
|
||||
|
||||
renderItems(results, query);
|
||||
const activeFilters: string[] = [];
|
||||
if (currentFilters.tags.length > 0) activeFilters.push(`${currentFilters.tags.length} tag${currentFilters.tags.length > 1 ? 's' : ''}`);
|
||||
if (currentFilters.featured) activeFilters.push('featured');
|
||||
let countText = `${results.length} of ${allItems.length} collections`;
|
||||
if (activeFilters.length > 0) {
|
||||
countText += ` (filtered by ${activeFilters.join(', ')})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Collection[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state"><h3>No collections found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${item.featured ? '⭐ ' : ''}${query ? search.highlight(item.name, query) : escapeHtml(item.name)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
<span class="resource-tag">${item.itemCount} items</span>
|
||||
${item.tags?.slice(0, 4).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
|
||||
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()">View on GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initCollectionsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const featuredCheckbox = document.getElementById('filter-featured') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
|
||||
const data = await fetchData<CollectionsData>('collections.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
|
||||
// Map collection items to search items
|
||||
const searchItems: SearchItem[] = allItems.map(item => ({
|
||||
...item,
|
||||
title: item.name,
|
||||
searchText: `${item.name} ${item.description} ${item.tags?.join(' ') || ''}`.toLowerCase()
|
||||
}));
|
||||
search.setItems(searchItems);
|
||||
|
||||
tagSelect = createChoices('#filter-tag', { placeholderValue: 'All Tags' });
|
||||
tagSelect.setChoices(data.filters.tags.map(t => ({ value: t, label: t })), 'value', 'label', true);
|
||||
document.getElementById('filter-tag')?.addEventListener('change', () => {
|
||||
currentFilters.tags = getChoicesValues(tagSelect);
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
featuredCheckbox?.addEventListener('change', () => {
|
||||
currentFilters.featured = featuredCheckbox.checked;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { tags: [], featured: false };
|
||||
tagSelect.removeActiveItems();
|
||||
if (featuredCheckbox) featuredCheckbox.checked = false;
|
||||
if (searchInput) searchInput.value = '';
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
setupModal();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initCollectionsPage);
|
||||
136
website/src/scripts/pages/index.ts
Normal file
136
website/src/scripts/pages/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Homepage functionality
|
||||
*/
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, truncate, getResourceIcon } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
|
||||
interface Manifest {
|
||||
counts: {
|
||||
agents: number;
|
||||
prompts: number;
|
||||
instructions: number;
|
||||
skills: number;
|
||||
collections: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
tags?: string[];
|
||||
featured?: boolean;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
interface CollectionsData {
|
||||
items: Collection[];
|
||||
}
|
||||
|
||||
export async function initHomepage(): Promise<void> {
|
||||
// Load manifest for stats
|
||||
const manifest = await fetchData<Manifest>('manifest.json');
|
||||
if (manifest && manifest.counts) {
|
||||
const statsEl = document.getElementById('stats');
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML = `
|
||||
<div class="stat"><span class="stat-value">${manifest.counts.agents}</span><span class="stat-label">Agents</span></div>
|
||||
<div class="stat"><span class="stat-value">${manifest.counts.prompts}</span><span class="stat-label">Prompts</span></div>
|
||||
<div class="stat"><span class="stat-value">${manifest.counts.instructions}</span><span class="stat-label">Instructions</span></div>
|
||||
<div class="stat"><span class="stat-value">${manifest.counts.skills}</span><span class="stat-label">Skills</span></div>
|
||||
<div class="stat"><span class="stat-value">${manifest.counts.collections}</span><span class="stat-label">Collections</span></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load search index
|
||||
const searchIndex = await fetchData<SearchItem[]>('search-index.json');
|
||||
if (searchIndex) {
|
||||
const search = new FuzzySearch();
|
||||
search.setItems(searchIndex);
|
||||
|
||||
const searchInput = document.getElementById('global-search') as HTMLInputElement;
|
||||
const resultsDiv = document.getElementById('search-results');
|
||||
|
||||
if (searchInput && resultsDiv) {
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
const query = searchInput.value.trim();
|
||||
if (query.length < 2) {
|
||||
resultsDiv.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const results = search.search(query).slice(0, 10);
|
||||
if (results.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="search-result-empty">No results found</div>';
|
||||
} else {
|
||||
resultsDiv.innerHTML = results.map(item => `
|
||||
<div class="search-result" data-path="${escapeHtml(item.path)}" data-type="${escapeHtml(item.type)}">
|
||||
<span class="search-result-type">${getResourceIcon(item.type)}</span>
|
||||
<div>
|
||||
<div class="search-result-title">${search.highlight(item.title, query)}</div>
|
||||
<div class="search-result-description">${truncate(item.description, 60)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
resultsDiv.querySelectorAll('.search-result').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
const type = (el as HTMLElement).dataset.type;
|
||||
if (path && type) openFileModal(path, type);
|
||||
});
|
||||
});
|
||||
}
|
||||
resultsDiv.classList.remove('hidden');
|
||||
}, 200));
|
||||
|
||||
// Close results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!searchInput.contains(e.target as Node) && !resultsDiv.contains(e.target as Node)) {
|
||||
resultsDiv.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load featured collections
|
||||
const collectionsData = await fetchData<CollectionsData>('collections.json');
|
||||
if (collectionsData && collectionsData.items) {
|
||||
const featured = collectionsData.items.filter(c => c.featured).slice(0, 6);
|
||||
const featuredEl = document.getElementById('featured-collections');
|
||||
if (featuredEl) {
|
||||
if (featured.length > 0) {
|
||||
featuredEl.innerHTML = featured.map(c => `
|
||||
<div class="card" data-path="${escapeHtml(c.path)}">
|
||||
<h3>${escapeHtml(c.name)}</h3>
|
||||
<p>${escapeHtml(truncate(c.description, 80))}</p>
|
||||
<div class="resource-meta">
|
||||
<span class="resource-tag">${c.itemCount} items</span>
|
||||
${c.tags?.slice(0, 3).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
featuredEl.querySelectorAll('.card').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, 'collection');
|
||||
});
|
||||
});
|
||||
} else {
|
||||
featuredEl.innerHTML = '<p style="text-align: center; color: var(--color-text-muted);">No featured collections yet</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Setup modal
|
||||
setupModal();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initHomepage);
|
||||
124
website/src/scripts/pages/instructions.ts
Normal file
124
website/src/scripts/pages/instructions.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Instructions page functionality
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
|
||||
interface Instruction {
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
applyTo?: string;
|
||||
extensions?: string[];
|
||||
}
|
||||
|
||||
interface InstructionsData {
|
||||
items: Instruction[];
|
||||
filters: {
|
||||
extensions: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const resourceType = 'instruction';
|
||||
let allItems: Instruction[] = [];
|
||||
let search = new FuzzySearch();
|
||||
let extensionSelect: Choices;
|
||||
let currentFilters = { extensions: [] as string[] };
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const countEl = document.getElementById('results-count');
|
||||
const query = searchInput?.value || '';
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
|
||||
if (currentFilters.extensions.length > 0) {
|
||||
results = results.filter(item => {
|
||||
if (currentFilters.extensions.includes('(none)') && (!item.extensions || item.extensions.length === 0)) {
|
||||
return true;
|
||||
}
|
||||
return item.extensions?.some(ext => currentFilters.extensions.includes(ext));
|
||||
});
|
||||
}
|
||||
|
||||
renderItems(results, query);
|
||||
let countText = `${results.length} of ${allItems.length} instructions`;
|
||||
if (currentFilters.extensions.length > 0) {
|
||||
countText += ` (filtered by ${currentFilters.extensions.length} extension${currentFilters.extensions.length > 1 ? 's' : ''})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Instruction[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state"><h3>No instructions found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${item.applyTo ? `<span class="resource-tag">applies to: ${escapeHtml(item.applyTo)}</span>` : ''}
|
||||
${item.extensions?.slice(0, 4).map(e => `<span class="resource-tag tag-extension">${escapeHtml(e)}</span>`).join('') || ''}
|
||||
${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()">View on GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initInstructionsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
|
||||
const data = await fetchData<InstructionsData>('instructions.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
search.setItems(allItems);
|
||||
|
||||
extensionSelect = createChoices('#filter-extension', { placeholderValue: 'All Extensions' });
|
||||
extensionSelect.setChoices(data.filters.extensions.map(e => ({ value: e, label: e })), 'value', 'label', true);
|
||||
document.getElementById('filter-extension')?.addEventListener('change', () => {
|
||||
currentFilters.extensions = getChoicesValues(extensionSelect);
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { extensions: [] };
|
||||
extensionSelect.removeActiveItems();
|
||||
if (searchInput) searchInput.value = '';
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
setupModal();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initInstructionsPage);
|
||||
119
website/src/scripts/pages/prompts.ts
Normal file
119
website/src/scripts/pages/prompts.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Prompts page functionality
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
|
||||
interface Prompt {
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
interface PromptsData {
|
||||
items: Prompt[];
|
||||
filters: {
|
||||
tools: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const resourceType = 'prompt';
|
||||
let allItems: Prompt[] = [];
|
||||
let search = new FuzzySearch();
|
||||
let toolSelect: Choices;
|
||||
let currentFilters = { tools: [] as string[] };
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const countEl = document.getElementById('results-count');
|
||||
const query = searchInput?.value || '';
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
|
||||
if (currentFilters.tools.length > 0) {
|
||||
results = results.filter(item =>
|
||||
item.tools?.some(tool => currentFilters.tools.includes(tool))
|
||||
);
|
||||
}
|
||||
|
||||
renderItems(results, query);
|
||||
let countText = `${results.length} of ${allItems.length} prompts`;
|
||||
if (currentFilters.tools.length > 0) {
|
||||
countText += ` (filtered by ${currentFilters.tools.length} tool${currentFilters.tools.length > 1 ? 's' : ''})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Prompt[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state"><h3>No prompts found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${item.tools?.slice(0, 4).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
|
||||
${item.tools && item.tools.length > 4 ? `<span class="resource-tag">+${item.tools.length - 4} more</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()">View on GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function initPromptsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
|
||||
const data = await fetchData<PromptsData>('prompts.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
search.setItems(allItems);
|
||||
|
||||
toolSelect = createChoices('#filter-tool', { placeholderValue: 'All Tools' });
|
||||
toolSelect.setChoices(data.filters.tools.map(t => ({ value: t, label: t })), 'value', 'label', true);
|
||||
document.getElementById('filter-tool')?.addEventListener('change', () => {
|
||||
currentFilters.tools = getChoicesValues(toolSelect);
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { tools: [] };
|
||||
toolSelect.removeActiveItems();
|
||||
if (searchInput) searchInput.value = '';
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
setupModal();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initPromptsPage);
|
||||
219
website/src/scripts/pages/skills.ts
Normal file
219
website/src/scripts/pages/skills.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Skills page functionality
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl, getRawGitHubUrl } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
import JSZip from '../jszip';
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface Skill {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
skillFile: string;
|
||||
category: string;
|
||||
hasAssets: boolean;
|
||||
assetCount: number;
|
||||
files: SkillFile[];
|
||||
}
|
||||
|
||||
interface SkillsData {
|
||||
items: Skill[];
|
||||
filters: {
|
||||
categories: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const resourceType = 'skill';
|
||||
let allItems: Skill[] = [];
|
||||
let search = new FuzzySearch();
|
||||
let categorySelect: Choices;
|
||||
let currentFilters = {
|
||||
categories: [] as string[],
|
||||
hasAssets: false
|
||||
};
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const countEl = document.getElementById('results-count');
|
||||
const query = searchInput?.value || '';
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
|
||||
if (currentFilters.categories.length > 0) {
|
||||
results = results.filter(item => currentFilters.categories.includes(item.category));
|
||||
}
|
||||
if (currentFilters.hasAssets) {
|
||||
results = results.filter(item => item.hasAssets);
|
||||
}
|
||||
|
||||
renderItems(results, query);
|
||||
const activeFilters: string[] = [];
|
||||
if (currentFilters.categories.length > 0) activeFilters.push(`${currentFilters.categories.length} categor${currentFilters.categories.length > 1 ? 'ies' : 'y'}`);
|
||||
if (currentFilters.hasAssets) activeFilters.push('has assets');
|
||||
let countText = `${results.length} of ${allItems.length} skills`;
|
||||
if (activeFilters.length > 0) {
|
||||
countText += ` (filtered by ${activeFilters.join(', ')})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Skill[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state"><h3>No skills found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.skillFile)}" data-skill-id="${escapeHtml(item.id)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
<span class="resource-tag tag-category">${escapeHtml(item.category)}</span>
|
||||
${item.hasAssets ? `<span class="resource-tag tag-assets">${item.assetCount} asset${item.assetCount === 1 ? '' : 's'}</span>` : ''}
|
||||
<span class="resource-tag">${item.files.length} file${item.files.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<button class="btn btn-primary download-skill-btn" data-skill-id="${escapeHtml(item.id)}" title="Download as ZIP">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/>
|
||||
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/>
|
||||
</svg>
|
||||
Download
|
||||
</button>
|
||||
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()">View Folder</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers for opening modal
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
// Don't trigger modal if clicking download button or github link
|
||||
if ((e.target as HTMLElement).closest('.resource-actions')) return;
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
});
|
||||
|
||||
// Add download handlers
|
||||
list.querySelectorAll('.download-skill-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const skillId = (btn as HTMLElement).dataset.skillId;
|
||||
if (skillId) downloadSkill(skillId, btn as HTMLButtonElement);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise<void> {
|
||||
const skill = allItems.find(item => item.id === skillId);
|
||||
if (!skill || !skill.files || skill.files.length === 0) {
|
||||
alert('No files found for this skill');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalContent = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="spinner" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 1 0 8 8h-1.5A6.5 6.5 0 1 1 8 1.5V0z"/></svg> Preparing...';
|
||||
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(skill.id);
|
||||
|
||||
const fetchPromises = skill.files.map(async (file) => {
|
||||
const url = getRawGitHubUrl(file.path);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const content = await response.text();
|
||||
return { name: file.name, content };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(fetchPromises);
|
||||
let addedFiles = 0;
|
||||
for (const result of results) {
|
||||
if (result && folder) {
|
||||
folder.file(result.name, result.content);
|
||||
addedFiles++;
|
||||
}
|
||||
}
|
||||
|
||||
if (addedFiles === 0) throw new Error('Failed to fetch any files');
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = `${skill.id}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Downloaded!';
|
||||
setTimeout(() => { btn.disabled = false; btn.innerHTML = originalContent; }, 2000);
|
||||
} catch {
|
||||
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 0 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z"/></svg> Failed';
|
||||
setTimeout(() => { btn.disabled = false; btn.innerHTML = originalContent; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initSkillsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const hasAssetsCheckbox = document.getElementById('filter-has-assets') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
|
||||
const data = await fetchData<SkillsData>('skills.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
search.setItems(allItems);
|
||||
|
||||
categorySelect = createChoices('#filter-category', { placeholderValue: 'All Categories' });
|
||||
categorySelect.setChoices(data.filters.categories.map(c => ({ value: c, label: c })), 'value', 'label', true);
|
||||
document.getElementById('filter-category')?.addEventListener('change', () => {
|
||||
currentFilters.categories = getChoicesValues(categorySelect);
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
hasAssetsCheckbox?.addEventListener('change', () => {
|
||||
currentFilters.hasAssets = hasAssetsCheckbox.checked;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { categories: [], hasAssets: false };
|
||||
categorySelect.removeActiveItems();
|
||||
if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false;
|
||||
if (searchInput) searchInput.value = '';
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
setupModal();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initSkillsPage);
|
||||
155
website/src/scripts/search.ts
Normal file
155
website/src/scripts/search.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Fuzzy search implementation for the Awesome Copilot website
|
||||
* Simple substring matching on title and description with scoring
|
||||
*/
|
||||
|
||||
import { escapeHtml, fetchData } from './utils';
|
||||
|
||||
export interface SearchItem {
|
||||
title: string;
|
||||
description?: string;
|
||||
searchText?: string;
|
||||
path: string;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
fields?: string[];
|
||||
limit?: number;
|
||||
minScore?: number;
|
||||
}
|
||||
|
||||
export class FuzzySearch {
|
||||
private items: SearchItem[] = [];
|
||||
|
||||
constructor(items: SearchItem[] = []) {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the items to search
|
||||
*/
|
||||
setItems(items: SearchItem[]): void {
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items with fuzzy matching
|
||||
*/
|
||||
search(query: string, options: SearchOptions = {}): SearchItem[] {
|
||||
const {
|
||||
fields = ['title', 'description', 'searchText'],
|
||||
limit = 50,
|
||||
minScore = 0,
|
||||
} = options;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return this.items.slice(0, limit);
|
||||
}
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
const queryWords = normalizedQuery.split(/\s+/);
|
||||
const results: Array<{ item: SearchItem; score: number }> = [];
|
||||
|
||||
for (const item of this.items) {
|
||||
const score = this.calculateScore(item, queryWords, fields);
|
||||
if (score > minScore) {
|
||||
results.push({ item, score });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
|
||||
return results.slice(0, limit).map(r => r.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match score for an item
|
||||
*/
|
||||
private calculateScore(item: SearchItem, queryWords: string[], fields: string[]): number {
|
||||
let totalScore = 0;
|
||||
|
||||
for (const word of queryWords) {
|
||||
let wordScore = 0;
|
||||
|
||||
for (const field of fields) {
|
||||
const value = item[field];
|
||||
if (!value) continue;
|
||||
|
||||
const normalizedValue = String(value).toLowerCase();
|
||||
|
||||
// Exact match in title gets highest score
|
||||
if (field === 'title' && normalizedValue === word) {
|
||||
wordScore = Math.max(wordScore, 100);
|
||||
}
|
||||
// Title starts with word
|
||||
else if (field === 'title' && normalizedValue.startsWith(word)) {
|
||||
wordScore = Math.max(wordScore, 80);
|
||||
}
|
||||
// Title contains word
|
||||
else if (field === 'title' && normalizedValue.includes(word)) {
|
||||
wordScore = Math.max(wordScore, 60);
|
||||
}
|
||||
// Description contains word
|
||||
else if (field === 'description' && normalizedValue.includes(word)) {
|
||||
wordScore = Math.max(wordScore, 30);
|
||||
}
|
||||
// searchText (includes tags, tools, etc) contains word
|
||||
else if (field === 'searchText' && normalizedValue.includes(word)) {
|
||||
wordScore = Math.max(wordScore, 20);
|
||||
}
|
||||
}
|
||||
|
||||
totalScore += wordScore;
|
||||
}
|
||||
|
||||
// Bonus for matching all words
|
||||
const matchesAllWords = queryWords.every(word =>
|
||||
fields.some(field => {
|
||||
const value = item[field];
|
||||
return value && String(value).toLowerCase().includes(word);
|
||||
})
|
||||
);
|
||||
|
||||
if (matchesAllWords && queryWords.length > 1) {
|
||||
totalScore *= 1.5;
|
||||
}
|
||||
|
||||
return totalScore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matching text in a string
|
||||
*/
|
||||
highlight(text: string, query: string): string {
|
||||
if (!query || !text) return escapeHtml(text || '');
|
||||
|
||||
const normalizedQuery = query.toLowerCase().trim();
|
||||
const words = normalizedQuery.split(/\s+/);
|
||||
let result = escapeHtml(text);
|
||||
|
||||
for (const word of words) {
|
||||
if (word.length < 2) continue;
|
||||
const regex = new RegExp(`(${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
result = result.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Global search instance
|
||||
export const globalSearch = new FuzzySearch();
|
||||
|
||||
/**
|
||||
* Initialize global search with search index
|
||||
*/
|
||||
export async function initGlobalSearch(): Promise<FuzzySearch> {
|
||||
const searchIndex = await fetchData<SearchItem[]>('search-index.json');
|
||||
if (searchIndex) {
|
||||
globalSearch.setItems(searchIndex);
|
||||
}
|
||||
return globalSearch;
|
||||
}
|
||||
62
website/src/scripts/theme.ts
Normal file
62
website/src/scripts/theme.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Theme management for the Awesome Copilot website
|
||||
* Supports light/dark mode with user preference storage
|
||||
*/
|
||||
|
||||
const THEME_KEY = 'theme';
|
||||
|
||||
/**
|
||||
* Get the current theme preference
|
||||
*/
|
||||
function getThemePreference(): 'light' | 'dark' {
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
if (stored === 'light' || stored === 'dark') {
|
||||
return stored;
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
return 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme to the document
|
||||
*/
|
||||
function applyTheme(theme: 'light' | 'dark'): void {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between light and dark theme
|
||||
*/
|
||||
export function toggleTheme(): void {
|
||||
const current = document.documentElement.getAttribute('data-theme') as 'light' | 'dark';
|
||||
const newTheme = current === 'light' ? 'dark' : 'light';
|
||||
applyTheme(newTheme);
|
||||
localStorage.setItem(THEME_KEY, newTheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize theme toggle button
|
||||
*/
|
||||
export function initThemeToggle(): void {
|
||||
const toggleBtn = document.getElementById('theme-toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', toggleTheme);
|
||||
}
|
||||
|
||||
// Listen for system theme changes
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
|
||||
// Only auto-switch if user hasn't set a preference
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
if (!stored) {
|
||||
applyTheme(e.matches ? 'light' : 'dark');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initThemeToggle);
|
||||
190
website/src/scripts/utils.ts
Normal file
190
website/src/scripts/utils.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Utility functions for the Awesome Copilot website
|
||||
*/
|
||||
|
||||
const REPO_BASE_URL = 'https://raw.githubusercontent.com/github/awesome-copilot/main';
|
||||
const REPO_GITHUB_URL = 'https://github.com/github/awesome-copilot/blob/main';
|
||||
|
||||
// VS Code install URL template
|
||||
const VSCODE_INSTALL_URLS: Record<string, string> = {
|
||||
instructions: 'https://aka.ms/awesome-copilot/install/instructions',
|
||||
prompt: 'https://aka.ms/awesome-copilot/install/prompt',
|
||||
agent: 'https://aka.ms/awesome-copilot/install/agent',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the base path for the site
|
||||
*/
|
||||
export function getBasePath(): string {
|
||||
// In Astro, import.meta.env.BASE_URL is available at build time
|
||||
// At runtime, we use a data attribute on the body
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.body.dataset.basePath || '/';
|
||||
}
|
||||
return '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON data from the data directory
|
||||
*/
|
||||
export async function fetchData<T = unknown>(filename: string): Promise<T | null> {
|
||||
try {
|
||||
const basePath = getBasePath();
|
||||
const response = await fetch(`${basePath}data/${filename}`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${filename}`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw file content from GitHub
|
||||
*/
|
||||
export async function fetchFileContent(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${filePath}`);
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching file content:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate VS Code install URL
|
||||
*/
|
||||
export function getVSCodeInstallUrl(type: string, filePath: string): string | null {
|
||||
const baseUrl = VSCODE_INSTALL_URLS[type];
|
||||
if (!baseUrl) return null;
|
||||
return `${baseUrl}?url=${encodeURIComponent(`${REPO_BASE_URL}/${filePath}`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub URL for a file
|
||||
*/
|
||||
export function getGitHubUrl(filePath: string): string {
|
||||
return `${REPO_GITHUB_URL}/${filePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw GitHub URL for a file (for fetching content)
|
||||
*/
|
||||
export function getRawGitHubUrl(filePath: string): string {
|
||||
return `${REPO_BASE_URL}/${filePath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification
|
||||
*/
|
||||
export function showToast(message: string, type: 'success' | 'error' = 'success'): void {
|
||||
const existing = document.querySelector('.toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce function for search input
|
||||
*/
|
||||
export function debounce<T extends (...args: unknown[]) => void>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return function executedFunction(...args: Parameters<T>) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
export function truncate(text: string | undefined, maxLength: number): string {
|
||||
if (!text || text.length <= maxLength) return text || '';
|
||||
return text.slice(0, maxLength).trim() + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get resource type from file path
|
||||
*/
|
||||
export function getResourceType(filePath: string): string {
|
||||
if (filePath.endsWith('.agent.md')) return 'agent';
|
||||
if (filePath.endsWith('.prompt.md')) return 'prompt';
|
||||
if (filePath.endsWith('.instructions.md')) return 'instruction';
|
||||
if (filePath.includes('/skills/') && filePath.endsWith('SKILL.md')) return 'skill';
|
||||
if (filePath.endsWith('.collection.yml')) return 'collection';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a resource type for display
|
||||
*/
|
||||
export function formatResourceType(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
agent: '🤖 Agent',
|
||||
prompt: '🎯 Prompt',
|
||||
instruction: '📋 Instruction',
|
||||
skill: '⚡ Skill',
|
||||
collection: '📦 Collection',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for resource type
|
||||
*/
|
||||
export function getResourceIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
agent: '🤖',
|
||||
prompt: '🎯',
|
||||
instruction: '📋',
|
||||
skill: '⚡',
|
||||
collection: '📦',
|
||||
};
|
||||
return icons[type] || '📄';
|
||||
}
|
||||
Reference in New Issue
Block a user