chore: publish from staged

This commit is contained in:
github-actions[bot]
2026-06-23 23:48:04 +00:00
parent 2c82748d95
commit b378771d9e
25 changed files with 1777 additions and 737 deletions
+3 -3
View File
@@ -1,18 +1,18 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import agentsData from '../../public/data/agents.json';
import Modal from '../components/Modal.astro';
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 Modal from '../components/Modal.astro';
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
const initialItems = sortAgents(agentsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Custom Agents', description: 'Specialized agents that enhance GitHub Copilot for specific technologies, workflows, and domains', template: 'splash', prev: false, next: false, editUrl: false }}>
<div id="main-content">
<div id="main-content" class="agents-page listing-cards-page">
<PageHeader title="Custom Agents" description="Specialized agents that enhance GitHub Copilot for specific technologies, workflows, and domains" icon="robot" />
<div class="page-content">
@@ -42,8 +42,8 @@ const initialItems = sortAgents(agentsData.items, 'title');
</div>
</div>
<Modal />
<BackToTop />
<Modal />
<EmbeddedPageData filename="agents.json" data={agentsData} />
<script>
+2 -16
View File
@@ -5,6 +5,7 @@ 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 Modal from '../components/Modal.astro';
import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render';
const initialItems = sortExtensions(extensionsData.items, 'title');
@@ -41,23 +42,13 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
</div>
</div>
<div class="resource-list" id="resource-list" role="list" set:html={renderExtensionsHtml(initialItems)}></div>
<div id="extension-preview-modal" class="extension-preview-modal hidden" aria-hidden="true">
<div class="extension-preview-dialog" role="dialog" aria-modal="true" aria-labelledby="extension-preview-title">
<div class="extension-preview-header">
<h3 id="extension-preview-title" class="extension-preview-title">Extension preview</h3>
<button id="extension-preview-close" class="btn btn-secondary extension-preview-close" type="button" aria-label="Close preview">Close</button>
</div>
<div class="extension-preview-body">
<img id="extension-preview-image" class="extension-preview-image" src="" alt="" />
</div>
</div>
</div>
<ContributeCTA resourceType="extensions" />
</div>
</div>
</div>
<BackToTop />
<Modal />
<EmbeddedPageData filename="extensions.json" data={extensionsData} />
<script>
@@ -65,9 +56,4 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
import '../scripts/pages/extensions';
</script>
<style>
.extensions-page .resource-preview {
cursor: default;
}
</style>
</StarlightPage>
+3 -3
View File
@@ -1,10 +1,10 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import PageHeader from '../components/PageHeader.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import hooksData from '../../public/data/hooks.json';
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render';
@@ -12,7 +12,7 @@ const initialItems = sortHooks(hooksData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Hooks', description: 'Automated workflows triggered by Copilot coding agent events', template: 'splash', prev: false, next: false, editUrl: false }}>
<div id="main-content">
<div id="main-content" class="hooks-page listing-cards-page">
<PageHeader title="Hooks" description="Automated workflows triggered by Copilot coding agent events" icon="hook" />
<div class="page-content">
@@ -47,8 +47,8 @@ const initialItems = sortHooks(hooksData.items, 'title');
</div>
</div>
<Modal />
<BackToTop />
<Modal />
<EmbeddedPageData filename="hooks.json" data={hooksData} />
<script>
+3 -3
View File
@@ -1,18 +1,18 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import instructionsData from '../../public/data/instructions.json';
import Modal from '../components/Modal.astro';
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 Modal from '../components/Modal.astro';
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
const initialItems = sortInstructions(instructionsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Instructions', description: 'Coding standards and best practices for GitHub Copilot', template: 'splash', prev: false, next: false, editUrl: false }}>
<div id="main-content">
<div id="main-content" class="instructions-page listing-cards-page">
<PageHeader title="Instructions" description="Coding standards and best practices for GitHub Copilot" icon="document" />
<div class="page-content">
@@ -47,8 +47,8 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
</div>
</div>
<Modal />
<BackToTop />
<Modal />
<EmbeddedPageData filename="instructions.json" data={instructionsData} />
<script>
+3 -3
View File
@@ -1,18 +1,18 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import pluginsData from '../../public/data/plugins.json';
import Modal from '../components/Modal.astro';
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 Modal from '../components/Modal.astro';
import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render';
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 }}>
<div id="main-content">
<div id="main-content" class="plugins-page listing-cards-page">
<PageHeader title="Plugins" description="Curated plugins of agents, hooks, and skills for specific workflows" icon="plug" />
<div class="page-content">
@@ -56,8 +56,8 @@ const initialItems = sortPlugins(pluginsData.items, 'title');
</div>
</div>
<Modal />
<BackToTop />
<Modal />
<EmbeddedPageData filename="plugins.json" data={pluginsData} />
<script>
+3 -3
View File
@@ -1,10 +1,10 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro';
import PageHeader from '../components/PageHeader.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import skillsData from '../../public/data/skills.json';
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render';
@@ -12,7 +12,7 @@ const initialItems = sortSkills(skillsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Skills', description: 'Self-contained agent skills with instructions and bundled resources', template: 'splash', prev: false, next: false, editUrl: false }}>
<div id="main-content">
<div id="main-content" class="skills-page listing-cards-page">
<PageHeader title="Skills" description="Self-contained agent skills with instructions and bundled resources" icon="lightning" />
<div class="page-content">
@@ -42,8 +42,8 @@ const initialItems = sortSkills(skillsData.items, 'title');
</div>
</div>
<Modal />
<BackToTop />
<Modal />
<EmbeddedPageData filename="skills.json" data={skillsData} />
<script>
+3 -3
View File
@@ -1,18 +1,18 @@
---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import workflowsData from '../../public/data/workflows.json';
import Modal from '../components/Modal.astro';
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 Modal from '../components/Modal.astro';
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
const initialItems = sortWorkflows(workflowsData.items, 'title');
---
<StarlightPage frontmatter={{ title: 'Agentic Workflows', description: 'AI-powered repository automations that run coding agents in GitHub Actions', template: 'splash', prev: false, next: false, editUrl: false }}>
<div id="main-content">
<div id="main-content" class="workflows-page listing-cards-page">
<PageHeader title="Agentic Workflows" description="AI-powered repository automations that run coding agents in GitHub Actions" icon="workflow" />
<div class="page-content">
@@ -47,8 +47,8 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
</div>
</div>
<Modal />
<BackToTop />
<Modal />
<EmbeddedPageData filename="workflows.json" data={workflowsData} />
<script>
+112 -3
View File
@@ -2,7 +2,6 @@
* Modal functionality for file viewing
*/
import { marked } from "marked";
import {
fetchFileContent,
fetchData,
@@ -18,7 +17,6 @@ import {
sanitizeUrl,
REPO_IDENTIFIER,
} from "./utils";
import fm from "front-matter";
type ModalViewMode = "rendered" | "raw";
@@ -352,7 +350,11 @@ async function renderCurrentFileContent(): Promise<void> {
const container = ensureDivContent("modal-rendered-content");
if (!container) return;
const { body: markdownBody } = fm(currentFileContent);
const [{ marked }, { default: fm }] = await Promise.all([
import("marked"),
import("front-matter"),
]);
const { body: markdownBody } = fm<string>(currentFileContent);
container.innerHTML = marked(markdownBody, { async: false });
} else {
await renderHighlightedCode(currentFileContent, currentFilePath);
@@ -452,6 +454,19 @@ interface PluginsData {
let pluginsCache: PluginsData | null = null;
interface OpenCardDetailsRequest {
title: string;
description: string;
previewIcon?: string;
previewText?: string;
metaHtml?: string;
tagsHtml?: string;
actionsHtml?: string;
detailsHtml?: string;
contentClassName?: string;
trigger?: HTMLElement;
}
/**
* Get all focusable elements within a container
*/
@@ -731,6 +746,19 @@ export function setupModal(): void {
}
});
document.addEventListener("click", async (event) => {
const target = event.target as HTMLElement;
const openFileButton = target.closest<HTMLElement>("[data-open-file-path]");
if (!openFileButton) return;
const filePath = openFileButton.dataset.openFilePath;
if (!filePath) return;
event.preventDefault();
const fileType = openFileButton.dataset.openFileType || getResourceType(filePath);
await openFileModal(filePath, fileType, true, openFileButton);
});
// Check for deep link on initial load
handleHashChange();
}
@@ -894,6 +922,8 @@ export async function openFileModal(
const closeBtn = document.getElementById("close-modal");
if (!modal || !title) return;
modal.classList.remove("details-mode");
currentFilePath = filePath;
currentFileType = type;
currentViewMode = "raw";
@@ -989,6 +1019,85 @@ export async function openFileModal(
await renderCurrentFileContent();
}
export function openCardDetailsModal({
title,
description,
previewIcon = "📄",
previewText = "",
metaHtml = "",
tagsHtml = "",
actionsHtml = "",
detailsHtml = "",
contentClassName = "modal-card-details",
trigger,
}: OpenCardDetailsRequest): void {
const modal = document.getElementById("file-modal");
const modalTitle = document.getElementById("modal-title");
const closeBtn = document.getElementById("close-modal");
const modalBody = getModalBody();
if (!modal || !modalTitle || !modalBody) return;
triggerElement = trigger || (document.activeElement as HTMLElement);
if (!originalDocumentTitle) {
originalDocumentTitle = document.title;
}
currentFilePath = null;
currentFileContent = null;
currentFileType = "details";
currentViewMode = "raw";
hideSkillFileSwitcher();
modal.classList.add("details-mode");
modalTitle.textContent = title;
document.title = `${title} | Awesome GitHub Copilot`;
const content = ensureDivContent(contentClassName);
if (!content) return;
content.innerHTML =
detailsHtml ||
`
<div class="resource-details-body modal-card-details-body">
<div class="resource-details-preview">
<div class="resource-details-preview-icon" aria-hidden="true">${escapeHtml(previewIcon)}</div>
${
previewText
? `<p class="resource-details-preview-text">${escapeHtml(previewText)}</p>`
: ""
}
</div>
<div class="resource-details-content">
<p class="resource-details-description">${escapeHtml(description)}</p>
${
metaHtml
? `<div class="resource-meta resource-details-meta">${metaHtml}</div>`
: ""
}
${
tagsHtml
? `<div class="resource-keywords resource-details-tags">${tagsHtml}</div>`
: ""
}
${
actionsHtml
? `<div class="resource-actions resource-details-actions">${actionsHtml}</div>`
: ""
}
</div>
</div>
`;
modalBody.scrollTop = 0;
modal.classList.remove("hidden");
modal.classList.add("visible");
setTimeout(() => {
closeBtn?.focus();
}, 0);
}
/**
* Open plugin modal with item list
*/
+45 -57
View File
@@ -5,6 +5,7 @@ import {
getInstallDropdownHtml,
getLastUpdatedHtml,
} from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableAgent {
title: string;
@@ -37,68 +38,55 @@ export function sortAgents<T extends RenderableAgent>(
export function renderAgentsHtml(items: RenderableAgent[]): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No agents found</h3>
<p>No agents are available right now.</p>
</div>
`;
return renderEmptyStateHtml("No agents found", "No agents are available right now.");
}
return items
.map((item) => {
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">${escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
${
item.model
? `<span class="resource-tag tag-model">${escapeHtml(
item.model
)}</span>`
: ""
}
${
item.tools
?.slice(0, 3)
.map(
(tool) =>
`<span class="resource-tag">${escapeHtml(tool)}</span>`
)
.join("") || ""
}
${
item.tools && item.tools.length > 3
? `<span class="resource-tag">+${
item.tools.length - 3
} more</span>`
: ""
}
${
item.hasHandoffs
? `<span class="resource-tag tag-handoffs">handoffs</span>`
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
</button>
<div class="resource-actions">
${getInstallDropdownHtml(resourceType, item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
</div>
</article>
const metaHtml = `
${
item.model
? `<span class="resource-tag tag-model">${escapeHtml(
Array.isArray(item.model) ? item.model.join(", ") : item.model
)}</span>`
: ""
}
${
item.tools
?.slice(0, 3)
.map((tool) => `<span class="resource-tag">${escapeHtml(tool)}</span>`)
.join("") || ""
}
${
item.tools && item.tools.length > 3
? `<span class="resource-tag">+${item.tools.length - 3} more</span>`
: ""
}
${
item.hasHandoffs
? `<span class="resource-tag tag-handoffs">handoffs</span>`
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
`;
const actionsHtml = `
${getInstallDropdownHtml(resourceType, item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
`;
return renderSharedCardHtml({
title: item.title,
description: item.description || "No description",
articleAttributes: {
"data-path": item.path,
},
metaHtml,
actionsHtml,
});
})
.join("");
}
+80 -4
View File
@@ -2,13 +2,16 @@
* Agents page functionality
*/
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
setupDropdownCloseHandlers,
getVSCodeInstallUrl,
setupActionHandlers,
setupDropdownCloseHandlers,
updateQueryParams,
} from '../utils';
import { setupModal, openFileModal } from '../modal';
import { openCardDetailsModal, setupModal } from '../modal';
import {
renderAgentsHtml,
sortAgents,
@@ -17,6 +20,7 @@ import {
} from './agents-render';
interface Agent extends RenderableAgent {
id?: string;
lastUpdated?: string | null;
}
@@ -25,8 +29,10 @@ interface AgentsData {
}
let allItems: Agent[] = [];
let agentByPath = new Map<string, Agent>();
let currentSort: AgentSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function applyFiltersAndRender(): void {
const countEl = document.getElementById('results-count');
@@ -45,6 +51,70 @@ function renderItems(items: Agent[]): void {
list.innerHTML = renderAgentsHtml(items);
}
function openAgentDetailsModal(path: string, trigger?: HTMLElement): void {
const item = agentByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
if (item.model) {
metaParts.push(
`<span class="resource-tag tag-model">${escapeHtml(
Array.isArray(item.model) ? item.model.join(', ') : item.model
)}</span>`
);
}
if (item.hasHandoffs) {
metaParts.push('<span class="resource-tag tag-handoffs">handoffs</span>');
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const toolItems = item.tools || [];
const displayTools = toolItems.slice(0, 24);
const tagParts = displayTools.map(
(tool) => `<span class="resource-tag">${escapeHtml(tool)}</span>`
);
if (toolItems.length > displayTools.length) {
tagParts.push(
`<span class="resource-tag">+${toolItems.length - displayTools.length} more</span>`
);
}
const vscodeUrl = getVSCodeInstallUrl('agent', path, false);
const insidersUrl = getVSCodeInstallUrl('agent', path, true);
const actions = [
vscodeUrl
? `<a class="btn btn-primary btn-small" href="${escapeHtml(vscodeUrl)}" target="_blank" rel="noopener noreferrer">Install (VS Code)</a>`
: '',
insidersUrl
? `<a class="btn btn-secondary btn-small" href="${escapeHtml(insidersUrl)}" target="_blank" rel="noopener noreferrer">Install (Insiders)</a>`
: '',
`<button class="btn btn-secondary btn-small" type="button" data-open-file-path="${escapeHtml(
path
)}" data-open-file-type="agent">Source</button>`,
].filter(Boolean);
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '🤖',
previewText: 'Agent metadata and install options',
metaHtml: metaParts.join(''),
tagsHtml: tagParts.join(''),
actionsHtml: actions.join(''),
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
@@ -55,9 +125,10 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
}
const item = target.closest('.resource-item') as HTMLElement | null;
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openFileModal(path, 'agent');
openAgentDetailsModal(path, button);
}
});
@@ -78,6 +149,11 @@ export async function initAgentsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<AgentsData>('agents.json');
@@ -87,6 +163,7 @@ export async function initAgentsPage(): Promise<void> {
}
allItems = data.items;
agentByPath = new Map(allItems.map((item) => [item.path, item]));
const initialSort = getQueryParam('sort');
if (initialSort === 'lastUpdated') {
@@ -101,7 +178,6 @@ export async function initAgentsPage(): Promise<void> {
});
applyFiltersAndRender();
setupModal();
setupDropdownCloseHandlers();
setupActionHandlers();
}
+54
View File
@@ -0,0 +1,54 @@
import { escapeHtml } from "../utils";
export interface SharedCardRenderItem {
title: string;
description?: string;
role?: string;
tabIndex?: number;
articleClassName?: string;
articleAttributes?: Record<string, string>;
previewMediaHtml?: string;
infoExtraHtml?: string;
metaHtml?: string;
actionsHtml?: string;
}
function renderAttributes(attributes?: Record<string, string>): string {
if (!attributes) return "";
return Object.entries(attributes)
.map(([key, value]) => ` ${key}="${escapeHtml(value)}"`)
.join("");
}
export function renderEmptyStateHtml(title: string, description: string): string {
return `
<div class="empty-state">
<h3>${escapeHtml(title)}</h3>
<p>${escapeHtml(description)}</p>
</div>
`;
}
export function renderSharedCardHtml(item: SharedCardRenderItem): string {
const role = item.role ?? "listitem";
const articleClass = item.articleClassName
? `resource-item ${item.articleClassName}`
: "resource-item";
return `
<article class="${articleClass}" role="${escapeHtml(role)}"${item.tabIndex !== undefined ? ` tabindex="${String(item.tabIndex)}"` : ""}${renderAttributes(item.articleAttributes)}>
<button type="button" class="resource-preview">
${item.previewMediaHtml || ""}
<div class="resource-info">
<div class="resource-title">${escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml(item.description || "No description")}</div>
${item.infoExtraHtml || ""}
<div class="resource-meta">
${item.metaHtml || ""}
</div>
</div>
</button>
${item.actionsHtml ? `<div class="resource-actions">${item.actionsHtml}</div>` : ""}
</article>
`;
}
+68 -65
View File
@@ -1,4 +1,5 @@
import { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableExtension {
id: string;
@@ -17,10 +18,16 @@ export interface RenderableExtension {
path?: string | null;
type?: string | null;
} | null;
gallery?: {
path?: string | null;
type?: string | null;
} | null;
gallery?:
| {
path?: string | null;
type?: string | null;
}
| Array<{
path?: string | null;
type?: string | null;
}>
| null;
} | null;
imageUrl?: string | null;
assetPath?: string | null;
@@ -48,12 +55,10 @@ export function sortExtensions<T extends RenderableExtension>(
export function renderExtensionsHtml(items: RenderableExtension[]): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No extensions found</h3>
<p>No canvas extensions are available right now.</p>
</div>
`;
return renderEmptyStateHtml(
"No extensions found",
"No canvas extensions are available right now."
);
}
return items
@@ -69,62 +74,60 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
const sourceUrl =
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
return `
<article id="${escapeHtml(item.id)}" class="resource-item" role="listitem">
<div class="resource-preview">
${
item.imageUrl
? `<button type="button" class="resource-thumbnail-btn" data-preview-url="${escapeHtml(item.imageUrl)}" data-preview-alt="${escapeHtml(item.name)} preview" aria-label="Open ${escapeHtml(item.name)} preview">
<img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
</button>`
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>`
}
<div class="resource-info">
<div class="resource-title">${escapeHtml(item.name)}</div>
<div class="resource-description">${escapeHtml(
item.description || "Canvas extension"
)}</div>
<div class="resource-keywords">
${
item.keywords && item.keywords.length > 0
? item.keywords
.map(
(kw) =>
`<span class="keyword-tag">${escapeHtml(kw)}</span>`
)
.join("")
: ""
}
</div>
<div class="resource-meta">
${
item.external
? '<span class="resource-tag">External</span>'
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
</div>
<div class="resource-actions">
<button
class="btn btn-primary btn-small copy-install-url-btn"
data-install-url="${escapeHtml(installUrl)}"
title="Copy install URL"
${installUrl ? "" : "disabled"}
>
Install
</button>
${
sourceUrl
? `<a href="${escapeHtml(
sourceUrl
)}" class="btn btn-secondary btn-small" target="_blank" rel="noopener noreferrer" title="View source">Source</a>`
: ""
}
</div>
</article>
const previewMediaHtml = item.imageUrl
? `<div class="resource-thumbnail-btn" data-extension-id="${escapeHtml(item.id)}" aria-hidden="true">
<img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
</div>`
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>`;
const infoExtraHtml = `
<div class="resource-keywords">
${
item.keywords && item.keywords.length > 0
? item.keywords
.map((kw) => `<span class="keyword-tag">${escapeHtml(kw)}</span>`)
.join("")
: ""
}
</div>
`;
const metaHtml = `
${item.external ? '<span class="resource-tag">External</span>' : ""}
${getLastUpdatedHtml(item.lastUpdated)}
`;
const actionsHtml = `
<button
class="btn btn-primary btn-small copy-install-url-btn"
data-install-url="${escapeHtml(installUrl)}"
title="Copy install URL"
${installUrl ? "" : "disabled"}
>
Install
</button>
${
sourceUrl
? `<a href="${escapeHtml(
sourceUrl
)}" class="btn btn-secondary btn-small" target="_blank" rel="noopener noreferrer" title="View source">Source</a>`
: ""
}
`;
return renderSharedCardHtml({
title: item.name,
description: item.description || "Canvas extension",
previewMediaHtml,
infoExtraHtml,
metaHtml,
actionsHtml,
tabIndex: 0,
articleAttributes: {
id: item.id,
"data-extension-id": item.id,
},
});
})
.join("");
}
+269 -49
View File
@@ -8,13 +8,17 @@ import {
type Choices,
} from "../choices";
import {
escapeHtml,
copyToClipboard,
fetchData,
formatRelativeTime,
getGitHubUrl,
getQueryParam,
getQueryParamValues,
showToast,
updateQueryParams,
} from "../utils";
import { openCardDetailsModal, setupModal } from "../modal";
import {
renderExtensionsHtml,
sortExtensions,
@@ -34,36 +38,200 @@ interface ExtensionsData {
};
}
interface ExtensionScreenshot {
path?: string | null;
type?: string | null;
}
let allItems: Extension[] = [];
let extensionById = new Map<string, Extension>();
let currentSort: ExtensionSortOption = "title";
let keywordSelect: Choices;
let currentFilters = {
keywords: [] as string[],
};
let actionHandlersReady = false;
let modalReady = false;
function openPreviewModal(url: string, alt: string): void {
const modal = document.getElementById("extension-preview-modal");
const image = document.getElementById("extension-preview-image") as HTMLImageElement | null;
const title = document.getElementById("extension-preview-title");
if (!modal || !image || !title) return;
image.src = url;
image.alt = alt;
title.textContent = alt.replace(/ preview$/i, "");
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
function normalizeScreenshotEntries(value: unknown): ExtensionScreenshot[] {
if (!value) return [];
if (Array.isArray(value)) {
return value.filter((entry): entry is ExtensionScreenshot => Boolean(entry));
}
if (typeof value === "object") {
return [value as ExtensionScreenshot];
}
return [];
}
function closePreviewModal(): void {
const modal = document.getElementById("extension-preview-modal");
if (!modal) return;
function getInstallUrl(item: Extension): string {
return (
item.installUrl ||
(item.path && item.ref
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
/\\/g,
"/"
)}`
: "")
);
}
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
document.body.style.overflow = "";
function getSourceUrl(item: Extension): string {
return item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
}
function toRawAssetUrl(item: Extension, assetPath: string | null | undefined): string {
if (!assetPath || !item.ref) return "";
if (/^https?:\/\//i.test(assetPath)) return assetPath;
return `https://raw.githubusercontent.com/github/awesome-copilot/${item.ref}/${assetPath.replace(
/\\/g,
"/"
)}`;
}
function getGalleryImages(item: Extension): string[] {
const images: string[] = [];
if (item.imageUrl) {
images.push(item.imageUrl);
}
const iconPath = item.screenshots?.icon?.path;
if (iconPath) {
const url = toRawAssetUrl(item, iconPath);
if (url) images.push(url);
}
const galleryPaths = normalizeScreenshotEntries(item.screenshots?.gallery);
for (const entry of galleryPaths) {
const url = toRawAssetUrl(item, entry.path);
if (url) images.push(url);
}
return Array.from(new Set(images));
}
function renderGalleryThumbnails(images: string[], selectedUrl: string): void {
const gallery = document.getElementById("extension-details-gallery");
if (!gallery) return;
gallery.innerHTML = "";
images.forEach((url, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "extension-details-thumbnail-btn";
button.dataset.galleryImageUrl = url;
button.setAttribute("aria-label", `Show image ${index + 1}`);
button.setAttribute("role", "listitem");
if (url === selectedUrl) {
button.classList.add("active");
button.setAttribute("aria-current", "true");
}
const image = document.createElement("img");
image.src = url;
image.alt = `Gallery image ${index + 1}`;
image.className = "extension-details-thumbnail";
image.loading = "lazy";
button.appendChild(image);
gallery.appendChild(button);
});
}
function setSelectedGalleryImage(url: string, extensionName: string): void {
const image = document.getElementById(
"extension-details-image"
) as HTMLImageElement | null;
const gallery = document.getElementById("extension-details-gallery");
if (!image) return;
image.src = url;
image.alt = `${extensionName} screenshot`;
gallery?.querySelectorAll<HTMLButtonElement>(".extension-details-thumbnail-btn").forEach((button) => {
const isActive = button.dataset.galleryImageUrl === url;
button.classList.toggle("active", isActive);
if (isActive) {
button.setAttribute("aria-current", "true");
} else {
button.removeAttribute("aria-current");
}
});
}
function openDetailsModal(
extensionId: string,
preferredImageUrl?: string,
trigger?: HTMLElement
): void {
const item = extensionById.get(extensionId);
if (!item) {
return;
}
const keywordHtml = (item.keywords || [])
.map((keyword) => `<span class="keyword-tag">${escapeHtml(keyword)}</span>`)
.join("");
const metaParts: string[] = [];
if (item.external) {
metaParts.push('<span class="resource-tag">External</span>');
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const installUrl = getInstallUrl(item);
const sourceUrl = getSourceUrl(item);
const detailsHtml = `
<div class="extension-details-body">
<div class="extension-details-main">
<img id="extension-details-image" class="extension-preview-image extension-details-image" src="" alt="" />
<div id="extension-details-gallery" class="extension-details-gallery" role="list"></div>
</div>
<div class="extension-details-content">
<p id="extension-details-description" class="extension-details-description">${escapeHtml(
item.description || "Canvas extension"
)}</p>
<div id="extension-details-keywords" class="resource-keywords extension-details-keywords">${keywordHtml}</div>
<div id="extension-details-meta" class="resource-meta extension-details-meta">${metaParts.join(
""
)}</div>
<div class="resource-actions extension-details-actions">
<button id="extension-details-install" class="btn btn-primary btn-small" type="button" data-install-url="${escapeHtml(
installUrl
)}" ${installUrl ? "" : "disabled"}>Install</button>
${
sourceUrl
? `<a id="extension-details-source" class="btn btn-secondary btn-small" href="${escapeHtml(
sourceUrl
)}" target="_blank" rel="noopener noreferrer">Source</a>`
: ""
}
</div>
</div>
</div>
`;
openCardDetailsModal({
title: item.name,
description: item.description || "Canvas extension",
detailsHtml,
contentClassName: "modal-card-details modal-card-details-extension",
trigger,
});
const galleryImages = getGalleryImages(item);
const initialImage = preferredImageUrl || galleryImages[0] || "";
renderGalleryThumbnails(galleryImages, initialImage);
if (initialImage) {
setSelectedGalleryImage(initialImage, item.name);
}
}
function sortItems(items: Extension[]): Extension[] {
@@ -108,19 +276,6 @@ function setupActionHandlers(list: HTMLElement | null): void {
list.addEventListener("click", async (event) => {
const target = event.target as HTMLElement;
const thumbnailButton = target.closest(
".resource-thumbnail-btn"
) as HTMLButtonElement | null;
if (thumbnailButton) {
event.preventDefault();
event.stopPropagation();
openPreviewModal(
thumbnailButton.dataset.previewUrl || "",
thumbnailButton.dataset.previewAlt || "Extension preview"
);
return;
}
const installButton = target.closest(
".copy-install-url-btn"
@@ -139,27 +294,86 @@ function setupActionHandlers(list: HTMLElement | null): void {
success ? "Install URL copied!" : "Failed to copy install URL",
success ? "success" : "error"
);
return;
});
const modal = document.getElementById("extension-preview-modal");
const closeButton = document.getElementById("extension-preview-close");
list.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
if (modal) {
modal.addEventListener("click", (event) => {
if (event.target === modal) {
closePreviewModal();
}
});
}
if (closeButton) {
closeButton.addEventListener("click", closePreviewModal);
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closePreviewModal();
const thumbnailButton = target.closest(
".resource-thumbnail-btn"
) as HTMLElement | null;
if (thumbnailButton) {
event.preventDefault();
event.stopPropagation();
const extensionId = thumbnailButton.dataset.extensionId;
if (!extensionId) return;
const previewButton = thumbnailButton.closest(".resource-preview") as HTMLElement | null;
openDetailsModal(extensionId, undefined, previewButton || undefined);
return;
}
if (
target.closest(".resource-actions") ||
target.closest(".extension-details-thumbnail-btn")
) {
return;
}
const card = target.closest(".resource-item") as HTMLElement | null;
const previewButton = card?.querySelector(".resource-preview") as HTMLElement | null;
const extensionId = card?.dataset.extensionId;
if (extensionId) {
openDetailsModal(extensionId, undefined, previewButton || undefined);
}
});
list.addEventListener("keydown", (event) => {
const target = event.target as HTMLElement;
const card = target.closest(".resource-item") as HTMLElement | null;
if (!card) return;
if (target.closest("a, button, select, input, textarea")) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const extensionId = card.dataset.extensionId;
const previewButton = card.querySelector(".resource-preview") as HTMLElement | null;
if (extensionId) {
openDetailsModal(extensionId, undefined, previewButton || undefined);
}
}
});
document.addEventListener("click", async (event) => {
const target = event.target as HTMLElement;
const detailsInstallButton = target.closest(
"#extension-details-install"
) as HTMLButtonElement | null;
if (detailsInstallButton) {
const installUrl = detailsInstallButton.dataset.installUrl || "";
if (!installUrl) {
showToast("No install URL available for this extension", "error");
return;
}
const success = await copyToClipboard(installUrl);
showToast(
success ? "Install URL copied!" : "Failed to copy install URL",
success ? "success" : "error"
);
return;
}
const button = target.closest(
".extension-details-thumbnail-btn"
) as HTMLButtonElement | null;
if (!button) return;
const imageUrl = button.dataset.galleryImageUrl;
const titleText = document.getElementById("modal-title")?.textContent;
if (!imageUrl || !titleText) return;
setSelectedGalleryImage(imageUrl, titleText);
});
actionHandlersReady = true;
@@ -180,6 +394,11 @@ export async function initExtensionsPage(): Promise<void> {
"sort-select"
) as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupActionHandlers(list as HTMLElement | null);
const data = await fetchData<ExtensionsData>("extensions.json");
@@ -191,6 +410,7 @@ export async function initExtensionsPage(): Promise<void> {
}
allItems = data.items;
extensionById = new Map(allItems.map((item) => [item.id, item]));
const availableKeywords = (
data.filters?.keywords ||
+45 -59
View File
@@ -3,6 +3,7 @@ import {
getGitHubUrl,
getLastUpdatedHtml,
} from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableHook {
id: string;
@@ -35,70 +36,55 @@ export function sortHooks<T extends RenderableHook>(
export function renderHooksHtml(items: RenderableHook[]): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No hooks found</h3>
<p>Try adjusting the selected filters.</p>
</div>
`;
return renderEmptyStateHtml("No hooks found", "Try adjusting the selected filters.");
}
return items
.map((item) => {
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">${escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
${item.hooks
.map(
(hook) =>
`<span class="resource-tag tag-hook">${escapeHtml(
hook
)}</span>`
)
.join("")}
${item.tags
.map(
(tag) =>
`<span class="resource-tag tag-tag">${escapeHtml(
tag
)}</span>`
)
.join("")}
${
item.assets.length > 0
? `<span class="resource-tag tag-assets">${
item.assets.length
} asset${item.assets.length === 1 ? "" : "s"}</span>`
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
</button>
<div class="resource-actions">
<button class="btn btn-primary download-hook-btn" data-hook-id="${escapeHtml(
item.id
)}" title="Download as ZIP">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/>
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/>
</svg>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</article>
const metaHtml = `
${item.hooks
.map(
(hook) => `<span class="resource-tag tag-hook">${escapeHtml(hook)}</span>`
)
.join("")}
${item.tags
.map((tag) => `<span class="resource-tag tag-tag">${escapeHtml(tag)}</span>`)
.join("")}
${
item.assets.length > 0
? `<span class="resource-tag tag-assets">${item.assets.length} asset${
item.assets.length === 1 ? "" : "s"
}</span>`
: ""
}
${getLastUpdatedHtml(item.lastUpdated)}
`;
const actionsHtml = `
<button class="btn btn-primary download-hook-btn" data-hook-id="${escapeHtml(
item.id
)}" title="Download as ZIP">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/>
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/>
</svg>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
`;
return renderSharedCardHtml({
title: item.title,
description: item.description || "No description",
articleAttributes: {
"data-path": item.readmeFile,
"data-hook-id": item.id,
},
metaHtml,
actionsHtml,
});
})
.join("");
}
+155 -106
View File
@@ -2,26 +2,23 @@
* Hooks page functionality
*/
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
showToast,
downloadZipBundle,
updateQueryParams,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderHooksHtml,
sortHooks,
type HookSortOption,
type RenderableHook,
} from "./hooks-render";
} from './hooks-render';
interface Hook extends RenderableHook {}
@@ -32,96 +29,54 @@ interface HooksData {
};
}
const resourceType = "hook";
let allItems: Hook[] = [];
let tagSelect: Choices;
let hookById = new Map<string, Hook>();
let tagSelectEl: HTMLSelectElement | null = null;
let currentFilters = {
tags: [] as string[],
};
let currentSort: HookSortOption = "title";
let currentSort: HookSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Hook[]): Hook[] {
return sortHooks(items, currentSort);
}
function applyFiltersAndRender(): void {
const countEl = document.getElementById("results-count");
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))
);
results = results.filter((item) => item.tags.some((tag) => currentFilters.tags.includes(tag)));
}
results = sortItems(results);
renderItems(results);
let countText = `${results.length} hook${results.length === 1 ? "" : "s"}`;
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" : ""})`;
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[]): void {
const list = document.getElementById("resource-list");
const list = document.getElementById('resource-list');
if (!list) return;
list.innerHTML = renderHooksHtml(items);
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener("click", (event) => {
const target = event.target as HTMLElement;
const downloadButton = target.closest(
".download-hook-btn"
) as HTMLButtonElement | null;
if (downloadButton) {
event.stopPropagation();
const hookId = downloadButton.dataset.hookId;
if (hookId) downloadHook(hookId, downloadButton);
return;
}
if (target.closest(".resource-actions")) {
return;
}
const item = target.closest(".resource-item") as HTMLElement | null;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
}
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: "",
hook: [],
tag: currentFilters.tags,
sort: currentSort === "title" ? "" : currentSort,
});
}
async function downloadHook(
hookId: string,
btn: HTMLButtonElement
): Promise<void> {
async function downloadHook(hookId: string, btn: HTMLButtonElement): Promise<void> {
const hook = allItems.find((item) => item.id === hookId);
if (!hook) {
showToast("Hook not found.", "error");
showToast('Hook not found.', 'error');
return;
}
const files = [
{ name: "README.md", path: hook.readmeFile },
{ name: 'README.md', path: hook.readmeFile },
...hook.assets.map((asset) => ({
name: asset,
path: `${hook.path}/${asset}`,
@@ -129,30 +84,26 @@ async function downloadHook(
];
if (files.length === 0) {
showToast("No files found for this hook.", "error");
showToast('No files found for this hook.', 'error');
return;
}
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML =
'<svg class="spinner" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 1 0 8 8h-1.5A6.5 6.5 0 1 1 8 1.5V0z"/></svg> Preparing...';
btn.innerHTML = '<svg class="spinner" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 1 0 8 8h-1.5A6.5 6.5 0 1 1 8 1.5V0z"/></svg> Preparing...';
try {
await downloadZipBundle(hook.id, files);
btn.innerHTML =
'<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Downloaded!';
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Downloaded!';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
}, 2000);
} catch (error) {
const message =
error instanceof Error ? error.message : "Download failed.";
showToast(message, "error");
btn.innerHTML =
'<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 0 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z"/></svg> Failed';
const message = error instanceof Error ? error.message : 'Download failed.';
showToast(message, 'error');
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 0 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z"/></svg> Failed';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
@@ -160,73 +111,171 @@ async function downloadHook(
}
}
function openHookDetailsModal(hookId: string, trigger?: HTMLElement): void {
const item = hookById.get(hookId);
if (!item) {
return;
}
const metaParts = item.hooks.map(
(hookName) => `<span class="resource-tag tag-hook">${escapeHtml(hookName)}</span>`
);
if (item.assets.length > 0) {
metaParts.push(
`<span class="resource-tag tag-assets">${item.assets.length} asset${
item.assets.length === 1 ? '' : 's'
}</span>`
);
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const tagHtml = item.tags
.map((tagText) => `<span class="resource-tag tag-tag">${escapeHtml(tagText)}</span>`)
.join('');
const actionsHtml = `
<button id="hook-details-download" class="btn btn-primary" type="button" data-hook-id="${escapeHtml(
item.id
)}">Download</button>
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.readmeFile
)}" data-open-file-type="hook">Source</button>
`;
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '🪝',
previewText: 'Hook events and download options',
metaHtml: metaParts.join(''),
tagsHtml: tagHtml,
actionsHtml,
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const downloadButton = target.closest('.download-hook-btn') as HTMLButtonElement | null;
if (downloadButton) {
event.stopPropagation();
const hookId = downloadButton.dataset.hookId;
if (hookId) downloadHook(hookId, downloadButton);
return;
}
if (target.closest('.resource-actions')) {
return;
}
const item = target.closest('.resource-item') as HTMLElement | null;
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const hookId = item?.dataset.hookId;
if (hookId) {
openHookDetailsModal(hookId, button);
}
});
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const modalDownloadButton = target.closest(
'#hook-details-download'
) as HTMLButtonElement | null;
if (!modalDownloadButton) return;
const hookId = modalDownloadButton.dataset.hookId;
if (hookId) downloadHook(hookId, modalDownloadButton);
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: '',
hook: [],
tag: currentFilters.tags,
sort: currentSort === 'title' ? '' : currentSort,
});
}
export async function initHooksPage(): Promise<void> {
const list = document.getElementById("resource-list");
const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById(
"sort-select"
) as HTMLSelectElement;
const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<HooksData>("hooks.json");
const data = await fetchData<HooksData>('hooks.json');
if (!data || !data.items) {
if (list)
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
return;
}
allItems = data.items;
hookById = new Map(allItems.map((item) => [item.id, item]));
tagSelect = createChoices("#filter-tag", {
placeholderValue: "All Tags",
});
tagSelect.setChoices(
data.filters.tags.map((tag) => ({ value: tag, label: tag })),
"value",
"label",
true
);
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
if (tagSelectEl) {
tagSelectEl.innerHTML = '';
data.filters.tags.forEach((tag) => {
const option = document.createElement('option');
option.value = tag;
option.textContent = tag;
tagSelectEl?.appendChild(option);
});
}
const initialTags = getQueryParamValues("tag").filter((tag) =>
data.filters.tags.includes(tag)
);
const initialSort = getQueryParam("sort");
const initialTags = getQueryParamValues('tag').filter((tag) => data.filters.tags.includes(tag));
const initialSort = getQueryParam('sort');
if (initialTags.length > 0) {
currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags);
setSelectValues(tagSelectEl, initialTags);
}
if (initialSort === "lastUpdated") {
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById("filter-tag")?.addEventListener("change", () => {
currentFilters.tags = getChoicesValues(tagSelect);
tagSelectEl?.addEventListener('change', () => {
currentFilters.tags = getSelectValues(tagSelectEl);
applyFiltersAndRender();
syncUrlState();
});
sortSelect?.addEventListener("change", () => {
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as HookSortOption;
applyFiltersAndRender();
syncUrlState();
});
clearFiltersBtn?.addEventListener("click", () => {
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tags: [] };
currentSort = "title";
tagSelect.removeActiveItems();
if (sortSelect) sortSelect.value = "title";
currentSort = 'title';
clearSelectValues(tagSelectEl);
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState();
});
applyFiltersAndRender();
setupModal();
}
// Auto-initialize when DOM is ready
document.addEventListener("DOMContentLoaded", initHooksPage);
document.addEventListener('DOMContentLoaded', initHooksPage);
@@ -5,6 +5,7 @@ import {
getInstallDropdownHtml,
getLastUpdatedHtml,
} from '../utils';
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
export interface RenderableInstruction {
title: string;
@@ -36,12 +37,7 @@ export function renderInstructionsHtml(
items: RenderableInstruction[]
): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No instructions found</h3>
<p>Try adjusting the selected filters.</p>
</div>
`;
return renderEmptyStateHtml('No instructions found', 'Try adjusting the selected filters.');
}
return items
@@ -50,29 +46,30 @@ export function renderInstructionsHtml(
? item.applyTo.join(', ')
: item.applyTo;
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">${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>` : ''}
${item.extensions?.slice(0, 4).map((extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`).join('') || ''}
${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
</button>
<div class="resource-actions">
${getInstallDropdownHtml('instructions', item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
</div>
</article>
const metaHtml = `
${applyToText ? `<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>` : ''}
${item.extensions?.slice(0, 4).map((extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`).join('') || ''}
${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
`;
const actionsHtml = `
${getInstallDropdownHtml('instructions', item.path, true)}
${getActionButtonsHtml(item.path, true)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary btn-small" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">
GitHub
</a>
`;
return renderSharedCardHtml({
title: item.title,
description: item.description || 'No description',
articleAttributes: {
'data-path': item.path,
},
metaHtml,
actionsHtml,
});
})
.join('');
}
+86 -23
View File
@@ -2,20 +2,18 @@
* Instructions page functionality
*/
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
setupDropdownCloseHandlers,
getVSCodeInstallUrl,
setupActionHandlers,
setupDropdownCloseHandlers,
updateQueryParams,
} from '../utils';
import { setupModal, openFileModal } from '../modal';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderInstructionsHtml,
sortInstructions,
@@ -37,12 +35,13 @@ interface InstructionsData {
};
}
const resourceType = 'instruction';
let allItems: Instruction[] = [];
let extensionSelect: Choices;
let instructionByPath = new Map<string, Instruction>();
let extensionSelectEl: HTMLSelectElement | null = null;
let currentFilters = { extensions: [] as string[] };
let currentSort: InstructionSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Instruction[]): Instruction[] {
return sortInstructions(items, currentSort);
@@ -53,11 +52,14 @@ function applyFiltersAndRender(): void {
let results = [...allItems];
if (currentFilters.extensions.length > 0) {
results = results.filter(item => {
if (currentFilters.extensions.includes('(none)') && (!item.extensions || item.extensions.length === 0)) {
results = results.filter((item) => {
if (
currentFilters.extensions.includes('(none)') &&
(!item.extensions || item.extensions.length === 0)
) {
return true;
}
return item.extensions?.some(ext => currentFilters.extensions.includes(ext));
return item.extensions?.some((ext) => currentFilters.extensions.includes(ext));
});
}
@@ -78,6 +80,53 @@ function renderItems(items: Instruction[]): void {
list.innerHTML = renderInstructionsHtml(items);
}
function openInstructionDetailsModal(path: string, trigger?: HTMLElement): void {
const item = instructionByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
const applyToText = Array.isArray(item.applyTo) ? item.applyTo.join(', ') : item.applyTo;
if (applyToText) {
metaParts.push(`<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>`);
}
metaParts.push(
...(item.extensions || []).map(
(extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`
)
);
if (item.lastUpdated) {
metaParts.push(`<span class="last-updated">Updated ${escapeHtml(formatRelativeTime(item.lastUpdated))}</span>`);
}
const vscodeUrl = getVSCodeInstallUrl('instructions', path, false);
const insidersUrl = getVSCodeInstallUrl('instructions', path, true);
const actions = [
vscodeUrl
? `<a class="btn btn-primary btn-small" href="${escapeHtml(vscodeUrl)}" target="_blank" rel="noopener noreferrer">Install (VS Code)</a>`
: '',
insidersUrl
? `<a class="btn btn-secondary btn-small" href="${escapeHtml(insidersUrl)}" target="_blank" rel="noopener noreferrer">Install (Insiders)</a>`
: '',
`<button class="btn btn-secondary btn-small" type="button" data-open-file-path="${escapeHtml(
path
)}" data-open-file-type="instruction">Source</button>`,
].filter(Boolean);
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '📋',
previewText: 'Instruction metadata and install options',
metaHtml: metaParts.join(''),
actionsHtml: actions.join(''),
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
@@ -88,9 +137,10 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
}
const item = target.closest('.resource-item') as HTMLElement | null;
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
openInstructionDetailsModal(path, button);
}
});
@@ -108,7 +158,12 @@ function syncUrlState(): void {
export async function initInstructionsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
@@ -119,24 +174,33 @@ export async function initInstructionsPage(): Promise<void> {
}
allItems = data.items;
instructionByPath = new Map(allItems.map((item) => [item.path, item]));
extensionSelect = createChoices('#filter-extension', { placeholderValue: 'All Extensions' });
extensionSelect.setChoices(data.filters.extensions.map(e => ({ value: e, label: e })), 'value', 'label', true);
extensionSelectEl = document.getElementById('filter-extension') as HTMLSelectElement | null;
if (extensionSelectEl) {
extensionSelectEl.innerHTML = '';
data.filters.extensions.forEach((ext) => {
const option = document.createElement('option');
option.value = ext;
option.textContent = ext;
extensionSelectEl?.appendChild(option);
});
}
const initialExtensions = getQueryParamValues('extension').filter(extension => data.filters.extensions.includes(extension));
const initialExtensions = getQueryParamValues('extension').filter((extension) => data.filters.extensions.includes(extension));
const initialSort = getQueryParam('sort');
if (initialExtensions.length > 0) {
currentFilters.extensions = initialExtensions;
setChoicesValues(extensionSelect, initialExtensions);
setSelectValues(extensionSelectEl, initialExtensions);
}
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById('filter-extension')?.addEventListener('change', () => {
currentFilters.extensions = getChoicesValues(extensionSelect);
extensionSelectEl?.addEventListener('change', () => {
currentFilters.extensions = getSelectValues(extensionSelectEl);
applyFiltersAndRender();
syncUrlState();
});
@@ -150,14 +214,13 @@ export async function initInstructionsPage(): Promise<void> {
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { extensions: [] };
currentSort = 'title';
extensionSelect.removeActiveItems();
clearSelectValues(extensionSelectEl);
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState();
});
applyFiltersAndRender();
setupModal();
setupDropdownCloseHandlers();
setupActionHandlers();
}
+22 -24
View File
@@ -3,6 +3,7 @@ import {
getGitHubUrl,
sanitizeUrl,
} from '../utils';
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
interface PluginAuthor {
name: string;
@@ -57,12 +58,7 @@ function getExternalPluginUrl(plugin: RenderablePlugin): string {
export function renderPluginsHtml(items: RenderablePlugin[]): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No plugins found</h3>
<p>Try different tags or clear the current filters</p>
</div>
`;
return renderEmptyStateHtml('No plugins found', 'Try different tags or clear the current filters');
}
return items
@@ -78,25 +74,27 @@ export function renderPluginsHtml(items: RenderablePlugin[]): string {
const githubHref = isExternal
? escapeHtml(getExternalPluginUrl(item))
: getGitHubUrl(item.path);
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">${escapeHtml(item.name)}</div>
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
<div class="resource-meta">
${metaTag}
${authorTag}
${item.tags?.slice(0, 4).map((tag) => `<span class="resource-tag">${escapeHtml(tag)}</span>`).join('') || ''}
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
</div>
</div>
</button>
<div class="resource-actions">
<a href="${githubHref}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="${isExternal ? 'View repository' : 'View on GitHub'}">${isExternal ? 'Repository' : 'GitHub'}</a>
</div>
</article>
const metaHtml = `
${metaTag}
${authorTag}
${item.tags?.slice(0, 4).map((tag) => `<span class="resource-tag">${escapeHtml(tag)}</span>`).join('') || ''}
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
`;
const actionsHtml = `
<a href="${githubHref}" class="btn btn-secondary" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()" title="${isExternal ? 'View repository' : 'View on GitHub'}">${isExternal ? 'Repository' : 'GitHub'}</a>
`;
return renderSharedCardHtml({
title: item.name,
description: item.description || 'No description',
articleClassName: isExternal ? 'resource-item-external' : '',
articleAttributes: {
'data-path': item.path,
},
metaHtml,
actionsHtml,
});
})
.join('');
}
+135 -20
View File
@@ -2,18 +2,18 @@
* Plugins page functionality
*/
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import {
copyToClipboard,
escapeHtml,
fetchData,
formatRelativeTime,
getGitHubUrl,
getQueryParam,
getQueryParamValues,
showToast,
updateQueryParams,
} from '../utils';
import { setupModal, openFileModal } from '../modal';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderPluginsHtml,
sortPlugins,
@@ -32,12 +32,18 @@ interface PluginSource {
path?: string;
}
interface PluginItem {
kind: string;
path: string;
}
interface Plugin extends RenderablePlugin {
id: string;
name: string;
path: string;
tags?: string[];
itemCount: number;
items?: PluginItem[];
external?: boolean;
repository?: string | null;
homepage?: string | null;
@@ -53,14 +59,15 @@ interface PluginsData {
};
}
const resourceType = 'plugin';
let allItems: Plugin[] = [];
let tagSelect: Choices;
let pluginByPath = new Map<string, Plugin>();
let tagSelectEl: HTMLSelectElement | null = null;
let currentSort: PluginSortOption = 'title';
let currentFilters = {
tags: [] as string[],
};
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Plugin[]): Plugin[] {
return sortPlugins(items, currentSort);
@@ -79,7 +86,7 @@ function applyFiltersAndRender(): void {
let results = [...allItems];
if (currentFilters.tags.length > 0) {
results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag)));
results = results.filter((item) => item.tags?.some((tag) => currentFilters.tags.includes(tag)));
}
results = sortItems(results);
@@ -95,6 +102,87 @@ function renderItems(items: Plugin[]): void {
list.innerHTML = renderPluginsHtml(items);
}
function getPluginRepositoryUrl(item: Plugin): string {
if (item.external && item.repository) return item.repository;
if (item.homepage) return item.homepage;
if (item.repository) return item.repository;
return getGitHubUrl(item.path);
}
function getPluginItemLabel(item: PluginItem): string {
const normalizedPath = item.path.replace(/^\.\//, '');
return `${item.kind}: ${normalizedPath}`;
}
function openPluginDetailsModal(path: string, trigger?: HTMLElement): void {
const item = pluginByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
metaParts.push(
`<span class="resource-tag">${
item.external ? 'External plugin' : `${item.itemCount} items`
}</span>`
);
if (item.author?.name) {
metaParts.push(`<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`);
}
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const tagHtml = (item.tags || [])
.map((tagText) => `<span class="resource-tag">${escapeHtml(tagText)}</span>`)
.join('');
const includedItems = item.items || [];
const includedItemHtml = includedItems
.slice(0, 24)
.map(
(pluginItem) =>
`<span class="resource-tag tag-plugin-item">${escapeHtml(getPluginItemLabel(pluginItem))}</span>`
)
.join('');
const includedMoreHtml =
includedItems.length > 24
? `<span class="resource-tag">+${includedItems.length - 24} more</span>`
: '';
const actions = [
item.external
? ''
: `<button id="plugin-details-install" class="btn btn-primary" type="button" data-plugin-name="${escapeHtml(
item.name
)}">Copy Install</button>`,
item.external
? `<a class="btn btn-secondary" href="${escapeHtml(
getPluginRepositoryUrl(item)
)}" target="_blank" rel="noopener noreferrer">Repository</a>`
: `<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.path
)}" data-open-file-type="plugin">Source</button>`,
].filter(Boolean);
openCardDetailsModal({
title: item.name,
description: item.description || 'No description',
previewIcon: '🔌',
previewText: 'Plugin metadata and install options',
metaHtml: metaParts.join(''),
tagsHtml: [tagHtml, includedItemHtml, includedMoreHtml].filter(Boolean).join(''),
actionsHtml: actions.join(''),
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
@@ -105,12 +193,26 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
}
const item = target.closest('.resource-item') as HTMLElement | null;
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
openPluginDetailsModal(path, button);
}
});
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement;
const installButton = target.closest(
'#plugin-details-install'
) as HTMLButtonElement | null;
if (!installButton) return;
const pluginName = installButton.dataset.pluginName || '';
if (!pluginName) return;
const command = `copilot plugin install ${pluginName}@awesome-copilot`;
const success = await copyToClipboard(command);
showToast(success ? 'Install command copied!' : 'Failed to copy', success ? 'success' : 'error');
});
resourceListHandlersReady = true;
}
@@ -125,7 +227,12 @@ function syncUrlState(): void {
export async function initPluginsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
@@ -136,20 +243,29 @@ export async function initPluginsPage(): Promise<void> {
}
allItems = data.items;
pluginByPath = new Map(allItems.map((item) => [item.path, item]));
tagSelect = createChoices('#filter-tag', { placeholderValue: 'All Tags' });
tagSelect.setChoices(data.filters.tags.map(t => ({ value: t, label: t })), 'value', 'label', true);
tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
if (tagSelectEl) {
tagSelectEl.innerHTML = '';
data.filters.tags.forEach((tag) => {
const option = document.createElement('option');
option.value = tag;
option.textContent = tag;
tagSelectEl?.appendChild(option);
});
}
const initialTags = getQueryParamValues('tag').filter(tag => data.filters.tags.includes(tag));
const initialTags = getQueryParamValues('tag').filter((tag) => data.filters.tags.includes(tag));
const initialSort = getQueryParam('sort');
if (initialTags.length > 0) {
currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags);
setSelectValues(tagSelectEl, initialTags);
}
document.getElementById('filter-tag')?.addEventListener('change', () => {
currentFilters.tags = getChoicesValues(tagSelect);
tagSelectEl?.addEventListener('change', () => {
currentFilters.tags = getSelectValues(tagSelectEl);
applyFiltersAndRender();
syncUrlState();
});
@@ -167,7 +283,7 @@ export async function initPluginsPage(): Promise<void> {
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tags: [] };
currentSort = 'title';
tagSelect.removeActiveItems();
clearSelectValues(tagSelectEl);
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState();
@@ -175,7 +291,6 @@ export async function initPluginsPage(): Promise<void> {
applyFiltersAndRender();
syncUrlState();
setupModal();
}
// Auto-initialize when DOM is ready
+19
View File
@@ -0,0 +1,19 @@
export function getSelectValues(select: HTMLSelectElement | null): string[] {
if (!select) return [];
return Array.from(select.selectedOptions).map((option) => option.value);
}
export function setSelectValues(select: HTMLSelectElement | null, values: string[]): void {
if (!select) return;
const selected = new Set(values);
Array.from(select.options).forEach((option) => {
option.selected = selected.has(option.value);
});
}
export function clearSelectValues(select: HTMLSelectElement | null): void {
if (!select) return;
Array.from(select.options).forEach((option) => {
option.selected = false;
});
}
+47 -53
View File
@@ -3,6 +3,7 @@ import {
getGitHubUrl,
getLastUpdatedHtml,
} from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableSkillFile {
name: string;
@@ -41,66 +42,59 @@ export function sortSkills<T extends RenderableSkill>(
export function renderSkillsHtml(items: RenderableSkill[]): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No skills found</h3>
<p>No skills are available right now.</p>
</div>
`;
return renderEmptyStateHtml("No skills found", "No skills are available right now.");
}
return items
.map((item) => {
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">${escapeHtml(item.title)}</div>
<div class="resource-description">${escapeHtml(
item.description || "No description"
)}</div>
<div class="resource-meta">
${
item.hasAssets
? `<span class="resource-tag tag-assets">${
item.assetCount
} asset${item.assetCount === 1 ? "" : "s"}</span>`
: ""
}
<span class="resource-tag">${item.files.length} file${
const metaHtml = `
${
item.hasAssets
? `<span class="resource-tag tag-assets">${item.assetCount} asset${
item.assetCount === 1 ? "" : "s"
}</span>`
: ""
}
<span class="resource-tag">${item.files.length} file${
item.files.length === 1 ? "" : "s"
}</span>
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
</button>
<div class="resource-actions">
<button class="btn btn-secondary copy-install-btn" data-skill-id="${escapeHtml(
item.id
)}" title="Copy install command">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
</svg>
Copy Install
</button>
<button class="btn btn-primary download-skill-btn" data-skill-id="${escapeHtml(
item.id
)}" title="Download as ZIP">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/>
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/>
</svg>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</article>
${getLastUpdatedHtml(item.lastUpdated)}
`;
const actionsHtml = `
<button class="btn btn-secondary copy-install-btn" data-skill-id="${escapeHtml(
item.id
)}" title="Copy install command">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
</svg>
Copy Install
</button>
<button class="btn btn-primary download-skill-btn" data-skill-id="${escapeHtml(
item.id
)}" title="Download as ZIP">
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z"/>
<path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06l1.97 1.969Z"/>
</svg>
Download
</button>
<a href="${getGitHubUrl(
item.path
)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
`;
return renderSharedCardHtml({
title: item.title,
description: item.description || "No description",
articleAttributes: {
"data-path": item.skillFile,
"data-skill-id": item.id,
},
metaHtml,
actionsHtml,
});
})
.join("");
}
+159 -91
View File
@@ -2,28 +2,30 @@
* Skills page functionality
*/
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
showToast,
downloadZipBundle,
updateQueryParams,
copyToClipboard,
REPO_IDENTIFIER,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import {
renderSkillsHtml,
sortSkills,
type RenderableSkill,
type SkillSortOption,
} from "./skills-render";
} from './skills-render';
interface SkillFile {
name: string;
path: string;
}
interface Skill extends Omit<RenderableSkill, "files"> {
interface Skill extends Omit<RenderableSkill, 'files'> {
files: SkillFile[];
}
@@ -31,37 +33,139 @@ interface SkillsData {
items: Skill[];
}
const resourceType = "skill";
let allItems: Skill[] = [];
let currentSort: SkillSortOption = "title";
let skillById = new Map<string, Skill>();
let currentSort: SkillSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function applyFiltersAndRender(): void {
const countEl = document.getElementById("results-count");
const countEl = document.getElementById('results-count');
const results = sortSkills(allItems, currentSort);
renderItems(results);
if (countEl) {
countEl.textContent = `${results.length} skill${results.length === 1 ? "" : "s"}`;
countEl.textContent = `${results.length} skill${results.length === 1 ? '' : 's'}`;
}
}
function renderItems(items: Skill[]): void {
const list = document.getElementById("resource-list");
const list = document.getElementById('resource-list');
if (!list) return;
list.innerHTML = renderSkillsHtml(items);
}
async function copyInstallCommand(skillId: string, btn: HTMLButtonElement): Promise<void> {
const command = `gh skills install ${REPO_IDENTIFIER} ${skillId}`;
const originalContent = btn.innerHTML;
const success = await copyToClipboard(command);
showToast(success ? 'Install command copied!' : 'Failed to copy', success ? 'success' : 'error');
if (success) {
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Copied!';
setTimeout(() => {
btn.innerHTML = originalContent;
}, 2000);
}
}
async function downloadSkill(skillId: string, btn: HTMLButtonElement): Promise<void> {
const skill = allItems.find((item) => item.id === skillId);
if (!skill || !skill.files || skill.files.length === 0) {
showToast('No files found for this skill.', 'error');
return;
}
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="spinner" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 1 0 8 8h-1.5A6.5 6.5 0 1 1 8 1.5V0z"/></svg> Preparing...';
try {
await downloadZipBundle(skill.id, skill.files);
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Downloaded!';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
}, 2000);
} catch (error) {
const message = error instanceof Error ? error.message : 'Download failed.';
showToast(message, 'error');
btn.innerHTML = '<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 0 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z"/></svg> Failed';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
}, 2000);
}
}
function openSkillDetailsModal(skillId: string, trigger?: HTMLElement): void {
const item = skillById.get(skillId);
if (!item) {
return;
}
const metaParts: string[] = [];
if (item.hasAssets) {
metaParts.push(
`<span class="resource-tag tag-assets">${item.assetCount} asset${
item.assetCount === 1 ? '' : 's'
}</span>`
);
}
metaParts.push(
`<span class="resource-tag">${item.files.length} file${
item.files.length === 1 ? '' : 's'
}</span>`
);
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const fileTagParts = item.files
.slice(0, 24)
.map((file) => `<span class="resource-tag">${escapeHtml(file.name)}</span>`);
if (item.files.length > 24) {
fileTagParts.push(`<span class="resource-tag">+${item.files.length - 24} more</span>`);
}
const actionsHtml = `
<button id="skill-details-install" class="btn btn-secondary" type="button" data-skill-id="${escapeHtml(
item.id
)}">Copy Install</button>
<button id="skill-details-download" class="btn btn-primary" type="button" data-skill-id="${escapeHtml(
item.id
)}">Download</button>
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.skillFile
)}" data-open-file-type="skill">Source</button>
`;
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '⚡',
previewText: 'Skill files and install options',
metaHtml: metaParts.join(''),
tagsHtml: fileTagParts.join(''),
actionsHtml,
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener("click", (event) => {
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const copyInstallButton = target.closest(
".copy-install-btn"
) as HTMLButtonElement | null;
const copyInstallButton = target.closest('.copy-install-btn') as HTMLButtonElement | null;
if (copyInstallButton) {
event.stopPropagation();
const skillId = copyInstallButton.dataset.skillId;
@@ -69,9 +173,7 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
return;
}
const downloadButton = target.closest(
".download-skill-btn"
) as HTMLButtonElement | null;
const downloadButton = target.closest('.download-skill-btn') as HTMLButtonElement | null;
if (downloadButton) {
event.stopPropagation();
const skillId = downloadButton.dataset.skillId;
@@ -79,11 +181,32 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
return;
}
if (target.closest(".resource-actions")) return;
if (target.closest('.resource-actions')) return;
const item = target.closest(".resource-item") as HTMLElement | null;
const path = item?.dataset.path;
if (path) openFileModal(path, resourceType);
const item = target.closest('.resource-item') as HTMLElement | null;
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const skillId = item?.dataset.skillId;
if (skillId) openSkillDetailsModal(skillId, button);
});
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const modalInstallButton = target.closest(
'#skill-details-install'
) as HTMLButtonElement | null;
if (modalInstallButton) {
const skillId = modalInstallButton.dataset.skillId;
if (skillId) copyInstallCommand(skillId, modalInstallButton);
return;
}
const modalDownloadButton = target.closest(
'#skill-details-download'
) as HTMLButtonElement | null;
if (modalDownloadButton) {
const skillId = modalDownloadButton.dataset.skillId;
if (skillId) downloadSkill(skillId, modalDownloadButton);
}
});
resourceListHandlersReady = true;
@@ -91,102 +214,47 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
function syncUrlState(): void {
updateQueryParams({
q: "",
q: '',
category: [],
hasAssets: false,
sort: currentSort === "title" ? "" : currentSort,
sort: currentSort === 'title' ? '' : currentSort,
});
}
async function copyInstallCommand(
skillId: string,
btn: HTMLButtonElement
): Promise<void> {
const command = `gh skills install ${REPO_IDENTIFIER} ${skillId}`;
const originalContent = btn.innerHTML;
const success = await copyToClipboard(command);
showToast(
success ? "Install command copied!" : "Failed to copy",
success ? "success" : "error"
);
if (success) {
btn.innerHTML =
'<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Copied!';
setTimeout(() => {
btn.innerHTML = originalContent;
}, 2000);
}
}
async function downloadSkill(
skillId: string,
btn: HTMLButtonElement
): Promise<void> {
const skill = allItems.find((item) => item.id === skillId);
if (!skill || !skill.files || skill.files.length === 0) {
showToast("No files found for this skill.", "error");
return;
}
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML =
'<svg class="spinner" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M8 0a8 8 0 1 0 8 8h-1.5A6.5 6.5 0 1 1 8 1.5V0z"/></svg> Preparing...';
try {
await downloadZipBundle(skill.id, skill.files);
btn.innerHTML =
'<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.75.75 0 0 1 1.06-1.06L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0z"/></svg> Downloaded!';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
}, 2000);
} catch (error) {
const message = error instanceof Error ? error.message : "Download failed.";
showToast(message, "error");
btn.innerHTML =
'<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.75.75 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.75.75 0 0 1-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 0 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06z"/></svg> Failed';
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
}, 2000);
}
}
export async function initSkillsPage(): Promise<void> {
const list = document.getElementById("resource-list");
const sortSelect = document.getElementById(
"sort-select"
) as HTMLSelectElement;
const list = document.getElementById('resource-list');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<SkillsData>("skills.json");
const data = await fetchData<SkillsData>('skills.json');
if (!data || !data.items) {
if (list)
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
return;
}
allItems = data.items;
skillById = new Map(allItems.map((item) => [item.id, item]));
const initialSort = getQueryParam("sort");
if (initialSort === "lastUpdated") {
const initialSort = getQueryParam('sort');
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
sortSelect?.addEventListener("change", () => {
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as SkillSortOption;
applyFiltersAndRender();
syncUrlState();
});
applyFiltersAndRender();
setupModal();
}
// Auto-initialize when DOM is ready
document.addEventListener("DOMContentLoaded", initSkillsPage);
document.addEventListener('DOMContentLoaded', initSkillsPage);
+22 -23
View File
@@ -4,6 +4,7 @@ import {
getGitHubUrl,
getLastUpdatedHtml,
} from '../utils';
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
export interface RenderableWorkflow {
title: string;
@@ -34,34 +35,32 @@ export function renderWorkflowsHtml(
items: RenderableWorkflow[]
): string {
if (items.length === 0) {
return `
<div class="empty-state">
<h3>No workflows found</h3>
<p>Try adjusting the selected filters.</p>
</div>
`;
return renderEmptyStateHtml('No workflows found', 'Try adjusting the selected filters.');
}
return items
.map((item) => {
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">${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('')}
${getLastUpdatedHtml(item.lastUpdated)}
</div>
</div>
</button>
<div class="resource-actions">
${getActionButtonsHtml(item.path)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
</div>
</article>
const metaHtml = `
${item.triggers
.map((trigger) => `<span class="resource-tag tag-trigger">${escapeHtml(trigger)}</span>`)
.join('')}
${getLastUpdatedHtml(item.lastUpdated)}
`;
const actionsHtml = `
${getActionButtonsHtml(item.path)}
<a href="${getGitHubUrl(item.path)}" class="btn btn-secondary" target="_blank" onclick="event.stopPropagation()" title="View on GitHub">GitHub</a>
`;
return renderSharedCardHtml({
title: item.title,
description: item.description || 'No description',
articleAttributes: {
'data-path': item.path,
},
metaHtml,
actionsHtml,
});
})
.join('');
}
+108 -58
View File
@@ -2,25 +2,24 @@
* Workflows page functionality
*/
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import {
copyToClipboard,
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
showToast,
setupActionHandlers,
updateQueryParams,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderWorkflowsHtml,
sortWorkflows,
type RenderableWorkflow,
type WorkflowSortOption,
} from "./workflows-render";
} from './workflows-render';
interface Workflow extends RenderableWorkflow {
id: string;
@@ -36,141 +35,192 @@ interface WorkflowsData {
};
}
const resourceType = "workflow";
let allItems: Workflow[] = [];
let triggerSelect: Choices;
let workflowByPath = new Map<string, Workflow>();
let triggerSelectEl: HTMLSelectElement | null = null;
let currentFilters = {
triggers: [] as string[],
};
let currentSort: WorkflowSortOption = "title";
let currentSort: WorkflowSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Workflow[]): Workflow[] {
return sortWorkflows(items, currentSort);
}
function applyFiltersAndRender(): void {
const countEl = document.getElementById("results-count");
const countEl = document.getElementById('results-count');
let results = [...allItems];
if (currentFilters.triggers.length > 0) {
results = results.filter((item) =>
item.triggers.some((trigger) => currentFilters.triggers.includes(trigger))
);
results = results.filter((item) => item.triggers.some((trigger) => currentFilters.triggers.includes(trigger)));
}
results = sortItems(results);
renderItems(results);
let countText = `${results.length} workflow${results.length === 1 ? "" : "s"}`;
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" : ""})`;
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[]): void {
const list = document.getElementById("resource-list");
const list = document.getElementById('resource-list');
if (!list) return;
list.innerHTML = renderWorkflowsHtml(items);
}
function openWorkflowDetailsModal(path: string, trigger?: HTMLElement): void {
const item = workflowByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const triggerTags = item.triggers
.map((triggerName) => `<span class="resource-tag tag-trigger">${escapeHtml(triggerName)}</span>`)
.join('');
const actionsHtml = `
<button id="workflow-details-copy-path" class="btn btn-secondary" type="button" data-workflow-path="${escapeHtml(
item.path
)}">Copy Path</button>
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.path
)}" data-open-file-type="workflow">Source</button>
`;
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '⚡',
previewText: 'Workflow trigger details and source',
metaHtml: metaParts.join(''),
tagsHtml: triggerTags,
actionsHtml,
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener("click", (event) => {
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.closest(".resource-actions")) {
if (target.closest('.resource-actions')) {
return;
}
const item = target.closest(".resource-item") as HTMLElement | null;
const item = target.closest('.resource-item') as HTMLElement | null;
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
openWorkflowDetailsModal(path, button);
}
});
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement;
const copyPathButton = target.closest(
'#workflow-details-copy-path'
) as HTMLButtonElement | null;
if (!copyPathButton) return;
const workflowPath = copyPathButton.dataset.workflowPath || '';
if (!workflowPath) return;
const success = await copyToClipboard(workflowPath);
showToast(success ? 'Path copied!' : 'Failed to copy path', success ? 'success' : 'error');
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: "",
q: '',
trigger: currentFilters.triggers,
sort: currentSort === "title" ? "" : currentSort,
sort: currentSort === 'title' ? '' : currentSort,
});
}
export async function initWorkflowsPage(): Promise<void> {
const list = document.getElementById("resource-list");
const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById(
"sort-select"
) as HTMLSelectElement;
const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<WorkflowsData>("workflows.json");
const data = await fetchData<WorkflowsData>('workflows.json');
if (!data || !data.items) {
if (list)
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
return;
}
allItems = data.items;
workflowByPath = new Map(allItems.map((item) => [item.path, item]));
triggerSelect = createChoices("#filter-trigger", {
placeholderValue: "All Triggers",
});
triggerSelect.setChoices(
data.filters.triggers.map((trigger) => ({ value: trigger, label: trigger })),
"value",
"label",
true
);
triggerSelectEl = document.getElementById('filter-trigger') as HTMLSelectElement | null;
if (triggerSelectEl) {
triggerSelectEl.innerHTML = '';
data.filters.triggers.forEach((trigger) => {
const option = document.createElement('option');
option.value = trigger;
option.textContent = trigger;
triggerSelectEl?.appendChild(option);
});
}
const initialTriggers = getQueryParamValues("trigger").filter((trigger) =>
data.filters.triggers.includes(trigger)
);
const initialSort = getQueryParam("sort");
const initialTriggers = getQueryParamValues('trigger').filter((trigger) => data.filters.triggers.includes(trigger));
const initialSort = getQueryParam('sort');
if (initialTriggers.length > 0) {
currentFilters.triggers = initialTriggers;
setChoicesValues(triggerSelect, initialTriggers);
setSelectValues(triggerSelectEl, initialTriggers);
}
if (initialSort === "lastUpdated") {
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById("filter-trigger")?.addEventListener("change", () => {
currentFilters.triggers = getChoicesValues(triggerSelect);
triggerSelectEl?.addEventListener('change', () => {
currentFilters.triggers = getSelectValues(triggerSelectEl);
applyFiltersAndRender();
syncUrlState();
});
sortSelect?.addEventListener("change", () => {
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as WorkflowSortOption;
applyFiltersAndRender();
syncUrlState();
});
clearFiltersBtn?.addEventListener("click", () => {
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { triggers: [] };
currentSort = "title";
triggerSelect.removeActiveItems();
if (sortSelect) sortSelect.value = "title";
currentSort = 'title';
clearSelectValues(triggerSelectEl);
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState();
});
applyFiltersAndRender();
setupModal();
setupActionHandlers();
}
// Auto-initialize when DOM is ready
document.addEventListener("DOMContentLoaded", initWorkflowsPage);
document.addEventListener('DOMContentLoaded', initWorkflowsPage);
+306 -40
View File
@@ -1157,6 +1157,29 @@ body:has(#main-content) {
min-height: 200px;
}
.modal.details-mode .modal-content {
max-width: 980px;
}
.modal.details-mode #modal-file-switcher,
.modal.details-mode #install-command-btn,
.modal.details-mode #copy-btn,
.modal.details-mode #download-btn,
.modal.details-mode #share-btn,
.modal.details-mode #render-btn,
.modal.details-mode #raw-btn,
.modal.details-mode #install-dropdown {
display: none !important;
}
.modal.details-mode .modal-card-details {
padding: 18px;
}
.modal.details-mode .modal-card-details .resource-details-body {
margin: 0;
}
.modal-rendered-content {
padding: 24px;
min-height: 200px;
@@ -1876,7 +1899,7 @@ body:has(#main-content) {
text-align: left;
cursor: pointer;
}
.resource-thumbnail-btn {
display: inline-flex;
align-items: center;
@@ -1887,13 +1910,13 @@ body:has(#main-content) {
cursor: pointer;
flex-shrink: 0;
}
.resource-thumbnail-btn:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 4px;
border-radius: var(--border-radius);
}
.resource-thumbnail {
width: clamp(120px, 24vw, 160px);
aspect-ratio: 16 / 10;
@@ -1905,13 +1928,13 @@ body:has(#main-content) {
box-shadow: var(--shadow);
transition: transform var(--transition), box-shadow var(--transition);
}
.resource-thumbnail-btn:hover .resource-thumbnail,
.resource-thumbnail-btn:focus-visible .resource-thumbnail {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.resource-thumbnail-placeholder {
display: flex;
align-items: center;
@@ -1925,7 +1948,7 @@ body:has(#main-content) {
linear-gradient(135deg, rgba(133, 52, 243, 0.18), rgba(254, 76, 37, 0.08)),
var(--color-bg-tertiary);
}
.resource-preview:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 4px;
@@ -1998,61 +2021,296 @@ body:has(#main-content) {
margin: 0px;
}
.extension-preview-modal {
position: fixed;
inset: 0;
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(5, 7, 15, 0.72);
backdrop-filter: blur(8px);
/* Extensions page grid layout */
.extensions-page .resource-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
align-items: stretch;
}
.extension-preview-modal.hidden {
display: none;
}
.extension-preview-dialog {
width: min(100%, 980px);
max-height: 90vh;
display: flex;
.extensions-page .resource-item {
height: 100%;
padding: 20px;
flex-direction: column;
gap: 12px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-glass-border);
align-items: stretch;
justify-content: space-between;
gap: 16px;
cursor: pointer;
margin-top: 0px;
}
.extensions-page .resource-item:hover,
.extensions-page .resource-item:focus-within {
transform: translateY(-2px);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
}
.extensions-page .resource-item:hover::before,
.extensions-page .resource-item:focus-within::before {
opacity: 0;
left: 0;
}
.extensions-page .resource-preview {
flex-direction: column;
align-items: stretch;
gap: 12px;
cursor: inherit;
}
.extensions-page .resource-thumbnail-btn {
width: 100%;
}
.extensions-page .resource-thumbnail {
width: 100%;
max-width: none;
}
.extensions-page .resource-item > .resource-actions {
justify-content: flex-start;
margin-top: auto;
}
/* Clamp keyword tags to ~2 rows; allow a little extra for font/rendering variance */
.extensions-page .resource-item .resource-keywords {
max-height: 58px;
overflow: hidden;
}
.extension-preview-header {
/* Clamp description to exactly 2 lines on cards */
.extensions-page .resource-item .resource-description {
-webkit-line-clamp: 2;
line-clamp: 2;
}
.extension-details-body {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 18px;
align-items: start;
justify-content: initial;
}
.extension-details-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px 0;
flex-direction: column;
gap: 10px;
}
.extension-preview-title {
margin: 0;
font-size: 1rem;
color: var(--color-text-emphasis);
.extension-details-image {
width: 100%;
max-width: none;
}
.extension-preview-close {
.extension-details-gallery {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 4px;
}
.extension-details-thumbnail-btn {
flex: 0 0 auto;
width: 88px;
height: 56px;
border: 1px solid var(--color-glass-border);
border-radius: var(--border-radius);
background: var(--color-bg-tertiary);
padding: 0;
cursor: pointer;
}
.extension-preview-body {
padding: 0 18px 18px;
.extension-details-thumbnail-btn.active {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.extension-details-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: calc(var(--border-radius) - 1px);
}
.extension-details-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.extension-details-description {
margin: 0;
color: var(--color-text-muted);
}
.extension-details-keywords {
margin-top: 0;
}
.extension-details-meta {
margin-top: 0;
}
.extension-details-actions {
margin-top: 4px;
}
/* Agents page grid layout prototype */
.agents-page .resource-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
align-items: stretch;
}
.agents-page .resource-item {
height: 100%;
padding: 20px;
flex-direction: column;
align-items: stretch;
justify-content: space-between;
gap: 16px;
cursor: pointer;
margin-top: 0px;
}
.agents-page .resource-item:hover,
.agents-page .resource-item:focus-within {
transform: translateY(-2px);
border-radius: var(--border-radius-lg);
}
.agents-page .resource-item:hover::before,
.agents-page .resource-item:focus-within::before {
opacity: 0;
left: 0;
}
.agents-page .resource-preview {
flex-direction: column;
align-items: stretch;
gap: 12px;
cursor: inherit;
}
.agents-page .resource-item > .resource-actions {
justify-content: flex-start;
margin-top: auto;
}
.agents-page .resource-item .resource-meta {
max-height: 76px;
overflow: hidden;
}
/* Shared grid layout prototype for listing pages */
.listing-cards-page .resource-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
align-items: stretch;
}
.listing-cards-page .resource-item {
height: 100%;
padding: 20px;
flex-direction: column;
align-items: stretch;
justify-content: space-between;
gap: 16px;
cursor: pointer;
margin-top: 0px;
}
.listing-cards-page .resource-item:hover,
.listing-cards-page .resource-item:focus-within {
transform: translateY(-2px);
border-radius: var(--border-radius-lg);
}
.listing-cards-page .resource-item:hover::before,
.listing-cards-page .resource-item:focus-within::before {
opacity: 0;
left: 0;
}
.listing-cards-page .resource-preview {
flex-direction: column;
align-items: stretch;
gap: 12px;
cursor: inherit;
}
.listing-cards-page .resource-item > .resource-actions {
justify-content: flex-start;
margin-top: auto;
}
.listing-cards-page .resource-item .resource-meta {
max-height: 92px;
overflow: hidden;
}
.resource-details-body {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr);
gap: 18px;
align-items: start;
justify-content: initial;
}
.resource-details-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 220px;
background: linear-gradient(135deg, rgba(133, 52, 243, 0.14), rgba(254, 76, 37, 0.08));
border: 1px solid var(--color-glass-border);
border-radius: var(--border-radius);
padding: 16px;
}
.resource-details-preview-icon {
font-size: 48px;
line-height: 1;
margin-bottom: 12px;
}
.resource-details-preview-text {
margin: 0;
color: var(--color-text-muted);
text-align: center;
}
.resource-details-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.resource-details-description {
margin: 0;
color: var(--color-text-muted);
}
.resource-details-meta,
.resource-details-tags {
margin-top: 0;
}
.resource-details-tags {
max-height: 180px;
overflow: auto;
}
.resource-details-actions {
margin-top: 4px;
flex-wrap: wrap;
}
.extension-preview-image {
display: block;
width: 100%;
@@ -2233,6 +2491,14 @@ body:has(#main-content) {
justify-content: flex-end;
}
.extension-details-body {
grid-template-columns: 1fr;
}
.resource-details-body {
grid-template-columns: 1fr;
}
/* Ensure touch targets are at least 44px */
.card,
.search-result,