mirror of
https://github.com/github/awesome-copilot.git
synced 2026-05-06 15:12:12 +00:00
Simplify website search and listing controls (#1553)
* Removing search from the home pageThis was a little confusing because there are two searches, but the overall site search is a lot more powerful * Prefilter website search by resource page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * small error handling and formatting * Simplify website listing controls Remove per-page text search, trim page-specific controls, and move remaining sort/filter controls into compact flyouts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__awesomeCopilotListingFlyoutsInitialized?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const FLYOUT_SELECTOR = '.listing-controls';
|
||||
|
||||
function closeFlyouts(except?: HTMLDetailsElement): void {
|
||||
document.querySelectorAll<HTMLDetailsElement>(FLYOUT_SELECTOR).forEach((flyout) => {
|
||||
if (flyout !== except) {
|
||||
flyout.open = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function initListingFlyouts(): void {
|
||||
if (window.__awesomeCopilotListingFlyoutsInitialized) return;
|
||||
|
||||
document.addEventListener(
|
||||
'toggle',
|
||||
(event) => {
|
||||
const flyout = event.target;
|
||||
if (!(flyout instanceof HTMLDetailsElement) || !flyout.matches(FLYOUT_SELECTOR) || !flyout.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeFlyouts(flyout);
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest(FLYOUT_SELECTOR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeFlyouts();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
|
||||
const activeFlyout = document.activeElement instanceof Element
|
||||
? (document.activeElement.closest(FLYOUT_SELECTOR) as HTMLDetailsElement | null)
|
||||
: null;
|
||||
|
||||
closeFlyouts();
|
||||
|
||||
const summary = activeFlyout?.querySelector('summary');
|
||||
if (summary instanceof HTMLElement) {
|
||||
summary.focus();
|
||||
}
|
||||
});
|
||||
|
||||
window.__awesomeCopilotListingFlyoutsInitialized = true;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initListingFlyouts, { once: true });
|
||||
} else {
|
||||
initListingFlyouts();
|
||||
}
|
||||
@@ -35,36 +35,23 @@ export function sortAgents<T extends RenderableAgent>(
|
||||
});
|
||||
}
|
||||
|
||||
export function renderAgentsHtml(
|
||||
items: RenderableAgent[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
export function renderAgentsHtml(items: RenderableAgent[]): string {
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No agents found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
<p>No agents are available right now.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem">
|
||||
<button type="button" class="resource-preview">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-title">${escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
|
||||
@@ -1,109 +1,48 @@
|
||||
/**
|
||||
* Agents page functionality
|
||||
*/
|
||||
import {
|
||||
createChoices,
|
||||
getChoicesValues,
|
||||
setChoicesValues,
|
||||
type Choices,
|
||||
} from '../choices';
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
getQueryParam,
|
||||
getQueryParamFlag,
|
||||
getQueryParamValues,
|
||||
setupDropdownCloseHandlers,
|
||||
setupActionHandlers,
|
||||
updateQueryParams,
|
||||
} from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
import { renderAgentsHtml, sortAgents, type AgentSortOption, type RenderableAgent } from './agents-render';
|
||||
import {
|
||||
renderAgentsHtml,
|
||||
sortAgents,
|
||||
type AgentSortOption,
|
||||
type RenderableAgent,
|
||||
} from './agents-render';
|
||||
|
||||
interface Agent extends SearchItem, RenderableAgent {
|
||||
model?: string | string[];
|
||||
tools?: string[];
|
||||
hasHandoffs?: boolean;
|
||||
interface Agent extends RenderableAgent {
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
interface AgentsData {
|
||||
items: Agent[];
|
||||
filters: {
|
||||
models: string[];
|
||||
tools: string[];
|
||||
};
|
||||
}
|
||||
|
||||
let allItems: Agent[] = [];
|
||||
let search = new FuzzySearch<Agent>();
|
||||
let modelSelect: Choices;
|
||||
let toolSelect: Choices;
|
||||
let currentSort: AgentSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
let currentFilters = {
|
||||
models: [] as string[],
|
||||
tools: [] as string[],
|
||||
hasHandoffs: false,
|
||||
};
|
||||
|
||||
function sortItems(items: Agent[]): Agent[] {
|
||||
return sortAgents(items, currentSort);
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const countEl = document.getElementById('results-count');
|
||||
const query = searchInput?.value || '';
|
||||
const results = sortAgents(allItems, currentSort);
|
||||
|
||||
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 && (Array.isArray(item.model) ? item.model.some(m => currentFilters.models.includes(m)) : currentFilters.models.includes(item.model));
|
||||
});
|
||||
renderItems(results);
|
||||
if (countEl) {
|
||||
countEl.textContent = `${results.length} agent${results.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
results = sortItems(results);
|
||||
|
||||
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 {
|
||||
function renderItems(items: Agent[]): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = renderAgentsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
|
||||
});
|
||||
list.innerHTML = renderAgentsHtml(items);
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
@@ -125,21 +64,18 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: searchInput?.value ?? '',
|
||||
model: currentFilters.models,
|
||||
tool: currentFilters.tools,
|
||||
handoffs: currentFilters.hasHandoffs,
|
||||
q: '',
|
||||
model: [],
|
||||
tool: [],
|
||||
handoffs: false,
|
||||
sort: currentSort === 'title' ? '' : currentSort,
|
||||
});
|
||||
}
|
||||
|
||||
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 sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
@@ -151,84 +87,17 @@ export async function initAgentsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const initialQuery = getQueryParam('q');
|
||||
const initialModels = getQueryParamValues('model').filter(model => data.filters.models.includes(model));
|
||||
const initialTools = getQueryParamValues('tool').filter(tool => data.filters.tools.includes(tool));
|
||||
const initialSort = getQueryParam('sort');
|
||||
|
||||
if (searchInput) searchInput.value = initialQuery;
|
||||
if (initialModels.length > 0) {
|
||||
currentFilters.models = initialModels;
|
||||
setChoicesValues(modelSelect, initialModels);
|
||||
}
|
||||
|
||||
document.getElementById('filter-model')?.addEventListener('change', () => {
|
||||
currentFilters.models = getChoicesValues(modelSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
// 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);
|
||||
if (initialTools.length > 0) {
|
||||
currentFilters.tools = initialTools;
|
||||
setChoicesValues(toolSelect, initialTools);
|
||||
}
|
||||
document.getElementById('filter-tool')?.addEventListener('change', () => {
|
||||
currentFilters.tools = getChoicesValues(toolSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
// Initialize sort select
|
||||
if (initialSort === 'lastUpdated') {
|
||||
currentSort = initialSort;
|
||||
if (sortSelect) sortSelect.value = initialSort;
|
||||
}
|
||||
|
||||
sortSelect?.addEventListener('change', () => {
|
||||
currentSort = sortSelect.value as AgentSortOption;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
const countEl = document.getElementById('results-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} agents`;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', debounce(() => {
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
}, 200));
|
||||
|
||||
if (getQueryParamFlag('handoffs')) {
|
||||
currentFilters.hasHandoffs = true;
|
||||
if (handoffsCheckbox) handoffsCheckbox.checked = true;
|
||||
}
|
||||
|
||||
handoffsCheckbox?.addEventListener('change', () => {
|
||||
currentFilters.hasHandoffs = handoffsCheckbox.checked;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { models: [], tools: [], hasHandoffs: false };
|
||||
currentSort = 'title';
|
||||
modelSelect.removeActiveItems();
|
||||
toolSelect.removeActiveItems();
|
||||
if (handoffsCheckbox) handoffsCheckbox.checked = false;
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (sortSelect) sortSelect.value = 'title';
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
|
||||
@@ -33,38 +33,25 @@ export function sortHooks<T extends RenderableHook>(
|
||||
});
|
||||
}
|
||||
|
||||
export function renderHooksHtml(
|
||||
items: RenderableHook[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
export function renderHooksHtml(items: RenderableHook[]): string {
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No hooks found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
<p>Try adjusting the selected filters.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<article class="resource-item" data-path="${escapeHtml(
|
||||
item.readmeFile
|
||||
)}" data-hook-id="${escapeHtml(item.id)}" role="listitem">
|
||||
<button type="button" class="resource-preview">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-title">${escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
setChoicesValues,
|
||||
type Choices,
|
||||
} from "../choices";
|
||||
import { FuzzySearch, type SearchItem } from "../search";
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
showToast,
|
||||
@@ -25,23 +23,19 @@ import {
|
||||
type RenderableHook,
|
||||
} from "./hooks-render";
|
||||
|
||||
interface Hook extends SearchItem, RenderableHook {}
|
||||
interface Hook extends RenderableHook {}
|
||||
|
||||
interface HooksData {
|
||||
items: Hook[];
|
||||
filters: {
|
||||
hooks: string[];
|
||||
tags: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const resourceType = "hook";
|
||||
let allItems: Hook[] = [];
|
||||
let search = new FuzzySearch<Hook>();
|
||||
let hookSelect: Choices;
|
||||
let tagSelect: Choices;
|
||||
let currentFilters = {
|
||||
hooks: [] as string[],
|
||||
tags: [] as string[],
|
||||
};
|
||||
let currentSort: HookSortOption = "title";
|
||||
@@ -52,57 +46,30 @@ function sortItems(items: Hook[]): Hook[] {
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
const countEl = document.getElementById("results-count");
|
||||
const query = searchInput?.value || "";
|
||||
let results = [...allItems];
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
|
||||
if (currentFilters.hooks.length > 0) {
|
||||
results = results.filter((item) =>
|
||||
item.hooks.some((h) => currentFilters.hooks.includes(h))
|
||||
);
|
||||
}
|
||||
if (currentFilters.tags.length > 0) {
|
||||
results = results.filter((item) =>
|
||||
item.tags.some((t) => currentFilters.tags.includes(t))
|
||||
item.tags.some((tag) => currentFilters.tags.includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
results = sortItems(results);
|
||||
|
||||
renderItems(results, query);
|
||||
const activeFilters: string[] = [];
|
||||
if (currentFilters.hooks.length > 0)
|
||||
activeFilters.push(
|
||||
`${currentFilters.hooks.length} hook event${
|
||||
currentFilters.hooks.length > 1 ? "s" : ""
|
||||
}`
|
||||
);
|
||||
if (currentFilters.tags.length > 0)
|
||||
activeFilters.push(
|
||||
`${currentFilters.tags.length} tag${
|
||||
currentFilters.tags.length > 1 ? "s" : ""
|
||||
}`
|
||||
);
|
||||
let countText = `${results.length} of ${allItems.length} hooks`;
|
||||
if (activeFilters.length > 0) {
|
||||
countText += ` (filtered by ${activeFilters.join(", ")})`;
|
||||
renderItems(results);
|
||||
let countText = `${results.length} hook${results.length === 1 ? "" : "s"}`;
|
||||
if (currentFilters.tags.length > 0) {
|
||||
countText = `${results.length} of ${allItems.length} hooks (filtered by ${currentFilters.tags.length} tag${currentFilters.tags.length > 1 ? "s" : ""})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Hook[], query = ""): void {
|
||||
function renderItems(items: Hook[]): void {
|
||||
const list = document.getElementById("resource-list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = renderHooksHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
list.innerHTML = renderHooksHtml(items);
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
@@ -134,10 +101,10 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: searchInput?.value ?? "",
|
||||
hook: currentFilters.hooks,
|
||||
q: "",
|
||||
hook: [],
|
||||
tag: currentFilters.tags,
|
||||
sort: currentSort === "title" ? "" : currentSort,
|
||||
});
|
||||
@@ -153,12 +120,11 @@ async function downloadHook(
|
||||
return;
|
||||
}
|
||||
|
||||
// Build file list: README.md + all assets
|
||||
const files = [
|
||||
{ name: "README.md", path: hook.readmeFile },
|
||||
...hook.assets.map((a) => ({
|
||||
name: a,
|
||||
path: `${hook.path}/${a}`,
|
||||
...hook.assets.map((asset) => ({
|
||||
name: asset,
|
||||
path: `${hook.path}/${asset}`,
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -196,9 +162,6 @@ async function downloadHook(
|
||||
|
||||
export async function initHooksPage(): Promise<void> {
|
||||
const list = document.getElementById("resource-list");
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById("clear-filters");
|
||||
const sortSelect = document.getElementById(
|
||||
"sort-select"
|
||||
@@ -215,90 +178,53 @@ export async function initHooksPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
search.setItems(allItems);
|
||||
|
||||
// Setup hook event filter
|
||||
hookSelect = createChoices("#filter-hook", {
|
||||
placeholderValue: "All Events",
|
||||
tagSelect = createChoices("#filter-tag", {
|
||||
placeholderValue: "All Tags",
|
||||
});
|
||||
hookSelect.setChoices(
|
||||
data.filters.hooks.map((h) => ({ value: h, label: h })),
|
||||
tagSelect.setChoices(
|
||||
data.filters.tags.map((tag) => ({ value: tag, label: tag })),
|
||||
"value",
|
||||
"label",
|
||||
true
|
||||
);
|
||||
|
||||
const initialQuery = getQueryParam("q");
|
||||
const initialHooks = getQueryParamValues("hook").filter((hook) =>
|
||||
data.filters.hooks.includes(hook)
|
||||
);
|
||||
const initialTags = getQueryParamValues("tag").filter((tag) =>
|
||||
data.filters.tags.includes(tag)
|
||||
);
|
||||
const initialSort = getQueryParam("sort");
|
||||
|
||||
if (searchInput) searchInput.value = initialQuery;
|
||||
if (initialHooks.length > 0) {
|
||||
currentFilters.hooks = initialHooks;
|
||||
setChoicesValues(hookSelect, initialHooks);
|
||||
}
|
||||
|
||||
document.getElementById("filter-hook")?.addEventListener("change", () => {
|
||||
currentFilters.hooks = getChoicesValues(hookSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
// Setup tag filter
|
||||
tagSelect = createChoices("#filter-tag", {
|
||||
placeholderValue: "All Tags",
|
||||
});
|
||||
tagSelect.setChoices(
|
||||
data.filters.tags.map((t) => ({ value: t, label: t })),
|
||||
"value",
|
||||
"label",
|
||||
true
|
||||
);
|
||||
if (initialTags.length > 0) {
|
||||
currentFilters.tags = initialTags;
|
||||
setChoicesValues(tagSelect, initialTags);
|
||||
}
|
||||
document.getElementById("filter-tag")?.addEventListener("change", () => {
|
||||
currentFilters.tags = getChoicesValues(tagSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
if (initialSort === "lastUpdated") {
|
||||
currentSort = initialSort;
|
||||
if (sortSelect) sortSelect.value = initialSort;
|
||||
}
|
||||
|
||||
document.getElementById("filter-tag")?.addEventListener("change", () => {
|
||||
currentFilters.tags = getChoicesValues(tagSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as HookSortOption;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener("click", () => {
|
||||
currentFilters = { tags: [] };
|
||||
currentSort = "title";
|
||||
tagSelect.removeActiveItems();
|
||||
if (sortSelect) sortSelect.value = "title";
|
||||
applyFiltersAndRender();
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
searchInput?.addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
}, 200)
|
||||
);
|
||||
|
||||
clearFiltersBtn?.addEventListener("click", () => {
|
||||
currentFilters = { hooks: [], tags: [] };
|
||||
currentSort = "title";
|
||||
hookSelect.removeActiveItems();
|
||||
tagSelect.removeActiveItems();
|
||||
if (searchInput) searchInput.value = "";
|
||||
if (sortSelect) sortSelect.value = "title";
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
setupModal();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,7 @@
|
||||
/**
|
||||
* Homepage functionality
|
||||
*/
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, truncate, getResourceIcon } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
|
||||
// SVG icon definitions for search results
|
||||
// Icons with `fill: true` use fill="currentColor", others use stroke
|
||||
const iconDefs: Record<string, { path: string; fill?: boolean }> = {
|
||||
// Agent icon - GitHub Primer's agent-24
|
||||
robot: {
|
||||
fill: true,
|
||||
path: '<path d="M22.5 13.919v-.278a5.097 5.097 0 0 0-4.961-5.086.858.858 0 0 1-.754-.497l-.149-.327A6.414 6.414 0 0 0 10.81 4a6.133 6.133 0 0 0-6.13 6.32l.019.628a.863.863 0 0 1-.67.869A3.263 3.263 0 0 0 1.5 14.996v.108A3.397 3.397 0 0 0 4.896 18.5h1.577a.75.75 0 0 1 0 1.5H4.896A4.896 4.896 0 0 1 0 15.104v-.108a4.761 4.761 0 0 1 3.185-4.493l-.004-.137A7.633 7.633 0 0 1 10.81 2.5a7.911 7.911 0 0 1 7.176 4.58C21.36 7.377 24 10.207 24 13.641v.278a.75.75 0 0 1-1.5 0Z"/><path d="m12.306 11.77 3.374 3.375a.749.749 0 0 1 0 1.061l-3.375 3.375-.057.051a.751.751 0 0 1-1.004-.051.751.751 0 0 1-.051-1.004l.051-.057 2.845-2.845-2.844-2.844a.75.75 0 1 1 1.061-1.061ZM22.5 19.8H18a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 0 1.5Z"/>'
|
||||
},
|
||||
document: {
|
||||
path: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
},
|
||||
lightning: {
|
||||
path: '<path d="M13 2 4.09 12.11a1.23 1.23 0 0 0 .13 1.72l.16.14a1.23 1.23 0 0 0 1.52 0L13 9.5V22l8.91-10.11a1.23 1.23 0 0 0-.13-1.72l-.16-.14a1.23 1.23 0 0 0-1.52 0L13 14.5V2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
},
|
||||
// Hook icon - GitHub Primer's sync-24
|
||||
hook: {
|
||||
fill: true,
|
||||
path: '<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>'
|
||||
},
|
||||
// Workflow icon - GitHub Primer's workflow-24
|
||||
workflow: {
|
||||
fill: true,
|
||||
path: '<path d="M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5ZM14.5 14a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z"/>'
|
||||
},
|
||||
// Plug icon - GitHub Primer's plug-24
|
||||
plug: {
|
||||
fill: true,
|
||||
path: '<path d="M7 11.5H2.938c-.794 0-1.438.644-1.438 1.437v8.313a.75.75 0 0 1-1.5 0v-8.312A2.939 2.939 0 0 1 2.937 10H7V6.151c0-.897.678-1.648 1.57-1.74l6.055-.626 1.006-1.174A1.752 1.752 0 0 1 16.96 2h1.29c.966 0 1.75.784 1.75 1.75V6h3.25a.75.75 0 0 1 0 1.5H20V14h3.25a.75.75 0 0 1 0 1.5H20v2.25a1.75 1.75 0 0 1-1.75 1.75h-1.29a1.75 1.75 0 0 1-1.329-.611l-1.006-1.174-6.055-.627A1.749 1.749 0 0 1 7 15.348Zm9.77-7.913v.001l-1.201 1.4a.75.75 0 0 1-.492.258l-6.353.657a.25.25 0 0 0-.224.249v9.196a.25.25 0 0 0 .224.249l6.353.657c.191.02.368.112.493.258l1.2 1.401a.252.252 0 0 0 .19.087h1.29a.25.25 0 0 0 .25-.25v-14a.25.25 0 0 0-.25-.25h-1.29a.252.252 0 0 0-.19.087Z"/>'
|
||||
},
|
||||
wrench: {
|
||||
path: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
}
|
||||
};
|
||||
|
||||
function getIconSvg(iconName: string): string {
|
||||
const icon = iconDefs[iconName] || iconDefs.document;
|
||||
const fill = icon.fill ? 'fill="currentColor"' : 'fill="none"';
|
||||
return `<svg viewBox="0 0 24 24" ${fill} aria-hidden="true">${icon.path}</svg>`;
|
||||
}
|
||||
import { fetchData } from "../utils";
|
||||
|
||||
interface Manifest {
|
||||
counts: {
|
||||
@@ -57,301 +15,30 @@ interface Manifest {
|
||||
};
|
||||
}
|
||||
|
||||
interface Plugin {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
tags?: string[];
|
||||
featured?: boolean;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
interface PluginsData {
|
||||
items: Plugin[];
|
||||
}
|
||||
|
||||
// Recent searches storage
|
||||
const RECENT_SEARCHES_KEY = 'awesome-copilot-recent-searches';
|
||||
const MAX_RECENT_SEARCHES = 5;
|
||||
|
||||
function getRecentSearches(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(RECENT_SEARCHES_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addRecentSearch(query: string): void {
|
||||
if (!query.trim()) return;
|
||||
const searches = getRecentSearches();
|
||||
const filtered = searches.filter(s => s.toLowerCase() !== query.toLowerCase());
|
||||
filtered.unshift(query);
|
||||
const limited = filtered.slice(0, MAX_RECENT_SEARCHES);
|
||||
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(limited));
|
||||
}
|
||||
|
||||
function removeRecentSearch(query: string): void {
|
||||
const searches = getRecentSearches();
|
||||
const filtered = searches.filter(s => s !== query);
|
||||
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(filtered));
|
||||
}
|
||||
|
||||
function clearRecentSearches(): void {
|
||||
localStorage.removeItem(RECENT_SEARCHES_KEY);
|
||||
}
|
||||
|
||||
export async function initHomepage(): Promise<void> {
|
||||
// Load manifest for stats
|
||||
const manifest = await fetchData<Manifest>('manifest.json');
|
||||
const manifest = await fetchData<Manifest>("manifest.json");
|
||||
if (manifest && manifest.counts) {
|
||||
// Populate counts in cards
|
||||
const countKeys = ['agents', 'instructions', 'skills', 'hooks', 'workflows', 'plugins', 'tools'] as const;
|
||||
countKeys.forEach(key => {
|
||||
const countEl = document.querySelector(`.card-count[data-count="${key}"]`);
|
||||
const countKeys = [
|
||||
"agents",
|
||||
"instructions",
|
||||
"skills",
|
||||
"hooks",
|
||||
"workflows",
|
||||
"plugins",
|
||||
"tools",
|
||||
] as const;
|
||||
countKeys.forEach((key) => {
|
||||
const countEl = document.querySelector(
|
||||
`.card-count[data-count="${key}"]`
|
||||
);
|
||||
if (countEl && manifest.counts[key] !== undefined) {
|
||||
countEl.textContent = manifest.counts[key].toString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load search index
|
||||
const searchIndex = await fetchData<SearchItem[]>('search-index.json');
|
||||
if (searchIndex) {
|
||||
const search = new FuzzySearch<SearchItem>();
|
||||
search.setItems(searchIndex);
|
||||
|
||||
const searchInput = document.getElementById('global-search') as HTMLInputElement;
|
||||
const resultsDiv = document.getElementById('search-results');
|
||||
|
||||
if (searchInput && resultsDiv) {
|
||||
const statusEl = document.getElementById("global-search-status");
|
||||
let isShowingRecent = false;
|
||||
|
||||
const hideResults = (): void => {
|
||||
resultsDiv.classList.add("hidden");
|
||||
isShowingRecent = false;
|
||||
};
|
||||
|
||||
const showResults = (): void => {
|
||||
resultsDiv.classList.remove("hidden");
|
||||
};
|
||||
|
||||
const getResultButtons = (): HTMLButtonElement[] =>
|
||||
Array.from(
|
||||
resultsDiv.querySelectorAll<HTMLButtonElement>(".search-result, .search-recent-item")
|
||||
);
|
||||
|
||||
const openResult = (resultEl: HTMLElement): void => {
|
||||
const path = resultEl.dataset.path;
|
||||
const type = resultEl.dataset.type;
|
||||
if (path && type) {
|
||||
hideResults();
|
||||
openFileModal(path, type);
|
||||
}
|
||||
};
|
||||
|
||||
// Render recent searches
|
||||
const renderRecentSearches = (): void => {
|
||||
const recent = getRecentSearches();
|
||||
if (recent.length === 0) return;
|
||||
|
||||
const clockIcon = `<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
|
||||
const xIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="search-recent-header">
|
||||
<span>Recent Searches</span>
|
||||
<button class="search-clear-recent" aria-label="Clear recent searches">Clear</button>
|
||||
</div>
|
||||
${recent.map(query => `
|
||||
<button type="button" class="search-recent-item" data-query="${escapeHtml(query)}">
|
||||
<span class="search-recent-icon">${clockIcon}</span>
|
||||
<span class="search-recent-text">${escapeHtml(query)}</span>
|
||||
<button type="button" class="search-recent-remove" data-query="${escapeHtml(query)}" aria-label="Remove from history">
|
||||
${xIcon}
|
||||
</button>
|
||||
</button>
|
||||
`).join('')}
|
||||
`;
|
||||
|
||||
// Add click handlers for recent items
|
||||
resultsDiv.querySelectorAll('.search-recent-item').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.search-recent-remove')) return;
|
||||
const query = (item as HTMLElement).dataset.query;
|
||||
if (query) {
|
||||
searchInput.value = query;
|
||||
searchInput.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add click handlers for remove buttons
|
||||
resultsDiv.querySelectorAll('.search-recent-remove').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const query = (btn as HTMLElement).dataset.query;
|
||||
if (query) {
|
||||
removeRecentSearch(query);
|
||||
renderRecentSearches();
|
||||
if (getRecentSearches().length === 0) {
|
||||
hideResults();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add clear all handler
|
||||
const clearBtn = resultsDiv.querySelector('.search-clear-recent');
|
||||
clearBtn?.addEventListener('click', () => {
|
||||
clearRecentSearches();
|
||||
hideResults();
|
||||
});
|
||||
|
||||
isShowingRecent = true;
|
||||
showResults();
|
||||
};
|
||||
|
||||
// Show recent searches on focus when empty
|
||||
searchInput.addEventListener('focus', () => {
|
||||
if (searchInput.value.trim().length === 0) {
|
||||
renderRecentSearches();
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
const query = searchInput.value.trim();
|
||||
if (query.length < 2) {
|
||||
if (query.length === 0) {
|
||||
renderRecentSearches();
|
||||
} else {
|
||||
resultsDiv.innerHTML = '';
|
||||
hideResults();
|
||||
}
|
||||
if (statusEl) {
|
||||
statusEl.textContent = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isShowingRecent = false;
|
||||
const results = search.search(query).slice(0, 10);
|
||||
if (results.length === 0) {
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="search-result-empty">
|
||||
<div class="search-result-empty-icon">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
<path d="M8 8l6 6M14 8l-6 6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="search-result-empty-title">No results found</div>
|
||||
<div class="search-result-empty-hint">Try different keywords or check your spelling</div>
|
||||
</div>
|
||||
`;
|
||||
if (statusEl) {
|
||||
statusEl.textContent = 'No results found.';
|
||||
}
|
||||
} else {
|
||||
// Add to recent searches when user gets results
|
||||
addRecentSearch(query);
|
||||
|
||||
resultsDiv.innerHTML = results.map(item => {
|
||||
const iconName = getResourceIcon(item.type);
|
||||
return `
|
||||
<button type="button" class="search-result" data-path="${escapeHtml(item.path)}" data-type="${escapeHtml(item.type)}">
|
||||
<span class="search-result-type" data-icon="${iconName}">${getIconSvg(iconName)}</span>
|
||||
<div>
|
||||
<div class="search-result-title">${search.highlight(item.title, query)}</div>
|
||||
<div class="search-result-description">${truncate(item.description, 60)}</div>
|
||||
</div>
|
||||
</button>
|
||||
`}).join('');
|
||||
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `${results.length} result${results.length === 1 ? '' : 's'} available.`;
|
||||
}
|
||||
|
||||
getResultButtons().forEach((el, index, buttons) => {
|
||||
el.addEventListener('click', () => {
|
||||
openResult(el);
|
||||
});
|
||||
|
||||
el.addEventListener("keydown", (event) => {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
buttons[(index + 1) % buttons.length]?.focus();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (index === 0) {
|
||||
searchInput.focus();
|
||||
} else {
|
||||
buttons[index - 1]?.focus();
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
buttons[0]?.focus();
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
buttons[buttons.length - 1]?.focus();
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
hideResults();
|
||||
searchInput.focus();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showResults();
|
||||
}, 200));
|
||||
|
||||
searchInput.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
const firstResult = getResultButtons()[0];
|
||||
if (firstResult) {
|
||||
event.preventDefault();
|
||||
firstResult.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
hideResults();
|
||||
}
|
||||
});
|
||||
|
||||
// Close results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!searchInput.contains(e.target as Node) && !resultsDiv.contains(e.target as Node)) {
|
||||
hideResults();
|
||||
}
|
||||
});
|
||||
|
||||
// Cmd/Ctrl + K to focus search
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Setup modal
|
||||
setupModal();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', initHomepage);
|
||||
document.addEventListener("DOMContentLoaded", initHomepage);
|
||||
|
||||
@@ -33,19 +33,13 @@ export function sortInstructions<T extends RenderableInstruction>(
|
||||
}
|
||||
|
||||
export function renderInstructionsHtml(
|
||||
items: RenderableInstruction[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
items: RenderableInstruction[]
|
||||
): string {
|
||||
const { query = '', highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No instructions found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
<p>Try adjusting the selected filters.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -55,16 +49,12 @@ export function renderInstructionsHtml(
|
||||
const applyToText = Array.isArray(item.applyTo)
|
||||
? item.applyTo.join(', ')
|
||||
: item.applyTo;
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem">
|
||||
<button type="button" class="resource-preview">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-title">${escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${applyToText ? `<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>` : ''}
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
setChoicesValues,
|
||||
type Choices,
|
||||
} from '../choices';
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
setupDropdownCloseHandlers,
|
||||
@@ -25,7 +23,7 @@ import {
|
||||
type RenderableInstruction,
|
||||
} from './instructions-render';
|
||||
|
||||
interface Instruction extends SearchItem, RenderableInstruction {
|
||||
interface Instruction extends RenderableInstruction {
|
||||
path: string;
|
||||
applyTo?: string | string[];
|
||||
extensions?: string[];
|
||||
@@ -41,7 +39,6 @@ interface InstructionsData {
|
||||
|
||||
const resourceType = 'instruction';
|
||||
let allItems: Instruction[] = [];
|
||||
let search = new FuzzySearch<Instruction>();
|
||||
let extensionSelect: Choices;
|
||||
let currentFilters = { extensions: [] as string[] };
|
||||
let currentSort: InstructionSortOption = 'title';
|
||||
@@ -52,11 +49,8 @@ function sortItems(items: Instruction[]): Instruction[] {
|
||||
}
|
||||
|
||||
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];
|
||||
let results = [...allItems];
|
||||
|
||||
if (currentFilters.extensions.length > 0) {
|
||||
results = results.filter(item => {
|
||||
@@ -69,22 +63,19 @@ function applyFiltersAndRender(): void {
|
||||
|
||||
results = sortItems(results);
|
||||
|
||||
renderItems(results, query);
|
||||
let countText = `${results.length} of ${allItems.length} instructions`;
|
||||
renderItems(results);
|
||||
let countText = `${results.length} instruction${results.length === 1 ? '' : 's'}`;
|
||||
if (currentFilters.extensions.length > 0) {
|
||||
countText += ` (filtered by ${currentFilters.extensions.length} extension${currentFilters.extensions.length > 1 ? 's' : ''})`;
|
||||
countText = `${results.length} of ${allItems.length} instructions (filtered by ${currentFilters.extensions.length} extension${currentFilters.extensions.length > 1 ? 's' : ''})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Instruction[], query = ''): void {
|
||||
function renderItems(items: Instruction[]): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = renderInstructionsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
|
||||
});
|
||||
list.innerHTML = renderInstructionsHtml(items);
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
@@ -106,9 +97,9 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: searchInput?.value ?? '',
|
||||
q: '',
|
||||
extension: currentFilters.extensions,
|
||||
sort: currentSort === 'title' ? '' : currentSort,
|
||||
});
|
||||
@@ -116,7 +107,6 @@ function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
|
||||
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 sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||
|
||||
@@ -129,16 +119,13 @@ export async function initInstructionsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const initialQuery = getQueryParam('q');
|
||||
const initialExtensions = getQueryParamValues('extension').filter(extension => data.filters.extensions.includes(extension));
|
||||
const initialSort = getQueryParam('sort');
|
||||
|
||||
if (searchInput) searchInput.value = initialQuery;
|
||||
if (initialExtensions.length > 0) {
|
||||
currentFilters.extensions = initialExtensions;
|
||||
setChoicesValues(extensionSelect, initialExtensions);
|
||||
@@ -151,33 +138,22 @@ export async function initInstructionsPage(): Promise<void> {
|
||||
document.getElementById('filter-extension')?.addEventListener('change', () => {
|
||||
currentFilters.extensions = getChoicesValues(extensionSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener('change', () => {
|
||||
currentSort = sortSelect.value as InstructionSortOption;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
const countEl = document.getElementById('results-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} instructions`;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', debounce(() => {
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
}, 200));
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { extensions: [] };
|
||||
currentSort = 'title';
|
||||
extensionSelect.removeActiveItems();
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (sortSelect) sortSelect.value = 'title';
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils';
|
||||
import {
|
||||
escapeHtml,
|
||||
getGitHubUrl,
|
||||
sanitizeUrl,
|
||||
} from '../utils';
|
||||
|
||||
interface PluginAuthor {
|
||||
name: string;
|
||||
@@ -17,6 +21,7 @@ export interface RenderablePlugin {
|
||||
path: string;
|
||||
tags?: string[];
|
||||
itemCount: number;
|
||||
lastUpdated?: string | null;
|
||||
external?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
@@ -24,6 +29,23 @@ export interface RenderablePlugin {
|
||||
source?: PluginSource | null;
|
||||
}
|
||||
|
||||
export type PluginSortOption = 'title' | 'lastUpdated';
|
||||
|
||||
export function sortPlugins<T extends RenderablePlugin>(
|
||||
items: T[],
|
||||
sort: PluginSortOption
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === 'lastUpdated') {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
function getExternalPluginUrl(plugin: RenderablePlugin): string {
|
||||
if (plugin.source?.source === 'github' && plugin.source.repo) {
|
||||
const base = `https://github.com/${plugin.source.repo}`;
|
||||
@@ -33,20 +55,12 @@ function getExternalPluginUrl(plugin: RenderablePlugin): string {
|
||||
return sanitizeUrl(plugin.repository || plugin.homepage);
|
||||
}
|
||||
|
||||
export function renderPluginsHtml(
|
||||
items: RenderablePlugin[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = '', highlightTitle } = options;
|
||||
|
||||
export function renderPluginsHtml(items: RenderablePlugin[]): string {
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No plugins found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
<p>Try different tags or clear the current filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -64,16 +78,11 @@ export function renderPluginsHtml(
|
||||
const githubHref = isExternal
|
||||
? escapeHtml(getExternalPluginUrl(item))
|
||||
: getGitHubUrl(item.path);
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.name, query)
|
||||
: escapeHtml(item.name);
|
||||
|
||||
return `
|
||||
<article class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}" role="listitem">
|
||||
<button type="button" class="resource-preview">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-title">${escapeHtml(item.name)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${metaTag}
|
||||
|
||||
@@ -7,16 +7,19 @@ import {
|
||||
setChoicesValues,
|
||||
type Choices,
|
||||
} from '../choices';
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
updateQueryParams,
|
||||
} from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
import { renderPluginsHtml, type RenderablePlugin } from './plugins-render';
|
||||
import {
|
||||
renderPluginsHtml,
|
||||
sortPlugins,
|
||||
type PluginSortOption,
|
||||
type RenderablePlugin,
|
||||
} from './plugins-render';
|
||||
|
||||
interface PluginAuthor {
|
||||
name: string;
|
||||
@@ -29,7 +32,7 @@ interface PluginSource {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface Plugin extends SearchItem, RenderablePlugin {
|
||||
interface Plugin extends RenderablePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -52,42 +55,44 @@ interface PluginsData {
|
||||
|
||||
const resourceType = 'plugin';
|
||||
let allItems: Plugin[] = [];
|
||||
let search = new FuzzySearch<Plugin>();
|
||||
let tagSelect: Choices;
|
||||
let currentSort: PluginSortOption = 'title';
|
||||
let currentFilters = {
|
||||
tags: [] as string[],
|
||||
};
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const countEl = document.getElementById('results-count');
|
||||
const query = searchInput?.value || '';
|
||||
function sortItems(items: Plugin[]): Plugin[] {
|
||||
return sortPlugins(items, currentSort);
|
||||
}
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
function getCountText(resultsCount: number): string {
|
||||
if (currentFilters.tags.length === 0) {
|
||||
return `${resultsCount} plugin${resultsCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
return `${resultsCount} of ${allItems.length} plugins (filtered by ${currentFilters.tags.length} tag${currentFilters.tags.length === 1 ? '' : 's'})`;
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const countEl = document.getElementById('results-count');
|
||||
let results = [...allItems];
|
||||
|
||||
if (currentFilters.tags.length > 0) {
|
||||
results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag)));
|
||||
}
|
||||
|
||||
renderItems(results, query);
|
||||
const activeFilters: string[] = [];
|
||||
if (currentFilters.tags.length > 0) activeFilters.push(`${currentFilters.tags.length} tag${currentFilters.tags.length > 1 ? 's' : ''}`);
|
||||
let countText = `${results.length} of ${allItems.length} plugins`;
|
||||
if (activeFilters.length > 0) {
|
||||
countText += ` (filtered by ${activeFilters.join(', ')})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
results = sortItems(results);
|
||||
|
||||
renderItems(results);
|
||||
if (countEl) countEl.textContent = getCountText(results.length);
|
||||
}
|
||||
|
||||
function renderItems(items: Plugin[], query = ''): void {
|
||||
function renderItems(items: Plugin[]): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = renderPluginsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
|
||||
});
|
||||
list.innerHTML = renderPluginsHtml(items);
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
@@ -109,17 +114,18 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: searchInput?.value ?? '',
|
||||
q: '',
|
||||
tag: currentFilters.tags,
|
||||
sort: currentSort === 'title' ? '' : currentSort,
|
||||
});
|
||||
}
|
||||
|
||||
export async function initPluginsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
@@ -131,21 +137,12 @@ export async function initPluginsPage(): Promise<void> {
|
||||
|
||||
allItems = data.items;
|
||||
|
||||
// Map plugin items to search items
|
||||
const searchItems = 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);
|
||||
|
||||
const initialQuery = getQueryParam('q');
|
||||
const initialTags = getQueryParamValues('tag').filter(tag => data.filters.tags.includes(tag));
|
||||
const initialSort = getQueryParam('sort');
|
||||
|
||||
if (searchInput) searchInput.value = initialQuery;
|
||||
if (initialTags.length > 0) {
|
||||
currentFilters.tags = initialTags;
|
||||
setChoicesValues(tagSelect, initialTags);
|
||||
@@ -154,28 +151,30 @@ export async function initPluginsPage(): Promise<void> {
|
||||
document.getElementById('filter-tag')?.addEventListener('change', () => {
|
||||
currentFilters.tags = getChoicesValues(tagSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
const countEl = document.getElementById('results-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} plugins`;
|
||||
if (initialSort === 'lastUpdated') {
|
||||
currentSort = initialSort;
|
||||
if (sortSelect) sortSelect.value = initialSort;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', debounce(() => {
|
||||
sortSelect?.addEventListener('change', () => {
|
||||
currentSort = sortSelect.value as PluginSortOption;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
}, 200));
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { tags: [] };
|
||||
currentSort = 'title';
|
||||
tagSelect.removeActiveItems();
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (sortSelect) sortSelect.value = 'title';
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
syncUrlState();
|
||||
setupModal();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,45 +39,29 @@ export function sortSkills<T extends RenderableSkill>(
|
||||
});
|
||||
}
|
||||
|
||||
export function renderSkillsHtml(
|
||||
items: RenderableSkill[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
export function renderSkillsHtml(items: RenderableSkill[]): string {
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No skills found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
<p>No skills are available right now.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<article class="resource-item" data-path="${escapeHtml(
|
||||
item.skillFile
|
||||
)}" data-skill-id="${escapeHtml(item.id)}" role="listitem">
|
||||
<button type="button" class="resource-preview">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-title">${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">${
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
/**
|
||||
* Skills page functionality
|
||||
*/
|
||||
import {
|
||||
createChoices,
|
||||
getChoicesValues,
|
||||
setChoicesValues,
|
||||
type Choices,
|
||||
} from "../choices";
|
||||
import { FuzzySearch, type SearchItem } from "../search";
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
getQueryParam,
|
||||
getQueryParamFlag,
|
||||
getQueryParamValues,
|
||||
showToast,
|
||||
downloadZipBundle,
|
||||
updateQueryParams,
|
||||
@@ -33,77 +23,34 @@ interface SkillFile {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface Skill extends SearchItem, Omit<RenderableSkill, "files"> {
|
||||
interface Skill extends Omit<RenderableSkill, "files"> {
|
||||
files: SkillFile[];
|
||||
}
|
||||
|
||||
interface SkillsData {
|
||||
items: Skill[];
|
||||
filters: {
|
||||
categories: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const resourceType = "skill";
|
||||
let allItems: Skill[] = [];
|
||||
let search = new FuzzySearch<Skill>();
|
||||
let categorySelect: Choices;
|
||||
let currentFilters = {
|
||||
categories: [] as string[],
|
||||
hasAssets: false,
|
||||
};
|
||||
let currentSort: SkillSortOption = "title";
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
function sortItems(items: Skill[]): Skill[] {
|
||||
return sortSkills(items, currentSort);
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
const countEl = document.getElementById("results-count");
|
||||
const query = searchInput?.value || "";
|
||||
const results = sortSkills(allItems, currentSort);
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
|
||||
if (currentFilters.categories.length > 0) {
|
||||
results = results.filter((item) =>
|
||||
currentFilters.categories.includes(item.category)
|
||||
);
|
||||
renderItems(results);
|
||||
if (countEl) {
|
||||
countEl.textContent = `${results.length} skill${results.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (currentFilters.hasAssets) {
|
||||
results = results.filter((item) => item.hasAssets);
|
||||
}
|
||||
|
||||
results = sortItems(results);
|
||||
|
||||
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 {
|
||||
function renderItems(items: Skill[]): void {
|
||||
const list = document.getElementById("resource-list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = renderSkillsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
list.innerHTML = renderSkillsHtml(items);
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
@@ -142,11 +89,11 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: searchInput?.value ?? "",
|
||||
category: currentFilters.categories,
|
||||
hasAssets: currentFilters.hasAssets,
|
||||
q: "",
|
||||
category: [],
|
||||
hasAssets: false,
|
||||
sort: currentSort === "title" ? "" : currentSort,
|
||||
});
|
||||
}
|
||||
@@ -209,13 +156,6 @@ async function downloadSkill(
|
||||
|
||||
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 sortSelect = document.getElementById(
|
||||
"sort-select"
|
||||
) as HTMLSelectElement;
|
||||
@@ -231,76 +171,20 @@ export async function initSkillsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
const initialQuery = getQueryParam("q");
|
||||
const initialCategories = getQueryParamValues("category").filter((category) =>
|
||||
data.filters.categories.includes(category)
|
||||
);
|
||||
const initialSort = getQueryParam("sort");
|
||||
|
||||
if (searchInput) searchInput.value = initialQuery;
|
||||
if (initialCategories.length > 0) {
|
||||
currentFilters.categories = initialCategories;
|
||||
setChoicesValues(categorySelect, initialCategories);
|
||||
}
|
||||
if (getQueryParamFlag("hasAssets")) {
|
||||
currentFilters.hasAssets = true;
|
||||
if (hasAssetsCheckbox) hasAssetsCheckbox.checked = true;
|
||||
}
|
||||
if (initialSort === "lastUpdated") {
|
||||
currentSort = initialSort;
|
||||
if (sortSelect) sortSelect.value = initialSort;
|
||||
}
|
||||
|
||||
document.getElementById("filter-category")?.addEventListener("change", () => {
|
||||
currentFilters.categories = getChoicesValues(categorySelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as SkillSortOption;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
searchInput?.addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
}, 200)
|
||||
);
|
||||
|
||||
hasAssetsCheckbox?.addEventListener("change", () => {
|
||||
currentFilters.hasAssets = hasAssetsCheckbox.checked;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener("click", () => {
|
||||
currentFilters = { categories: [], hasAssets: false };
|
||||
currentSort = "title";
|
||||
categorySelect.removeActiveItems();
|
||||
if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false;
|
||||
if (searchInput) searchInput.value = "";
|
||||
if (sortSelect) sortSelect.value = "title";
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
});
|
||||
|
||||
setupModal();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,22 @@ export interface RenderableTool {
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export type ToolSortOption = "featured" | "title";
|
||||
|
||||
export function sortTools<T extends RenderableTool>(
|
||||
tools: T[],
|
||||
sort: ToolSortOption
|
||||
): T[] {
|
||||
return [...tools].sort((a, b) => {
|
||||
if (sort === "featured") {
|
||||
if (a.featured && !b.featured) return -1;
|
||||
if (!a.featured && b.featured) return 1;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
function formatMultilineText(text: string): string {
|
||||
return escapeHtml(text).replace(/\r?\n/g, "<br>");
|
||||
}
|
||||
@@ -61,19 +77,13 @@ function getToolActionLink(
|
||||
}
|
||||
|
||||
export function renderToolsHtml(
|
||||
tools: RenderableTool[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
tools: RenderableTool[]
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
if (tools.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No tools found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
<p>Try a different category or clear the current filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -172,15 +182,10 @@ export function renderToolsHtml(
|
||||
? `<div class="tool-actions">${actions.join("")}</div>`
|
||||
: "";
|
||||
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(tool.name, query)
|
||||
: escapeHtml(tool.name);
|
||||
|
||||
return `
|
||||
<div class="tool-card">
|
||||
<div class="tool-header">
|
||||
<h2>${titleHtml}</h2>
|
||||
<h2>${escapeHtml(tool.name)}</h2>
|
||||
<div class="tool-badges">
|
||||
${badges.join("")}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* Tools page functionality
|
||||
*/
|
||||
import { FuzzySearch, type SearchableItem } from "../search";
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
getQueryParam,
|
||||
updateQueryParams,
|
||||
} from "../utils";
|
||||
import { renderToolsHtml } from "./tools-render";
|
||||
import {
|
||||
renderToolsHtml,
|
||||
sortTools,
|
||||
type ToolSortOption,
|
||||
} from "./tools-render";
|
||||
|
||||
export interface Tool extends SearchableItem {
|
||||
export interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
@@ -46,23 +48,28 @@ interface ToolsData {
|
||||
}
|
||||
|
||||
let allItems: Tool[] = [];
|
||||
let search = new FuzzySearch<Tool>();
|
||||
let currentFilters = {
|
||||
categories: [] as string[],
|
||||
query: "",
|
||||
};
|
||||
let currentSort: ToolSortOption = "featured";
|
||||
let copyHandlersReady = false;
|
||||
let initialized = false;
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
const countEl = document.getElementById("results-count");
|
||||
const query = searchInput?.value || "";
|
||||
currentFilters.query = query;
|
||||
function sortItems(items: Tool[]): Tool[] {
|
||||
return sortTools(items, currentSort);
|
||||
}
|
||||
|
||||
let results = query ? search.search(query) : [...allItems];
|
||||
function getCountText(resultsCount: number): string {
|
||||
if (currentFilters.categories.length === 0) {
|
||||
return `${resultsCount} tool${resultsCount === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
return `${resultsCount} of ${allItems.length} tools (filtered by ${currentFilters.categories.length} categor${currentFilters.categories.length === 1 ? "y" : "ies"})`;
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const countEl = document.getElementById("results-count");
|
||||
let results = [...allItems];
|
||||
|
||||
if (currentFilters.categories.length > 0) {
|
||||
results = results.filter((item) =>
|
||||
@@ -70,29 +77,23 @@ function applyFiltersAndRender(): void {
|
||||
);
|
||||
}
|
||||
|
||||
renderTools(results, query);
|
||||
results = sortItems(results);
|
||||
|
||||
let countText = `${results.length} of ${allItems.length} tools`;
|
||||
if (currentFilters.categories.length > 0) {
|
||||
countText += ` (filtered by ${currentFilters.categories.length} categories)`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
renderTools(results);
|
||||
if (countEl) countEl.textContent = getCountText(results.length);
|
||||
}
|
||||
|
||||
function renderTools(tools: Tool[], query = ""): void {
|
||||
function renderTools(tools: Tool[]): void {
|
||||
const container = document.getElementById("tools-list");
|
||||
if (!container) return;
|
||||
container.innerHTML = renderToolsHtml(tools, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
container.innerHTML = renderToolsHtml(tools);
|
||||
}
|
||||
|
||||
function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: searchInput?.value ?? "",
|
||||
q: "",
|
||||
category: currentFilters.categories,
|
||||
sort: currentSort === "featured" ? "" : currentSort,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,13 +134,11 @@ export async function initToolsPage(): Promise<void> {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
const categoryFilter = document.getElementById(
|
||||
"filter-category"
|
||||
) as HTMLSelectElement;
|
||||
const clearFiltersBtn = document.getElementById("clear-filters");
|
||||
const sortSelect = document.getElementById("sort-select") as HTMLSelectElement;
|
||||
|
||||
const data = await fetchData<ToolsData>("tools.json");
|
||||
if (!data || !data.items) {
|
||||
@@ -156,9 +155,6 @@ export async function initToolsPage(): Promise<void> {
|
||||
title: item.name, // FuzzySearch uses title
|
||||
}));
|
||||
|
||||
search = new FuzzySearch<Tool>();
|
||||
search.setItems(allItems);
|
||||
|
||||
// Populate category filter
|
||||
if (categoryFilter && data.filters.categories) {
|
||||
categoryFilter.innerHTML =
|
||||
@@ -180,31 +176,32 @@ export async function initToolsPage(): Promise<void> {
|
||||
? [categoryFilter.value]
|
||||
: [];
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
}
|
||||
|
||||
const initialQuery = getQueryParam("q");
|
||||
if (searchInput) searchInput.value = initialQuery;
|
||||
const initialSort = getQueryParam("sort");
|
||||
if (initialSort === "title") {
|
||||
currentSort = initialSort;
|
||||
if (sortSelect) sortSelect.value = initialSort;
|
||||
}
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as ToolSortOption;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
|
||||
// Search input handler
|
||||
searchInput?.addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
}, 200)
|
||||
);
|
||||
syncUrlState();
|
||||
|
||||
// Clear filters
|
||||
clearFiltersBtn?.addEventListener("click", () => {
|
||||
currentFilters = { categories: [], query: "" };
|
||||
currentFilters = { categories: [] };
|
||||
currentSort = "featured";
|
||||
if (categoryFilter) categoryFilter.value = "";
|
||||
if (searchInput) searchInput.value = "";
|
||||
if (sortSelect) sortSelect.value = "featured";
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
setupCopyConfigHandlers();
|
||||
|
||||
@@ -31,35 +31,24 @@ export function sortWorkflows<T extends RenderableWorkflow>(
|
||||
}
|
||||
|
||||
export function renderWorkflowsHtml(
|
||||
items: RenderableWorkflow[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
items: RenderableWorkflow[]
|
||||
): string {
|
||||
const { query = '', highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No workflows found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
<p>Try adjusting the selected filters.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem">
|
||||
<button type="button" class="resource-preview">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-title">${escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${item.triggers.map((trigger) => `<span class="resource-tag tag-trigger">${escapeHtml(trigger)}</span>`).join('')}
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
setChoicesValues,
|
||||
type Choices,
|
||||
} from "../choices";
|
||||
import { FuzzySearch, type SearchItem } from "../search";
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
getQueryParam,
|
||||
getQueryParamValues,
|
||||
setupActionHandlers,
|
||||
@@ -24,7 +22,7 @@ import {
|
||||
type WorkflowSortOption,
|
||||
} from "./workflows-render";
|
||||
|
||||
interface Workflow extends SearchItem, RenderableWorkflow {
|
||||
interface Workflow extends RenderableWorkflow {
|
||||
id: string;
|
||||
path: string;
|
||||
triggers: string[];
|
||||
@@ -40,7 +38,6 @@ interface WorkflowsData {
|
||||
|
||||
const resourceType = "workflow";
|
||||
let allItems: Workflow[] = [];
|
||||
let search = new FuzzySearch<Workflow>();
|
||||
let triggerSelect: Choices;
|
||||
let currentFilters = {
|
||||
triggers: [] as string[],
|
||||
@@ -53,46 +50,30 @@ function sortItems(items: Workflow[]): Workflow[] {
|
||||
}
|
||||
|
||||
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];
|
||||
let results = [...allItems];
|
||||
|
||||
if (currentFilters.triggers.length > 0) {
|
||||
results = results.filter((item) =>
|
||||
item.triggers.some((t) => currentFilters.triggers.includes(t))
|
||||
item.triggers.some((trigger) => currentFilters.triggers.includes(trigger))
|
||||
);
|
||||
}
|
||||
|
||||
results = sortItems(results);
|
||||
|
||||
renderItems(results, query);
|
||||
const activeFilters: string[] = [];
|
||||
if (currentFilters.triggers.length > 0)
|
||||
activeFilters.push(
|
||||
`${currentFilters.triggers.length} trigger${
|
||||
currentFilters.triggers.length > 1 ? "s" : ""
|
||||
}`
|
||||
);
|
||||
let countText = `${results.length} of ${allItems.length} workflows`;
|
||||
if (activeFilters.length > 0) {
|
||||
countText += ` (filtered by ${activeFilters.join(", ")})`;
|
||||
renderItems(results);
|
||||
let countText = `${results.length} workflow${results.length === 1 ? "" : "s"}`;
|
||||
if (currentFilters.triggers.length > 0) {
|
||||
countText = `${results.length} of ${allItems.length} workflows (filtered by ${currentFilters.triggers.length} trigger${currentFilters.triggers.length > 1 ? "s" : ""})`;
|
||||
}
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function renderItems(items: Workflow[], query = ""): void {
|
||||
function renderItems(items: Workflow[]): void {
|
||||
const list = document.getElementById("resource-list");
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = renderWorkflowsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
list.innerHTML = renderWorkflowsHtml(items);
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
@@ -114,9 +95,9 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
function syncUrlState(): void {
|
||||
updateQueryParams({
|
||||
q: searchInput?.value ?? "",
|
||||
q: "",
|
||||
trigger: currentFilters.triggers,
|
||||
sort: currentSort === "title" ? "" : currentSort,
|
||||
});
|
||||
@@ -124,9 +105,6 @@ function syncUrlState(searchInput: HTMLInputElement | null): void {
|
||||
|
||||
export async function initWorkflowsPage(): Promise<void> {
|
||||
const list = document.getElementById("resource-list");
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById("clear-filters");
|
||||
const sortSelect = document.getElementById(
|
||||
"sort-select"
|
||||
@@ -143,26 +121,22 @@ export async function initWorkflowsPage(): Promise<void> {
|
||||
}
|
||||
|
||||
allItems = data.items;
|
||||
search.setItems(allItems);
|
||||
|
||||
// Setup trigger filter
|
||||
triggerSelect = createChoices("#filter-trigger", {
|
||||
placeholderValue: "All Triggers",
|
||||
});
|
||||
triggerSelect.setChoices(
|
||||
data.filters.triggers.map((t) => ({ value: t, label: t })),
|
||||
data.filters.triggers.map((trigger) => ({ value: trigger, label: trigger })),
|
||||
"value",
|
||||
"label",
|
||||
true
|
||||
);
|
||||
|
||||
const initialQuery = getQueryParam("q");
|
||||
const initialTriggers = getQueryParamValues("trigger").filter((trigger) =>
|
||||
data.filters.triggers.includes(trigger)
|
||||
);
|
||||
const initialSort = getQueryParam("sort");
|
||||
|
||||
if (searchInput) searchInput.value = initialQuery;
|
||||
if (initialTriggers.length > 0) {
|
||||
currentFilters.triggers = initialTriggers;
|
||||
setChoicesValues(triggerSelect, initialTriggers);
|
||||
@@ -175,36 +149,22 @@ export async function initWorkflowsPage(): Promise<void> {
|
||||
document.getElementById("filter-trigger")?.addEventListener("change", () => {
|
||||
currentFilters.triggers = getChoicesValues(triggerSelect);
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as WorkflowSortOption;
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
const countEl = document.getElementById("results-count");
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} workflows`;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
}, 200)
|
||||
);
|
||||
|
||||
clearFiltersBtn?.addEventListener("click", () => {
|
||||
currentFilters = { triggers: [] };
|
||||
currentSort = "title";
|
||||
triggerSelect.removeActiveItems();
|
||||
if (searchInput) searchInput.value = "";
|
||||
if (sortSelect) sortSelect.value = "title";
|
||||
applyFiltersAndRender();
|
||||
syncUrlState(searchInput);
|
||||
syncUrlState();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
|
||||
Reference in New Issue
Block a user