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