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:
Aaron Powell
2026-04-29 16:03:08 +10:00
committed by GitHub
parent 0d7a5ad4c2
commit 76ac13a9b8
29 changed files with 1166 additions and 1331 deletions

View File

@@ -354,50 +354,6 @@ function generateInstructionsData(gitDates) {
}; };
} }
/**
* Categorize a skill based on its name and description
*/
function categorizeSkill(name, description) {
const text = `${name} ${description}`.toLowerCase();
if (text.includes("azure") || text.includes("appinsights")) return "Azure";
if (
text.includes("github") ||
text.includes("gh-cli") ||
text.includes("git-commit") ||
text.includes("git ")
)
return "Git & GitHub";
if (text.includes("vscode") || text.includes("vs code")) return "VS Code";
if (
text.includes("test") ||
text.includes("qa") ||
text.includes("playwright")
)
return "Testing";
if (
text.includes("microsoft") ||
text.includes("m365") ||
text.includes("workiq")
)
return "Microsoft";
if (text.includes("cli") || text.includes("command")) return "CLI Tools";
if (
text.includes("diagram") ||
text.includes("plantuml") ||
text.includes("visual")
)
return "Diagrams";
if (
text.includes("nuget") ||
text.includes("dotnet") ||
text.includes(".net")
)
return ".NET";
return "Other";
}
/** /**
* Generate skills metadata * Generate skills metadata
*/ */
@@ -405,15 +361,13 @@ function generateSkillsData(gitDates) {
const skills = []; const skills = [];
if (!fs.existsSync(SKILLS_DIR)) { if (!fs.existsSync(SKILLS_DIR)) {
return { items: [], filters: { categories: [], hasAssets: ["Yes", "No"] } }; return { items: [], filters: { hasAssets: ["Yes", "No"] } };
} }
const folders = fs const folders = fs
.readdirSync(SKILLS_DIR) .readdirSync(SKILLS_DIR)
.filter((f) => fs.statSync(path.join(SKILLS_DIR, f)).isDirectory()); .filter((f) => fs.statSync(path.join(SKILLS_DIR, f)).isDirectory());
const allCategories = new Set();
for (const folder of folders) { for (const folder of folders) {
const skillPath = path.join(SKILLS_DIR, folder); const skillPath = path.join(SKILLS_DIR, folder);
const metadata = parseSkillMetadata(skillPath); const metadata = parseSkillMetadata(skillPath);
@@ -422,8 +376,6 @@ function generateSkillsData(gitDates) {
const relativePath = path const relativePath = path
.relative(ROOT_FOLDER, skillPath) .relative(ROOT_FOLDER, skillPath)
.replace(/\\/g, "/"); .replace(/\\/g, "/");
const category = categorizeSkill(metadata.name, metadata.description);
allCategories.add(category);
// Get all files in the skill folder recursively // Get all files in the skill folder recursively
const files = getSkillFiles(skillPath, relativePath); const files = getSkillFiles(skillPath, relativePath);
@@ -440,7 +392,6 @@ function generateSkillsData(gitDates) {
folder, folder,
metadata.name, metadata.name,
relativePath, relativePath,
category,
] ]
.join(" ") .join(" ")
.toLowerCase(); .toLowerCase();
@@ -453,7 +404,6 @@ function generateSkillsData(gitDates) {
assets: metadata.assets, assets: metadata.assets,
hasAssets: metadata.assets.length > 0, hasAssets: metadata.assets.length > 0,
assetCount: metadata.assets.length, assetCount: metadata.assets.length,
category: category,
path: relativePath, path: relativePath,
skillFile: skillFilePath, skillFile: skillFilePath,
files: files, files: files,
@@ -468,7 +418,6 @@ function generateSkillsData(gitDates) {
return { return {
items: sortedSkills, items: sortedSkills,
filters: { filters: {
categories: Array.from(allCategories).sort(),
hasAssets: ["Yes", "No"], hasAssets: ["Yes", "No"],
}, },
}; };
@@ -976,9 +925,7 @@ async function main() {
const skillsData = generateSkillsData(gitDates); const skillsData = generateSkillsData(gitDates);
const skills = skillsData.items; const skills = skillsData.items;
console.log( console.log(`✓ Generated ${skills.length} skills`);
`✓ Generated ${skills.length} skills (${skillsData.filters.categories.length} categories)`
);
const pluginsData = generatePluginsData(gitDates); const pluginsData = generatePluginsData(gitDates);
const plugins = pluginsData.items; const plugins = pluginsData.items;

View File

@@ -119,6 +119,7 @@ export default defineConfig({
components: { components: {
Head: "./src/components/Head.astro", Head: "./src/components/Head.astro",
Footer: "./src/components/Footer.astro", Footer: "./src/components/Footer.astro",
Search: "./src/components/Search.astro",
}, },
}), }),
sitemap(), sitemap(),

View File

@@ -0,0 +1,516 @@
---
import Icon from './Icon.astro';
import project from 'virtual:starlight/project-context';
const pagefindTranslations = {
placeholder: Astro.locals.t('search.label'),
...Object.fromEntries(
Object.entries(Astro.locals.t.all())
.filter(([key]) => key.startsWith('pagefind.'))
.map(([key, value]) => [key.replace('pagefind.', ''), value])
),
};
const dataAttributes: DOMStringMap = { 'data-translations': JSON.stringify(pagefindTranslations) };
if (project.trailingSlash === 'never') dataAttributes['data-strip-trailing-slash'] = '';
---
<site-search class={Astro.props.class} {...dataAttributes}>
<button
data-open-modal
disabled
aria-label={Astro.locals.t('search.label')}
aria-keyshortcuts="Control+K"
>
<Icon name="search" />
<span class="sl-hidden md:sl-block" aria-hidden="true">{Astro.locals.t('search.label')}</span>
<kbd class="sl-hidden md:sl-flex" style="display: none;">
<kbd>{Astro.locals.t('search.ctrlKey')}</kbd><kbd>K</kbd>
</kbd>
</button>
<dialog style="padding:0" aria-label={Astro.locals.t('search.label')}>
<div class="dialog-frame sl-flex">
{
/* TODO: Make the layout of this button flexible to accommodate different word lengths. Currently hard-coded for English: “Cancel” */
}
<button data-close-modal class="sl-flex md:sl-hidden">
{Astro.locals.t('search.cancelLabel')}
</button>
{
import.meta.env.DEV ? (
<div style="margin: auto; text-align: center; white-space: pre-line;" dir="ltr">
<p>{Astro.locals.t('search.devWarning')}</p>
</div>
) : (
<div class="search-container">
<div id="starlight__search" />
</div>
)
}
</div>
</dialog>
</site-search>
{
/**
* This is intentionally inlined to avoid briefly showing an invalid shortcut.
* Purposely using the deprecated `navigator.platform` property to detect Apple devices, as the
* user agent is spoofed by some browsers when opening the devtools.
*/
}
<script is:inline>
(() => {
const openBtn = document.querySelector('button[data-open-modal]');
const shortcut = openBtn?.querySelector('kbd');
if (!openBtn || !(shortcut instanceof HTMLElement)) return;
const platformKey = shortcut.querySelector('kbd');
if (platformKey && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)) {
platformKey.textContent = '⌘';
openBtn.setAttribute('aria-keyshortcuts', 'Meta+K');
}
shortcut.style.display = '';
})();
</script>
<script>
import { pagefindUserConfig } from 'virtual:starlight/pagefind-config';
const ROUTE_TYPE_FILTERS: Record<string, string> = {
'/agents': 'agent',
'/instructions': 'instruction',
'/skills': 'skill',
'/hooks': 'hook',
'/workflows': 'workflow',
'/plugins': 'plugin',
'/tools': 'tool',
};
function getRouteTypeFilter(pathname: string, baseUrl: string): string | undefined {
const normalizedBaseUrl = baseUrl === '/' ? '' : baseUrl.replace(/\/$/, '');
const pathWithoutBase =
normalizedBaseUrl && pathname.startsWith(normalizedBaseUrl)
? pathname.slice(normalizedBaseUrl.length) || '/'
: pathname;
const normalizedPath = pathWithoutBase.replace(/\/$/, '') || '/';
return ROUTE_TYPE_FILTERS[normalizedPath];
}
class SiteSearch extends HTMLElement {
constructor() {
super();
const openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')!;
const closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')!;
const dialog = this.querySelector('dialog')!;
const dialogFrame = this.querySelector('.dialog-frame')!;
/** Close the modal if a user clicks on a link or outside of the modal. */
const onClick = (event: MouseEvent) => {
const isLink = 'href' in (event.target || {});
if (
isLink ||
(document.body.contains(event.target as Node) &&
!dialogFrame.contains(event.target as Node))
) {
closeModal();
}
};
const openModal = (event?: MouseEvent) => {
dialog.showModal();
document.body.toggleAttribute('data-search-modal-open', true);
this.querySelector('input')?.focus();
event?.stopPropagation();
window.addEventListener('click', onClick);
};
const closeModal = () => dialog.close();
openBtn.addEventListener('click', openModal);
openBtn.disabled = false;
closeBtn.addEventListener('click', closeModal);
dialog.addEventListener('close', () => {
document.body.toggleAttribute('data-search-modal-open', false);
window.removeEventListener('click', onClick);
});
// Listen for `ctrl + k` and `cmd + k` keyboard shortcuts.
window.addEventListener('keydown', (e) => {
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
dialog.open ? closeModal() : openModal();
e.preventDefault();
}
});
let translations = {};
try {
translations = JSON.parse(this.dataset.translations || '{}');
} catch {}
const shouldStrip = this.dataset.stripTrailingSlash !== undefined;
const stripTrailingSlash = (path: string) => path.replace(/(.)\/(#.*)?$/, '$1$2');
const formatURL = shouldStrip ? stripTrailingSlash : (path: string) => path;
window.addEventListener('DOMContentLoaded', () => {
if (import.meta.env.DEV) return;
const onIdle = window.requestIdleCallback || ((cb) => setTimeout(cb, 1));
onIdle(async () => {
// @ts-expect-error — Missing types for @pagefind/default-ui package.
const { PagefindUI } = await import('@pagefind/default-ui');
const pagefind = new PagefindUI({
...pagefindUserConfig,
element: '#starlight__search',
baseUrl: import.meta.env.BASE_URL,
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, '') + '/pagefind/',
showImages: false,
translations,
showSubResults: true,
processResult: (result: { url: string; sub_results: Array<{ url: string }> }) => {
result.url = formatURL(result.url);
result.sub_results = result.sub_results.map((sub_result) => {
sub_result.url = formatURL(sub_result.url);
return sub_result;
});
},
});
const routeTypeFilter = getRouteTypeFilter(
window.location.pathname,
import.meta.env.BASE_URL
);
if (routeTypeFilter) {
pagefind.triggerFilters({ type: routeTypeFilter });
}
});
});
}
}
customElements.define('site-search', SiteSearch);
</script>
<style>
@layer starlight.core {
site-search {
display: contents;
}
button[data-open-modal] {
display: flex;
align-items: center;
gap: 0.5rem;
border: 0;
background-color: transparent;
color: var(--sl-color-gray-1);
cursor: pointer;
height: 2.5rem;
font-size: var(--sl-text-xl);
}
@media (min-width: 50rem) {
button[data-open-modal] {
border: 1px solid var(--sl-color-gray-5);
border-radius: 0.5rem;
padding-inline-start: 0.75rem;
padding-inline-end: 0.5rem;
background-color: var(--sl-color-black);
color: var(--sl-color-gray-2);
font-size: var(--sl-text-sm);
width: 100%;
max-width: 22rem;
}
button[data-open-modal]:hover {
border-color: var(--sl-color-gray-2);
color: var(--sl-color-white);
}
button[data-open-modal] > :last-child {
margin-inline-start: auto;
}
}
button > kbd {
border-radius: 0.25rem;
font-size: var(--sl-text-2xs);
gap: 0.25em;
padding-inline: 0.375rem;
background-color: var(--sl-color-gray-6);
}
kbd {
font-family: var(--__sl-font);
}
dialog {
margin: 0;
background-color: var(--sl-color-gray-6);
border: 1px solid var(--sl-color-gray-5);
width: 100%;
max-width: 100%;
height: 100%;
max-height: 100%;
box-shadow: var(--sl-shadow-lg);
}
dialog[open] {
display: flex;
}
dialog::backdrop {
background-color: var(--sl-color-backdrop-overlay);
-webkit-backdrop-filter: blur(0.25rem);
backdrop-filter: blur(0.25rem);
}
.dialog-frame {
position: relative;
overflow: auto;
flex-direction: column;
flex-grow: 1;
gap: 1rem;
padding: 1rem;
}
button[data-close-modal] {
position: absolute;
z-index: 1;
align-items: center;
align-self: flex-end;
height: calc(64px * var(--pagefind-ui-scale));
padding: 0.25rem;
border: 0;
background: transparent;
cursor: pointer;
color: var(--sl-color-text-accent);
}
#starlight__search {
--pagefind-ui-primary: var(--sl-color-text);
--pagefind-ui-text: var(--sl-color-gray-2);
--pagefind-ui-font: var(--__sl-font);
--pagefind-ui-background: var(--sl-color-black);
--pagefind-ui-border: var(--sl-color-gray-5);
--pagefind-ui-border-width: 1px;
--pagefind-ui-tag: var(--sl-color-gray-5);
--sl-search-cancel-space: 5rem;
}
:root[data-theme='light'] #starlight__search {
--pagefind-ui-tag: var(--sl-color-gray-6);
}
@media (min-width: 50rem) {
#starlight__search {
--sl-search-cancel-space: 0px;
}
dialog {
margin: 4rem auto auto;
border-radius: 0.5rem;
width: 90%;
max-width: 40rem;
height: max-content;
min-height: 15rem;
max-height: calc(100% - 8rem);
}
.dialog-frame {
padding: 1.5rem;
}
}
}
</style>
<style is:global>
@import url('@pagefind/default-ui/css/ui.css') layer(starlight.core);
@layer starlight.core {
[data-search-modal-open] {
overflow: hidden;
}
#starlight__search {
--sl-search-result-spacing: calc(1.25rem * var(--pagefind-ui-scale));
--sl-search-result-pad-inline-start: calc(3.75rem * var(--pagefind-ui-scale));
--sl-search-result-pad-inline-end: calc(1.25rem * var(--pagefind-ui-scale));
--sl-search-result-pad-block: calc(0.9375rem * var(--pagefind-ui-scale));
--sl-search-result-nested-pad-block: calc(0.625rem * var(--pagefind-ui-scale));
--sl-search-corners: calc(0.3125rem * var(--pagefind-ui-scale));
--sl-search-page-icon-size: calc(1.875rem * var(--pagefind-ui-scale));
--sl-search-page-icon-inline-start: calc(
(var(--sl-search-result-pad-inline-start) - var(--sl-search-page-icon-size)) / 2
);
--sl-search-tree-diagram-size: calc(2.5rem * var(--pagefind-ui-scale));
--sl-search-tree-diagram-inline-start: calc(
(var(--sl-search-result-pad-inline-start) - var(--sl-search-tree-diagram-size)) / 2
);
}
#starlight__search .pagefind-ui__form::before {
--pagefind-ui-text: var(--sl-color-gray-1);
opacity: 1;
}
#starlight__search .pagefind-ui__search-input {
color: var(--sl-color-white);
font-weight: 400;
width: calc(100% - var(--sl-search-cancel-space));
}
#starlight__search input:focus {
--pagefind-ui-border: var(--sl-color-accent);
}
#starlight__search .pagefind-ui__search-clear {
inset-inline-end: var(--sl-search-cancel-space);
width: calc(60px * var(--pagefind-ui-scale));
padding: 0;
background-color: transparent;
overflow: hidden;
}
#starlight__search .pagefind-ui__search-clear:focus {
outline: 1px solid var(--sl-color-accent);
}
#starlight__search .pagefind-ui__search-clear::before {
content: '';
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
center / 50% no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
center / 50% no-repeat;
background-color: var(--sl-color-text-accent);
display: block;
width: 100%;
height: 100%;
}
#starlight__search .pagefind-ui__results > * + * {
margin-top: var(--sl-search-result-spacing);
}
#starlight__search .pagefind-ui__result {
border: 0;
padding: 0;
}
#starlight__search .pagefind-ui__result-nested {
position: relative;
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
padding-inline-start: var(--sl-search-result-pad-inline-start);
}
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)),
#starlight__search .pagefind-ui__result-nested {
position: relative;
background-color: var(--sl-color-black);
}
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):hover,
#starlight__search
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
#starlight__search .pagefind-ui__result-nested:hover,
#starlight__search .pagefind-ui__result-nested:focus-within {
outline: 1px solid var(--sl-color-accent-high);
}
#starlight__search
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
#starlight__search .pagefind-ui__result-nested:focus-within {
background-color: var(--sl-color-accent-low);
}
#starlight__search .pagefind-ui__result-thumb,
#starlight__search .pagefind-ui__result-inner {
margin-top: 0;
}
#starlight__search .pagefind-ui__result-inner > :first-child {
border-radius: var(--sl-search-corners) var(--sl-search-corners) 0 0;
}
#starlight__search .pagefind-ui__result-inner > :last-child {
border-radius: 0 0 var(--sl-search-corners) var(--sl-search-corners);
}
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title {
padding: var(--sl-search-result-pad-block) var(--sl-search-result-pad-inline-end);
padding-inline-start: var(--sl-search-result-pad-inline-start);
}
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: var(--sl-search-page-icon-inline-start);
width: var(--sl-search-page-icon-size);
background: var(--sl-color-gray-3);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
center no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
center no-repeat;
}
#starlight__search .pagefind-ui__result-inner {
align-items: stretch;
gap: 1px;
}
#starlight__search .pagefind-ui__result-link {
position: unset;
--pagefind-ui-text: var(--sl-color-white);
font-weight: 600;
}
#starlight__search .pagefind-ui__result-link:hover {
text-decoration: none;
}
#starlight__search .pagefind-ui__result-nested .pagefind-ui__result-link::before {
content: unset;
}
#starlight__search .pagefind-ui__result-nested::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: var(--sl-search-tree-diagram-inline-start);
width: var(--sl-search-tree-diagram-size);
background: var(--sl-color-gray-4);
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
0% 0% / 100% no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
0% 0% / 100% no-repeat;
}
#starlight__search .pagefind-ui__result-nested:last-of-type::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
}
/* Flip page and tree icons around the vertical axis when in an RTL layout. */
[dir='rtl'] .pagefind-ui__result-title::before,
[dir='rtl'] .pagefind-ui__result-nested::before {
transform: matrix(-1, 0, 0, 1, 0, 0);
}
#starlight__search .pagefind-ui__result-link::after {
content: '';
position: absolute;
inset: 0;
}
#starlight__search .pagefind-ui__result-excerpt {
font-size: calc(1rem * var(--pagefind-ui-scale));
overflow-wrap: anywhere;
}
#starlight__search mark {
color: var(--sl-color-gray-2);
background-color: transparent;
font-weight: 600;
}
#starlight__search .pagefind-ui__filter-value::before {
border-color: var(--sl-color-text-invert);
}
#starlight__search .pagefind-ui__result-tags {
background-color: var(--sl-color-black);
margin-top: 0;
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
}
}
</style>

View File

@@ -64,6 +64,10 @@ export default function pagefindResources(): AstroIntegration {
} }
const { index } = response; const { index } = response;
if (!index) {
throw new Error("Pagefind index is undefined");
}
// Index all built HTML pages (same as Starlight's default) // Index all built HTML pages (same as Starlight's default)
const indexResult = await index.addDirectory({ const indexResult = await index.addDirectory({
path: fileURLToPath(dir), path: fileURLToPath(dir),
@@ -82,7 +86,9 @@ export default function pagefindResources(): AstroIntegration {
try { try {
records = JSON.parse(readFileSync(searchIndexPath, "utf-8")); records = JSON.parse(readFileSync(searchIndexPath, "utf-8"));
} catch { } catch {
log.warn("Could not read search-index.json, skipping resource indexing."); log.warn(
"Could not read search-index.json, skipping resource indexing."
);
records = []; records = [];
} }
@@ -94,12 +100,15 @@ export default function pagefindResources(): AstroIntegration {
const typePage = TYPE_PAGES[record.type]; const typePage = TYPE_PAGES[record.type];
if (!typePage) continue; if (!typePage) continue;
const url = `${base}${typePage.slice(1)}#file=${encodeURIComponent(record.path)}`; const url = `${base}${typePage.slice(1)}#file=${encodeURIComponent(
record.path
)}`;
const typeLabel = TYPE_LABELS[record.type] || record.type; const typeLabel = TYPE_LABELS[record.type] || record.type;
const addResult = await index.addCustomRecord({ const addResult = await index.addCustomRecord({
url, url,
content: record.searchText || `${record.title} ${record.description}`, content:
record.searchText || `${record.title} ${record.description}`,
language: "en", language: "en",
meta: { meta: {
title: `${record.title}${typeLabel}`, title: `${record.title}${typeLabel}`,
@@ -110,7 +119,8 @@ export default function pagefindResources(): AstroIntegration {
}); });
if (addResult.errors.length > 0) { if (addResult.errors.length > 0) {
for (const err of addResult.errors) log.warn(`Record ${record.id}: ${err}`); for (const err of addResult.errors)
log.warn(`Record ${record.id}: ${err}`);
} else { } else {
added++; added++;
} }
@@ -129,7 +139,11 @@ export default function pagefindResources(): AstroIntegration {
const elapsed = performance.now() - now; const elapsed = performance.now() - now;
log.info( log.info(
`Search index built in ${elapsed < 750 ? `${Math.round(elapsed)}ms` : `${(elapsed / 1000).toFixed(2)}s`}.` `Search index built in ${
elapsed < 750
? `${Math.round(elapsed)}ms`
: `${(elapsed / 1000).toFixed(2)}s`
}.`
); );
} catch (cause) { } catch (cause) {
throw new Error("Failed to build Pagefind search index.", { cause }); throw new Error("Failed to build Pagefind search index.", { cause });

View File

@@ -18,27 +18,12 @@ const initialItems = sortAgents(agentsData.items, 'title');
<div class="page-content"> <div class="page-content">
<div class="container"> <div class="container">
<div class="listing-toolbar"> <div class="listing-toolbar">
<div class="search-bar"> <div class="listing-toolbar-row">
<label for="search-input" class="sr-only">Search agents</label> <div class="results-count" id="results-count" aria-live="polite">{initialItems.length} agents</div>
<input type="text" id="search-input" placeholder="Search agents..." autocomplete="off"> <details class="listing-controls">
</div> <summary class="listing-controls-trigger btn btn-secondary btn-small">Sort</summary>
<div class="listing-controls-panel">
<!-- Filters -->
<div class="filters-bar" id="filters-bar"> <div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-model">Model:</label>
<select id="filter-model" multiple aria-label="Filter by model"></select>
</div>
<div class="filter-group">
<label for="filter-tool">Tool:</label>
<select id="filter-tool" multiple aria-label="Filter by tool"></select>
</div>
<div class="filter-group">
<label class="checkbox-label">
<input type="checkbox" id="filter-handoffs">
Has Handoffs
</label>
</div>
<div class="filter-group"> <div class="filter-group">
<label for="sort-select">Sort:</label> <label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by"> <select id="sort-select" aria-label="Sort by">
@@ -46,11 +31,11 @@ const initialItems = sortAgents(agentsData.items, 'title');
<option value="lastUpdated">Recently Updated</option> <option value="lastUpdated">Recently Updated</option>
</select> </select>
</div> </div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>
</div> </div>
</details>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} agents</div> </div>
</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderAgentsHtml(initialItems)}></div> <div class="resource-list" id="resource-list" role="list" set:html={renderAgentsHtml(initialItems)}></div>
<ContributeCTA resourceType="agents" /> <ContributeCTA resourceType="agents" />
</div> </div>
@@ -62,6 +47,7 @@ const initialItems = sortAgents(agentsData.items, 'title');
<EmbeddedPageData filename="agents.json" data={agentsData} /> <EmbeddedPageData filename="agents.json" data={agentsData} />
<script> <script>
import '../scripts/listing-flyouts';
import '../scripts/pages/agents'; import '../scripts/pages/agents';
</script> </script>
</StarlightPage> </StarlightPage>

View File

@@ -18,16 +18,12 @@ const initialItems = sortHooks(hooksData.items, 'title');
<div class="page-content"> <div class="page-content">
<div class="container"> <div class="container">
<div class="listing-toolbar"> <div class="listing-toolbar">
<div class="search-bar"> <div class="listing-toolbar-row">
<label for="search-input" class="sr-only">Search hooks</label> <div class="results-count" id="results-count" aria-live="polite">{initialItems.length} hooks</div>
<input type="text" id="search-input" placeholder="Search hooks..." autocomplete="off"> <details class="listing-controls">
</div> <summary class="listing-controls-trigger btn btn-secondary btn-small">Sort &amp; Filter</summary>
<div class="listing-controls-panel">
<div class="filters-bar" id="filters-bar"> <div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-hook">Hook Event:</label>
<select id="filter-hook" multiple aria-label="Filter by hook event"></select>
</div>
<div class="filter-group"> <div class="filter-group">
<label for="filter-tag">Tag:</label> <label for="filter-tag">Tag:</label>
<select id="filter-tag" multiple aria-label="Filter by tag"></select> <select id="filter-tag" multiple aria-label="Filter by tag"></select>
@@ -42,8 +38,9 @@ const initialItems = sortHooks(hooksData.items, 'title');
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>
</div> </div>
</details>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} hooks</div> </div>
</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderHooksHtml(initialItems)}></div> <div class="resource-list" id="resource-list" role="list" set:html={renderHooksHtml(initialItems)}></div>
<ContributeCTA resourceType="hooks" /> <ContributeCTA resourceType="hooks" />
</div> </div>
@@ -55,6 +52,7 @@ const initialItems = sortHooks(hooksData.items, 'title');
<EmbeddedPageData filename="hooks.json" data={hooksData} /> <EmbeddedPageData filename="hooks.json" data={hooksData} />
<script> <script>
import '../scripts/listing-flyouts';
import '../scripts/pages/hooks'; import '../scripts/pages/hooks';
</script> </script>
</StarlightPage> </StarlightPage>

View File

@@ -40,49 +40,6 @@ const base = import.meta.env.BASE_URL;
Community-contributed agents, instructions, and skills to enhance your Community-contributed agents, instructions, and skills to enhance your
GitHub Copilot experience GitHub Copilot experience
</p> </p>
<div class="hero-search">
<label for="global-search" class="sr-only">Search all resources</label
>
<p id="global-search-help" class="sr-only">
Type at least two characters to show matching resources, then press
the Down Arrow key to move into the results.
</p>
<p id="global-search-status" class="sr-only" aria-live="polite"></p>
<div class="search-row">
<input
type="text"
id="global-search"
placeholder="Search all resources..."
autocomplete="off"
aria-describedby="global-search-help global-search-status"
/>
<a
href="https://github.com/github/awesome-copilot"
target="_blank"
rel="noopener"
class="github-btn"
aria-label="View on GitHub"
>
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="currentColor"
>
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
></path>
</svg>
<span>GitHub</span>
</a>
</div>
<div
id="search-results"
class="search-results hidden"
aria-label="Search results"
>
</div>
</div>
</div> </div>
</section> </section>

View File

@@ -18,11 +18,11 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
<div class="page-content"> <div class="page-content">
<div class="container"> <div class="container">
<div class="listing-toolbar"> <div class="listing-toolbar">
<div class="search-bar"> <div class="listing-toolbar-row">
<label for="search-input" class="sr-only">Search instructions</label> <div class="results-count" id="results-count" aria-live="polite">{initialItems.length} instructions</div>
<input type="text" id="search-input" placeholder="Search instructions..." autocomplete="off"> <details class="listing-controls">
</div> <summary class="listing-controls-trigger btn btn-secondary btn-small">Sort &amp; Filter</summary>
<div class="listing-controls-panel">
<div class="filters-bar" id="filters-bar"> <div class="filters-bar" id="filters-bar">
<div class="filter-group"> <div class="filter-group">
<label for="filter-extension">File Extension:</label> <label for="filter-extension">File Extension:</label>
@@ -38,8 +38,9 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>
</div> </div>
</details>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} instructions</div> </div>
</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderInstructionsHtml(initialItems)}></div> <div class="resource-list" id="resource-list" role="list" set:html={renderInstructionsHtml(initialItems)}></div>
<ContributeCTA resourceType="instructions" /> <ContributeCTA resourceType="instructions" />
</div> </div>
@@ -51,6 +52,7 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
<EmbeddedPageData filename="instructions.json" data={instructionsData} /> <EmbeddedPageData filename="instructions.json" data={instructionsData} />
<script> <script>
import '../scripts/listing-flyouts';
import '../scripts/pages/instructions'; import '../scripts/pages/instructions';
</script> </script>
</StarlightPage> </StarlightPage>

View File

@@ -6,9 +6,9 @@ import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import { renderPluginsHtml } from '../scripts/pages/plugins-render'; import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render';
const initialItems = pluginsData.items; const initialItems = sortPlugins(pluginsData.items, 'title');
--- ---
<StarlightPage frontmatter={{ title: 'Plugins', description: 'Curated plugins of agents, hooks, and skills for specific workflows', template: 'splash', prev: false, next: false, editUrl: false }}> <StarlightPage frontmatter={{ title: 'Plugins', description: 'Curated plugins of agents, hooks, and skills for specific workflows', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -27,21 +27,29 @@ const initialItems = pluginsData.items;
</div> </div>
<div class="listing-toolbar"> <div class="listing-toolbar">
<div class="search-bar"> <div class="listing-toolbar-row">
<label for="search-input" class="sr-only">Search plugins</label> <div class="results-count" id="results-count" aria-live="polite">{initialItems.length} plugins</div>
<input type="text" id="search-input" placeholder="Search plugins..." autocomplete="off"> <details class="listing-controls">
</div> <summary class="listing-controls-trigger btn btn-secondary btn-small">Sort &amp; Filter</summary>
<div class="listing-controls-panel">
<div class="filters-bar" id="filters-bar"> <div class="filters-bar" id="filters-bar">
<div class="filter-group"> <div class="filter-group">
<label for="filter-tag">Tag:</label> <label for="filter-tag">Tag:</label>
<select id="filter-tag" multiple aria-label="Filter by tag"></select> <select id="filter-tag" multiple aria-label="Filter by tag"></select>
</div> </div>
<div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort plugins">
<option value="title">Title</option>
<option value="lastUpdated">Recently Updated</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>
</div> </div>
</details>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} plugins</div> </div>
</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderPluginsHtml(initialItems)}></div> <div class="resource-list" id="resource-list" role="list" set:html={renderPluginsHtml(initialItems)}></div>
<ContributeCTA resourceType="plugins" /> <ContributeCTA resourceType="plugins" />
</div> </div>
@@ -53,6 +61,7 @@ const initialItems = pluginsData.items;
<EmbeddedPageData filename="plugins.json" data={pluginsData} /> <EmbeddedPageData filename="plugins.json" data={pluginsData} />
<script> <script>
import '../scripts/listing-flyouts';
import '../scripts/pages/plugins'; import '../scripts/pages/plugins';
</script> </script>

View File

@@ -18,22 +18,12 @@ const initialItems = sortSkills(skillsData.items, 'title');
<div class="page-content"> <div class="page-content">
<div class="container"> <div class="container">
<div class="listing-toolbar"> <div class="listing-toolbar">
<div class="search-bar"> <div class="listing-toolbar-row">
<label for="search-input" class="sr-only">Search skills</label> <div class="results-count" id="results-count" aria-live="polite">{initialItems.length} skills</div>
<input type="text" id="search-input" placeholder="Search skills..." autocomplete="off"> <details class="listing-controls">
</div> <summary class="listing-controls-trigger btn btn-secondary btn-small">Sort</summary>
<div class="listing-controls-panel">
<div class="filters-bar" id="filters-bar"> <div class="filters-bar" id="filters-bar">
<div class="filter-group">
<label for="filter-category">Category:</label>
<select id="filter-category" multiple aria-label="Filter by category"></select>
</div>
<div class="filter-group">
<label class="checkbox-label">
<input type="checkbox" id="filter-has-assets">
Has Bundled Assets
</label>
</div>
<div class="filter-group"> <div class="filter-group">
<label for="sort-select">Sort:</label> <label for="sort-select">Sort:</label>
<select id="sort-select" aria-label="Sort by"> <select id="sort-select" aria-label="Sort by">
@@ -41,11 +31,11 @@ const initialItems = sortSkills(skillsData.items, 'title');
<option value="lastUpdated">Recently Updated</option> <option value="lastUpdated">Recently Updated</option>
</select> </select>
</div> </div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>
</div> </div>
</details>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} skills</div> </div>
</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderSkillsHtml(initialItems)}></div> <div class="resource-list" id="resource-list" role="list" set:html={renderSkillsHtml(initialItems)}></div>
<ContributeCTA resourceType="skills" /> <ContributeCTA resourceType="skills" />
</div> </div>
@@ -57,6 +47,7 @@ const initialItems = sortSkills(skillsData.items, 'title');
<EmbeddedPageData filename="skills.json" data={skillsData} /> <EmbeddedPageData filename="skills.json" data={skillsData} />
<script> <script>
import '../scripts/listing-flyouts';
import '../scripts/pages/skills'; import '../scripts/pages/skills';
</script> </script>
</StarlightPage> </StarlightPage>

View File

@@ -6,12 +6,15 @@ import ContributeCTA from "../components/ContributeCTA.astro";
import EmbeddedPageData from "../components/EmbeddedPageData.astro"; import EmbeddedPageData from "../components/EmbeddedPageData.astro";
import PageHeader from "../components/PageHeader.astro"; import PageHeader from "../components/PageHeader.astro";
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import { renderToolsHtml } from "../scripts/pages/tools-render"; import { renderToolsHtml, sortTools } from "../scripts/pages/tools-render";
const initialItems = toolsData.items.map((item) => ({ const initialItems = sortTools(
toolsData.items.map((item) => ({
...item, ...item,
title: item.name, title: item.name,
})); })),
"title"
);
--- ---
<StarlightPage frontmatter={{ title: 'Tools', description: 'MCP servers and developer tools for GitHub Copilot', template: 'splash', prev: false, next: false, editUrl: false }}> <StarlightPage frontmatter={{ title: 'Tools', description: 'MCP servers and developer tools for GitHub Copilot', template: 'splash', prev: false, next: false, editUrl: false }}>
@@ -20,29 +23,34 @@ const initialItems = toolsData.items.map((item) => ({
<div class="page-content"> <div class="page-content">
<div class="container"> <div class="container">
<div class="search-section"> <div class="listing-toolbar">
<div class="search-bar"> <div class="listing-toolbar-row">
<label for="search-input" class="sr-only">Search tools</label> <div id="results-count" class="results-count" aria-live="polite">{initialItems.length} tools</div>
<input <details class="listing-controls">
type="text" <summary class="listing-controls-trigger btn btn-secondary btn-small">Sort &amp; Filter</summary>
id="search-input" <div class="listing-controls-panel">
placeholder="Search tools..." <div class="filters-bar" id="filters-bar">
class="search-input" <div class="filter-group">
/> <label for="filter-category">Category:</label>
</div>
<div class="filters">
<label for="filter-category" class="sr-only">Filter by category</label>
<select id="filter-category" class="filter-select" aria-label="Filter by category"> <select id="filter-category" class="filter-select" aria-label="Filter by category">
<option value="">All Categories</option> <option value="">All Categories</option>
{toolsData.filters.categories.map((category) => ( {toolsData.filters.categories.map((category) => (
<option value={category}>{category}</option> <option value={category}>{category}</option>
))} ))}
</select> </select>
<button id="clear-filters" class="btn btn-secondary btn-small"
>Clear</button
>
</div> </div>
<div id="results-count" class="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} tools</div> <div class="filter-group">
<label for="sort-select">Sort:</label>
<select id="sort-select" class="filter-select" aria-label="Sort tools">
<option value="featured">Featured First</option>
<option value="title">Title</option>
</select>
</div>
<button id="clear-filters" class="btn btn-secondary btn-small">Clear</button>
</div>
</div>
</details>
</div>
</div> </div>
<div id="tools-list" role="list" set:html={renderToolsHtml(initialItems)}></div> <div id="tools-list" role="list" set:html={renderToolsHtml(initialItems)}></div>
@@ -64,53 +72,6 @@ const initialItems = toolsData.items.map((item) => ({
<EmbeddedPageData filename="tools.json" data={toolsData} /> <EmbeddedPageData filename="tools.json" data={toolsData} />
<style is:global> <style is:global>
.search-section {
margin-bottom: 24px;
}
.search-bar {
margin-bottom: 16px;
}
.search-input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-card-bg);
color: var(--color-text-primary);
font-size: 16px;
}
.search-input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(133, 52, 243, 0.1);
}
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.filter-select {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-card-bg);
color: var(--color-text-primary);
font-size: 14px;
min-width: 180px;
}
.results-count {
margin-top: 12px;
font-size: 14px;
color: var(--color-text-muted);
}
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 48px; padding: 48px;
@@ -310,6 +271,7 @@ const initialItems = toolsData.items.map((item) => ({
</style> </style>
<script> <script>
import '../scripts/listing-flyouts';
import '../scripts/pages/tools'; import '../scripts/pages/tools';
</script> </script>
</StarlightPage> </StarlightPage>

View File

@@ -18,11 +18,11 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
<div class="page-content"> <div class="page-content">
<div class="container"> <div class="container">
<div class="listing-toolbar"> <div class="listing-toolbar">
<div class="search-bar"> <div class="listing-toolbar-row">
<label for="search-input" class="sr-only">Search workflows</label> <div class="results-count" id="results-count" aria-live="polite">{initialItems.length} workflows</div>
<input type="text" id="search-input" placeholder="Search workflows..." autocomplete="off"> <details class="listing-controls">
</div> <summary class="listing-controls-trigger btn btn-secondary btn-small">Sort &amp; Filter</summary>
<div class="listing-controls-panel">
<div class="filters-bar" id="filters-bar"> <div class="filters-bar" id="filters-bar">
<div class="filter-group"> <div class="filter-group">
<label for="filter-trigger">Trigger:</label> <label for="filter-trigger">Trigger:</label>
@@ -38,8 +38,9 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>
</div> </div>
</details>
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} workflows</div> </div>
</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderWorkflowsHtml(initialItems)}></div> <div class="resource-list" id="resource-list" role="list" set:html={renderWorkflowsHtml(initialItems)}></div>
<ContributeCTA resourceType="workflows" /> <ContributeCTA resourceType="workflows" />
</div> </div>
@@ -51,6 +52,7 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
<EmbeddedPageData filename="workflows.json" data={workflowsData} /> <EmbeddedPageData filename="workflows.json" data={workflowsData} />
<script> <script>
import '../scripts/listing-flyouts';
import '../scripts/pages/workflows'; import '../scripts/pages/workflows';
</script> </script>
</StarlightPage> </StarlightPage>

View File

@@ -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();
}

View File

@@ -35,36 +35,23 @@ export function sortAgents<T extends RenderableAgent>(
}); });
} }
export function renderAgentsHtml( export function renderAgentsHtml(items: RenderableAgent[]): string {
items: RenderableAgent[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = "", highlightTitle } = options;
if (items.length === 0) { if (items.length === 0) {
return ` return `
<div class="empty-state"> <div class="empty-state">
<h3>No agents found</h3> <h3>No agents found</h3>
<p>Try a different search term or adjust filters</p> <p>No agents are available right now.</p>
</div> </div>
`; `;
} }
return items return items
.map((item) => { .map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return ` return `
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem"> <article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem">
<button type="button" class="resource-preview"> <button type="button" class="resource-preview">
<div class="resource-info"> <div class="resource-info">
<div class="resource-title">${titleHtml}</div> <div class="resource-title">${escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml( <div class="resource-description">${escapeHtml(
item.description || "No description" item.description || "No description"
)}</div> )}</div>

View File

@@ -1,109 +1,48 @@
/** /**
* Agents page functionality * Agents page functionality
*/ */
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { import {
fetchData, fetchData,
debounce,
getQueryParam, getQueryParam,
getQueryParamFlag,
getQueryParamValues,
setupDropdownCloseHandlers, setupDropdownCloseHandlers,
setupActionHandlers, setupActionHandlers,
updateQueryParams, updateQueryParams,
} from '../utils'; } from '../utils';
import { setupModal, openFileModal } from '../modal'; 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 { interface Agent extends RenderableAgent {
model?: string | string[];
tools?: string[];
hasHandoffs?: boolean;
lastUpdated?: string | null; lastUpdated?: string | null;
} }
interface AgentsData { interface AgentsData {
items: Agent[]; items: Agent[];
filters: {
models: string[];
tools: string[];
};
} }
let allItems: Agent[] = []; let allItems: Agent[] = [];
let search = new FuzzySearch<Agent>();
let modelSelect: Choices;
let toolSelect: Choices;
let currentSort: AgentSortOption = 'title'; let currentSort: AgentSortOption = 'title';
let resourceListHandlersReady = false; 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 { function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const countEl = document.getElementById('results-count'); const countEl = document.getElementById('results-count');
const query = searchInput?.value || ''; const results = sortAgents(allItems, currentSort);
let results = query ? search.search(query) : [...allItems]; renderItems(results);
if (countEl) {
if (currentFilters.models.length > 0) { countEl.textContent = `${results.length} agent${results.length === 1 ? '' : 's'}`;
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));
});
}
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'); const list = document.getElementById('resource-list');
if (!list) return; if (!list) return;
list.innerHTML = renderAgentsHtml(items, { list.innerHTML = renderAgentsHtml(items);
query,
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
});
} }
function setupResourceListHandlers(list: HTMLElement | null): void { function setupResourceListHandlers(list: HTMLElement | null): void {
@@ -125,21 +64,18 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true; resourceListHandlersReady = true;
} }
function syncUrlState(searchInput: HTMLInputElement | null): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: searchInput?.value ?? '', q: '',
model: currentFilters.models, model: [],
tool: currentFilters.tools, tool: [],
handoffs: currentFilters.hasHandoffs, handoffs: false,
sort: currentSort === 'title' ? '' : currentSort, sort: currentSort === 'title' ? '' : currentSort,
}); });
} }
export async function initAgentsPage(): Promise<void> { export async function initAgentsPage(): Promise<void> {
const list = document.getElementById('resource-list'); 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; const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
@@ -151,84 +87,17 @@ export async function initAgentsPage(): Promise<void> {
} }
allItems = data.items; 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'); 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') { if (initialSort === 'lastUpdated') {
currentSort = initialSort; currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort; if (sortSelect) sortSelect.value = initialSort;
} }
sortSelect?.addEventListener('change', () => { sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as AgentSortOption; currentSort = sortSelect.value as AgentSortOption;
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
});
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);
}); });
applyFiltersAndRender(); applyFiltersAndRender();

View File

@@ -33,38 +33,25 @@ export function sortHooks<T extends RenderableHook>(
}); });
} }
export function renderHooksHtml( export function renderHooksHtml(items: RenderableHook[]): string {
items: RenderableHook[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = "", highlightTitle } = options;
if (items.length === 0) { if (items.length === 0) {
return ` return `
<div class="empty-state"> <div class="empty-state">
<h3>No hooks found</h3> <h3>No hooks found</h3>
<p>Try a different search term or adjust filters</p> <p>Try adjusting the selected filters.</p>
</div> </div>
`; `;
} }
return items return items
.map((item) => { .map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return ` return `
<article class="resource-item" data-path="${escapeHtml( <article class="resource-item" data-path="${escapeHtml(
item.readmeFile item.readmeFile
)}" data-hook-id="${escapeHtml(item.id)}" role="listitem"> )}" data-hook-id="${escapeHtml(item.id)}" role="listitem">
<button type="button" class="resource-preview"> <button type="button" class="resource-preview">
<div class="resource-info"> <div class="resource-info">
<div class="resource-title">${titleHtml}</div> <div class="resource-title">${escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml( <div class="resource-description">${escapeHtml(
item.description || "No description" item.description || "No description"
)}</div> )}</div>

View File

@@ -7,10 +7,8 @@ import {
setChoicesValues, setChoicesValues,
type Choices, type Choices,
} from "../choices"; } from "../choices";
import { FuzzySearch, type SearchItem } from "../search";
import { import {
fetchData, fetchData,
debounce,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
showToast, showToast,
@@ -25,23 +23,19 @@ import {
type RenderableHook, type RenderableHook,
} from "./hooks-render"; } from "./hooks-render";
interface Hook extends SearchItem, RenderableHook {} interface Hook extends RenderableHook {}
interface HooksData { interface HooksData {
items: Hook[]; items: Hook[];
filters: { filters: {
hooks: string[];
tags: string[]; tags: string[];
}; };
} }
const resourceType = "hook"; const resourceType = "hook";
let allItems: Hook[] = []; let allItems: Hook[] = [];
let search = new FuzzySearch<Hook>();
let hookSelect: Choices;
let tagSelect: Choices; let tagSelect: Choices;
let currentFilters = { let currentFilters = {
hooks: [] as string[],
tags: [] as string[], tags: [] as string[],
}; };
let currentSort: HookSortOption = "title"; let currentSort: HookSortOption = "title";
@@ -52,57 +46,30 @@ function sortItems(items: Hook[]): Hook[] {
} }
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
const countEl = document.getElementById("results-count"); 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) { if (currentFilters.tags.length > 0) {
results = results.filter((item) => results = results.filter((item) =>
item.tags.some((t) => currentFilters.tags.includes(t)) item.tags.some((tag) => currentFilters.tags.includes(tag))
); );
} }
results = sortItems(results); results = sortItems(results);
renderItems(results, query); renderItems(results);
const activeFilters: string[] = []; let countText = `${results.length} hook${results.length === 1 ? "" : "s"}`;
if (currentFilters.hooks.length > 0) if (currentFilters.tags.length > 0) {
activeFilters.push( countText = `${results.length} of ${allItems.length} hooks (filtered by ${currentFilters.tags.length} tag${currentFilters.tags.length > 1 ? "s" : ""})`;
`${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(", ")})`;
} }
if (countEl) countEl.textContent = countText; if (countEl) countEl.textContent = countText;
} }
function renderItems(items: Hook[], query = ""): void { function renderItems(items: Hook[]): void {
const list = document.getElementById("resource-list"); const list = document.getElementById("resource-list");
if (!list) return; if (!list) return;
list.innerHTML = renderHooksHtml(items, { list.innerHTML = renderHooksHtml(items);
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
} }
function setupResourceListHandlers(list: HTMLElement | null): void { function setupResourceListHandlers(list: HTMLElement | null): void {
@@ -134,10 +101,10 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true; resourceListHandlersReady = true;
} }
function syncUrlState(searchInput: HTMLInputElement | null): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: searchInput?.value ?? "", q: "",
hook: currentFilters.hooks, hook: [],
tag: currentFilters.tags, tag: currentFilters.tags,
sort: currentSort === "title" ? "" : currentSort, sort: currentSort === "title" ? "" : currentSort,
}); });
@@ -153,12 +120,11 @@ async function downloadHook(
return; return;
} }
// Build file list: README.md + all assets
const files = [ const files = [
{ name: "README.md", path: hook.readmeFile }, { name: "README.md", path: hook.readmeFile },
...hook.assets.map((a) => ({ ...hook.assets.map((asset) => ({
name: a, name: asset,
path: `${hook.path}/${a}`, path: `${hook.path}/${asset}`,
})), })),
]; ];
@@ -196,9 +162,6 @@ async function downloadHook(
export async function initHooksPage(): Promise<void> { export async function initHooksPage(): Promise<void> {
const list = document.getElementById("resource-list"); const list = document.getElementById("resource-list");
const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
const clearFiltersBtn = document.getElementById("clear-filters"); const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById( const sortSelect = document.getElementById(
"sort-select" "sort-select"
@@ -215,90 +178,53 @@ export async function initHooksPage(): Promise<void> {
} }
allItems = data.items; allItems = data.items;
search.setItems(allItems);
// Setup hook event filter tagSelect = createChoices("#filter-tag", {
hookSelect = createChoices("#filter-hook", { placeholderValue: "All Tags",
placeholderValue: "All Events",
}); });
hookSelect.setChoices( tagSelect.setChoices(
data.filters.hooks.map((h) => ({ value: h, label: h })), data.filters.tags.map((tag) => ({ value: tag, label: tag })),
"value", "value",
"label", "label",
true true
); );
const initialQuery = getQueryParam("q");
const initialHooks = getQueryParamValues("hook").filter((hook) =>
data.filters.hooks.includes(hook)
);
const initialTags = getQueryParamValues("tag").filter((tag) => const initialTags = getQueryParamValues("tag").filter((tag) =>
data.filters.tags.includes(tag) data.filters.tags.includes(tag)
); );
const initialSort = getQueryParam("sort"); 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) { if (initialTags.length > 0) {
currentFilters.tags = initialTags; currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags); setChoicesValues(tagSelect, initialTags);
} }
document.getElementById("filter-tag")?.addEventListener("change", () => {
currentFilters.tags = getChoicesValues(tagSelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
if (initialSort === "lastUpdated") { if (initialSort === "lastUpdated") {
currentSort = initialSort; currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort; if (sortSelect) sortSelect.value = initialSort;
} }
document.getElementById("filter-tag")?.addEventListener("change", () => {
currentFilters.tags = getChoicesValues(tagSelect);
applyFiltersAndRender();
syncUrlState();
});
sortSelect?.addEventListener("change", () => { sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as HookSortOption; currentSort = sortSelect.value as HookSortOption;
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
applyFiltersAndRender();
searchInput?.addEventListener(
"input",
debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200)
);
clearFiltersBtn?.addEventListener("click", () => { clearFiltersBtn?.addEventListener("click", () => {
currentFilters = { hooks: [], tags: [] }; currentFilters = { tags: [] };
currentSort = "title"; currentSort = "title";
hookSelect.removeActiveItems();
tagSelect.removeActiveItems(); tagSelect.removeActiveItems();
if (searchInput) searchInput.value = "";
if (sortSelect) sortSelect.value = "title"; if (sortSelect) sortSelect.value = "title";
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
applyFiltersAndRender();
setupModal(); setupModal();
} }

View File

@@ -1,49 +1,7 @@
/** /**
* Homepage functionality * Homepage functionality
*/ */
import { FuzzySearch, type SearchItem } from '../search'; import { fetchData } from "../utils";
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>`;
}
interface Manifest { interface Manifest {
counts: { 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> { export async function initHomepage(): Promise<void> {
// Load manifest for stats // Load manifest for stats
const manifest = await fetchData<Manifest>('manifest.json'); const manifest = await fetchData<Manifest>("manifest.json");
if (manifest && manifest.counts) { if (manifest && manifest.counts) {
// Populate counts in cards // Populate counts in cards
const countKeys = ['agents', 'instructions', 'skills', 'hooks', 'workflows', 'plugins', 'tools'] as const; const countKeys = [
countKeys.forEach(key => { "agents",
const countEl = document.querySelector(`.card-count[data-count="${key}"]`); "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) { if (countEl && manifest.counts[key] !== undefined) {
countEl.textContent = manifest.counts[key].toString(); 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 // Auto-initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initHomepage); document.addEventListener("DOMContentLoaded", initHomepage);

View File

@@ -33,19 +33,13 @@ export function sortInstructions<T extends RenderableInstruction>(
} }
export function renderInstructionsHtml( export function renderInstructionsHtml(
items: RenderableInstruction[], items: RenderableInstruction[]
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string { ): string {
const { query = '', highlightTitle } = options;
if (items.length === 0) { if (items.length === 0) {
return ` return `
<div class="empty-state"> <div class="empty-state">
<h3>No instructions found</h3> <h3>No instructions found</h3>
<p>Try a different search term or adjust filters</p> <p>Try adjusting the selected filters.</p>
</div> </div>
`; `;
} }
@@ -55,16 +49,12 @@ export function renderInstructionsHtml(
const applyToText = Array.isArray(item.applyTo) const applyToText = Array.isArray(item.applyTo)
? item.applyTo.join(', ') ? item.applyTo.join(', ')
: item.applyTo; : item.applyTo;
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return ` return `
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem"> <article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem">
<button type="button" class="resource-preview"> <button type="button" class="resource-preview">
<div class="resource-info"> <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-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta"> <div class="resource-meta">
${applyToText ? `<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>` : ''} ${applyToText ? `<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>` : ''}

View File

@@ -7,10 +7,8 @@ import {
setChoicesValues, setChoicesValues,
type Choices, type Choices,
} from '../choices'; } from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { import {
fetchData, fetchData,
debounce,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
setupDropdownCloseHandlers, setupDropdownCloseHandlers,
@@ -25,7 +23,7 @@ import {
type RenderableInstruction, type RenderableInstruction,
} from './instructions-render'; } from './instructions-render';
interface Instruction extends SearchItem, RenderableInstruction { interface Instruction extends RenderableInstruction {
path: string; path: string;
applyTo?: string | string[]; applyTo?: string | string[];
extensions?: string[]; extensions?: string[];
@@ -41,7 +39,6 @@ interface InstructionsData {
const resourceType = 'instruction'; const resourceType = 'instruction';
let allItems: Instruction[] = []; let allItems: Instruction[] = [];
let search = new FuzzySearch<Instruction>();
let extensionSelect: Choices; let extensionSelect: Choices;
let currentFilters = { extensions: [] as string[] }; let currentFilters = { extensions: [] as string[] };
let currentSort: InstructionSortOption = 'title'; let currentSort: InstructionSortOption = 'title';
@@ -52,11 +49,8 @@ function sortItems(items: Instruction[]): Instruction[] {
} }
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const countEl = document.getElementById('results-count'); const countEl = document.getElementById('results-count');
const query = searchInput?.value || ''; let results = [...allItems];
let results = query ? search.search(query) : [...allItems];
if (currentFilters.extensions.length > 0) { if (currentFilters.extensions.length > 0) {
results = results.filter(item => { results = results.filter(item => {
@@ -69,22 +63,19 @@ function applyFiltersAndRender(): void {
results = sortItems(results); results = sortItems(results);
renderItems(results, query); renderItems(results);
let countText = `${results.length} of ${allItems.length} instructions`; let countText = `${results.length} instruction${results.length === 1 ? '' : 's'}`;
if (currentFilters.extensions.length > 0) { 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; if (countEl) countEl.textContent = countText;
} }
function renderItems(items: Instruction[], query = ''): void { function renderItems(items: Instruction[]): void {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
if (!list) return; if (!list) return;
list.innerHTML = renderInstructionsHtml(items, { list.innerHTML = renderInstructionsHtml(items);
query,
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
});
} }
function setupResourceListHandlers(list: HTMLElement | null): void { function setupResourceListHandlers(list: HTMLElement | null): void {
@@ -106,9 +97,9 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true; resourceListHandlersReady = true;
} }
function syncUrlState(searchInput: HTMLInputElement | null): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: searchInput?.value ?? '', q: '',
extension: currentFilters.extensions, extension: currentFilters.extensions,
sort: currentSort === 'title' ? '' : currentSort, sort: currentSort === 'title' ? '' : currentSort,
}); });
@@ -116,7 +107,6 @@ function syncUrlState(searchInput: HTMLInputElement | null): void {
export async function initInstructionsPage(): Promise<void> { export async function initInstructionsPage(): Promise<void> {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const clearFiltersBtn = document.getElementById('clear-filters'); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement; const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
@@ -129,16 +119,13 @@ export async function initInstructionsPage(): Promise<void> {
} }
allItems = data.items; allItems = data.items;
search.setItems(allItems);
extensionSelect = createChoices('#filter-extension', { placeholderValue: 'All Extensions' }); extensionSelect = createChoices('#filter-extension', { placeholderValue: 'All Extensions' });
extensionSelect.setChoices(data.filters.extensions.map(e => ({ value: e, label: e })), 'value', 'label', true); 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 initialExtensions = getQueryParamValues('extension').filter(extension => data.filters.extensions.includes(extension));
const initialSort = getQueryParam('sort'); const initialSort = getQueryParam('sort');
if (searchInput) searchInput.value = initialQuery;
if (initialExtensions.length > 0) { if (initialExtensions.length > 0) {
currentFilters.extensions = initialExtensions; currentFilters.extensions = initialExtensions;
setChoicesValues(extensionSelect, initialExtensions); setChoicesValues(extensionSelect, initialExtensions);
@@ -151,33 +138,22 @@ export async function initInstructionsPage(): Promise<void> {
document.getElementById('filter-extension')?.addEventListener('change', () => { document.getElementById('filter-extension')?.addEventListener('change', () => {
currentFilters.extensions = getChoicesValues(extensionSelect); currentFilters.extensions = getChoicesValues(extensionSelect);
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
sortSelect?.addEventListener('change', () => { sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as InstructionSortOption; currentSort = sortSelect.value as InstructionSortOption;
applyFiltersAndRender(); 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', () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { extensions: [] }; currentFilters = { extensions: [] };
currentSort = 'title'; currentSort = 'title';
extensionSelect.removeActiveItems(); extensionSelect.removeActiveItems();
if (searchInput) searchInput.value = '';
if (sortSelect) sortSelect.value = 'title'; if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
applyFiltersAndRender(); applyFiltersAndRender();

View File

@@ -1,4 +1,8 @@
import { escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils'; import {
escapeHtml,
getGitHubUrl,
sanitizeUrl,
} from '../utils';
interface PluginAuthor { interface PluginAuthor {
name: string; name: string;
@@ -17,6 +21,7 @@ export interface RenderablePlugin {
path: string; path: string;
tags?: string[]; tags?: string[];
itemCount: number; itemCount: number;
lastUpdated?: string | null;
external?: boolean; external?: boolean;
repository?: string | null; repository?: string | null;
homepage?: string | null; homepage?: string | null;
@@ -24,6 +29,23 @@ export interface RenderablePlugin {
source?: PluginSource | null; 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 { function getExternalPluginUrl(plugin: RenderablePlugin): string {
if (plugin.source?.source === 'github' && plugin.source.repo) { if (plugin.source?.source === 'github' && plugin.source.repo) {
const base = `https://github.com/${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); return sanitizeUrl(plugin.repository || plugin.homepage);
} }
export function renderPluginsHtml( export function renderPluginsHtml(items: RenderablePlugin[]): string {
items: RenderablePlugin[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = '', highlightTitle } = options;
if (items.length === 0) { if (items.length === 0) {
return ` return `
<div class="empty-state"> <div class="empty-state">
<h3>No plugins found</h3> <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> </div>
`; `;
} }
@@ -64,16 +78,11 @@ export function renderPluginsHtml(
const githubHref = isExternal const githubHref = isExternal
? escapeHtml(getExternalPluginUrl(item)) ? escapeHtml(getExternalPluginUrl(item))
: getGitHubUrl(item.path); : getGitHubUrl(item.path);
const titleHtml =
query && highlightTitle
? highlightTitle(item.name, query)
: escapeHtml(item.name);
return ` return `
<article class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}" role="listitem"> <article class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}" role="listitem">
<button type="button" class="resource-preview"> <button type="button" class="resource-preview">
<div class="resource-info"> <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-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta"> <div class="resource-meta">
${metaTag} ${metaTag}

View File

@@ -7,16 +7,19 @@ import {
setChoicesValues, setChoicesValues,
type Choices, type Choices,
} from '../choices'; } from '../choices';
import { FuzzySearch, type SearchItem } from '../search';
import { import {
fetchData, fetchData,
debounce,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
updateQueryParams, updateQueryParams,
} from '../utils'; } from '../utils';
import { setupModal, openFileModal } from '../modal'; import { setupModal, openFileModal } from '../modal';
import { renderPluginsHtml, type RenderablePlugin } from './plugins-render'; import {
renderPluginsHtml,
sortPlugins,
type PluginSortOption,
type RenderablePlugin,
} from './plugins-render';
interface PluginAuthor { interface PluginAuthor {
name: string; name: string;
@@ -29,7 +32,7 @@ interface PluginSource {
path?: string; path?: string;
} }
interface Plugin extends SearchItem, RenderablePlugin { interface Plugin extends RenderablePlugin {
id: string; id: string;
name: string; name: string;
path: string; path: string;
@@ -52,42 +55,44 @@ interface PluginsData {
const resourceType = 'plugin'; const resourceType = 'plugin';
let allItems: Plugin[] = []; let allItems: Plugin[] = [];
let search = new FuzzySearch<Plugin>();
let tagSelect: Choices; let tagSelect: Choices;
let currentSort: PluginSortOption = 'title';
let currentFilters = { let currentFilters = {
tags: [] as string[], tags: [] as string[],
}; };
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
function applyFiltersAndRender(): void { function sortItems(items: Plugin[]): Plugin[] {
const searchInput = document.getElementById('search-input') as HTMLInputElement; return sortPlugins(items, currentSort);
const countEl = document.getElementById('results-count'); }
const query = searchInput?.value || '';
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) { if (currentFilters.tags.length > 0) {
results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag))); results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag)));
} }
renderItems(results, query); results = sortItems(results);
const activeFilters: string[] = [];
if (currentFilters.tags.length > 0) activeFilters.push(`${currentFilters.tags.length} tag${currentFilters.tags.length > 1 ? 's' : ''}`); renderItems(results);
let countText = `${results.length} of ${allItems.length} plugins`; if (countEl) countEl.textContent = getCountText(results.length);
if (activeFilters.length > 0) {
countText += ` (filtered by ${activeFilters.join(', ')})`;
}
if (countEl) countEl.textContent = countText;
} }
function renderItems(items: Plugin[], query = ''): void { function renderItems(items: Plugin[]): void {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
if (!list) return; if (!list) return;
list.innerHTML = renderPluginsHtml(items, { list.innerHTML = renderPluginsHtml(items);
query,
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
});
} }
function setupResourceListHandlers(list: HTMLElement | null): void { function setupResourceListHandlers(list: HTMLElement | null): void {
@@ -109,17 +114,18 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true; resourceListHandlersReady = true;
} }
function syncUrlState(searchInput: HTMLInputElement | null): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: searchInput?.value ?? '', q: '',
tag: currentFilters.tags, tag: currentFilters.tags,
sort: currentSort === 'title' ? '' : currentSort,
}); });
} }
export async function initPluginsPage(): Promise<void> { export async function initPluginsPage(): Promise<void> {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement;
const clearFiltersBtn = document.getElementById('clear-filters'); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
@@ -131,21 +137,12 @@ export async function initPluginsPage(): Promise<void> {
allItems = data.items; 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 = createChoices('#filter-tag', { placeholderValue: 'All Tags' });
tagSelect.setChoices(data.filters.tags.map(t => ({ value: t, label: t })), 'value', 'label', true); 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 initialTags = getQueryParamValues('tag').filter(tag => data.filters.tags.includes(tag));
const initialSort = getQueryParam('sort');
if (searchInput) searchInput.value = initialQuery;
if (initialTags.length > 0) { if (initialTags.length > 0) {
currentFilters.tags = initialTags; currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags); setChoicesValues(tagSelect, initialTags);
@@ -154,28 +151,30 @@ export async function initPluginsPage(): Promise<void> {
document.getElementById('filter-tag')?.addEventListener('change', () => { document.getElementById('filter-tag')?.addEventListener('change', () => {
currentFilters.tags = getChoicesValues(tagSelect); currentFilters.tags = getChoicesValues(tagSelect);
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
const countEl = document.getElementById('results-count'); if (initialSort === 'lastUpdated') {
if (countEl) { currentSort = initialSort;
countEl.textContent = `${allItems.length} of ${allItems.length} plugins`; if (sortSelect) sortSelect.value = initialSort;
} }
sortSelect?.addEventListener('change', () => {
searchInput?.addEventListener('input', debounce(() => { currentSort = sortSelect.value as PluginSortOption;
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}, 200)); });
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tags: [] }; currentFilters = { tags: [] };
currentSort = 'title';
tagSelect.removeActiveItems(); tagSelect.removeActiveItems();
if (searchInput) searchInput.value = ''; if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState();
setupModal(); setupModal();
} }

View File

@@ -39,45 +39,29 @@ export function sortSkills<T extends RenderableSkill>(
}); });
} }
export function renderSkillsHtml( export function renderSkillsHtml(items: RenderableSkill[]): string {
items: RenderableSkill[],
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string {
const { query = "", highlightTitle } = options;
if (items.length === 0) { if (items.length === 0) {
return ` return `
<div class="empty-state"> <div class="empty-state">
<h3>No skills found</h3> <h3>No skills found</h3>
<p>Try a different search term or adjust filters</p> <p>No skills are available right now.</p>
</div> </div>
`; `;
} }
return items return items
.map((item) => { .map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return ` return `
<article class="resource-item" data-path="${escapeHtml( <article class="resource-item" data-path="${escapeHtml(
item.skillFile item.skillFile
)}" data-skill-id="${escapeHtml(item.id)}" role="listitem"> )}" data-skill-id="${escapeHtml(item.id)}" role="listitem">
<button type="button" class="resource-preview"> <button type="button" class="resource-preview">
<div class="resource-info"> <div class="resource-info">
<div class="resource-title">${titleHtml}</div> <div class="resource-title">${escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml( <div class="resource-description">${escapeHtml(
item.description || "No description" item.description || "No description"
)}</div> )}</div>
<div class="resource-meta"> <div class="resource-meta">
<span class="resource-tag tag-category">${escapeHtml(
item.category
)}</span>
${ ${
item.hasAssets item.hasAssets
? `<span class="resource-tag tag-assets">${ ? `<span class="resource-tag tag-assets">${

View File

@@ -1,19 +1,9 @@
/** /**
* Skills page functionality * Skills page functionality
*/ */
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import { FuzzySearch, type SearchItem } from "../search";
import { import {
fetchData, fetchData,
debounce,
getQueryParam, getQueryParam,
getQueryParamFlag,
getQueryParamValues,
showToast, showToast,
downloadZipBundle, downloadZipBundle,
updateQueryParams, updateQueryParams,
@@ -33,77 +23,34 @@ interface SkillFile {
path: string; path: string;
} }
interface Skill extends SearchItem, Omit<RenderableSkill, "files"> { interface Skill extends Omit<RenderableSkill, "files"> {
files: SkillFile[]; files: SkillFile[];
} }
interface SkillsData { interface SkillsData {
items: Skill[]; items: Skill[];
filters: {
categories: string[];
};
} }
const resourceType = "skill"; const resourceType = "skill";
let allItems: Skill[] = []; let allItems: Skill[] = [];
let search = new FuzzySearch<Skill>();
let categorySelect: Choices;
let currentFilters = {
categories: [] as string[],
hasAssets: false,
};
let currentSort: SkillSortOption = "title"; let currentSort: SkillSortOption = "title";
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
function sortItems(items: Skill[]): Skill[] {
return sortSkills(items, currentSort);
}
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
const countEl = document.getElementById("results-count"); const countEl = document.getElementById("results-count");
const query = searchInput?.value || ""; const results = sortSkills(allItems, currentSort);
let results = query ? search.search(query) : [...allItems]; renderItems(results);
if (countEl) {
if (currentFilters.categories.length > 0) { countEl.textContent = `${results.length} skill${results.length === 1 ? "" : "s"}`;
results = results.filter((item) =>
currentFilters.categories.includes(item.category)
);
} }
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"); const list = document.getElementById("resource-list");
if (!list) return; if (!list) return;
list.innerHTML = renderSkillsHtml(items, { list.innerHTML = renderSkillsHtml(items);
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
} }
function setupResourceListHandlers(list: HTMLElement | null): void { function setupResourceListHandlers(list: HTMLElement | null): void {
@@ -142,11 +89,11 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true; resourceListHandlersReady = true;
} }
function syncUrlState(searchInput: HTMLInputElement | null): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: searchInput?.value ?? "", q: "",
category: currentFilters.categories, category: [],
hasAssets: currentFilters.hasAssets, hasAssets: false,
sort: currentSort === "title" ? "" : currentSort, sort: currentSort === "title" ? "" : currentSort,
}); });
} }
@@ -209,13 +156,6 @@ async function downloadSkill(
export async function initSkillsPage(): Promise<void> { export async function initSkillsPage(): Promise<void> {
const list = document.getElementById("resource-list"); 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( const sortSelect = document.getElementById(
"sort-select" "sort-select"
) as HTMLSelectElement; ) as HTMLSelectElement;
@@ -231,76 +171,20 @@ export async function initSkillsPage(): Promise<void> {
} }
allItems = data.items; 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"); 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") { if (initialSort === "lastUpdated") {
currentSort = initialSort; currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort; if (sortSelect) sortSelect.value = initialSort;
} }
document.getElementById("filter-category")?.addEventListener("change", () => {
currentFilters.categories = getChoicesValues(categorySelect);
applyFiltersAndRender();
syncUrlState(searchInput);
});
sortSelect?.addEventListener("change", () => { sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as SkillSortOption; currentSort = sortSelect.value as SkillSortOption;
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
applyFiltersAndRender(); 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(); setupModal();
} }

View File

@@ -27,6 +27,22 @@ export interface RenderableTool {
tags: string[]; 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 { function formatMultilineText(text: string): string {
return escapeHtml(text).replace(/\r?\n/g, "<br>"); return escapeHtml(text).replace(/\r?\n/g, "<br>");
} }
@@ -61,19 +77,13 @@ function getToolActionLink(
} }
export function renderToolsHtml( export function renderToolsHtml(
tools: RenderableTool[], tools: RenderableTool[]
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string { ): string {
const { query = "", highlightTitle } = options;
if (tools.length === 0) { if (tools.length === 0) {
return ` return `
<div class="empty-state"> <div class="empty-state">
<h3>No tools found</h3> <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> </div>
`; `;
} }
@@ -172,15 +182,10 @@ export function renderToolsHtml(
? `<div class="tool-actions">${actions.join("")}</div>` ? `<div class="tool-actions">${actions.join("")}</div>`
: ""; : "";
const titleHtml =
query && highlightTitle
? highlightTitle(tool.name, query)
: escapeHtml(tool.name);
return ` return `
<div class="tool-card"> <div class="tool-card">
<div class="tool-header"> <div class="tool-header">
<h2>${titleHtml}</h2> <h2>${escapeHtml(tool.name)}</h2>
<div class="tool-badges"> <div class="tool-badges">
${badges.join("")} ${badges.join("")}
</div> </div>

View File

@@ -1,16 +1,18 @@
/** /**
* Tools page functionality * Tools page functionality
*/ */
import { FuzzySearch, type SearchableItem } from "../search";
import { import {
fetchData, fetchData,
debounce,
getQueryParam, getQueryParam,
updateQueryParams, updateQueryParams,
} from "../utils"; } 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; id: string;
name: string; name: string;
title: string; title: string;
@@ -46,23 +48,28 @@ interface ToolsData {
} }
let allItems: Tool[] = []; let allItems: Tool[] = [];
let search = new FuzzySearch<Tool>();
let currentFilters = { let currentFilters = {
categories: [] as string[], categories: [] as string[],
query: "",
}; };
let currentSort: ToolSortOption = "featured";
let copyHandlersReady = false; let copyHandlersReady = false;
let initialized = false; let initialized = false;
function applyFiltersAndRender(): void { function sortItems(items: Tool[]): Tool[] {
const searchInput = document.getElementById( return sortTools(items, currentSort);
"search-input" }
) as HTMLInputElement;
const countEl = document.getElementById("results-count");
const query = searchInput?.value || "";
currentFilters.query = query;
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) { if (currentFilters.categories.length > 0) {
results = results.filter((item) => 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`; renderTools(results);
if (currentFilters.categories.length > 0) { if (countEl) countEl.textContent = getCountText(results.length);
countText += ` (filtered by ${currentFilters.categories.length} categories)`;
}
if (countEl) countEl.textContent = countText;
} }
function renderTools(tools: Tool[], query = ""): void { function renderTools(tools: Tool[]): void {
const container = document.getElementById("tools-list"); const container = document.getElementById("tools-list");
if (!container) return; if (!container) return;
container.innerHTML = renderToolsHtml(tools, { container.innerHTML = renderToolsHtml(tools);
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
} }
function syncUrlState(searchInput: HTMLInputElement | null): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: searchInput?.value ?? "", q: "",
category: currentFilters.categories, category: currentFilters.categories,
sort: currentSort === "featured" ? "" : currentSort,
}); });
} }
@@ -133,13 +134,11 @@ export async function initToolsPage(): Promise<void> {
if (initialized) return; if (initialized) return;
initialized = true; initialized = true;
const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
const categoryFilter = document.getElementById( const categoryFilter = document.getElementById(
"filter-category" "filter-category"
) as HTMLSelectElement; ) as HTMLSelectElement;
const clearFiltersBtn = document.getElementById("clear-filters"); const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById("sort-select") as HTMLSelectElement;
const data = await fetchData<ToolsData>("tools.json"); const data = await fetchData<ToolsData>("tools.json");
if (!data || !data.items) { if (!data || !data.items) {
@@ -156,9 +155,6 @@ export async function initToolsPage(): Promise<void> {
title: item.name, // FuzzySearch uses title title: item.name, // FuzzySearch uses title
})); }));
search = new FuzzySearch<Tool>();
search.setItems(allItems);
// Populate category filter // Populate category filter
if (categoryFilter && data.filters.categories) { if (categoryFilter && data.filters.categories) {
categoryFilter.innerHTML = categoryFilter.innerHTML =
@@ -180,31 +176,32 @@ export async function initToolsPage(): Promise<void> {
? [categoryFilter.value] ? [categoryFilter.value]
: []; : [];
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
} }
const initialQuery = getQueryParam("q"); const initialSort = getQueryParam("sort");
if (searchInput) searchInput.value = initialQuery; if (initialSort === "title") {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as ToolSortOption;
applyFiltersAndRender();
syncUrlState();
});
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState();
// Search input handler
searchInput?.addEventListener(
"input",
debounce(() => {
applyFiltersAndRender();
syncUrlState(searchInput);
}, 200)
);
// Clear filters // Clear filters
clearFiltersBtn?.addEventListener("click", () => { clearFiltersBtn?.addEventListener("click", () => {
currentFilters = { categories: [], query: "" }; currentFilters = { categories: [] };
currentSort = "featured";
if (categoryFilter) categoryFilter.value = ""; if (categoryFilter) categoryFilter.value = "";
if (searchInput) searchInput.value = ""; if (sortSelect) sortSelect.value = "featured";
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
setupCopyConfigHandlers(); setupCopyConfigHandlers();

View File

@@ -31,35 +31,24 @@ export function sortWorkflows<T extends RenderableWorkflow>(
} }
export function renderWorkflowsHtml( export function renderWorkflowsHtml(
items: RenderableWorkflow[], items: RenderableWorkflow[]
options: {
query?: string;
highlightTitle?: (title: string, query: string) => string;
} = {}
): string { ): string {
const { query = '', highlightTitle } = options;
if (items.length === 0) { if (items.length === 0) {
return ` return `
<div class="empty-state"> <div class="empty-state">
<h3>No workflows found</h3> <h3>No workflows found</h3>
<p>Try a different search term or adjust filters</p> <p>Try adjusting the selected filters.</p>
</div> </div>
`; `;
} }
return items return items
.map((item) => { .map((item) => {
const titleHtml =
query && highlightTitle
? highlightTitle(item.title, query)
: escapeHtml(item.title);
return ` return `
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem"> <article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem">
<button type="button" class="resource-preview"> <button type="button" class="resource-preview">
<div class="resource-info"> <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-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta"> <div class="resource-meta">
${item.triggers.map((trigger) => `<span class="resource-tag tag-trigger">${escapeHtml(trigger)}</span>`).join('')} ${item.triggers.map((trigger) => `<span class="resource-tag tag-trigger">${escapeHtml(trigger)}</span>`).join('')}

View File

@@ -7,10 +7,8 @@ import {
setChoicesValues, setChoicesValues,
type Choices, type Choices,
} from "../choices"; } from "../choices";
import { FuzzySearch, type SearchItem } from "../search";
import { import {
fetchData, fetchData,
debounce,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
setupActionHandlers, setupActionHandlers,
@@ -24,7 +22,7 @@ import {
type WorkflowSortOption, type WorkflowSortOption,
} from "./workflows-render"; } from "./workflows-render";
interface Workflow extends SearchItem, RenderableWorkflow { interface Workflow extends RenderableWorkflow {
id: string; id: string;
path: string; path: string;
triggers: string[]; triggers: string[];
@@ -40,7 +38,6 @@ interface WorkflowsData {
const resourceType = "workflow"; const resourceType = "workflow";
let allItems: Workflow[] = []; let allItems: Workflow[] = [];
let search = new FuzzySearch<Workflow>();
let triggerSelect: Choices; let triggerSelect: Choices;
let currentFilters = { let currentFilters = {
triggers: [] as string[], triggers: [] as string[],
@@ -53,46 +50,30 @@ function sortItems(items: Workflow[]): Workflow[] {
} }
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
const countEl = document.getElementById("results-count"); const countEl = document.getElementById("results-count");
const query = searchInput?.value || ""; let results = [...allItems];
let results = query ? search.search(query) : [...allItems];
if (currentFilters.triggers.length > 0) { if (currentFilters.triggers.length > 0) {
results = results.filter((item) => results = results.filter((item) =>
item.triggers.some((t) => currentFilters.triggers.includes(t)) item.triggers.some((trigger) => currentFilters.triggers.includes(trigger))
); );
} }
results = sortItems(results); results = sortItems(results);
renderItems(results, query); renderItems(results);
const activeFilters: string[] = []; let countText = `${results.length} workflow${results.length === 1 ? "" : "s"}`;
if (currentFilters.triggers.length > 0) if (currentFilters.triggers.length > 0) {
activeFilters.push( countText = `${results.length} of ${allItems.length} workflows (filtered by ${currentFilters.triggers.length} trigger${currentFilters.triggers.length > 1 ? "s" : ""})`;
`${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(", ")})`;
} }
if (countEl) countEl.textContent = countText; if (countEl) countEl.textContent = countText;
} }
function renderItems(items: Workflow[], query = ""): void { function renderItems(items: Workflow[]): void {
const list = document.getElementById("resource-list"); const list = document.getElementById("resource-list");
if (!list) return; if (!list) return;
list.innerHTML = renderWorkflowsHtml(items, { list.innerHTML = renderWorkflowsHtml(items);
query,
highlightTitle: (title, highlightQuery) =>
search.highlight(title, highlightQuery),
});
} }
function setupResourceListHandlers(list: HTMLElement | null): void { function setupResourceListHandlers(list: HTMLElement | null): void {
@@ -114,9 +95,9 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
resourceListHandlersReady = true; resourceListHandlersReady = true;
} }
function syncUrlState(searchInput: HTMLInputElement | null): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: searchInput?.value ?? "", q: "",
trigger: currentFilters.triggers, trigger: currentFilters.triggers,
sort: currentSort === "title" ? "" : currentSort, sort: currentSort === "title" ? "" : currentSort,
}); });
@@ -124,9 +105,6 @@ function syncUrlState(searchInput: HTMLInputElement | null): void {
export async function initWorkflowsPage(): Promise<void> { export async function initWorkflowsPage(): Promise<void> {
const list = document.getElementById("resource-list"); const list = document.getElementById("resource-list");
const searchInput = document.getElementById(
"search-input"
) as HTMLInputElement;
const clearFiltersBtn = document.getElementById("clear-filters"); const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById( const sortSelect = document.getElementById(
"sort-select" "sort-select"
@@ -143,26 +121,22 @@ export async function initWorkflowsPage(): Promise<void> {
} }
allItems = data.items; allItems = data.items;
search.setItems(allItems);
// Setup trigger filter
triggerSelect = createChoices("#filter-trigger", { triggerSelect = createChoices("#filter-trigger", {
placeholderValue: "All Triggers", placeholderValue: "All Triggers",
}); });
triggerSelect.setChoices( triggerSelect.setChoices(
data.filters.triggers.map((t) => ({ value: t, label: t })), data.filters.triggers.map((trigger) => ({ value: trigger, label: trigger })),
"value", "value",
"label", "label",
true true
); );
const initialQuery = getQueryParam("q");
const initialTriggers = getQueryParamValues("trigger").filter((trigger) => const initialTriggers = getQueryParamValues("trigger").filter((trigger) =>
data.filters.triggers.includes(trigger) data.filters.triggers.includes(trigger)
); );
const initialSort = getQueryParam("sort"); const initialSort = getQueryParam("sort");
if (searchInput) searchInput.value = initialQuery;
if (initialTriggers.length > 0) { if (initialTriggers.length > 0) {
currentFilters.triggers = initialTriggers; currentFilters.triggers = initialTriggers;
setChoicesValues(triggerSelect, initialTriggers); setChoicesValues(triggerSelect, initialTriggers);
@@ -175,36 +149,22 @@ export async function initWorkflowsPage(): Promise<void> {
document.getElementById("filter-trigger")?.addEventListener("change", () => { document.getElementById("filter-trigger")?.addEventListener("change", () => {
currentFilters.triggers = getChoicesValues(triggerSelect); currentFilters.triggers = getChoicesValues(triggerSelect);
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
sortSelect?.addEventListener("change", () => { sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as WorkflowSortOption; currentSort = sortSelect.value as WorkflowSortOption;
applyFiltersAndRender(); 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", () => { clearFiltersBtn?.addEventListener("click", () => {
currentFilters = { triggers: [] }; currentFilters = { triggers: [] };
currentSort = "title"; currentSort = "title";
triggerSelect.removeActiveItems(); triggerSelect.removeActiveItems();
if (searchInput) searchInput.value = "";
if (sortSelect) sortSelect.value = "title"; if (sortSelect) sortSelect.value = "title";
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(searchInput); syncUrlState();
}); });
applyFiltersAndRender(); applyFiltersAndRender();

View File

@@ -1428,15 +1428,135 @@ body:has(#main-content) {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 16px; margin-bottom: 16px;
padding: 10px 14px; padding: 0;
background: var(--color-glass); background: transparent;
backdrop-filter: blur(10px); border: 0;
border: 1px solid var(--color-glass-border); border-radius: 0;
border-radius: var(--border-radius-lg);
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
.listing-toolbar-row {
display: flex;
flex: 1 1 100%;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.listing-toolbar .results-count {
margin: 0;
}
.listing-controls {
position: relative;
margin-left: auto;
}
.listing-controls[open] {
z-index: 20;
}
.listing-controls-trigger {
list-style: none;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 36px;
padding: 7px 12px;
border: 1px solid var(--color-glass-border);
border-radius: 999px;
background: var(--color-glass);
color: var(--color-text-muted);
font-size: 13px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
user-select: none;
transition: border-color var(--transition), background-color var(--transition), color var(--transition), box-shadow var(--transition);
}
.listing-controls-trigger::-webkit-details-marker {
display: none;
}
.listing-controls-trigger::after {
content: '▾';
font-size: 11px;
line-height: 1;
color: var(--color-text-muted);
transition: transform var(--transition), color var(--transition);
}
.listing-controls-trigger:hover,
.listing-controls[open] .listing-controls-trigger {
background: var(--color-bg-secondary);
border-color: var(--color-border);
color: var(--color-text-primary);
}
.listing-controls[open] .listing-controls-trigger {
box-shadow: var(--shadow);
}
.listing-controls[open] .listing-controls-trigger::after {
transform: rotate(180deg);
color: var(--color-text-primary);
}
.listing-controls-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(336px, calc(100vw - 32px));
padding: 14px;
background: var(--color-card-bg);
border: 1px solid var(--color-glass-border);
border-radius: var(--border-radius-lg);
backdrop-filter: blur(18px);
box-shadow: var(--shadow-md);
}
.listing-controls-panel .filters-bar {
display: flex;
flex-direction: column;
gap: 12px;
}
.listing-controls-panel .filter-group {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.listing-controls-panel .filters-bar .filter-group > label:not(.checkbox-label) {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
}
.listing-controls-panel .filter-group select,
.listing-controls-panel .filter-group .choices {
width: 100%;
min-width: 0;
}
.listing-controls-panel .filter-group .choices {
margin-top: 0;
}
.listing-controls-panel .choices__inner {
min-height: 40px;
}
.listing-controls-panel .filters-bar button {
align-self: flex-end;
margin-top: 4px;
}
.search-bar { .search-bar {
display: contents; display: contents;
} }
@@ -1673,6 +1793,23 @@ body:has(#main-content) {
.listing-toolbar { .listing-toolbar {
align-items: stretch; align-items: stretch;
} }
.listing-toolbar-row {
align-items: flex-start;
}
.listing-controls {
width: 100%;
margin-left: 0;
}
.listing-controls-trigger {
width: 100%;
}
.listing-controls-panel {
width: 100%;
}
} }
/* Resource List */ /* Resource List */