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