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