chore: publish from staged

This commit is contained in:
github-actions[bot]
2026-06-23 23:48:03 +00:00
parent 3304e8e11c
commit 347bed17c5
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 StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import agentsData from '../../public/data/agents.json'; import agentsData from '../../public/data/agents.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro'; import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render'; import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
const initialItems = sortAgents(agentsData.items, 'title'); 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 }}> <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" /> <PageHeader title="Custom Agents" description="Specialized agents that enhance GitHub Copilot for specific technologies, workflows, and domains" icon="robot" />
<div class="page-content"> <div class="page-content">
@@ -42,8 +42,8 @@ const initialItems = sortAgents(agentsData.items, 'title');
</div> </div>
</div> </div>
<Modal />
<BackToTop /> <BackToTop />
<Modal />
<EmbeddedPageData filename="agents.json" data={agentsData} /> <EmbeddedPageData filename="agents.json" data={agentsData} />
<script> <script>
+2 -16
View File
@@ -5,6 +5,7 @@ import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render'; import { renderExtensionsHtml, sortExtensions } from '../scripts/pages/extensions-render';
const initialItems = sortExtensions(extensionsData.items, 'title'); const initialItems = sortExtensions(extensionsData.items, 'title');
@@ -41,23 +42,13 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
</div> </div>
</div> </div>
<div class="resource-list" id="resource-list" role="list" set:html={renderExtensionsHtml(initialItems)}></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" /> <ContributeCTA resourceType="extensions" />
</div> </div>
</div> </div>
</div> </div>
<BackToTop /> <BackToTop />
<Modal />
<EmbeddedPageData filename="extensions.json" data={extensionsData} /> <EmbeddedPageData filename="extensions.json" data={extensionsData} />
<script> <script>
@@ -65,9 +56,4 @@ const initialItems = sortExtensions(extensionsData.items, 'title');
import '../scripts/pages/extensions'; import '../scripts/pages/extensions';
</script> </script>
<style>
.extensions-page .resource-preview {
cursor: default;
}
</style>
</StarlightPage> </StarlightPage>
+3 -3
View File
@@ -1,10 +1,10 @@
--- ---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro'; import ContributeCTA from '../components/ContributeCTA.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import hooksData from '../../public/data/hooks.json'; import hooksData from '../../public/data/hooks.json';
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render'; 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 }}> <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" /> <PageHeader title="Hooks" description="Automated workflows triggered by Copilot coding agent events" icon="hook" />
<div class="page-content"> <div class="page-content">
@@ -47,8 +47,8 @@ const initialItems = sortHooks(hooksData.items, 'title');
</div> </div>
</div> </div>
<Modal />
<BackToTop /> <BackToTop />
<Modal />
<EmbeddedPageData filename="hooks.json" data={hooksData} /> <EmbeddedPageData filename="hooks.json" data={hooksData} />
<script> <script>
+3 -3
View File
@@ -1,18 +1,18 @@
--- ---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import instructionsData from '../../public/data/instructions.json'; import instructionsData from '../../public/data/instructions.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro'; import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render'; import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
const initialItems = sortInstructions(instructionsData.items, 'title'); 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 }}> <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" /> <PageHeader title="Instructions" description="Coding standards and best practices for GitHub Copilot" icon="document" />
<div class="page-content"> <div class="page-content">
@@ -47,8 +47,8 @@ const initialItems = sortInstructions(instructionsData.items, 'title');
</div> </div>
</div> </div>
<Modal />
<BackToTop /> <BackToTop />
<Modal />
<EmbeddedPageData filename="instructions.json" data={instructionsData} /> <EmbeddedPageData filename="instructions.json" data={instructionsData} />
<script> <script>
+3 -3
View File
@@ -1,18 +1,18 @@
--- ---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import pluginsData from '../../public/data/plugins.json'; import pluginsData from '../../public/data/plugins.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro'; import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render'; import { renderPluginsHtml, sortPlugins } from '../scripts/pages/plugins-render';
const initialItems = sortPlugins(pluginsData.items, 'title'); const initialItems = sortPlugins(pluginsData.items, 'title');
--- ---
<StarlightPage frontmatter={{ title: 'Plugins', description: 'Curated plugins of agents, hooks, and skills for specific workflows', template: 'splash', prev: false, next: false, editUrl: false }}> <StarlightPage frontmatter={{ title: 'Plugins', description: 'Curated plugins of agents, hooks, and skills for specific workflows', template: 'splash', prev: false, next: false, editUrl: false }}>
<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" /> <PageHeader title="Plugins" description="Curated plugins of agents, hooks, and skills for specific workflows" icon="plug" />
<div class="page-content"> <div class="page-content">
@@ -56,8 +56,8 @@ const initialItems = sortPlugins(pluginsData.items, 'title');
</div> </div>
</div> </div>
<Modal />
<BackToTop /> <BackToTop />
<Modal />
<EmbeddedPageData filename="plugins.json" data={pluginsData} /> <EmbeddedPageData filename="plugins.json" data={pluginsData} />
<script> <script>
+3 -3
View File
@@ -1,10 +1,10 @@
--- ---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro'; import ContributeCTA from '../components/ContributeCTA.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import skillsData from '../../public/data/skills.json'; import skillsData from '../../public/data/skills.json';
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render'; 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 }}> <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" /> <PageHeader title="Skills" description="Self-contained agent skills with instructions and bundled resources" icon="lightning" />
<div class="page-content"> <div class="page-content">
@@ -42,8 +42,8 @@ const initialItems = sortSkills(skillsData.items, 'title');
</div> </div>
</div> </div>
<Modal />
<BackToTop /> <BackToTop />
<Modal />
<EmbeddedPageData filename="skills.json" data={skillsData} /> <EmbeddedPageData filename="skills.json" data={skillsData} />
<script> <script>
+3 -3
View File
@@ -1,18 +1,18 @@
--- ---
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro'; import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
import workflowsData from '../../public/data/workflows.json'; import workflowsData from '../../public/data/workflows.json';
import Modal from '../components/Modal.astro';
import ContributeCTA from '../components/ContributeCTA.astro'; import ContributeCTA from '../components/ContributeCTA.astro';
import EmbeddedPageData from '../components/EmbeddedPageData.astro'; import EmbeddedPageData from '../components/EmbeddedPageData.astro';
import PageHeader from '../components/PageHeader.astro'; import PageHeader from '../components/PageHeader.astro';
import BackToTop from '../components/BackToTop.astro'; import BackToTop from '../components/BackToTop.astro';
import Modal from '../components/Modal.astro';
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render'; import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
const initialItems = sortWorkflows(workflowsData.items, 'title'); 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 }}> <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" /> <PageHeader title="Agentic Workflows" description="AI-powered repository automations that run coding agents in GitHub Actions" icon="workflow" />
<div class="page-content"> <div class="page-content">
@@ -47,8 +47,8 @@ const initialItems = sortWorkflows(workflowsData.items, 'title');
</div> </div>
</div> </div>
<Modal />
<BackToTop /> <BackToTop />
<Modal />
<EmbeddedPageData filename="workflows.json" data={workflowsData} /> <EmbeddedPageData filename="workflows.json" data={workflowsData} />
<script> <script>
+112 -3
View File
@@ -2,7 +2,6 @@
* Modal functionality for file viewing * Modal functionality for file viewing
*/ */
import { marked } from "marked";
import { import {
fetchFileContent, fetchFileContent,
fetchData, fetchData,
@@ -18,7 +17,6 @@ import {
sanitizeUrl, sanitizeUrl,
REPO_IDENTIFIER, REPO_IDENTIFIER,
} from "./utils"; } from "./utils";
import fm from "front-matter";
type ModalViewMode = "rendered" | "raw"; type ModalViewMode = "rendered" | "raw";
@@ -352,7 +350,11 @@ async function renderCurrentFileContent(): Promise<void> {
const container = ensureDivContent("modal-rendered-content"); const container = ensureDivContent("modal-rendered-content");
if (!container) return; 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 }); container.innerHTML = marked(markdownBody, { async: false });
} else { } else {
await renderHighlightedCode(currentFileContent, currentFilePath); await renderHighlightedCode(currentFileContent, currentFilePath);
@@ -452,6 +454,19 @@ interface PluginsData {
let pluginsCache: PluginsData | null = null; 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 * 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 // Check for deep link on initial load
handleHashChange(); handleHashChange();
} }
@@ -894,6 +922,8 @@ export async function openFileModal(
const closeBtn = document.getElementById("close-modal"); const closeBtn = document.getElementById("close-modal");
if (!modal || !title) return; if (!modal || !title) return;
modal.classList.remove("details-mode");
currentFilePath = filePath; currentFilePath = filePath;
currentFileType = type; currentFileType = type;
currentViewMode = "raw"; currentViewMode = "raw";
@@ -989,6 +1019,85 @@ export async function openFileModal(
await renderCurrentFileContent(); 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 * Open plugin modal with item list
*/ */
+45 -57
View File
@@ -5,6 +5,7 @@ import {
getInstallDropdownHtml, getInstallDropdownHtml,
getLastUpdatedHtml, getLastUpdatedHtml,
} from "../utils"; } from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableAgent { export interface RenderableAgent {
title: string; title: string;
@@ -37,68 +38,55 @@ export function sortAgents<T extends RenderableAgent>(
export function renderAgentsHtml(items: RenderableAgent[]): string { export function renderAgentsHtml(items: RenderableAgent[]): string {
if (items.length === 0) { if (items.length === 0) {
return ` return renderEmptyStateHtml("No agents found", "No agents are available right now.");
<div class="empty-state">
<h3>No agents found</h3>
<p>No agents are available right now.</p>
</div>
`;
} }
return items return items
.map((item) => { .map((item) => {
return ` const metaHtml = `
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem"> ${
<button type="button" class="resource-preview"> item.model
<div class="resource-info"> ? `<span class="resource-tag tag-model">${escapeHtml(
<div class="resource-title">${escapeHtml(item.title)}</div> Array.isArray(item.model) ? item.model.join(", ") : item.model
<div class="resource-description">${escapeHtml( )}</span>`
item.description || "No description" : ""
)}</div> }
<div class="resource-meta"> ${
${ item.tools
item.model ?.slice(0, 3)
? `<span class="resource-tag tag-model">${escapeHtml( .map((tool) => `<span class="resource-tag">${escapeHtml(tool)}</span>`)
item.model .join("") || ""
)}</span>` }
: "" ${
} item.tools && item.tools.length > 3
${ ? `<span class="resource-tag">+${item.tools.length - 3} more</span>`
item.tools : ""
?.slice(0, 3) }
.map( ${
(tool) => item.hasHandoffs
`<span class="resource-tag">${escapeHtml(tool)}</span>` ? `<span class="resource-tag tag-handoffs">handoffs</span>`
) : ""
.join("") || "" }
} ${getLastUpdatedHtml(item.lastUpdated)}
${
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 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(""); .join("");
} }
+80 -4
View File
@@ -2,13 +2,16 @@
* Agents page functionality * Agents page functionality
*/ */
import { import {
escapeHtml,
fetchData, fetchData,
formatRelativeTime,
getQueryParam, getQueryParam,
setupDropdownCloseHandlers, getVSCodeInstallUrl,
setupActionHandlers, setupActionHandlers,
setupDropdownCloseHandlers,
updateQueryParams, updateQueryParams,
} from '../utils'; } from '../utils';
import { setupModal, openFileModal } from '../modal'; import { openCardDetailsModal, setupModal } from '../modal';
import { import {
renderAgentsHtml, renderAgentsHtml,
sortAgents, sortAgents,
@@ -17,6 +20,7 @@ import {
} from './agents-render'; } from './agents-render';
interface Agent extends RenderableAgent { interface Agent extends RenderableAgent {
id?: string;
lastUpdated?: string | null; lastUpdated?: string | null;
} }
@@ -25,8 +29,10 @@ interface AgentsData {
} }
let allItems: Agent[] = []; let allItems: Agent[] = [];
let agentByPath = new Map<string, Agent>();
let currentSort: AgentSortOption = 'title'; let currentSort: AgentSortOption = 'title';
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
let modalReady = false;
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const countEl = document.getElementById('results-count'); const countEl = document.getElementById('results-count');
@@ -45,6 +51,70 @@ function renderItems(items: Agent[]): void {
list.innerHTML = renderAgentsHtml(items); 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 { function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return; if (!list || resourceListHandlersReady) return;
@@ -55,9 +125,10 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
} }
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; const path = item?.dataset.path;
if (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 list = document.getElementById('resource-list');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement; const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<AgentsData>('agents.json'); const data = await fetchData<AgentsData>('agents.json');
@@ -87,6 +163,7 @@ export async function initAgentsPage(): Promise<void> {
} }
allItems = data.items; allItems = data.items;
agentByPath = new Map(allItems.map((item) => [item.path, item]));
const initialSort = getQueryParam('sort'); const initialSort = getQueryParam('sort');
if (initialSort === 'lastUpdated') { if (initialSort === 'lastUpdated') {
@@ -101,7 +178,6 @@ export async function initAgentsPage(): Promise<void> {
}); });
applyFiltersAndRender(); applyFiltersAndRender();
setupModal();
setupDropdownCloseHandlers(); setupDropdownCloseHandlers();
setupActionHandlers(); 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 { escapeHtml, getGitHubUrl, getLastUpdatedHtml } from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableExtension { export interface RenderableExtension {
id: string; id: string;
@@ -17,10 +18,16 @@ export interface RenderableExtension {
path?: string | null; path?: string | null;
type?: string | null; type?: string | null;
} | null; } | null;
gallery?: { gallery?:
path?: string | null; | {
type?: string | null; path?: string | null;
} | null; type?: string | null;
}
| Array<{
path?: string | null;
type?: string | null;
}>
| null;
} | null; } | null;
imageUrl?: string | null; imageUrl?: string | null;
assetPath?: string | null; assetPath?: string | null;
@@ -48,12 +55,10 @@ export function sortExtensions<T extends RenderableExtension>(
export function renderExtensionsHtml(items: RenderableExtension[]): string { export function renderExtensionsHtml(items: RenderableExtension[]): string {
if (items.length === 0) { if (items.length === 0) {
return ` return renderEmptyStateHtml(
<div class="empty-state"> "No extensions found",
<h3>No extensions found</h3> "No canvas extensions are available right now."
<p>No canvas extensions are available right now.</p> );
</div>
`;
} }
return items return items
@@ -69,62 +74,60 @@ export function renderExtensionsHtml(items: RenderableExtension[]): string {
const sourceUrl = const sourceUrl =
item.sourceUrl || (item.path ? getGitHubUrl(item.path) : ""); item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
return ` const previewMediaHtml = item.imageUrl
<article id="${escapeHtml(item.id)}" class="resource-item" role="listitem"> ? `<div class="resource-thumbnail-btn" data-extension-id="${escapeHtml(item.id)}" aria-hidden="true">
<div class="resource-preview"> <img class="resource-thumbnail" src="${escapeHtml(item.imageUrl)}" alt="${escapeHtml(item.name)} preview" loading="lazy" />
${ </div>`
item.imageUrl : `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>`;
? `<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" /> const infoExtraHtml = `
</button>` <div class="resource-keywords">
: `<div class="resource-thumbnail resource-thumbnail-placeholder" aria-hidden="true">Canvas</div>` ${
} item.keywords && item.keywords.length > 0
<div class="resource-info"> ? item.keywords
<div class="resource-title">${escapeHtml(item.name)}</div> .map((kw) => `<span class="keyword-tag">${escapeHtml(kw)}</span>`)
<div class="resource-description">${escapeHtml( .join("")
item.description || "Canvas extension" : ""
)}</div> }
<div class="resource-keywords"> </div>
${
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 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(""); .join("");
} }
+269 -49
View File
@@ -8,13 +8,17 @@ import {
type Choices, type Choices,
} from "../choices"; } from "../choices";
import { import {
escapeHtml,
copyToClipboard, copyToClipboard,
fetchData, fetchData,
formatRelativeTime,
getGitHubUrl,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
showToast, showToast,
updateQueryParams, updateQueryParams,
} from "../utils"; } from "../utils";
import { openCardDetailsModal, setupModal } from "../modal";
import { import {
renderExtensionsHtml, renderExtensionsHtml,
sortExtensions, sortExtensions,
@@ -34,36 +38,200 @@ interface ExtensionsData {
}; };
} }
interface ExtensionScreenshot {
path?: string | null;
type?: string | null;
}
let allItems: Extension[] = []; let allItems: Extension[] = [];
let extensionById = new Map<string, Extension>();
let currentSort: ExtensionSortOption = "title"; let currentSort: ExtensionSortOption = "title";
let keywordSelect: Choices; let keywordSelect: Choices;
let currentFilters = { let currentFilters = {
keywords: [] as string[], keywords: [] as string[],
}; };
let actionHandlersReady = false; let actionHandlersReady = false;
let modalReady = false;
function openPreviewModal(url: string, alt: string): void { function normalizeScreenshotEntries(value: unknown): ExtensionScreenshot[] {
const modal = document.getElementById("extension-preview-modal"); if (!value) return [];
const image = document.getElementById("extension-preview-image") as HTMLImageElement | null; if (Array.isArray(value)) {
const title = document.getElementById("extension-preview-title"); return value.filter((entry): entry is ExtensionScreenshot => Boolean(entry));
}
if (!modal || !image || !title) return; if (typeof value === "object") {
return [value as ExtensionScreenshot];
image.src = url; }
image.alt = alt; return [];
title.textContent = alt.replace(/ preview$/i, "");
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
document.body.style.overflow = "hidden";
} }
function closePreviewModal(): void { function getInstallUrl(item: Extension): string {
const modal = document.getElementById("extension-preview-modal"); return (
if (!modal) return; item.installUrl ||
(item.path && item.ref
? `https://github.com/github/awesome-copilot/tree/${item.ref}/${item.path.replace(
/\\/g,
"/"
)}`
: "")
);
}
modal.classList.add("hidden"); function getSourceUrl(item: Extension): string {
modal.setAttribute("aria-hidden", "true"); return item.sourceUrl || (item.path ? getGitHubUrl(item.path) : "");
document.body.style.overflow = ""; }
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[] { function sortItems(items: Extension[]): Extension[] {
@@ -108,19 +276,6 @@ function setupActionHandlers(list: HTMLElement | null): void {
list.addEventListener("click", async (event) => { list.addEventListener("click", async (event) => {
const target = event.target as HTMLElement; 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( const installButton = target.closest(
".copy-install-url-btn" ".copy-install-url-btn"
@@ -139,27 +294,86 @@ function setupActionHandlers(list: HTMLElement | null): void {
success ? "Install URL copied!" : "Failed to copy install URL", success ? "Install URL copied!" : "Failed to copy install URL",
success ? "success" : "error" success ? "success" : "error"
); );
return;
}); });
const modal = document.getElementById("extension-preview-modal"); list.addEventListener("click", (event) => {
const closeButton = document.getElementById("extension-preview-close"); const target = event.target as HTMLElement;
if (modal) { const thumbnailButton = target.closest(
modal.addEventListener("click", (event) => { ".resource-thumbnail-btn"
if (event.target === modal) { ) as HTMLElement | null;
closePreviewModal(); if (thumbnailButton) {
} event.preventDefault();
}); event.stopPropagation();
} const extensionId = thumbnailButton.dataset.extensionId;
if (!extensionId) return;
if (closeButton) { const previewButton = thumbnailButton.closest(".resource-preview") as HTMLElement | null;
closeButton.addEventListener("click", closePreviewModal); openDetailsModal(extensionId, undefined, previewButton || undefined);
} return;
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closePreviewModal();
} }
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; actionHandlersReady = true;
@@ -180,6 +394,11 @@ export async function initExtensionsPage(): Promise<void> {
"sort-select" "sort-select"
) as HTMLSelectElement; ) as HTMLSelectElement;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupActionHandlers(list as HTMLElement | null); setupActionHandlers(list as HTMLElement | null);
const data = await fetchData<ExtensionsData>("extensions.json"); const data = await fetchData<ExtensionsData>("extensions.json");
@@ -191,6 +410,7 @@ export async function initExtensionsPage(): Promise<void> {
} }
allItems = data.items; allItems = data.items;
extensionById = new Map(allItems.map((item) => [item.id, item]));
const availableKeywords = ( const availableKeywords = (
data.filters?.keywords || data.filters?.keywords ||
+45 -59
View File
@@ -3,6 +3,7 @@ import {
getGitHubUrl, getGitHubUrl,
getLastUpdatedHtml, getLastUpdatedHtml,
} from "../utils"; } from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableHook { export interface RenderableHook {
id: string; id: string;
@@ -35,70 +36,55 @@ export function sortHooks<T extends RenderableHook>(
export function renderHooksHtml(items: RenderableHook[]): string { export function renderHooksHtml(items: RenderableHook[]): string {
if (items.length === 0) { if (items.length === 0) {
return ` return renderEmptyStateHtml("No hooks found", "Try adjusting the selected filters.");
<div class="empty-state">
<h3>No hooks found</h3>
<p>Try adjusting the selected filters.</p>
</div>
`;
} }
return items return items
.map((item) => { .map((item) => {
return ` const metaHtml = `
<article class="resource-item" data-path="${escapeHtml( ${item.hooks
item.readmeFile .map(
)}" data-hook-id="${escapeHtml(item.id)}" role="listitem"> (hook) => `<span class="resource-tag tag-hook">${escapeHtml(hook)}</span>`
<button type="button" class="resource-preview"> )
<div class="resource-info"> .join("")}
<div class="resource-title">${escapeHtml(item.title)}</div> ${item.tags
<div class="resource-description">${escapeHtml( .map((tag) => `<span class="resource-tag tag-tag">${escapeHtml(tag)}</span>`)
item.description || "No description" .join("")}
)}</div> ${
<div class="resource-meta"> item.assets.length > 0
${item.hooks ? `<span class="resource-tag tag-assets">${item.assets.length} asset${
.map( item.assets.length === 1 ? "" : "s"
(hook) => }</span>`
`<span class="resource-tag tag-hook">${escapeHtml( : ""
hook }
)}</span>` ${getLastUpdatedHtml(item.lastUpdated)}
)
.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 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(""); .join("");
} }
+155 -106
View File
@@ -2,26 +2,23 @@
* Hooks page functionality * Hooks page functionality
*/ */
import { import {
createChoices, escapeHtml,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import {
fetchData, fetchData,
formatRelativeTime,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
showToast, showToast,
downloadZipBundle, downloadZipBundle,
updateQueryParams, updateQueryParams,
} from "../utils"; } from '../utils';
import { setupModal, openFileModal } from "../modal"; import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import { import {
renderHooksHtml, renderHooksHtml,
sortHooks, sortHooks,
type HookSortOption, type HookSortOption,
type RenderableHook, type RenderableHook,
} from "./hooks-render"; } from './hooks-render';
interface Hook extends RenderableHook {} interface Hook extends RenderableHook {}
@@ -32,96 +29,54 @@ interface HooksData {
}; };
} }
const resourceType = "hook";
let allItems: Hook[] = []; let allItems: Hook[] = [];
let tagSelect: Choices; let hookById = new Map<string, Hook>();
let tagSelectEl: HTMLSelectElement | null = null;
let currentFilters = { let currentFilters = {
tags: [] as string[], tags: [] as string[],
}; };
let currentSort: HookSortOption = "title"; let currentSort: HookSortOption = 'title';
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Hook[]): Hook[] { function sortItems(items: Hook[]): Hook[] {
return sortHooks(items, currentSort); return sortHooks(items, currentSort);
} }
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const countEl = document.getElementById("results-count"); const countEl = document.getElementById('results-count');
let results = [...allItems]; let results = [...allItems];
if (currentFilters.tags.length > 0) { if (currentFilters.tags.length > 0) {
results = results.filter((item) => results = results.filter((item) => item.tags.some((tag) => currentFilters.tags.includes(tag)));
item.tags.some((tag) => currentFilters.tags.includes(tag))
);
} }
results = sortItems(results); results = sortItems(results);
renderItems(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) { 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; if (countEl) countEl.textContent = countText;
} }
function renderItems(items: Hook[]): void { function renderItems(items: Hook[]): void {
const list = document.getElementById("resource-list"); const list = document.getElementById('resource-list');
if (!list) return; if (!list) return;
list.innerHTML = renderHooksHtml(items); list.innerHTML = renderHooksHtml(items);
} }
function setupResourceListHandlers(list: HTMLElement | null): void { async function downloadHook(hookId: string, btn: HTMLButtonElement): Promise<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> {
const hook = allItems.find((item) => item.id === hookId); const hook = allItems.find((item) => item.id === hookId);
if (!hook) { if (!hook) {
showToast("Hook not found.", "error"); showToast('Hook not found.', 'error');
return; return;
} }
const files = [ const files = [
{ name: "README.md", path: hook.readmeFile }, { name: 'README.md', path: hook.readmeFile },
...hook.assets.map((asset) => ({ ...hook.assets.map((asset) => ({
name: asset, name: asset,
path: `${hook.path}/${asset}`, path: `${hook.path}/${asset}`,
@@ -129,30 +84,26 @@ async function downloadHook(
]; ];
if (files.length === 0) { if (files.length === 0) {
showToast("No files found for this hook.", "error"); showToast('No files found for this hook.', 'error');
return; return;
} }
const originalContent = btn.innerHTML; const originalContent = btn.innerHTML;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = 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...';
'<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 { try {
await downloadZipBundle(hook.id, files); await downloadZipBundle(hook.id, files);
btn.innerHTML = 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!';
'<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(() => { setTimeout(() => {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = originalContent; btn.innerHTML = originalContent;
}, 2000); }, 2000);
} catch (error) { } catch (error) {
const message = const message = error instanceof Error ? error.message : 'Download failed.';
error instanceof Error ? error.message : "Download failed."; showToast(message, 'error');
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';
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(() => { setTimeout(() => {
btn.disabled = false; btn.disabled = false;
btn.innerHTML = originalContent; 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> { export async function initHooksPage(): Promise<void> {
const list = document.getElementById("resource-list"); const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById("clear-filters"); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById( const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
"sort-select"
) as HTMLSelectElement; if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<HooksData>("hooks.json"); const data = await fetchData<HooksData>('hooks.json');
if (!data || !data.items) { if (!data || !data.items) {
if (list) if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
return; return;
} }
allItems = data.items; allItems = data.items;
hookById = new Map(allItems.map((item) => [item.id, item]));
tagSelect = createChoices("#filter-tag", { tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
placeholderValue: "All Tags", if (tagSelectEl) {
}); tagSelectEl.innerHTML = '';
tagSelect.setChoices( data.filters.tags.forEach((tag) => {
data.filters.tags.map((tag) => ({ value: tag, label: tag })), const option = document.createElement('option');
"value", option.value = tag;
"label", option.textContent = tag;
true tagSelectEl?.appendChild(option);
); });
}
const initialTags = getQueryParamValues("tag").filter((tag) => const initialTags = getQueryParamValues('tag').filter((tag) => data.filters.tags.includes(tag));
data.filters.tags.includes(tag) const initialSort = getQueryParam('sort');
);
const initialSort = getQueryParam("sort");
if (initialTags.length > 0) { if (initialTags.length > 0) {
currentFilters.tags = initialTags; currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags); setSelectValues(tagSelectEl, initialTags);
} }
if (initialSort === "lastUpdated") { if (initialSort === 'lastUpdated') {
currentSort = initialSort; currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort; if (sortSelect) sortSelect.value = initialSort;
} }
document.getElementById("filter-tag")?.addEventListener("change", () => { tagSelectEl?.addEventListener('change', () => {
currentFilters.tags = getChoicesValues(tagSelect); currentFilters.tags = getSelectValues(tagSelectEl);
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
sortSelect?.addEventListener("change", () => { sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as HookSortOption; currentSort = sortSelect.value as HookSortOption;
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
clearFiltersBtn?.addEventListener("click", () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tags: [] }; currentFilters = { tags: [] };
currentSort = "title"; currentSort = 'title';
tagSelect.removeActiveItems(); clearSelectValues(tagSelectEl);
if (sortSelect) sortSelect.value = "title"; if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
applyFiltersAndRender(); applyFiltersAndRender();
setupModal();
} }
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
document.addEventListener("DOMContentLoaded", initHooksPage); document.addEventListener('DOMContentLoaded', initHooksPage);
@@ -5,6 +5,7 @@ import {
getInstallDropdownHtml, getInstallDropdownHtml,
getLastUpdatedHtml, getLastUpdatedHtml,
} from '../utils'; } from '../utils';
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
export interface RenderableInstruction { export interface RenderableInstruction {
title: string; title: string;
@@ -36,12 +37,7 @@ export function renderInstructionsHtml(
items: RenderableInstruction[] items: RenderableInstruction[]
): string { ): string {
if (items.length === 0) { if (items.length === 0) {
return ` return renderEmptyStateHtml('No instructions found', 'Try adjusting the selected filters.');
<div class="empty-state">
<h3>No instructions found</h3>
<p>Try adjusting the selected filters.</p>
</div>
`;
} }
return items return items
@@ -50,29 +46,30 @@ export function renderInstructionsHtml(
? item.applyTo.join(', ') ? item.applyTo.join(', ')
: item.applyTo; : item.applyTo;
return ` const metaHtml = `
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem"> ${applyToText ? `<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>` : ''}
<button type="button" class="resource-preview"> ${item.extensions?.slice(0, 4).map((extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`).join('') || ''}
<div class="resource-info"> ${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
<div class="resource-title">${escapeHtml(item.title)}</div> ${getLastUpdatedHtml(item.lastUpdated)}
<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 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(''); .join('');
} }
+86 -23
View File
@@ -2,20 +2,18 @@
* Instructions page functionality * Instructions page functionality
*/ */
import { import {
createChoices, escapeHtml,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import {
fetchData, fetchData,
formatRelativeTime,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
setupDropdownCloseHandlers, getVSCodeInstallUrl,
setupActionHandlers, setupActionHandlers,
setupDropdownCloseHandlers,
updateQueryParams, updateQueryParams,
} from '../utils'; } from '../utils';
import { setupModal, openFileModal } from '../modal'; import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import { import {
renderInstructionsHtml, renderInstructionsHtml,
sortInstructions, sortInstructions,
@@ -37,12 +35,13 @@ interface InstructionsData {
}; };
} }
const resourceType = 'instruction';
let allItems: Instruction[] = []; let allItems: Instruction[] = [];
let extensionSelect: Choices; let instructionByPath = new Map<string, Instruction>();
let extensionSelectEl: HTMLSelectElement | null = null;
let currentFilters = { extensions: [] as string[] }; let currentFilters = { extensions: [] as string[] };
let currentSort: InstructionSortOption = 'title'; let currentSort: InstructionSortOption = 'title';
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Instruction[]): Instruction[] { function sortItems(items: Instruction[]): Instruction[] {
return sortInstructions(items, currentSort); return sortInstructions(items, currentSort);
@@ -53,11 +52,14 @@ function applyFiltersAndRender(): void {
let results = [...allItems]; let results = [...allItems];
if (currentFilters.extensions.length > 0) { if (currentFilters.extensions.length > 0) {
results = results.filter(item => { results = results.filter((item) => {
if (currentFilters.extensions.includes('(none)') && (!item.extensions || item.extensions.length === 0)) { if (
currentFilters.extensions.includes('(none)') &&
(!item.extensions || item.extensions.length === 0)
) {
return true; 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); 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 { function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return; if (!list || resourceListHandlersReady) return;
@@ -88,9 +137,10 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
} }
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; const path = item?.dataset.path;
if (path) { if (path) {
openFileModal(path, resourceType); openInstructionDetailsModal(path, button);
} }
}); });
@@ -108,7 +158,12 @@ function syncUrlState(): void {
export async function initInstructionsPage(): Promise<void> { export async function initInstructionsPage(): Promise<void> {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters'); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement; const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
@@ -119,24 +174,33 @@ export async function initInstructionsPage(): Promise<void> {
} }
allItems = data.items; allItems = data.items;
instructionByPath = new Map(allItems.map((item) => [item.path, item]));
extensionSelect = createChoices('#filter-extension', { placeholderValue: 'All Extensions' }); extensionSelectEl = document.getElementById('filter-extension') as HTMLSelectElement | null;
extensionSelect.setChoices(data.filters.extensions.map(e => ({ value: e, label: e })), 'value', 'label', true); 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'); const initialSort = getQueryParam('sort');
if (initialExtensions.length > 0) { if (initialExtensions.length > 0) {
currentFilters.extensions = initialExtensions; currentFilters.extensions = initialExtensions;
setChoicesValues(extensionSelect, initialExtensions); setSelectValues(extensionSelectEl, initialExtensions);
} }
if (initialSort === 'lastUpdated') { if (initialSort === 'lastUpdated') {
currentSort = initialSort; currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort; if (sortSelect) sortSelect.value = initialSort;
} }
document.getElementById('filter-extension')?.addEventListener('change', () => { extensionSelectEl?.addEventListener('change', () => {
currentFilters.extensions = getChoicesValues(extensionSelect); currentFilters.extensions = getSelectValues(extensionSelectEl);
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
@@ -150,14 +214,13 @@ export async function initInstructionsPage(): Promise<void> {
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { extensions: [] }; currentFilters = { extensions: [] };
currentSort = 'title'; currentSort = 'title';
extensionSelect.removeActiveItems(); clearSelectValues(extensionSelectEl);
if (sortSelect) sortSelect.value = 'title'; if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
applyFiltersAndRender(); applyFiltersAndRender();
setupModal();
setupDropdownCloseHandlers(); setupDropdownCloseHandlers();
setupActionHandlers(); setupActionHandlers();
} }
+22 -24
View File
@@ -3,6 +3,7 @@ import {
getGitHubUrl, getGitHubUrl,
sanitizeUrl, sanitizeUrl,
} from '../utils'; } from '../utils';
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
interface PluginAuthor { interface PluginAuthor {
name: string; name: string;
@@ -57,12 +58,7 @@ function getExternalPluginUrl(plugin: RenderablePlugin): string {
export function renderPluginsHtml(items: RenderablePlugin[]): string { export function renderPluginsHtml(items: RenderablePlugin[]): string {
if (items.length === 0) { if (items.length === 0) {
return ` return renderEmptyStateHtml('No plugins found', 'Try different tags or clear the current filters');
<div class="empty-state">
<h3>No plugins found</h3>
<p>Try different tags or clear the current filters</p>
</div>
`;
} }
return items return items
@@ -78,25 +74,27 @@ export function renderPluginsHtml(items: RenderablePlugin[]): string {
const githubHref = isExternal const githubHref = isExternal
? escapeHtml(getExternalPluginUrl(item)) ? escapeHtml(getExternalPluginUrl(item))
: getGitHubUrl(item.path); : getGitHubUrl(item.path);
return ` const metaHtml = `
<article class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}" role="listitem"> ${metaTag}
<button type="button" class="resource-preview"> ${authorTag}
<div class="resource-info"> ${item.tags?.slice(0, 4).map((tag) => `<span class="resource-tag">${escapeHtml(tag)}</span>`).join('') || ''}
<div class="resource-title">${escapeHtml(item.name)}</div> ${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
<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 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(''); .join('');
} }
+135 -20
View File
@@ -2,18 +2,18 @@
* Plugins page functionality * Plugins page functionality
*/ */
import { import {
createChoices, copyToClipboard,
getChoicesValues, escapeHtml,
setChoicesValues,
type Choices,
} from '../choices';
import {
fetchData, fetchData,
formatRelativeTime,
getGitHubUrl,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
showToast,
updateQueryParams, updateQueryParams,
} from '../utils'; } from '../utils';
import { setupModal, openFileModal } from '../modal'; import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import { import {
renderPluginsHtml, renderPluginsHtml,
sortPlugins, sortPlugins,
@@ -32,12 +32,18 @@ interface PluginSource {
path?: string; path?: string;
} }
interface PluginItem {
kind: string;
path: string;
}
interface Plugin extends RenderablePlugin { interface Plugin extends RenderablePlugin {
id: string; id: string;
name: string; name: string;
path: string; path: string;
tags?: string[]; tags?: string[];
itemCount: number; itemCount: number;
items?: PluginItem[];
external?: boolean; external?: boolean;
repository?: string | null; repository?: string | null;
homepage?: string | null; homepage?: string | null;
@@ -53,14 +59,15 @@ interface PluginsData {
}; };
} }
const resourceType = 'plugin';
let allItems: Plugin[] = []; let allItems: Plugin[] = [];
let tagSelect: Choices; let pluginByPath = new Map<string, Plugin>();
let tagSelectEl: HTMLSelectElement | null = null;
let currentSort: PluginSortOption = 'title'; let currentSort: PluginSortOption = 'title';
let currentFilters = { let currentFilters = {
tags: [] as string[], tags: [] as string[],
}; };
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Plugin[]): Plugin[] { function sortItems(items: Plugin[]): Plugin[] {
return sortPlugins(items, currentSort); return sortPlugins(items, currentSort);
@@ -79,7 +86,7 @@ function applyFiltersAndRender(): void {
let results = [...allItems]; let results = [...allItems];
if (currentFilters.tags.length > 0) { if (currentFilters.tags.length > 0) {
results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag))); results = results.filter((item) => item.tags?.some((tag) => currentFilters.tags.includes(tag)));
} }
results = sortItems(results); results = sortItems(results);
@@ -95,6 +102,87 @@ function renderItems(items: Plugin[]): void {
list.innerHTML = renderPluginsHtml(items); 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 { function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return; if (!list || resourceListHandlersReady) return;
@@ -105,12 +193,26 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
} }
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; const path = item?.dataset.path;
if (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; resourceListHandlersReady = true;
} }
@@ -125,7 +227,12 @@ function syncUrlState(): void {
export async function initPluginsPage(): Promise<void> { export async function initPluginsPage(): Promise<void> {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters'); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement; const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
@@ -136,20 +243,29 @@ export async function initPluginsPage(): Promise<void> {
} }
allItems = data.items; allItems = data.items;
pluginByPath = new Map(allItems.map((item) => [item.path, item]));
tagSelect = createChoices('#filter-tag', { placeholderValue: 'All Tags' }); tagSelectEl = document.getElementById('filter-tag') as HTMLSelectElement | null;
tagSelect.setChoices(data.filters.tags.map(t => ({ value: t, label: t })), 'value', 'label', true); 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'); const initialSort = getQueryParam('sort');
if (initialTags.length > 0) { if (initialTags.length > 0) {
currentFilters.tags = initialTags; currentFilters.tags = initialTags;
setChoicesValues(tagSelect, initialTags); setSelectValues(tagSelectEl, initialTags);
} }
document.getElementById('filter-tag')?.addEventListener('change', () => { tagSelectEl?.addEventListener('change', () => {
currentFilters.tags = getChoicesValues(tagSelect); currentFilters.tags = getSelectValues(tagSelectEl);
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
@@ -167,7 +283,7 @@ export async function initPluginsPage(): Promise<void> {
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tags: [] }; currentFilters = { tags: [] };
currentSort = 'title'; currentSort = 'title';
tagSelect.removeActiveItems(); clearSelectValues(tagSelectEl);
if (sortSelect) sortSelect.value = 'title'; if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
@@ -175,7 +291,6 @@ export async function initPluginsPage(): Promise<void> {
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
setupModal();
} }
// Auto-initialize when DOM is ready // 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, getGitHubUrl,
getLastUpdatedHtml, getLastUpdatedHtml,
} from "../utils"; } from "../utils";
import { renderEmptyStateHtml, renderSharedCardHtml } from "./card-render";
export interface RenderableSkillFile { export interface RenderableSkillFile {
name: string; name: string;
@@ -41,66 +42,59 @@ export function sortSkills<T extends RenderableSkill>(
export function renderSkillsHtml(items: RenderableSkill[]): string { export function renderSkillsHtml(items: RenderableSkill[]): string {
if (items.length === 0) { if (items.length === 0) {
return ` return renderEmptyStateHtml("No skills found", "No skills are available right now.");
<div class="empty-state">
<h3>No skills found</h3>
<p>No skills are available right now.</p>
</div>
`;
} }
return items return items
.map((item) => { .map((item) => {
return ` const metaHtml = `
<article class="resource-item" data-path="${escapeHtml( ${
item.skillFile item.hasAssets
)}" data-skill-id="${escapeHtml(item.id)}" role="listitem"> ? `<span class="resource-tag tag-assets">${item.assetCount} asset${
<button type="button" class="resource-preview"> item.assetCount === 1 ? "" : "s"
<div class="resource-info"> }</span>`
<div class="resource-title">${escapeHtml(item.title)}</div> : ""
<div class="resource-description">${escapeHtml( }
item.description || "No description" <span class="resource-tag">${item.files.length} file${
)}</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${
item.files.length === 1 ? "" : "s" item.files.length === 1 ? "" : "s"
}</span> }</span>
${getLastUpdatedHtml(item.lastUpdated)} ${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>
`; `;
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(""); .join("");
} }
+159 -91
View File
@@ -2,28 +2,30 @@
* Skills page functionality * Skills page functionality
*/ */
import { import {
escapeHtml,
fetchData, fetchData,
formatRelativeTime,
getQueryParam, getQueryParam,
showToast, showToast,
downloadZipBundle, downloadZipBundle,
updateQueryParams, updateQueryParams,
copyToClipboard, copyToClipboard,
REPO_IDENTIFIER, REPO_IDENTIFIER,
} from "../utils"; } from '../utils';
import { setupModal, openFileModal } from "../modal"; import { openCardDetailsModal, setupModal } from '../modal';
import { import {
renderSkillsHtml, renderSkillsHtml,
sortSkills, sortSkills,
type RenderableSkill, type RenderableSkill,
type SkillSortOption, type SkillSortOption,
} from "./skills-render"; } from './skills-render';
interface SkillFile { interface SkillFile {
name: string; name: string;
path: string; path: string;
} }
interface Skill extends Omit<RenderableSkill, "files"> { interface Skill extends Omit<RenderableSkill, 'files'> {
files: SkillFile[]; files: SkillFile[];
} }
@@ -31,37 +33,139 @@ interface SkillsData {
items: Skill[]; items: Skill[];
} }
const resourceType = "skill";
let allItems: Skill[] = []; let allItems: Skill[] = [];
let currentSort: SkillSortOption = "title"; let skillById = new Map<string, Skill>();
let currentSort: SkillSortOption = 'title';
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
let modalReady = false;
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const countEl = document.getElementById("results-count"); const countEl = document.getElementById('results-count');
const results = sortSkills(allItems, currentSort); const results = sortSkills(allItems, currentSort);
renderItems(results); renderItems(results);
if (countEl) { 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 { function renderItems(items: Skill[]): void {
const list = document.getElementById("resource-list"); const list = document.getElementById('resource-list');
if (!list) return; if (!list) return;
list.innerHTML = renderSkillsHtml(items); list.innerHTML = renderSkillsHtml(items);
} }
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 { function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return; if (!list || resourceListHandlersReady) return;
list.addEventListener("click", (event) => { list.addEventListener('click', (event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const copyInstallButton = target.closest( const copyInstallButton = target.closest('.copy-install-btn') as HTMLButtonElement | null;
".copy-install-btn"
) as HTMLButtonElement | null;
if (copyInstallButton) { if (copyInstallButton) {
event.stopPropagation(); event.stopPropagation();
const skillId = copyInstallButton.dataset.skillId; const skillId = copyInstallButton.dataset.skillId;
@@ -69,9 +173,7 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
return; return;
} }
const downloadButton = target.closest( const downloadButton = target.closest('.download-skill-btn') as HTMLButtonElement | null;
".download-skill-btn"
) as HTMLButtonElement | null;
if (downloadButton) { if (downloadButton) {
event.stopPropagation(); event.stopPropagation();
const skillId = downloadButton.dataset.skillId; const skillId = downloadButton.dataset.skillId;
@@ -79,11 +181,32 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
return; return;
} }
if (target.closest(".resource-actions")) return; 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 path = item?.dataset.path; const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
if (path) openFileModal(path, resourceType); 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; resourceListHandlersReady = true;
@@ -91,102 +214,47 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
function syncUrlState(): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: "", q: '',
category: [], category: [],
hasAssets: false, 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> { export async function initSkillsPage(): Promise<void> {
const list = document.getElementById("resource-list"); const list = document.getElementById('resource-list');
const sortSelect = document.getElementById( const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
"sort-select"
) as HTMLSelectElement; if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<SkillsData>("skills.json"); const data = await fetchData<SkillsData>('skills.json');
if (!data || !data.items) { if (!data || !data.items) {
if (list) if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
return; return;
} }
allItems = data.items; allItems = data.items;
skillById = new Map(allItems.map((item) => [item.id, item]));
const initialSort = getQueryParam("sort"); const initialSort = getQueryParam('sort');
if (initialSort === "lastUpdated") { if (initialSort === 'lastUpdated') {
currentSort = initialSort; currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort; if (sortSelect) sortSelect.value = initialSort;
} }
sortSelect?.addEventListener("change", () => { sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as SkillSortOption; currentSort = sortSelect.value as SkillSortOption;
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
applyFiltersAndRender(); applyFiltersAndRender();
setupModal();
} }
// Auto-initialize when DOM is ready // Auto-initialize when DOM is ready
document.addEventListener("DOMContentLoaded", initSkillsPage); document.addEventListener('DOMContentLoaded', initSkillsPage);
+22 -23
View File
@@ -4,6 +4,7 @@ import {
getGitHubUrl, getGitHubUrl,
getLastUpdatedHtml, getLastUpdatedHtml,
} from '../utils'; } from '../utils';
import { renderEmptyStateHtml, renderSharedCardHtml } from './card-render';
export interface RenderableWorkflow { export interface RenderableWorkflow {
title: string; title: string;
@@ -34,34 +35,32 @@ export function renderWorkflowsHtml(
items: RenderableWorkflow[] items: RenderableWorkflow[]
): string { ): string {
if (items.length === 0) { if (items.length === 0) {
return ` return renderEmptyStateHtml('No workflows found', 'Try adjusting the selected filters.');
<div class="empty-state">
<h3>No workflows found</h3>
<p>Try adjusting the selected filters.</p>
</div>
`;
} }
return items return items
.map((item) => { .map((item) => {
return ` const metaHtml = `
<article class="resource-item" data-path="${escapeHtml(item.path)}" role="listitem"> ${item.triggers
<button type="button" class="resource-preview"> .map((trigger) => `<span class="resource-tag tag-trigger">${escapeHtml(trigger)}</span>`)
<div class="resource-info"> .join('')}
<div class="resource-title">${escapeHtml(item.title)}</div> ${getLastUpdatedHtml(item.lastUpdated)}
<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 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(''); .join('');
} }
+108 -58
View File
@@ -2,25 +2,24 @@
* Workflows page functionality * Workflows page functionality
*/ */
import { import {
createChoices, copyToClipboard,
getChoicesValues, escapeHtml,
setChoicesValues,
type Choices,
} from "../choices";
import {
fetchData, fetchData,
formatRelativeTime,
getQueryParam, getQueryParam,
getQueryParamValues, getQueryParamValues,
showToast,
setupActionHandlers, setupActionHandlers,
updateQueryParams, updateQueryParams,
} from "../utils"; } from '../utils';
import { setupModal, openFileModal } from "../modal"; import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import { import {
renderWorkflowsHtml, renderWorkflowsHtml,
sortWorkflows, sortWorkflows,
type RenderableWorkflow, type RenderableWorkflow,
type WorkflowSortOption, type WorkflowSortOption,
} from "./workflows-render"; } from './workflows-render';
interface Workflow extends RenderableWorkflow { interface Workflow extends RenderableWorkflow {
id: string; id: string;
@@ -36,141 +35,192 @@ interface WorkflowsData {
}; };
} }
const resourceType = "workflow";
let allItems: Workflow[] = []; let allItems: Workflow[] = [];
let triggerSelect: Choices; let workflowByPath = new Map<string, Workflow>();
let triggerSelectEl: HTMLSelectElement | null = null;
let currentFilters = { let currentFilters = {
triggers: [] as string[], triggers: [] as string[],
}; };
let currentSort: WorkflowSortOption = "title"; let currentSort: WorkflowSortOption = 'title';
let resourceListHandlersReady = false; let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Workflow[]): Workflow[] { function sortItems(items: Workflow[]): Workflow[] {
return sortWorkflows(items, currentSort); return sortWorkflows(items, currentSort);
} }
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const countEl = document.getElementById("results-count"); const countEl = document.getElementById('results-count');
let results = [...allItems]; let results = [...allItems];
if (currentFilters.triggers.length > 0) { if (currentFilters.triggers.length > 0) {
results = results.filter((item) => results = results.filter((item) => item.triggers.some((trigger) => currentFilters.triggers.includes(trigger)));
item.triggers.some((trigger) => currentFilters.triggers.includes(trigger))
);
} }
results = sortItems(results); results = sortItems(results);
renderItems(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) { 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; if (countEl) countEl.textContent = countText;
} }
function renderItems(items: Workflow[]): void { function renderItems(items: Workflow[]): void {
const list = document.getElementById("resource-list"); const list = document.getElementById('resource-list');
if (!list) return; if (!list) return;
list.innerHTML = renderWorkflowsHtml(items); list.innerHTML = renderWorkflowsHtml(items);
} }
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 { function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return; if (!list || resourceListHandlersReady) return;
list.addEventListener("click", (event) => { list.addEventListener('click', (event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest(".resource-actions")) { if (target.closest('.resource-actions')) {
return; 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; const path = item?.dataset.path;
if (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; resourceListHandlersReady = true;
} }
function syncUrlState(): void { function syncUrlState(): void {
updateQueryParams({ updateQueryParams({
q: "", q: '',
trigger: currentFilters.triggers, trigger: currentFilters.triggers,
sort: currentSort === "title" ? "" : currentSort, sort: currentSort === 'title' ? '' : currentSort,
}); });
} }
export async function initWorkflowsPage(): Promise<void> { export async function initWorkflowsPage(): Promise<void> {
const list = document.getElementById("resource-list"); const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById("clear-filters"); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById( const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
"sort-select"
) as HTMLSelectElement; if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null); setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<WorkflowsData>("workflows.json"); const data = await fetchData<WorkflowsData>('workflows.json');
if (!data || !data.items) { if (!data || !data.items) {
if (list) if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
return; return;
} }
allItems = data.items; allItems = data.items;
workflowByPath = new Map(allItems.map((item) => [item.path, item]));
triggerSelect = createChoices("#filter-trigger", { triggerSelectEl = document.getElementById('filter-trigger') as HTMLSelectElement | null;
placeholderValue: "All Triggers", if (triggerSelectEl) {
}); triggerSelectEl.innerHTML = '';
triggerSelect.setChoices( data.filters.triggers.forEach((trigger) => {
data.filters.triggers.map((trigger) => ({ value: trigger, label: trigger })), const option = document.createElement('option');
"value", option.value = trigger;
"label", option.textContent = trigger;
true triggerSelectEl?.appendChild(option);
); });
}
const initialTriggers = getQueryParamValues("trigger").filter((trigger) => const initialTriggers = getQueryParamValues('trigger').filter((trigger) => data.filters.triggers.includes(trigger));
data.filters.triggers.includes(trigger) const initialSort = getQueryParam('sort');
);
const initialSort = getQueryParam("sort");
if (initialTriggers.length > 0) { if (initialTriggers.length > 0) {
currentFilters.triggers = initialTriggers; currentFilters.triggers = initialTriggers;
setChoicesValues(triggerSelect, initialTriggers); setSelectValues(triggerSelectEl, initialTriggers);
} }
if (initialSort === "lastUpdated") { if (initialSort === 'lastUpdated') {
currentSort = initialSort; currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort; if (sortSelect) sortSelect.value = initialSort;
} }
document.getElementById("filter-trigger")?.addEventListener("change", () => { triggerSelectEl?.addEventListener('change', () => {
currentFilters.triggers = getChoicesValues(triggerSelect); currentFilters.triggers = getSelectValues(triggerSelectEl);
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
sortSelect?.addEventListener("change", () => { sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as WorkflowSortOption; currentSort = sortSelect.value as WorkflowSortOption;
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
clearFiltersBtn?.addEventListener("click", () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { triggers: [] }; currentFilters = { triggers: [] };
currentSort = "title"; currentSort = 'title';
triggerSelect.removeActiveItems(); clearSelectValues(triggerSelectEl);
if (sortSelect) sortSelect.value = "title"; if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
syncUrlState(); syncUrlState();
}); });
applyFiltersAndRender(); applyFiltersAndRender();
setupModal();
setupActionHandlers(); setupActionHandlers();
} }
// Auto-initialize when DOM is ready // 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; 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 { .modal-rendered-content {
padding: 24px; padding: 24px;
min-height: 200px; min-height: 200px;
@@ -1876,7 +1899,7 @@ body:has(#main-content) {
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
} }
.resource-thumbnail-btn { .resource-thumbnail-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1887,13 +1910,13 @@ body:has(#main-content) {
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
} }
.resource-thumbnail-btn:focus-visible { .resource-thumbnail-btn:focus-visible {
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 4px; outline-offset: 4px;
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
.resource-thumbnail { .resource-thumbnail {
width: clamp(120px, 24vw, 160px); width: clamp(120px, 24vw, 160px);
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
@@ -1905,13 +1928,13 @@ body:has(#main-content) {
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: transform var(--transition), box-shadow var(--transition); transition: transform var(--transition), box-shadow var(--transition);
} }
.resource-thumbnail-btn:hover .resource-thumbnail, .resource-thumbnail-btn:hover .resource-thumbnail,
.resource-thumbnail-btn:focus-visible .resource-thumbnail { .resource-thumbnail-btn:focus-visible .resource-thumbnail {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
} }
.resource-thumbnail-placeholder { .resource-thumbnail-placeholder {
display: flex; display: flex;
align-items: center; 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)), linear-gradient(135deg, rgba(133, 52, 243, 0.18), rgba(254, 76, 37, 0.08)),
var(--color-bg-tertiary); var(--color-bg-tertiary);
} }
.resource-preview:focus-visible { .resource-preview:focus-visible {
outline: 2px solid var(--color-accent); outline: 2px solid var(--color-accent);
outline-offset: 4px; outline-offset: 4px;
@@ -1998,61 +2021,296 @@ body:has(#main-content) {
margin: 0px; margin: 0px;
} }
.extension-preview-modal { /* Extensions page grid layout */
position: fixed; .extensions-page .resource-list {
inset: 0; display: grid;
z-index: 100000; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
display: flex; gap: 16px;
align-items: center; align-items: stretch;
justify-content: center;
padding: 24px;
background: rgba(5, 7, 15, 0.72);
backdrop-filter: blur(8px);
} }
.extension-preview-modal.hidden { .extensions-page .resource-item {
display: none; height: 100%;
} padding: 20px;
.extension-preview-dialog {
width: min(100%, 980px);
max-height: 90vh;
display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; align-items: stretch;
background: var(--color-bg-secondary); justify-content: space-between;
border: 1px solid var(--color-glass-border); 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); 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; 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; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 10px;
gap: 12px;
padding: 16px 18px 0;
} }
.extension-preview-title { .extension-details-image {
margin: 0; width: 100%;
font-size: 1rem; max-width: none;
color: var(--color-text-emphasis);
} }
.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: 1px solid var(--color-glass-border);
border-radius: var(--border-radius);
background: var(--color-bg-tertiary); background: var(--color-bg-tertiary);
padding: 0;
cursor: pointer;
} }
.extension-preview-body { .extension-details-thumbnail-btn.active {
padding: 0 18px 18px; 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; 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; 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; overflow: auto;
} }
.resource-details-actions {
margin-top: 4px;
flex-wrap: wrap;
}
.extension-preview-image { .extension-preview-image {
display: block; display: block;
width: 100%; width: 100%;
@@ -2233,6 +2491,14 @@ body:has(#main-content) {
justify-content: flex-end; 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 */ /* Ensure touch targets are at least 44px */
.card, .card,
.search-result, .search-result,