diff --git a/website/src/scripts/choices.ts b/website/src/scripts/choices.ts index 60d14253..d3c27443 100644 --- a/website/src/scripts/choices.ts +++ b/website/src/scripts/choices.ts @@ -12,6 +12,16 @@ export function getChoicesValues(choices: Choices): string[] { return Array.isArray(val) ? val : (val ? [val] : []); } +/** + * Restore selected values on a Choices instance. + */ +export function setChoicesValues(choices: Choices, values: string[]): void { + // Clear any existing active items so that the final selection matches `values` + choices.removeActiveItems(); + // Set all provided values as the current selection + choices.setChoiceByValue(values); +} + /** * Create a new Choices instance with sensible defaults */ diff --git a/website/src/scripts/pages/agents.ts b/website/src/scripts/pages/agents.ts index 1f2c3f79..3d353b73 100644 --- a/website/src/scripts/pages/agents.ts +++ b/website/src/scripts/pages/agents.ts @@ -1,9 +1,23 @@ /** * Agents page functionality */ -import { createChoices, getChoicesValues, type Choices } from '../choices'; +import { + createChoices, + getChoicesValues, + setChoicesValues, + type Choices, +} from '../choices'; import { FuzzySearch, type SearchItem } from '../search'; -import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils'; +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'; @@ -111,6 +125,16 @@ function setupResourceListHandlers(list: HTMLElement | null): void { resourceListHandlersReady = true; } +function syncUrlState(searchInput: HTMLInputElement | null): void { + updateQueryParams({ + q: searchInput?.value ?? '', + model: currentFilters.models, + tool: currentFilters.tools, + handoffs: currentFilters.hasHandoffs, + sort: currentSort === 'title' ? '' : currentSort, + }); +} + export async function initAgentsPage(): Promise { const list = document.getElementById('resource-list'); const searchInput = document.getElementById('search-input') as HTMLInputElement; @@ -132,23 +156,46 @@ export async function initAgentsPage(): Promise { // 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'); @@ -156,11 +203,20 @@ export async function initAgentsPage(): Promise { countEl.textContent = `${allItems.length} of ${allItems.length} agents`; } - searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); + 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', () => { @@ -172,8 +228,10 @@ export async function initAgentsPage(): Promise { if (searchInput) searchInput.value = ''; if (sortSelect) sortSelect.value = 'title'; applyFiltersAndRender(); + syncUrlState(searchInput); }); + applyFiltersAndRender(); setupModal(); setupDropdownCloseHandlers(); setupActionHandlers(); diff --git a/website/src/scripts/pages/hooks.ts b/website/src/scripts/pages/hooks.ts index a958fb97..9a1e5d48 100644 --- a/website/src/scripts/pages/hooks.ts +++ b/website/src/scripts/pages/hooks.ts @@ -1,13 +1,21 @@ /** * Hooks page functionality */ -import { createChoices, getChoicesValues, type Choices } from "../choices"; +import { + createChoices, + getChoicesValues, + setChoicesValues, + type Choices, +} from "../choices"; import { FuzzySearch, type SearchItem } from "../search"; import { fetchData, debounce, + getQueryParam, + getQueryParamValues, showToast, downloadZipBundle, + updateQueryParams, } from "../utils"; import { setupModal, openFileModal } from "../modal"; import { @@ -126,6 +134,15 @@ function setupResourceListHandlers(list: HTMLElement | null): void { resourceListHandlersReady = true; } +function syncUrlState(searchInput: HTMLInputElement | null): void { + updateQueryParams({ + q: searchInput?.value ?? "", + hook: currentFilters.hooks, + tag: currentFilters.tags, + sort: currentSort === "title" ? "" : currentSort, + }); +} + async function downloadHook( hookId: string, btn: HTMLButtonElement @@ -210,9 +227,26 @@ export async function initHooksPage(): Promise { "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 @@ -225,20 +259,33 @@ export async function initHooksPage(): Promise { "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; + } sortSelect?.addEventListener("change", () => { currentSort = sortSelect.value as HookSortOption; applyFiltersAndRender(); + syncUrlState(searchInput); }); applyFiltersAndRender(); searchInput?.addEventListener( "input", - debounce(() => applyFiltersAndRender(), 200) + debounce(() => { + applyFiltersAndRender(); + syncUrlState(searchInput); + }, 200) ); clearFiltersBtn?.addEventListener("click", () => { @@ -249,6 +296,7 @@ export async function initHooksPage(): Promise { if (searchInput) searchInput.value = ""; if (sortSelect) sortSelect.value = "title"; applyFiltersAndRender(); + syncUrlState(searchInput); }); setupModal(); diff --git a/website/src/scripts/pages/instructions.ts b/website/src/scripts/pages/instructions.ts index 8130e012..bdc5df0e 100644 --- a/website/src/scripts/pages/instructions.ts +++ b/website/src/scripts/pages/instructions.ts @@ -1,9 +1,22 @@ /** * Instructions page functionality */ -import { createChoices, getChoicesValues, type Choices } from '../choices'; +import { + createChoices, + getChoicesValues, + setChoicesValues, + type Choices, +} from '../choices'; import { FuzzySearch, type SearchItem } from '../search'; -import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils'; +import { + fetchData, + debounce, + getQueryParam, + getQueryParamValues, + setupDropdownCloseHandlers, + setupActionHandlers, + updateQueryParams, +} from '../utils'; import { setupModal, openFileModal } from '../modal'; import { renderInstructionsHtml, @@ -93,6 +106,14 @@ function setupResourceListHandlers(list: HTMLElement | null): void { resourceListHandlersReady = true; } +function syncUrlState(searchInput: HTMLInputElement | null): void { + updateQueryParams({ + q: searchInput?.value ?? '', + extension: currentFilters.extensions, + sort: currentSort === 'title' ? '' : currentSort, + }); +} + export async function initInstructionsPage(): Promise { const list = document.getElementById('resource-list'); const searchInput = document.getElementById('search-input') as HTMLInputElement; @@ -112,14 +133,31 @@ export async function initInstructionsPage(): Promise { 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); + } + if (initialSort === 'lastUpdated') { + currentSort = initialSort; + if (sortSelect) sortSelect.value = initialSort; + } + document.getElementById('filter-extension')?.addEventListener('change', () => { currentFilters.extensions = getChoicesValues(extensionSelect); applyFiltersAndRender(); + syncUrlState(searchInput); }); sortSelect?.addEventListener('change', () => { currentSort = sortSelect.value as InstructionSortOption; applyFiltersAndRender(); + syncUrlState(searchInput); }); const countEl = document.getElementById('results-count'); @@ -127,7 +165,10 @@ export async function initInstructionsPage(): Promise { countEl.textContent = `${allItems.length} of ${allItems.length} instructions`; } - searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); + searchInput?.addEventListener('input', debounce(() => { + applyFiltersAndRender(); + syncUrlState(searchInput); + }, 200)); clearFiltersBtn?.addEventListener('click', () => { currentFilters = { extensions: [] }; @@ -136,8 +177,10 @@ export async function initInstructionsPage(): Promise { if (searchInput) searchInput.value = ''; if (sortSelect) sortSelect.value = 'title'; applyFiltersAndRender(); + syncUrlState(searchInput); }); + applyFiltersAndRender(); setupModal(); setupDropdownCloseHandlers(); setupActionHandlers(); diff --git a/website/src/scripts/pages/plugins.ts b/website/src/scripts/pages/plugins.ts index eccbc519..7a694dbe 100644 --- a/website/src/scripts/pages/plugins.ts +++ b/website/src/scripts/pages/plugins.ts @@ -1,9 +1,20 @@ /** * Plugins page functionality */ -import { createChoices, getChoicesValues, type Choices } from '../choices'; +import { + createChoices, + getChoicesValues, + setChoicesValues, + type Choices, +} from '../choices'; import { FuzzySearch, type SearchItem } from '../search'; -import { fetchData, debounce } from '../utils'; +import { + fetchData, + debounce, + getQueryParam, + getQueryParamValues, + updateQueryParams, +} from '../utils'; import { setupModal, openFileModal } from '../modal'; import { renderPluginsHtml, type RenderablePlugin } from './plugins-render'; @@ -98,6 +109,13 @@ function setupResourceListHandlers(list: HTMLElement | null): void { resourceListHandlersReady = true; } +function syncUrlState(searchInput: HTMLInputElement | null): void { + updateQueryParams({ + q: searchInput?.value ?? '', + tag: currentFilters.tags, + }); +} + export async function initPluginsPage(): Promise { const list = document.getElementById('resource-list'); const searchInput = document.getElementById('search-input') as HTMLInputElement; @@ -123,9 +141,20 @@ export async function initPluginsPage(): Promise { 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)); + + if (searchInput) searchInput.value = initialQuery; + if (initialTags.length > 0) { + currentFilters.tags = initialTags; + setChoicesValues(tagSelect, initialTags); + } + document.getElementById('filter-tag')?.addEventListener('change', () => { currentFilters.tags = getChoicesValues(tagSelect); applyFiltersAndRender(); + syncUrlState(searchInput); }); const countEl = document.getElementById('results-count'); @@ -133,15 +162,20 @@ export async function initPluginsPage(): Promise { countEl.textContent = `${allItems.length} of ${allItems.length} plugins`; } - searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); + searchInput?.addEventListener('input', debounce(() => { + applyFiltersAndRender(); + syncUrlState(searchInput); + }, 200)); clearFiltersBtn?.addEventListener('click', () => { currentFilters = { tags: [] }; tagSelect.removeActiveItems(); if (searchInput) searchInput.value = ''; applyFiltersAndRender(); + syncUrlState(searchInput); }); + applyFiltersAndRender(); setupModal(); } diff --git a/website/src/scripts/pages/skills.ts b/website/src/scripts/pages/skills.ts index ac88e14a..40017b4e 100644 --- a/website/src/scripts/pages/skills.ts +++ b/website/src/scripts/pages/skills.ts @@ -1,13 +1,22 @@ /** * Skills page functionality */ -import { createChoices, getChoicesValues, type Choices } from "../choices"; +import { + createChoices, + getChoicesValues, + setChoicesValues, + type Choices, +} from "../choices"; import { FuzzySearch, type SearchItem } from "../search"; import { fetchData, debounce, + getQueryParam, + getQueryParamFlag, + getQueryParamValues, showToast, downloadZipBundle, + updateQueryParams, } from "../utils"; import { setupModal, openFileModal } from "../modal"; import { @@ -120,6 +129,15 @@ function setupResourceListHandlers(list: HTMLElement | null): void { resourceListHandlersReady = true; } +function syncUrlState(searchInput: HTMLInputElement | null): void { + updateQueryParams({ + q: searchInput?.value ?? "", + category: currentFilters.categories, + hasAssets: currentFilters.hasAssets, + sort: currentSort === "title" ? "" : currentSort, + }); +} + async function downloadSkill( skillId: string, btn: HTMLButtonElement @@ -189,25 +207,52 @@ export async function initSkillsPage(): Promise { "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); }); applyFiltersAndRender(); searchInput?.addEventListener( "input", - debounce(() => applyFiltersAndRender(), 200) + debounce(() => { + applyFiltersAndRender(); + syncUrlState(searchInput); + }, 200) ); hasAssetsCheckbox?.addEventListener("change", () => { currentFilters.hasAssets = hasAssetsCheckbox.checked; applyFiltersAndRender(); + syncUrlState(searchInput); }); clearFiltersBtn?.addEventListener("click", () => { @@ -218,6 +263,7 @@ export async function initSkillsPage(): Promise { if (searchInput) searchInput.value = ""; if (sortSelect) sortSelect.value = "title"; applyFiltersAndRender(); + syncUrlState(searchInput); }); setupModal(); diff --git a/website/src/scripts/pages/tools.ts b/website/src/scripts/pages/tools.ts index 4ce2b136..c168a392 100644 --- a/website/src/scripts/pages/tools.ts +++ b/website/src/scripts/pages/tools.ts @@ -2,7 +2,12 @@ * Tools page functionality */ import { FuzzySearch, type SearchableItem } from "../search"; -import { fetchData, debounce } from "../utils"; +import { + fetchData, + debounce, + getQueryParam, + updateQueryParams, +} from "../utils"; import { renderToolsHtml } from "./tools-render"; export interface Tool extends SearchableItem { @@ -84,6 +89,13 @@ function renderTools(tools: Tool[], query = ""): void { }); } +function syncUrlState(searchInput: HTMLInputElement | null): void { + updateQueryParams({ + q: searchInput?.value ?? "", + category: currentFilters.categories, + }); +} + function setupCopyConfigHandlers(): void { if (copyHandlersReady) return; @@ -157,18 +169,33 @@ export async function initToolsPage(): Promise { ) .join(""); + const initialCategory = getQueryParam("category"); + if (initialCategory && data.filters.categories.includes(initialCategory)) { + currentFilters.categories = [initialCategory]; + categoryFilter.value = initialCategory; + } + categoryFilter.addEventListener("change", () => { currentFilters.categories = categoryFilter.value ? [categoryFilter.value] : []; applyFiltersAndRender(); + syncUrlState(searchInput); }); } + const initialQuery = getQueryParam("q"); + if (searchInput) searchInput.value = initialQuery; + + applyFiltersAndRender(); + // Search input handler searchInput?.addEventListener( "input", - debounce(() => applyFiltersAndRender(), 200) + debounce(() => { + applyFiltersAndRender(); + syncUrlState(searchInput); + }, 200) ); // Clear filters @@ -177,6 +204,7 @@ export async function initToolsPage(): Promise { if (categoryFilter) categoryFilter.value = ""; if (searchInput) searchInput.value = ""; applyFiltersAndRender(); + syncUrlState(searchInput); }); setupCopyConfigHandlers(); diff --git a/website/src/scripts/pages/workflows.ts b/website/src/scripts/pages/workflows.ts index b2a72f9f..a27766bb 100644 --- a/website/src/scripts/pages/workflows.ts +++ b/website/src/scripts/pages/workflows.ts @@ -1,12 +1,20 @@ /** * Workflows page functionality */ -import { createChoices, getChoicesValues, type Choices } from "../choices"; +import { + createChoices, + getChoicesValues, + setChoicesValues, + type Choices, +} from "../choices"; import { FuzzySearch, type SearchItem } from "../search"; import { fetchData, debounce, + getQueryParam, + getQueryParamValues, setupActionHandlers, + updateQueryParams, } from "../utils"; import { setupModal, openFileModal } from "../modal"; import { @@ -106,6 +114,14 @@ function setupResourceListHandlers(list: HTMLElement | null): void { resourceListHandlersReady = true; } +function syncUrlState(searchInput: HTMLInputElement | null): void { + updateQueryParams({ + q: searchInput?.value ?? "", + trigger: currentFilters.triggers, + sort: currentSort === "title" ? "" : currentSort, + }); +} + export async function initWorkflowsPage(): Promise { const list = document.getElementById("resource-list"); const searchInput = document.getElementById( @@ -139,14 +155,33 @@ export async function initWorkflowsPage(): Promise { "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); + } + if (initialSort === "lastUpdated") { + currentSort = initialSort; + if (sortSelect) sortSelect.value = initialSort; + } + document.getElementById("filter-trigger")?.addEventListener("change", () => { currentFilters.triggers = getChoicesValues(triggerSelect); applyFiltersAndRender(); + syncUrlState(searchInput); }); sortSelect?.addEventListener("change", () => { currentSort = sortSelect.value as WorkflowSortOption; applyFiltersAndRender(); + syncUrlState(searchInput); }); const countEl = document.getElementById("results-count"); @@ -156,7 +191,10 @@ export async function initWorkflowsPage(): Promise { searchInput?.addEventListener( "input", - debounce(() => applyFiltersAndRender(), 200) + debounce(() => { + applyFiltersAndRender(); + syncUrlState(searchInput); + }, 200) ); clearFiltersBtn?.addEventListener("click", () => { @@ -166,8 +204,10 @@ export async function initWorkflowsPage(): Promise { if (searchInput) searchInput.value = ""; if (sortSelect) sortSelect.value = "title"; applyFiltersAndRender(); + syncUrlState(searchInput); }); + applyFiltersAndRender(); setupModal(); setupActionHandlers(); } diff --git a/website/src/scripts/utils.ts b/website/src/scripts/utils.ts index d11cb763..5d8c5af1 100644 --- a/website/src/scripts/utils.ts +++ b/website/src/scripts/utils.ts @@ -235,6 +235,83 @@ export async function shareFile(filePath: string): Promise { return copyToClipboard(deepLinkUrl); } +type QueryParamValue = string | string[] | boolean | null | undefined; + +/** + * Read a single query parameter. + */ +export function getQueryParam(name: string): string { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get(name)?.trim() ?? ""; +} + +/** + * Read repeated query parameter values. + */ +export function getQueryParamValues(name: string): string[] { + if (typeof window === "undefined") return []; + const values = new URLSearchParams(window.location.search) + .getAll(name) + .map((value) => value.trim()) + .filter(Boolean); + return Array.from(new Set(values)); +} + +/** + * Read a boolean-style query parameter. + */ +export function getQueryParamFlag(name: string): boolean { + const value = getQueryParam(name).toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +/** + * Update query parameters while preserving the current hash. + */ +export function updateQueryParams( + updates: Record +): void { + if (typeof window === "undefined") return; + + const url = new URL(window.location.href); + + for (const [key, value] of Object.entries(updates)) { + url.searchParams.delete(key); + + if (Array.isArray(value)) { + for (const item of value) { + const normalized = item.trim(); + if (normalized) { + url.searchParams.append(key, normalized); + } + } + continue; + } + + if (typeof value === "boolean") { + if (value) { + url.searchParams.set(key, "1"); + } + continue; + } + + if (typeof value === "string") { + const normalized = value.trim(); + if (normalized) { + url.searchParams.set(key, normalized); + } + } + } + + const search = url.searchParams.toString(); + const nextUrl = `${url.pathname}${search ? `?${search}` : ""}${url.hash}`; + const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + + if (nextUrl !== currentUrl) { + history.replaceState(null, "", nextUrl); + } +} + /** * Show a toast notification */