mirror of
https://github.com/github/awesome-copilot.git
synced 2026-03-15 13:45:12 +00:00
More website tweaks (#977)
* Some layout tweaks * SSR resource listing pages Render resource listing pages in Astro for first paint and hydrate client filtering/search behavior on top. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing font path * removing feature plugin reference as we don't track that anymore * button alignment * rendering markdown * Improve skills modal file browsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Improving the layout of the search/filter section --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
29
website/src/scripts/embedded-data.ts
Normal file
29
website/src/scripts/embedded-data.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
const embeddedDataCache = new Map<string, unknown>();
|
||||
|
||||
export function getEmbeddedDataElementId(filename: string): string {
|
||||
return `page-data-${filename.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function serializeEmbeddedData(data: unknown): string {
|
||||
return JSON.stringify(data).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
export function getEmbeddedData<T>(filename: string): T | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
if (embeddedDataCache.has(filename)) {
|
||||
return embeddedDataCache.get(filename) as T;
|
||||
}
|
||||
|
||||
const element = document.getElementById(getEmbeddedDataElementId(filename));
|
||||
if (!(element instanceof HTMLScriptElement)) return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(element.textContent || "null") as T;
|
||||
embeddedDataCache.set(filename, data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing embedded data for ${filename}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
* Modal functionality for file viewing
|
||||
*/
|
||||
|
||||
import { marked } from "marked";
|
||||
import {
|
||||
fetchFileContent,
|
||||
fetchData,
|
||||
@@ -15,11 +16,15 @@ import {
|
||||
getResourceIcon,
|
||||
sanitizeUrl,
|
||||
} from "./utils";
|
||||
import fm from "front-matter";
|
||||
|
||||
type ModalViewMode = "rendered" | "raw";
|
||||
|
||||
// Modal state
|
||||
let currentFilePath: string | null = null;
|
||||
let currentFileContent: string | null = null;
|
||||
let currentFileType: string | null = null;
|
||||
let currentViewMode: ModalViewMode = "raw";
|
||||
let triggerElement: HTMLElement | null = null;
|
||||
let originalDocumentTitle: string | null = null;
|
||||
|
||||
@@ -35,6 +40,22 @@ interface ResourceData {
|
||||
|
||||
const resourceDataCache: Record<string, ResourceData | null> = {};
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SkillItem extends ResourceItem {
|
||||
skillFile: string;
|
||||
files: SkillFile[];
|
||||
}
|
||||
|
||||
interface SkillsData {
|
||||
items: SkillItem[];
|
||||
}
|
||||
|
||||
let skillsCache: SkillsData | null | undefined;
|
||||
|
||||
const RESOURCE_TYPE_TO_JSON: Record<string, string> = {
|
||||
agent: "agents.json",
|
||||
instruction: "instructions.json",
|
||||
@@ -66,17 +87,313 @@ async function resolveResourceTitle(
|
||||
const item = data.items.find((i) => i.path === filePath);
|
||||
if (item) return item.title;
|
||||
|
||||
// For skills/hooks, the modal receives the file path (e.g. skills/foo/SKILL.md)
|
||||
// but JSON stores the folder path (e.g. skills/foo)
|
||||
const parentPath = filePath.substring(0, filePath.lastIndexOf("/"));
|
||||
if (parentPath) {
|
||||
const parentItem = data.items.find((i) => i.path === parentPath);
|
||||
// For skills/hooks, bundled files live under the resource folder while
|
||||
// JSON stores the folder path itself (for example, skills/foo).
|
||||
const collectionRootPath =
|
||||
type === "skill"
|
||||
? getCollectionRootPath(filePath, "skills")
|
||||
: type === "hook"
|
||||
? getCollectionRootPath(filePath, "hooks")
|
||||
: filePath.substring(0, filePath.lastIndexOf("/"));
|
||||
|
||||
if (collectionRootPath) {
|
||||
const parentItem = data.items.find((i) => i.path === collectionRootPath);
|
||||
if (parentItem) return parentItem.title;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getFileName(filePath: string): string {
|
||||
return filePath.split("/").pop() || filePath;
|
||||
}
|
||||
|
||||
function isMarkdownFile(filePath: string): boolean {
|
||||
return /\.(md|markdown|mdx)$/i.test(filePath);
|
||||
}
|
||||
|
||||
function getCollectionRootPath(filePath: string, collectionName: string): string | null {
|
||||
const segments = filePath.split("/");
|
||||
const collectionIndex = segments.indexOf(collectionName);
|
||||
if (collectionIndex === -1 || segments.length <= collectionIndex + 1) {
|
||||
return null;
|
||||
}
|
||||
return segments.slice(0, collectionIndex + 2).join("/");
|
||||
}
|
||||
|
||||
function getSkillRootPath(filePath: string): string | null {
|
||||
return getCollectionRootPath(filePath, "skills");
|
||||
}
|
||||
|
||||
async function getSkillsData(): Promise<SkillsData | null> {
|
||||
if (skillsCache === undefined) {
|
||||
skillsCache = await fetchData<SkillsData>("skills.json");
|
||||
}
|
||||
|
||||
return skillsCache;
|
||||
}
|
||||
|
||||
async function getSkillItemByFilePath(filePath: string): Promise<SkillItem | null> {
|
||||
if (getResourceType(filePath) !== "skill") return null;
|
||||
|
||||
const skillsData = await getSkillsData();
|
||||
if (!skillsData) return null;
|
||||
|
||||
const rootPath = getSkillRootPath(filePath);
|
||||
if (!rootPath) return null;
|
||||
|
||||
return (
|
||||
skillsData.items.find(
|
||||
(item) =>
|
||||
item.path === rootPath ||
|
||||
item.skillFile === filePath ||
|
||||
item.files.some((file) => file.path === filePath)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function updateModalTitle(titleText: string, filePath: string): void {
|
||||
const title = document.getElementById("modal-title");
|
||||
if (title) {
|
||||
title.textContent = titleText;
|
||||
}
|
||||
|
||||
const fileName = getFileName(filePath);
|
||||
document.title =
|
||||
titleText === fileName
|
||||
? `${titleText} | Awesome GitHub Copilot`
|
||||
: `${titleText} · ${fileName} | Awesome GitHub Copilot`;
|
||||
}
|
||||
|
||||
function getModalBody(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>(".modal-body");
|
||||
}
|
||||
|
||||
function getModalContent(): HTMLElement | null {
|
||||
return document.getElementById("modal-content");
|
||||
}
|
||||
|
||||
function ensurePreContent(): HTMLPreElement | null {
|
||||
let modalContent = getModalContent();
|
||||
if (!modalContent) return null;
|
||||
|
||||
if (modalContent.tagName === "PRE") {
|
||||
modalContent.className = "";
|
||||
if (!modalContent.querySelector("code")) {
|
||||
modalContent.innerHTML = "<code></code>";
|
||||
}
|
||||
return modalContent as HTMLPreElement;
|
||||
}
|
||||
|
||||
const modalBody = getModalBody();
|
||||
if (!modalBody) return null;
|
||||
|
||||
const pre = document.createElement("pre");
|
||||
pre.id = "modal-content";
|
||||
pre.innerHTML = "<code></code>";
|
||||
modalBody.replaceChild(pre, modalContent);
|
||||
return pre;
|
||||
}
|
||||
|
||||
function ensureDivContent(className: string): HTMLDivElement | null {
|
||||
let modalContent = getModalContent();
|
||||
if (!modalContent) return null;
|
||||
|
||||
if (modalContent.tagName === "DIV") {
|
||||
modalContent.className = className;
|
||||
return modalContent as HTMLDivElement;
|
||||
}
|
||||
|
||||
const modalBody = getModalBody();
|
||||
if (!modalBody) return null;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.id = "modal-content";
|
||||
div.className = className;
|
||||
modalBody.replaceChild(div, modalContent);
|
||||
return div;
|
||||
}
|
||||
|
||||
function renderPlainText(content: string): void {
|
||||
const pre = ensurePreContent();
|
||||
const codeEl = pre?.querySelector("code");
|
||||
if (codeEl) {
|
||||
codeEl.textContent = content;
|
||||
}
|
||||
}
|
||||
|
||||
const EXTENSION_LANGUAGE_MAP: Record<string, string> = {
|
||||
bicep: "bicep",
|
||||
cjs: "javascript",
|
||||
css: "css",
|
||||
cs: "csharp",
|
||||
go: "go",
|
||||
html: "html",
|
||||
java: "java",
|
||||
js: "javascript",
|
||||
json: "json",
|
||||
jsx: "jsx",
|
||||
md: "md",
|
||||
markdown: "md",
|
||||
mdx: "mdx",
|
||||
mjs: "javascript",
|
||||
ps1: "powershell",
|
||||
psm1: "powershell",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
rs: "rust",
|
||||
scss: "scss",
|
||||
sh: "bash",
|
||||
sql: "sql",
|
||||
toml: "toml",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
txt: "text",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
};
|
||||
|
||||
const FILE_NAME_LANGUAGE_MAP: Record<string, string> = {
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
};
|
||||
|
||||
function getLanguageForFile(filePath: string): string {
|
||||
const fileName = getFileName(filePath);
|
||||
const lowerFileName = fileName.toLowerCase();
|
||||
|
||||
if (FILE_NAME_LANGUAGE_MAP[lowerFileName]) {
|
||||
return FILE_NAME_LANGUAGE_MAP[lowerFileName];
|
||||
}
|
||||
|
||||
const extension = lowerFileName.includes(".")
|
||||
? lowerFileName.split(".").pop()
|
||||
: "";
|
||||
|
||||
if (extension && EXTENSION_LANGUAGE_MAP[extension]) {
|
||||
return EXTENSION_LANGUAGE_MAP[extension];
|
||||
}
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
async function renderHighlightedCode(content: string, filePath: string): Promise<void> {
|
||||
try {
|
||||
const { codeToHtml } = await import("shiki");
|
||||
const container = ensureDivContent("modal-code-content");
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = await codeToHtml(content, {
|
||||
lang: getLanguageForFile(filePath),
|
||||
themes: {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
renderPlainText(content);
|
||||
}
|
||||
}
|
||||
|
||||
function updateViewButtons(): void {
|
||||
const renderBtn = document.getElementById("render-btn");
|
||||
const rawBtn = document.getElementById("raw-btn");
|
||||
const markdownFile = currentFilePath ? isMarkdownFile(currentFilePath) : false;
|
||||
|
||||
if (!renderBtn || !rawBtn) return;
|
||||
|
||||
if (!markdownFile) {
|
||||
renderBtn.classList.add("hidden");
|
||||
rawBtn.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentViewMode === "rendered") {
|
||||
renderBtn.classList.add("hidden");
|
||||
rawBtn.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
rawBtn.classList.add("hidden");
|
||||
renderBtn.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function renderCurrentFileContent(): Promise<void> {
|
||||
if (!currentFilePath) return;
|
||||
|
||||
updateViewButtons();
|
||||
|
||||
if (!currentFileContent) {
|
||||
renderPlainText(
|
||||
"Failed to load file content. Click the button below to view on GitHub."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarkdownFile(currentFilePath) && currentViewMode === "rendered") {
|
||||
const container = ensureDivContent("modal-rendered-content");
|
||||
if (!container) return;
|
||||
|
||||
const { body: markdownBody } = fm(currentFileContent);
|
||||
container.innerHTML = marked(markdownBody, { async: false });
|
||||
} else {
|
||||
await renderHighlightedCode(currentFileContent, currentFilePath);
|
||||
}
|
||||
|
||||
const modalBody = getModalBody();
|
||||
if (modalBody) {
|
||||
modalBody.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function configureSkillFileSwitcher(filePath: string): Promise<void> {
|
||||
const switcher = document.getElementById("modal-file-switcher");
|
||||
const fileButtonLabel = document.getElementById("modal-file-button-label");
|
||||
const menu = document.getElementById("modal-file-menu");
|
||||
|
||||
if (!switcher || !fileButtonLabel || !menu) return;
|
||||
|
||||
const skillItem = await getSkillItemByFilePath(filePath);
|
||||
if (currentFilePath !== filePath) return;
|
||||
|
||||
if (!skillItem || skillItem.files.length <= 1) {
|
||||
switcher.classList.add("hidden");
|
||||
fileButtonLabel.textContent = "";
|
||||
menu.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
fileButtonLabel.textContent = getFileName(filePath);
|
||||
menu.innerHTML = skillItem.files
|
||||
.map(
|
||||
(file) =>
|
||||
`<button type="button" class="modal-file-menu-item${
|
||||
file.path === filePath ? " active" : ""
|
||||
}" data-path="${escapeHtml(file.path)}" role="menuitemradio" aria-checked="${
|
||||
file.path === filePath ? "true" : "false"
|
||||
}">${escapeHtml(file.name)}</button>`
|
||||
)
|
||||
.join("");
|
||||
switcher.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideSkillFileSwitcher(): void {
|
||||
const switcher = document.getElementById("modal-file-switcher");
|
||||
const fileButtonLabel = document.getElementById("modal-file-button-label");
|
||||
const menu = document.getElementById("modal-file-menu");
|
||||
const dropdown = document.getElementById("modal-file-dropdown");
|
||||
const fileButton = document.getElementById("modal-file-button");
|
||||
const fileToggle = document.getElementById("modal-file-toggle");
|
||||
|
||||
switcher?.classList.add("hidden");
|
||||
dropdown?.classList.remove("open");
|
||||
fileButton?.setAttribute("aria-expanded", "false");
|
||||
fileToggle?.setAttribute("aria-expanded", "false");
|
||||
if (fileButtonLabel) fileButtonLabel.textContent = "";
|
||||
if (menu) menu.innerHTML = "";
|
||||
}
|
||||
|
||||
// Plugin data cache
|
||||
interface PluginItem {
|
||||
path: string;
|
||||
@@ -170,10 +487,16 @@ export function setupModal(): void {
|
||||
const copyBtn = document.getElementById("copy-btn");
|
||||
const downloadBtn = document.getElementById("download-btn");
|
||||
const shareBtn = document.getElementById("share-btn");
|
||||
const renderBtn = document.getElementById("render-btn");
|
||||
const rawBtn = document.getElementById("raw-btn");
|
||||
const fileDropdown = document.getElementById("modal-file-dropdown");
|
||||
const fileButton = document.getElementById("modal-file-button");
|
||||
const fileToggle = document.getElementById("modal-file-toggle");
|
||||
const fileMenu = document.getElementById("modal-file-menu");
|
||||
|
||||
if (!modal) return;
|
||||
|
||||
closeBtn?.addEventListener("click", closeModal);
|
||||
closeBtn?.addEventListener("click", () => closeModal());
|
||||
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeModal();
|
||||
@@ -219,12 +542,124 @@ export function setupModal(): void {
|
||||
}
|
||||
});
|
||||
|
||||
renderBtn?.addEventListener("click", async () => {
|
||||
currentViewMode = "rendered";
|
||||
await renderCurrentFileContent();
|
||||
});
|
||||
|
||||
rawBtn?.addEventListener("click", async () => {
|
||||
currentViewMode = "raw";
|
||||
await renderCurrentFileContent();
|
||||
});
|
||||
|
||||
const setFileMenuOpen = (isOpen: boolean): void => {
|
||||
if (!fileDropdown) return;
|
||||
fileDropdown.classList.toggle("open", isOpen);
|
||||
fileButton?.setAttribute("aria-expanded", String(isOpen));
|
||||
fileToggle?.setAttribute("aria-expanded", String(isOpen));
|
||||
};
|
||||
|
||||
const toggleFileMenu = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const isOpen = !fileDropdown?.classList.contains("open");
|
||||
setFileMenuOpen(Boolean(isOpen));
|
||||
if (isOpen) {
|
||||
fileMenu
|
||||
?.querySelector<HTMLElement>(".modal-file-menu-item.active, .modal-file-menu-item")
|
||||
?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
fileButton?.addEventListener("click", toggleFileMenu);
|
||||
fileToggle?.addEventListener("click", toggleFileMenu);
|
||||
|
||||
fileButton?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
||||
toggleFileMenu(e);
|
||||
}
|
||||
});
|
||||
|
||||
fileToggle?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
||||
toggleFileMenu(e);
|
||||
}
|
||||
});
|
||||
|
||||
fileMenu?.addEventListener("click", async (event) => {
|
||||
const target = (event.target as HTMLElement).closest<HTMLButtonElement>(
|
||||
".modal-file-menu-item"
|
||||
);
|
||||
const targetPath = target?.dataset.path;
|
||||
if (!target || !targetPath || !currentFileType) return;
|
||||
setFileMenuOpen(false);
|
||||
await openFileModal(
|
||||
targetPath,
|
||||
currentFileType,
|
||||
true,
|
||||
triggerElement || undefined
|
||||
);
|
||||
});
|
||||
|
||||
fileMenu?.addEventListener("keydown", async (event) => {
|
||||
const items = Array.from(
|
||||
fileMenu.querySelectorAll<HTMLButtonElement>(".modal-file-menu-item")
|
||||
);
|
||||
const currentIndex = items.findIndex((item) => item === event.target);
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
if (currentIndex >= 0 && currentIndex < items.length - 1) {
|
||||
items[currentIndex + 1].focus();
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (currentIndex > 0) {
|
||||
items[currentIndex - 1].focus();
|
||||
} else {
|
||||
fileButton?.focus();
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
setFileMenuOpen(false);
|
||||
fileButton?.focus();
|
||||
break;
|
||||
case "Tab":
|
||||
setFileMenuOpen(false);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (currentIndex >= 0 && currentFileType) {
|
||||
const targetPath = items[currentIndex].dataset.path;
|
||||
if (!targetPath) return;
|
||||
event.preventDefault();
|
||||
setFileMenuOpen(false);
|
||||
await openFileModal(
|
||||
targetPath,
|
||||
currentFileType,
|
||||
true,
|
||||
triggerElement || undefined
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Setup install dropdown toggle
|
||||
setupInstallDropdown("install-dropdown");
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (fileDropdown && !fileDropdown.contains(e.target as Node)) {
|
||||
setFileMenuOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Check for deep link on initial load
|
||||
handleHashChange();
|
||||
}
|
||||
@@ -372,8 +807,6 @@ export async function openFileModal(
|
||||
): Promise<void> {
|
||||
const modal = document.getElementById("file-modal");
|
||||
const title = document.getElementById("modal-title");
|
||||
let modalContent = document.getElementById("modal-content");
|
||||
const contentEl = modalContent?.querySelector("code");
|
||||
const installDropdown = document.getElementById("install-dropdown");
|
||||
const installBtnMain = document.getElementById(
|
||||
"install-btn-main"
|
||||
@@ -387,38 +820,29 @@ export async function openFileModal(
|
||||
const copyBtn = document.getElementById("copy-btn");
|
||||
const downloadBtn = document.getElementById("download-btn");
|
||||
const closeBtn = document.getElementById("close-modal");
|
||||
|
||||
if (!modal || !title || !modalContent) return;
|
||||
if (!modal || !title) return;
|
||||
|
||||
currentFilePath = filePath;
|
||||
currentFileType = type;
|
||||
currentViewMode = "raw";
|
||||
|
||||
// Track trigger element for focus return
|
||||
triggerElement = trigger || (document.activeElement as HTMLElement);
|
||||
triggerElement =
|
||||
trigger || triggerElement || (document.activeElement as HTMLElement);
|
||||
|
||||
// Update URL for deep linking
|
||||
if (updateUrl) {
|
||||
updateHash(filePath);
|
||||
}
|
||||
|
||||
// Show modal with loading state
|
||||
const fallbackName = filePath.split("/").pop() || filePath;
|
||||
title.textContent = fallbackName;
|
||||
modal.classList.remove("hidden");
|
||||
|
||||
// Update document title to reflect the open file
|
||||
if (!originalDocumentTitle) {
|
||||
originalDocumentTitle = document.title;
|
||||
}
|
||||
document.title = `${fallbackName} | Awesome GitHub Copilot`;
|
||||
|
||||
// Resolve the proper title from JSON data asynchronously
|
||||
resolveResourceTitle(filePath, type).then((resolvedTitle) => {
|
||||
if (currentFilePath === filePath) {
|
||||
title.textContent = resolvedTitle;
|
||||
document.title = `${resolvedTitle} | Awesome GitHub Copilot`;
|
||||
}
|
||||
});
|
||||
// Show modal with loading state
|
||||
const fallbackName = getFileName(filePath);
|
||||
updateModalTitle(fallbackName, filePath);
|
||||
modal.classList.remove("hidden");
|
||||
|
||||
// Set focus to close button for accessibility
|
||||
setTimeout(() => {
|
||||
@@ -427,6 +851,9 @@ export async function openFileModal(
|
||||
|
||||
// Handle plugins differently - show as item list
|
||||
if (type === "plugin") {
|
||||
const modalContent = getModalContent();
|
||||
if (!modalContent) return;
|
||||
hideSkillFileSwitcher();
|
||||
await openPluginModal(
|
||||
filePath,
|
||||
title,
|
||||
@@ -438,27 +865,12 @@ export async function openFileModal(
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular file modal
|
||||
if (contentEl) {
|
||||
contentEl.textContent = "Loading...";
|
||||
}
|
||||
|
||||
// Show copy/download buttons for regular files
|
||||
if (copyBtn) copyBtn.style.display = "inline-flex";
|
||||
if (downloadBtn) downloadBtn.style.display = "inline-flex";
|
||||
|
||||
// Restore pre/code structure if it was replaced by plugin view
|
||||
if (modalContent.tagName !== 'PRE') {
|
||||
const modalBody = modalContent.parentElement;
|
||||
if (modalBody) {
|
||||
const pre = document.createElement("pre");
|
||||
pre.id = "modal-content";
|
||||
pre.innerHTML = "<code></code>";
|
||||
modalBody.replaceChild(pre, modalContent);
|
||||
modalContent = pre;
|
||||
}
|
||||
}
|
||||
const codeEl = modalContent.querySelector("code");
|
||||
renderPlainText("Loading...");
|
||||
hideSkillFileSwitcher();
|
||||
updateViewButtons();
|
||||
|
||||
// Setup install dropdown
|
||||
const vscodeUrl = getVSCodeInstallUrl(type, filePath, false);
|
||||
@@ -474,16 +886,19 @@ export async function openFileModal(
|
||||
installDropdown.style.display = "none";
|
||||
}
|
||||
|
||||
// Fetch and display content
|
||||
const fileContent = await fetchFileContent(filePath);
|
||||
currentFileContent = fileContent;
|
||||
const [resolvedTitle, fileContent] = await Promise.all([
|
||||
resolveResourceTitle(filePath, type),
|
||||
fetchFileContent(filePath),
|
||||
type === "skill" ? configureSkillFileSwitcher(filePath) : Promise.resolve(),
|
||||
]);
|
||||
|
||||
if (fileContent && codeEl) {
|
||||
codeEl.textContent = fileContent;
|
||||
} else if (codeEl) {
|
||||
codeEl.textContent =
|
||||
"Failed to load file content. Click the button below to view on GitHub.";
|
||||
if (currentFilePath !== filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateModalTitle(resolvedTitle, filePath);
|
||||
currentFileContent = fileContent;
|
||||
await renderCurrentFileContent();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,7 +926,8 @@ async function openPluginModal(
|
||||
modalBody.replaceChild(div, modalContent);
|
||||
modalContent = div;
|
||||
} else {
|
||||
modalContent.innerHTML = '<div class="collection-loading">Loading plugin...</div>';
|
||||
modalContent.innerHTML =
|
||||
'<div class="collection-loading">Loading plugin...</div>';
|
||||
}
|
||||
|
||||
// Load plugins data if not cached
|
||||
@@ -551,7 +967,9 @@ async function openPluginModal(
|
||||
function getExternalPluginUrl(plugin: Plugin): string {
|
||||
if (plugin.source?.source === "github" && plugin.source.repo) {
|
||||
const base = `https://github.com/${plugin.source.repo}`;
|
||||
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
|
||||
return plugin.source.path
|
||||
? `${base}/tree/main/${plugin.source.path}`
|
||||
: base;
|
||||
}
|
||||
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
|
||||
return sanitizeUrl(plugin.repository || plugin.homepage);
|
||||
@@ -569,7 +987,11 @@ function renderExternalPluginModal(
|
||||
<span class="external-plugin-meta-label">Author</span>
|
||||
<span class="external-plugin-meta-value">${
|
||||
plugin.author.url
|
||||
? `<a href="${sanitizeUrl(plugin.author.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.author.name)}</a>`
|
||||
? `<a href="${sanitizeUrl(
|
||||
plugin.author.url
|
||||
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
|
||||
plugin.author.name
|
||||
)}</a>`
|
||||
: escapeHtml(plugin.author.name)
|
||||
}</span>
|
||||
</div>`
|
||||
@@ -578,7 +1000,11 @@ function renderExternalPluginModal(
|
||||
const repoHtml = plugin.repository
|
||||
? `<div class="external-plugin-meta-row">
|
||||
<span class="external-plugin-meta-label">Repository</span>
|
||||
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(plugin.repository)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.repository)}</a></span>
|
||||
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(
|
||||
plugin.repository
|
||||
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
|
||||
plugin.repository
|
||||
)}</a></span>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
@@ -586,21 +1012,31 @@ function renderExternalPluginModal(
|
||||
plugin.homepage && plugin.homepage !== plugin.repository
|
||||
? `<div class="external-plugin-meta-row">
|
||||
<span class="external-plugin-meta-label">Homepage</span>
|
||||
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(plugin.homepage)}" target="_blank" rel="noopener noreferrer">${escapeHtml(plugin.homepage)}</a></span>
|
||||
<span class="external-plugin-meta-value"><a href="${sanitizeUrl(
|
||||
plugin.homepage
|
||||
)}" target="_blank" rel="noopener noreferrer">${escapeHtml(
|
||||
plugin.homepage
|
||||
)}</a></span>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const licenseHtml = plugin.license
|
||||
? `<div class="external-plugin-meta-row">
|
||||
<span class="external-plugin-meta-label">License</span>
|
||||
<span class="external-plugin-meta-value">${escapeHtml(plugin.license)}</span>
|
||||
<span class="external-plugin-meta-value">${escapeHtml(
|
||||
plugin.license
|
||||
)}</span>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const sourceHtml = plugin.source?.repo
|
||||
? `<div class="external-plugin-meta-row">
|
||||
<span class="external-plugin-meta-label">Source</span>
|
||||
<span class="external-plugin-meta-value">GitHub: ${escapeHtml(plugin.source.repo)}${plugin.source.path ? ` (${escapeHtml(plugin.source.path)})` : ""}</span>
|
||||
<span class="external-plugin-meta-value">GitHub: ${escapeHtml(
|
||||
plugin.source.repo
|
||||
)}${
|
||||
plugin.source.path ? ` (${escapeHtml(plugin.source.path)})` : ""
|
||||
}</span>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
@@ -608,12 +1044,18 @@ function renderExternalPluginModal(
|
||||
|
||||
modalContent.innerHTML = `
|
||||
<div class="collection-view">
|
||||
<div class="collection-description">${escapeHtml(plugin.description || "")}</div>
|
||||
<div class="collection-description">${escapeHtml(
|
||||
plugin.description || ""
|
||||
)}</div>
|
||||
${
|
||||
plugin.tags && plugin.tags.length > 0
|
||||
? `<div class="collection-tags">
|
||||
<span class="resource-tag resource-tag-external">🔗 External Plugin</span>
|
||||
${plugin.tags.map((t) => `<span class="resource-tag">${escapeHtml(t)}</span>`).join("")}
|
||||
${plugin.tags
|
||||
.map(
|
||||
(t) => `<span class="resource-tag">${escapeHtml(t)}</span>`
|
||||
)
|
||||
.join("")}
|
||||
</div>`
|
||||
: `<div class="collection-tags">
|
||||
<span class="resource-tag resource-tag-external">🔗 External Plugin</span>
|
||||
@@ -627,7 +1069,9 @@ function renderExternalPluginModal(
|
||||
${sourceHtml}
|
||||
</div>
|
||||
<div class="external-plugin-cta">
|
||||
<a href="${sanitizeUrl(repoUrl)}" class="btn btn-primary external-plugin-repo-btn" target="_blank" rel="noopener noreferrer">
|
||||
<a href="${sanitizeUrl(
|
||||
repoUrl
|
||||
)}" class="btn btn-primary external-plugin-repo-btn" target="_blank" rel="noopener noreferrer">
|
||||
View Repository →
|
||||
</a>
|
||||
</div>
|
||||
@@ -745,7 +1189,9 @@ export function closeModal(updateUrl = true): void {
|
||||
currentFilePath = null;
|
||||
currentFileContent = null;
|
||||
currentFileType = null;
|
||||
currentViewMode = "raw";
|
||||
triggerElement = null;
|
||||
hideSkillFileSwitcher();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
115
website/src/scripts/pages/agents-render.ts
Normal file
115
website/src/scripts/pages/agents-render.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getActionButtonsHtml,
|
||||
getGitHubUrl,
|
||||
getInstallDropdownHtml,
|
||||
getLastUpdatedHtml,
|
||||
} from "../utils";
|
||||
|
||||
export interface RenderableAgent {
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
hasHandoffs?: boolean;
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export type AgentSortOption = "title" | "lastUpdated";
|
||||
|
||||
const resourceType = "agent";
|
||||
|
||||
export function sortAgents<T extends RenderableAgent>(
|
||||
items: T[],
|
||||
sort: AgentSortOption
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === "lastUpdated") {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderAgentsHtml(
|
||||
items: RenderableAgent[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No agents found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
<div class="resource-meta">
|
||||
${
|
||||
item.model
|
||||
? `<span class="resource-tag tag-model">${escapeHtml(
|
||||
item.model
|
||||
)}</span>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
item.tools
|
||||
?.slice(0, 3)
|
||||
.map(
|
||||
(tool) =>
|
||||
`<span class="resource-tag">${escapeHtml(tool)}</span>`
|
||||
)
|
||||
.join("") || ""
|
||||
}
|
||||
${
|
||||
item.tools && item.tools.length > 3
|
||||
? `<span class="resource-tag">+${
|
||||
item.tools.length - 3
|
||||
} more</span>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
item.hasHandoffs
|
||||
? `<span class="resource-tag tag-handoffs">handoffs</span>`
|
||||
: ""
|
||||
}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils';
|
||||
import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
import { renderAgentsHtml, sortAgents, type AgentSortOption, type RenderableAgent } from './agents-render';
|
||||
|
||||
interface Agent extends SearchItem {
|
||||
path: string;
|
||||
interface Agent extends SearchItem, RenderableAgent {
|
||||
model?: string;
|
||||
tools?: string[];
|
||||
hasHandoffs?: boolean;
|
||||
@@ -22,14 +22,12 @@ interface AgentsData {
|
||||
};
|
||||
}
|
||||
|
||||
type SortOption = 'title' | 'lastUpdated';
|
||||
|
||||
const resourceType = 'agent';
|
||||
let allItems: Agent[] = [];
|
||||
let search = new FuzzySearch<Agent>();
|
||||
let modelSelect: Choices;
|
||||
let toolSelect: Choices;
|
||||
let currentSort: SortOption = 'title';
|
||||
let currentSort: AgentSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
let currentFilters = {
|
||||
models: [] as string[],
|
||||
@@ -38,16 +36,7 @@ let currentFilters = {
|
||||
};
|
||||
|
||||
function sortItems(items: Agent[]): Agent[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (currentSort === 'lastUpdated') {
|
||||
// Sort by last updated (newest first), with null/undefined at end
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
// Default: sort by title
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return sortAgents(items, currentSort);
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
@@ -97,48 +86,31 @@ function renderItems(items: Agent[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No agents found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${item.model ? `<span class="resource-tag tag-model">${escapeHtml(item.model)}</span>` : ''}
|
||||
${item.tools?.slice(0, 3).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
|
||||
${item.tools && item.tools.length > 3 ? `<span class="resource-tag">+${item.tools.length - 3} more</span>` : ''}
|
||||
${item.hasHandoffs ? `<span class="resource-tag tag-handoffs">handoffs</span>` : ''}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
list.innerHTML = renderAgentsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('.resource-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = target.closest('.resource-item') as HTMLElement | null;
|
||||
const path = item?.dataset.path;
|
||||
if (path) {
|
||||
openFileModal(path, 'agent');
|
||||
}
|
||||
});
|
||||
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
export async function initAgentsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
@@ -146,6 +118,8 @@ export async function initAgentsPage(): Promise<void> {
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<AgentsData>('agents.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
@@ -173,11 +147,14 @@ export async function initAgentsPage(): Promise<void> {
|
||||
|
||||
// Initialize sort select
|
||||
sortSelect?.addEventListener('change', () => {
|
||||
currentSort = sortSelect.value as SortOption;
|
||||
currentSort = sortSelect.value as AgentSortOption;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
const countEl = document.getElementById('results-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} agents`;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
|
||||
115
website/src/scripts/pages/hooks-render.ts
Normal file
115
website/src/scripts/pages/hooks-render.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getGitHubUrl,
|
||||
getLastUpdatedHtml,
|
||||
} from "../utils";
|
||||
|
||||
export interface RenderableHook {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
readmeFile: string;
|
||||
hooks: string[];
|
||||
tags: string[];
|
||||
assets: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export type HookSortOption = "title" | "lastUpdated";
|
||||
|
||||
export function sortHooks<T extends RenderableHook>(
|
||||
items: T[],
|
||||
sort: HookSortOption
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === "lastUpdated") {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderHooksHtml(
|
||||
items: RenderableHook[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No hooks found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<div class="resource-item" data-path="${escapeHtml(
|
||||
item.readmeFile
|
||||
)}" data-hook-id="${escapeHtml(item.id)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
<div class="resource-meta">
|
||||
${item.hooks
|
||||
.map(
|
||||
(hook) =>
|
||||
`<span class="resource-tag tag-hook">${escapeHtml(
|
||||
hook
|
||||
)}</span>`
|
||||
)
|
||||
.join("")}
|
||||
${item.tags
|
||||
.map(
|
||||
(tag) =>
|
||||
`<span class="resource-tag tag-tag">${escapeHtml(
|
||||
tag
|
||||
)}</span>`
|
||||
)
|
||||
.join("")}
|
||||
${
|
||||
item.assets.length > 0
|
||||
? `<span class="resource-tag tag-assets">${
|
||||
item.assets.length
|
||||
} asset${item.assets.length === 1 ? "" : "s"}</span>`
|
||||
: ""
|
||||
}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -6,24 +6,19 @@ import { FuzzySearch, type SearchItem } from "../search";
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
escapeHtml,
|
||||
getGitHubUrl,
|
||||
getRawGitHubUrl,
|
||||
showToast,
|
||||
getLastUpdatedHtml,
|
||||
loadJSZip,
|
||||
} from "../utils";
|
||||
import { setupModal, openFileModal } from "../modal";
|
||||
import JSZip from "../jszip";
|
||||
import {
|
||||
renderHooksHtml,
|
||||
sortHooks,
|
||||
type HookSortOption,
|
||||
type RenderableHook,
|
||||
} from "./hooks-render";
|
||||
|
||||
interface Hook extends SearchItem {
|
||||
id: string;
|
||||
path: string;
|
||||
readmeFile: string;
|
||||
hooks: string[];
|
||||
tags: string[];
|
||||
assets: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
interface Hook extends SearchItem, RenderableHook {}
|
||||
|
||||
interface HooksData {
|
||||
items: Hook[];
|
||||
@@ -33,8 +28,6 @@ interface HooksData {
|
||||
};
|
||||
}
|
||||
|
||||
type SortOption = "title" | "lastUpdated";
|
||||
|
||||
const resourceType = "hook";
|
||||
let allItems: Hook[] = [];
|
||||
let search = new FuzzySearch<Hook>();
|
||||
@@ -44,17 +37,11 @@ let currentFilters = {
|
||||
hooks: [] as string[],
|
||||
tags: [] as string[],
|
||||
};
|
||||
let currentSort: SortOption = "title";
|
||||
let currentSort: HookSortOption = "title";
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
function sortItems(items: Hook[]): Hook[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (currentSort === "lastUpdated") {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return sortHooks(items, currentSort);
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
@@ -104,84 +91,40 @@ function renderItems(items: Hook[], query = ""): void {
|
||||
const list = document.getElementById("resource-list");
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="empty-state"><h3>No hooks found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = renderHooksHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
}
|
||||
|
||||
list.innerHTML = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="resource-item" data-path="${escapeHtml(
|
||||
item.readmeFile
|
||||
)}" data-hook-id="${escapeHtml(item.id)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${
|
||||
query ? search.highlight(item.title, query) : escapeHtml(item.title)
|
||||
}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
<div class="resource-meta">
|
||||
${item.hooks
|
||||
.map(
|
||||
(h) =>
|
||||
`<span class="resource-tag tag-hook">${escapeHtml(h)}</span>`
|
||||
)
|
||||
.join("")}
|
||||
${item.tags
|
||||
.map(
|
||||
(t) =>
|
||||
`<span class="resource-tag tag-tag">${escapeHtml(t)}</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>
|
||||
<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>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
// Add click handlers for opening modal
|
||||
list.querySelectorAll(".resource-item").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".resource-actions")) return;
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Add download handlers
|
||||
list.querySelectorAll(".download-hook-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const hookId = (btn as HTMLElement).dataset.hookId;
|
||||
if (hookId) downloadHook(hookId, btn as HTMLButtonElement);
|
||||
});
|
||||
});
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
async function downloadHook(
|
||||
@@ -214,6 +157,7 @@ async function downloadHook(
|
||||
'<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 {
|
||||
const JSZip = await loadJSZip();
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(hook.id);
|
||||
|
||||
@@ -279,6 +223,8 @@ export async function initHooksPage(): Promise<void> {
|
||||
"sort-select"
|
||||
) as HTMLSelectElement;
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<HooksData>("hooks.json");
|
||||
if (!data || !data.items) {
|
||||
if (list)
|
||||
@@ -321,7 +267,7 @@ export async function initHooksPage(): Promise<void> {
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as SortOption;
|
||||
currentSort = sortSelect.value as HookSortOption;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
|
||||
86
website/src/scripts/pages/instructions-render.ts
Normal file
86
website/src/scripts/pages/instructions-render.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getActionButtonsHtml,
|
||||
getGitHubUrl,
|
||||
getInstallDropdownHtml,
|
||||
getLastUpdatedHtml,
|
||||
} from '../utils';
|
||||
|
||||
export interface RenderableInstruction {
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
applyTo?: string | string[] | null;
|
||||
extensions?: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export type InstructionSortOption = 'title' | 'lastUpdated';
|
||||
|
||||
export function sortInstructions<T extends RenderableInstruction>(
|
||||
items: T[],
|
||||
sort: InstructionSortOption
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === 'lastUpdated') {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderInstructionsHtml(
|
||||
items: RenderableInstruction[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = '', highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No instructions found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const applyToText = Array.isArray(item.applyTo)
|
||||
? item.applyTo.join(', ')
|
||||
: item.applyTo;
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${applyToText ? `<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>` : ''}
|
||||
${item.extensions?.slice(0, 4).map((extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`).join('') || ''}
|
||||
${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -3,12 +3,18 @@
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils';
|
||||
import { fetchData, debounce, setupDropdownCloseHandlers, setupActionHandlers } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
import {
|
||||
renderInstructionsHtml,
|
||||
sortInstructions,
|
||||
type InstructionSortOption,
|
||||
type RenderableInstruction,
|
||||
} from './instructions-render';
|
||||
|
||||
interface Instruction extends SearchItem {
|
||||
interface Instruction extends SearchItem, RenderableInstruction {
|
||||
path: string;
|
||||
applyTo?: string;
|
||||
applyTo?: string | string[];
|
||||
extensions?: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
@@ -20,24 +26,16 @@ interface InstructionsData {
|
||||
};
|
||||
}
|
||||
|
||||
type SortOption = 'title' | 'lastUpdated';
|
||||
|
||||
const resourceType = 'instruction';
|
||||
let allItems: Instruction[] = [];
|
||||
let search = new FuzzySearch<Instruction>();
|
||||
let extensionSelect: Choices;
|
||||
let currentFilters = { extensions: [] as string[] };
|
||||
let currentSort: SortOption = 'title';
|
||||
let currentSort: InstructionSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
function sortItems(items: Instruction[]): Instruction[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (currentSort === 'lastUpdated') {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return sortInstructions(items, currentSort);
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
@@ -70,48 +68,39 @@ function renderItems(items: Instruction[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state"><h3>No instructions found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${query ? search.highlight(item.title, query) : escapeHtml(item.title)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${item.applyTo ? `<span class="resource-tag">applies to: ${escapeHtml(item.applyTo)}</span>` : ''}
|
||||
${item.extensions?.slice(0, 4).map(e => `<span class="resource-tag tag-extension">${escapeHtml(e)}</span>`).join('') || ''}
|
||||
${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Add click handlers
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
list.innerHTML = renderInstructionsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
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;
|
||||
}
|
||||
|
||||
export async function initInstructionsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<InstructionsData>('instructions.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
@@ -129,11 +118,15 @@ export async function initInstructionsPage(): Promise<void> {
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener('change', () => {
|
||||
currentSort = sortSelect.value as SortOption;
|
||||
currentSort = sortSelect.value as InstructionSortOption;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
const countEl = document.getElementById('results-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} instructions`;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
|
||||
91
website/src/scripts/pages/plugins-render.ts
Normal file
91
website/src/scripts/pages/plugins-render.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils';
|
||||
|
||||
interface PluginAuthor {
|
||||
name: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface PluginSource {
|
||||
source: string;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface RenderablePlugin {
|
||||
name: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
tags?: string[];
|
||||
itemCount: number;
|
||||
external?: boolean;
|
||||
repository?: string | null;
|
||||
homepage?: string | null;
|
||||
author?: PluginAuthor | null;
|
||||
source?: PluginSource | null;
|
||||
}
|
||||
|
||||
function getExternalPluginUrl(plugin: RenderablePlugin): string {
|
||||
if (plugin.source?.source === 'github' && plugin.source.repo) {
|
||||
const base = `https://github.com/${plugin.source.repo}`;
|
||||
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
|
||||
}
|
||||
|
||||
return sanitizeUrl(plugin.repository || plugin.homepage);
|
||||
}
|
||||
|
||||
export function renderPluginsHtml(
|
||||
items: RenderablePlugin[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = '', highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No plugins found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const isExternal = item.external === true;
|
||||
const metaTag = isExternal
|
||||
? '<span class="resource-tag resource-tag-external">🔗 External</span>'
|
||||
: `<span class="resource-tag">${item.itemCount} items</span>`;
|
||||
const authorTag =
|
||||
isExternal && item.author?.name
|
||||
? `<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`
|
||||
: '';
|
||||
const githubHref = isExternal
|
||||
? escapeHtml(getExternalPluginUrl(item))
|
||||
: getGitHubUrl(item.path);
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.name, query)
|
||||
: escapeHtml(item.name);
|
||||
|
||||
return `
|
||||
<div class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${metaTag}
|
||||
${authorTag}
|
||||
${item.tags?.slice(0, 4).map((tag) => `<span class="resource-tag">${escapeHtml(tag)}</span>`).join('') || ''}
|
||||
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
import { createChoices, getChoicesValues, type Choices } from '../choices';
|
||||
import { FuzzySearch, type SearchItem } from '../search';
|
||||
import { fetchData, debounce, escapeHtml, getGitHubUrl, sanitizeUrl } from '../utils';
|
||||
import { fetchData, debounce } from '../utils';
|
||||
import { setupModal, openFileModal } from '../modal';
|
||||
import { renderPluginsHtml, type RenderablePlugin } from './plugins-render';
|
||||
|
||||
interface PluginAuthor {
|
||||
name: string;
|
||||
@@ -17,12 +18,11 @@ interface PluginSource {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface Plugin extends SearchItem {
|
||||
interface Plugin extends SearchItem, RenderablePlugin {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
tags?: string[];
|
||||
featured?: boolean;
|
||||
itemCount: number;
|
||||
external?: boolean;
|
||||
repository?: string | null;
|
||||
@@ -45,8 +45,8 @@ let search = new FuzzySearch<Plugin>();
|
||||
let tagSelect: Choices;
|
||||
let currentFilters = {
|
||||
tags: [] as string[],
|
||||
featured: false
|
||||
};
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
@@ -58,14 +58,10 @@ function applyFiltersAndRender(): void {
|
||||
if (currentFilters.tags.length > 0) {
|
||||
results = results.filter(item => item.tags?.some(tag => currentFilters.tags.includes(tag)));
|
||||
}
|
||||
if (currentFilters.featured) {
|
||||
results = results.filter(item => item.featured);
|
||||
}
|
||||
|
||||
renderItems(results, query);
|
||||
const activeFilters: string[] = [];
|
||||
if (currentFilters.tags.length > 0) activeFilters.push(`${currentFilters.tags.length} tag${currentFilters.tags.length > 1 ? 's' : ''}`);
|
||||
if (currentFilters.featured) activeFilters.push('featured');
|
||||
let countText = `${results.length} of ${allItems.length} plugins`;
|
||||
if (activeFilters.length > 0) {
|
||||
countText += ` (filtered by ${activeFilters.join(', ')})`;
|
||||
@@ -73,69 +69,42 @@ function applyFiltersAndRender(): void {
|
||||
if (countEl) countEl.textContent = countText;
|
||||
}
|
||||
|
||||
function getExternalPluginUrl(plugin: Plugin): string {
|
||||
if (plugin.source?.source === 'github' && plugin.source.repo) {
|
||||
const base = `https://github.com/${plugin.source.repo}`;
|
||||
return plugin.source.path ? `${base}/tree/main/${plugin.source.path}` : base;
|
||||
}
|
||||
// Sanitize URLs from JSON to prevent XSS via javascript:/data: schemes
|
||||
return sanitizeUrl(plugin.repository || plugin.homepage);
|
||||
}
|
||||
|
||||
function renderItems(items: Plugin[], query = ''): void {
|
||||
const list = document.getElementById('resource-list');
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state"><h3>No plugins found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items.map(item => {
|
||||
const isExternal = item.external === true;
|
||||
const metaTag = isExternal
|
||||
? `<span class="resource-tag resource-tag-external">🔗 External</span>`
|
||||
: `<span class="resource-tag">${item.itemCount} items</span>`;
|
||||
const authorTag = isExternal && item.author?.name
|
||||
? `<span class="resource-tag">by ${escapeHtml(item.author.name)}</span>`
|
||||
: '';
|
||||
const githubHref = isExternal
|
||||
? escapeHtml(getExternalPluginUrl(item))
|
||||
: getGitHubUrl(item.path);
|
||||
|
||||
return `
|
||||
<div class="resource-item${isExternal ? ' resource-item-external' : ''}" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${item.featured ? '⭐ ' : ''}${query ? search.highlight(item.name, query) : escapeHtml(item.name)}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${metaTag}
|
||||
${authorTag}
|
||||
${item.tags?.slice(0, 4).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
|
||||
${item.tags && item.tags.length > 4 ? `<span class="resource-tag">+${item.tags.length - 4} more</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers
|
||||
list.querySelectorAll('.resource-item').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
list.innerHTML = renderPluginsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) => search.highlight(title, highlightQuery),
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
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;
|
||||
}
|
||||
|
||||
export async function initPluginsPage(): Promise<void> {
|
||||
const list = document.getElementById('resource-list');
|
||||
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||
const featuredCheckbox = document.getElementById('filter-featured') as HTMLInputElement;
|
||||
const clearFiltersBtn = document.getElementById('clear-filters');
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<PluginsData>('plugins.json');
|
||||
if (!data || !data.items) {
|
||||
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
|
||||
@@ -159,18 +128,16 @@ export async function initPluginsPage(): Promise<void> {
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
const countEl = document.getElementById('results-count');
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} plugins`;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
|
||||
|
||||
featuredCheckbox?.addEventListener('change', () => {
|
||||
currentFilters.featured = featuredCheckbox.checked;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
clearFiltersBtn?.addEventListener('click', () => {
|
||||
currentFilters = { tags: [], featured: false };
|
||||
currentFilters = { tags: [] };
|
||||
tagSelect.removeActiveItems();
|
||||
if (featuredCheckbox) featuredCheckbox.checked = false;
|
||||
if (searchInput) searchInput.value = '';
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
251
website/src/scripts/pages/samples-render.ts
Normal file
251
website/src/scripts/pages/samples-render.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { escapeHtml, sanitizeUrl } from "../utils";
|
||||
|
||||
export interface Language {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
export interface RecipeVariant {
|
||||
doc: string;
|
||||
example: string | null;
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
languages: string[];
|
||||
variants: Record<string, RecipeVariant>;
|
||||
external?: boolean;
|
||||
url?: string | null;
|
||||
author?: { name: string; url?: string } | null;
|
||||
}
|
||||
|
||||
export interface Cookbook {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
featured: boolean;
|
||||
languages: Language[];
|
||||
recipes: Recipe[];
|
||||
}
|
||||
|
||||
export interface CookbookRecipeMatch {
|
||||
cookbook: Cookbook;
|
||||
recipe: Recipe;
|
||||
highlightedName?: string;
|
||||
}
|
||||
|
||||
export function getRecipeResultsCountText(
|
||||
filteredCount: number,
|
||||
totalCount: number
|
||||
): string {
|
||||
if (filteredCount === totalCount) {
|
||||
return `${totalCount} recipe${totalCount !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
return `${filteredCount} of ${totalCount} recipe${
|
||||
totalCount !== 1 ? "s" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function renderCookbookSectionsHtml(
|
||||
matches: CookbookRecipeMatch[],
|
||||
options: {
|
||||
selectedLanguage?: string | null;
|
||||
} = {}
|
||||
): string {
|
||||
if (matches.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No Results Found</h3>
|
||||
<p>Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { selectedLanguage = null } = options;
|
||||
const byCookbook = new Map<
|
||||
string,
|
||||
{ cookbook: Cookbook; recipes: { recipe: Recipe; highlightedName?: string }[] }
|
||||
>();
|
||||
|
||||
matches.forEach(({ cookbook, recipe, highlightedName }) => {
|
||||
if (!byCookbook.has(cookbook.id)) {
|
||||
byCookbook.set(cookbook.id, { cookbook, recipes: [] });
|
||||
}
|
||||
byCookbook.get(cookbook.id)?.recipes.push({ recipe, highlightedName });
|
||||
});
|
||||
|
||||
let html = "";
|
||||
byCookbook.forEach(({ cookbook, recipes }) => {
|
||||
html += renderCookbookSection(cookbook, recipes, selectedLanguage);
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderCookbookSection(
|
||||
cookbook: Cookbook,
|
||||
recipes: { recipe: Recipe; highlightedName?: string }[],
|
||||
selectedLanguage: string | null
|
||||
): string {
|
||||
const languageTabs = cookbook.languages
|
||||
.map(
|
||||
(language) => `
|
||||
<button class="lang-tab${selectedLanguage === language.id ? " active" : ""}"
|
||||
data-lang="${escapeHtml(language.id)}"
|
||||
title="${escapeHtml(language.name)}">
|
||||
${escapeHtml(language.icon)}
|
||||
</button>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const recipeCards = recipes
|
||||
.map(({ recipe, highlightedName }) =>
|
||||
renderRecipeCard(cookbook, recipe, selectedLanguage, highlightedName)
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="cookbook-section" data-cookbook="${escapeHtml(cookbook.id)}">
|
||||
<div class="cookbook-header">
|
||||
<div class="cookbook-info">
|
||||
<h2>${escapeHtml(cookbook.name)}</h2>
|
||||
<p>${escapeHtml(cookbook.description)}</p>
|
||||
</div>
|
||||
<div class="cookbook-languages">
|
||||
${languageTabs}
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipes-grid">
|
||||
${recipeCards}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecipeCard(
|
||||
cookbook: Cookbook,
|
||||
recipe: Recipe,
|
||||
selectedLanguage: string | null,
|
||||
highlightedName?: string
|
||||
): string {
|
||||
const recipeKey = `${cookbook.id}-${recipe.id}`;
|
||||
const tags = recipe.tags
|
||||
.map((tag) => `<span class="recipe-tag">${escapeHtml(tag)}</span>`)
|
||||
.join("");
|
||||
const titleHtml = highlightedName || escapeHtml(recipe.name);
|
||||
|
||||
if (recipe.external && recipe.url) {
|
||||
const authorHtml = recipe.author
|
||||
? `<span class="recipe-author">by ${
|
||||
recipe.author.url
|
||||
? `<a href="${sanitizeUrl(
|
||||
recipe.author.url
|
||||
)}" target="_blank" rel="noopener">${escapeHtml(
|
||||
recipe.author.name
|
||||
)}</a>`
|
||||
: escapeHtml(recipe.author.name)
|
||||
}</span>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="recipe-card external" data-recipe="${escapeHtml(recipeKey)}">
|
||||
<div class="recipe-header">
|
||||
<h3>${titleHtml}</h3>
|
||||
<span class="recipe-badge external-badge" title="External project">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>
|
||||
</svg>
|
||||
Community
|
||||
</span>
|
||||
</div>
|
||||
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
|
||||
${authorHtml ? `<div class="recipe-author-line">${authorHtml}</div>` : ""}
|
||||
<div class="recipe-tags">${tags}</div>
|
||||
<div class="recipe-actions">
|
||||
<a href="${sanitizeUrl(
|
||||
recipe.url
|
||||
)}" class="btn btn-primary btn-small" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const displayLanguage = selectedLanguage || cookbook.languages?.[0]?.id || "nodejs";
|
||||
const variant = recipe.variants[displayLanguage];
|
||||
const langIndicators = (cookbook.languages ?? [])
|
||||
.filter((language) => recipe.variants[language.id])
|
||||
.map(
|
||||
(language) =>
|
||||
`<span class="lang-indicator" title="${escapeHtml(language.name)}">${escapeHtml(
|
||||
language.icon
|
||||
)}</span>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="recipe-card" data-recipe="${escapeHtml(
|
||||
recipeKey
|
||||
)}" data-cookbook="${escapeHtml(cookbook.id)}" data-recipe-id="${escapeHtml(
|
||||
recipe.id
|
||||
)}">
|
||||
<div class="recipe-header">
|
||||
<h3>${titleHtml}</h3>
|
||||
<div class="recipe-langs">${langIndicators}</div>
|
||||
</div>
|
||||
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
|
||||
<div class="recipe-tags">${tags}</div>
|
||||
<div class="recipe-actions">
|
||||
${
|
||||
variant
|
||||
? `
|
||||
<button class="btn btn-secondary btn-small view-recipe-btn" data-doc="${escapeHtml(
|
||||
variant.doc
|
||||
)}">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75zM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5z"/>
|
||||
</svg>
|
||||
View Recipe
|
||||
</button>
|
||||
${
|
||||
variant.example
|
||||
? `
|
||||
<button class="btn btn-secondary btn-small view-example-btn" data-example="${escapeHtml(
|
||||
variant.example
|
||||
)}">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M4.72 3.22a.75.75 0 0 1 1.06 0l3.5 3.5a.75.75 0 0 1 0 1.06l-3.5 3.5a.75.75 0 0 1-1.06-1.06L7.69 7.5 4.72 4.28a.75.75 0 0 1 0-1.06zm6.25 1.06L10.22 5l.75.75-2.25 2.25 2.25 2.25-.75.75-.75-.72L11.97 7.5z"/>
|
||||
</svg>
|
||||
View Example
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<a href="https://github.com/github/awesome-copilot/blob/main/${escapeHtml(
|
||||
variant.doc
|
||||
)}"
|
||||
class="btn btn-secondary btn-small" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
`
|
||||
: '<span class="no-variant">Not available for selected language</span>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -3,44 +3,16 @@
|
||||
*/
|
||||
|
||||
import { FuzzySearch, type SearchableItem } from "../search";
|
||||
import { fetchData, escapeHtml } from "../utils";
|
||||
import { fetchData, debounce } from "../utils";
|
||||
import { createChoices, getChoicesValues, type Choices } from "../choices";
|
||||
import { setupModal } from "../modal";
|
||||
|
||||
// Types
|
||||
interface Language {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
interface RecipeVariant {
|
||||
doc: string;
|
||||
example: string | null;
|
||||
}
|
||||
|
||||
interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
languages: string[];
|
||||
variants: Record<string, RecipeVariant>;
|
||||
external?: boolean;
|
||||
url?: string | null;
|
||||
author?: { name: string; url?: string } | null;
|
||||
}
|
||||
|
||||
interface Cookbook {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
path: string;
|
||||
featured: boolean;
|
||||
languages: Language[];
|
||||
recipes: Recipe[];
|
||||
}
|
||||
import {
|
||||
getRecipeResultsCountText,
|
||||
renderCookbookSectionsHtml,
|
||||
type Cookbook,
|
||||
type CookbookRecipeMatch,
|
||||
type Language,
|
||||
} from "./samples-render";
|
||||
|
||||
interface SamplesData {
|
||||
cookbooks: Cookbook[];
|
||||
@@ -57,13 +29,16 @@ let samplesData: SamplesData | null = null;
|
||||
let search: FuzzySearch<SearchableItem> | null = null;
|
||||
let selectedLanguage: string | null = null;
|
||||
let selectedTags: string[] = [];
|
||||
let expandedRecipes: Set<string> = new Set();
|
||||
let tagChoices: Choices | null = null;
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the samples page
|
||||
*/
|
||||
export async function initSamplesPage(): Promise<void> {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
// Load samples data
|
||||
samplesData = await fetchData<SamplesData>("samples.json");
|
||||
@@ -90,7 +65,7 @@ export async function initSamplesPage(): Promise<void> {
|
||||
setupModal();
|
||||
setupFilters();
|
||||
setupSearch();
|
||||
renderCookbooks();
|
||||
setupRecipeListeners();
|
||||
updateResultsCount();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize samples page:", error);
|
||||
@@ -186,14 +161,13 @@ function setupSearch(): void {
|
||||
) as HTMLInputElement;
|
||||
if (!searchInput) return;
|
||||
|
||||
let debounceTimer: number;
|
||||
searchInput.addEventListener("input", () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => {
|
||||
searchInput.addEventListener(
|
||||
"input",
|
||||
debounce(() => {
|
||||
renderCookbooks();
|
||||
updateResultsCount();
|
||||
}, 200);
|
||||
});
|
||||
}, 200)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,11 +199,7 @@ function clearFilters(): void {
|
||||
/**
|
||||
* Get filtered recipes
|
||||
*/
|
||||
function getFilteredRecipes(): {
|
||||
cookbook: Cookbook;
|
||||
recipe: Recipe;
|
||||
highlighted?: string;
|
||||
}[] {
|
||||
function getFilteredRecipes(): CookbookRecipeMatch[] {
|
||||
if (!samplesData || !search) return [];
|
||||
|
||||
const searchInput = document.getElementById(
|
||||
@@ -237,8 +207,7 @@ function getFilteredRecipes(): {
|
||||
) as HTMLInputElement;
|
||||
const query = searchInput?.value.trim() || "";
|
||||
|
||||
let results: { cookbook: Cookbook; recipe: Recipe; highlighted?: string }[] =
|
||||
[];
|
||||
let results: CookbookRecipeMatch[] = [];
|
||||
|
||||
if (query) {
|
||||
// Use fuzzy search - returns SearchableItem[] directly
|
||||
@@ -250,8 +219,8 @@ function getFilteredRecipes(): {
|
||||
)!;
|
||||
return {
|
||||
cookbook,
|
||||
recipe: recipe as unknown as Recipe,
|
||||
highlighted: search!.highlight(recipe.title, query),
|
||||
recipe: recipe as unknown as CookbookRecipeMatch["recipe"],
|
||||
highlightedName: search!.highlight(recipe.title, query),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
@@ -285,204 +254,14 @@ function renderCookbooks(): void {
|
||||
const container = document.getElementById("samples-list");
|
||||
if (!container || !samplesData) return;
|
||||
|
||||
const filteredResults = getFilteredRecipes();
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No Results Found</h3>
|
||||
<p>Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by cookbook
|
||||
const byCookbook = new Map<
|
||||
string,
|
||||
{ cookbook: Cookbook; recipes: { recipe: Recipe; highlighted?: string }[] }
|
||||
>();
|
||||
filteredResults.forEach(({ cookbook, recipe, highlighted }) => {
|
||||
if (!byCookbook.has(cookbook.id)) {
|
||||
byCookbook.set(cookbook.id, { cookbook, recipes: [] });
|
||||
}
|
||||
byCookbook.get(cookbook.id)!.recipes.push({ recipe, highlighted });
|
||||
container.innerHTML = renderCookbookSectionsHtml(getFilteredRecipes(), {
|
||||
selectedLanguage,
|
||||
});
|
||||
|
||||
let html = "";
|
||||
byCookbook.forEach(({ cookbook, recipes }) => {
|
||||
html += renderCookbookSection(cookbook, recipes);
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Setup event listeners
|
||||
setupRecipeListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a cookbook section
|
||||
*/
|
||||
function renderCookbookSection(
|
||||
cookbook: Cookbook,
|
||||
recipes: { recipe: Recipe; highlighted?: string }[]
|
||||
): string {
|
||||
const languageTabs = cookbook.languages
|
||||
.map(
|
||||
(lang) => `
|
||||
<button class="lang-tab${selectedLanguage === lang.id ? " active" : ""}"
|
||||
data-lang="${lang.id}"
|
||||
title="${lang.name}">
|
||||
${lang.icon}
|
||||
</button>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const recipeCards = recipes
|
||||
.map(({ recipe, highlighted }) =>
|
||||
renderRecipeCard(cookbook, recipe, highlighted)
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="cookbook-section" data-cookbook="${cookbook.id}">
|
||||
<div class="cookbook-header">
|
||||
<div class="cookbook-info">
|
||||
<h2>${escapeHtml(cookbook.name)}</h2>
|
||||
<p>${escapeHtml(cookbook.description)}</p>
|
||||
</div>
|
||||
<div class="cookbook-languages">
|
||||
${languageTabs}
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipes-grid">
|
||||
${recipeCards}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a recipe card
|
||||
*/
|
||||
function renderRecipeCard(
|
||||
cookbook: Cookbook,
|
||||
recipe: Recipe,
|
||||
highlightedName?: string
|
||||
): string {
|
||||
const recipeKey = `${cookbook.id}-${recipe.id}`;
|
||||
const isExpanded = expandedRecipes.has(recipeKey);
|
||||
|
||||
const tags = recipe.tags
|
||||
.map((tag) => `<span class="recipe-tag">${escapeHtml(tag)}</span>`)
|
||||
.join("");
|
||||
|
||||
// External recipe — link to external URL
|
||||
if (recipe.external && recipe.url) {
|
||||
const authorHtml = recipe.author
|
||||
? `<span class="recipe-author">by ${
|
||||
recipe.author.url
|
||||
? `<a href="${escapeHtml(recipe.author.url)}" target="_blank" rel="noopener">${escapeHtml(recipe.author.name)}</a>`
|
||||
: escapeHtml(recipe.author.name)
|
||||
}</span>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<div class="recipe-card external${
|
||||
isExpanded ? " expanded" : ""
|
||||
}" data-recipe="${escapeHtml(recipeKey)}">
|
||||
<div class="recipe-header">
|
||||
<h3>${highlightedName || escapeHtml(recipe.name)}</h3>
|
||||
<span class="recipe-badge external-badge" title="External project">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z"/>
|
||||
</svg>
|
||||
Community
|
||||
</span>
|
||||
</div>
|
||||
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
|
||||
${authorHtml ? `<div class="recipe-author-line">${authorHtml}</div>` : ""}
|
||||
<div class="recipe-tags">${tags}</div>
|
||||
<div class="recipe-actions">
|
||||
<a href="${escapeHtml(recipe.url)}"
|
||||
class="btn btn-primary btn-small" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Local recipe — existing behavior
|
||||
// Determine which language to show
|
||||
const displayLang = selectedLanguage || cookbook.languages?.[0]?.id || "nodejs";
|
||||
const variant = recipe.variants[displayLang];
|
||||
|
||||
const langIndicators = (cookbook.languages ?? [])
|
||||
.filter((lang) => recipe.variants[lang.id])
|
||||
.map(
|
||||
(lang) =>
|
||||
`<span class="lang-indicator" title="${lang.name}">${lang.icon}</span>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="recipe-card${
|
||||
isExpanded ? " expanded" : ""
|
||||
}" data-recipe="${recipeKey}" data-cookbook="${
|
||||
cookbook.id
|
||||
}" data-recipe-id="${recipe.id}">
|
||||
<div class="recipe-header">
|
||||
<h3>${highlightedName || escapeHtml(recipe.name)}</h3>
|
||||
<div class="recipe-langs">${langIndicators}</div>
|
||||
</div>
|
||||
<p class="recipe-description">${escapeHtml(recipe.description)}</p>
|
||||
<div class="recipe-tags">${tags}</div>
|
||||
<div class="recipe-actions">
|
||||
${
|
||||
variant
|
||||
? `
|
||||
<button class="btn btn-secondary btn-small view-recipe-btn" data-doc="${
|
||||
variant.doc
|
||||
}">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M1 2.75A.75.75 0 0 1 1.75 2h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 2.75zm0 5A.75.75 0 0 1 1.75 7h12.5a.75.75 0 0 1 0 1.5H1.75A.75.75 0 0 1 1 7.75zM1.75 12h12.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5z"/>
|
||||
</svg>
|
||||
View Recipe
|
||||
</button>
|
||||
${
|
||||
variant.example
|
||||
? `
|
||||
<button class="btn btn-secondary btn-small view-example-btn" data-example="${variant.example}">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M4.72 3.22a.75.75 0 0 1 1.06 0l3.5 3.5a.75.75 0 0 1 0 1.06l-3.5 3.5a.75.75 0 0 1-1.06-1.06L7.69 7.5 4.72 4.28a.75.75 0 0 1 0-1.06zm6.25 1.06L10.22 5l.75.75-2.25 2.25 2.25 2.25-.75.75-.75-.72L11.97 7.5z"/>
|
||||
</svg>
|
||||
View Example
|
||||
</button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<a href="https://github.com/github/awesome-copilot/blob/main/${
|
||||
variant.doc
|
||||
}"
|
||||
class="btn btn-secondary btn-small" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
`
|
||||
: '<span class="no-variant">Not available for selected language</span>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for recipe interactions
|
||||
*/
|
||||
@@ -548,14 +327,7 @@ function updateResultsCount(): void {
|
||||
|
||||
const filtered = getFilteredRecipes();
|
||||
const total = samplesData.totalRecipes;
|
||||
|
||||
if (filtered.length === total) {
|
||||
resultsCount.textContent = `${total} recipe${total !== 1 ? "s" : ""}`;
|
||||
} else {
|
||||
resultsCount.textContent = `${filtered.length} of ${total} recipe${
|
||||
total !== 1 ? "s" : ""
|
||||
}`;
|
||||
}
|
||||
resultsCount.textContent = getRecipeResultsCountText(filtered.length, total);
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
|
||||
111
website/src/scripts/pages/skills-render.ts
Normal file
111
website/src/scripts/pages/skills-render.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getGitHubUrl,
|
||||
getLastUpdatedHtml,
|
||||
} from "../utils";
|
||||
|
||||
export interface RenderableSkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RenderableSkill {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
skillFile: string;
|
||||
category: string;
|
||||
hasAssets: boolean;
|
||||
assetCount: number;
|
||||
files: RenderableSkillFile[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export type SkillSortOption = "title" | "lastUpdated";
|
||||
|
||||
export function sortSkills<T extends RenderableSkill>(
|
||||
items: T[],
|
||||
sort: SkillSortOption
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === "lastUpdated") {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderSkillsHtml(
|
||||
items: RenderableSkill[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No skills found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<div class="resource-item" data-path="${escapeHtml(
|
||||
item.skillFile
|
||||
)}" data-skill-id="${escapeHtml(item.id)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
<div class="resource-meta">
|
||||
<span class="resource-tag tag-category">${escapeHtml(
|
||||
item.category
|
||||
)}</span>
|
||||
${
|
||||
item.hasAssets
|
||||
? `<span class="resource-tag tag-assets">${
|
||||
item.assetCount
|
||||
} asset${item.assetCount === 1 ? "" : "s"}</span>`
|
||||
: ""
|
||||
}
|
||||
<span class="resource-tag">${item.files.length} file${
|
||||
item.files.length === 1 ? "" : "s"
|
||||
}</span>
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -6,29 +6,25 @@ import { FuzzySearch, type SearchItem } from "../search";
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
escapeHtml,
|
||||
getGitHubUrl,
|
||||
getRawGitHubUrl,
|
||||
showToast,
|
||||
getLastUpdatedHtml,
|
||||
loadJSZip,
|
||||
} from "../utils";
|
||||
import { setupModal, openFileModal } from "../modal";
|
||||
import JSZip from "../jszip";
|
||||
import {
|
||||
renderSkillsHtml,
|
||||
sortSkills,
|
||||
type RenderableSkill,
|
||||
type SkillSortOption,
|
||||
} from "./skills-render";
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface Skill extends SearchItem {
|
||||
id: string;
|
||||
path: string;
|
||||
skillFile: string;
|
||||
category: string;
|
||||
hasAssets: boolean;
|
||||
assetCount: number;
|
||||
interface Skill extends SearchItem, Omit<RenderableSkill, "files"> {
|
||||
files: SkillFile[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
interface SkillsData {
|
||||
@@ -38,8 +34,6 @@ interface SkillsData {
|
||||
};
|
||||
}
|
||||
|
||||
type SortOption = 'title' | 'lastUpdated';
|
||||
|
||||
const resourceType = "skill";
|
||||
let allItems: Skill[] = [];
|
||||
let search = new FuzzySearch<Skill>();
|
||||
@@ -48,17 +42,11 @@ let currentFilters = {
|
||||
categories: [] as string[],
|
||||
hasAssets: false,
|
||||
};
|
||||
let currentSort: SortOption = 'title';
|
||||
let currentSort: SkillSortOption = 'title';
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
function sortItems(items: Skill[]): Skill[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (currentSort === 'lastUpdated') {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return sortSkills(items, currentSort);
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
@@ -101,79 +89,36 @@ function renderItems(items: Skill[], query = ""): void {
|
||||
const list = document.getElementById("resource-list");
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="empty-state"><h3>No skills found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = renderSkillsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
}
|
||||
|
||||
list.innerHTML = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="resource-item" data-path="${escapeHtml(
|
||||
item.skillFile
|
||||
)}" data-skill-id="${escapeHtml(item.id)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${
|
||||
query ? search.highlight(item.title, query) : escapeHtml(item.title)
|
||||
}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
<div class="resource-meta">
|
||||
<span class="resource-tag tag-category">${escapeHtml(
|
||||
item.category
|
||||
)}</span>
|
||||
${
|
||||
item.hasAssets
|
||||
? `<span class="resource-tag tag-assets">${
|
||||
item.assetCount
|
||||
} asset${item.assetCount === 1 ? "" : "s"}</span>`
|
||||
: ""
|
||||
}
|
||||
<span class="resource-tag">${item.files.length} file${
|
||||
item.files.length === 1 ? "" : "s"
|
||||
}</span>
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-actions">
|
||||
<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>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
// Add click handlers for opening modal
|
||||
list.querySelectorAll(".resource-item").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
// Don't trigger modal if clicking download button or github link
|
||||
if ((e.target as HTMLElement).closest(".resource-actions")) return;
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
list.addEventListener("click", (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const downloadButton = target.closest(
|
||||
".download-skill-btn"
|
||||
) as HTMLButtonElement | null;
|
||||
if (downloadButton) {
|
||||
event.stopPropagation();
|
||||
const skillId = downloadButton.dataset.skillId;
|
||||
if (skillId) downloadSkill(skillId, 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);
|
||||
});
|
||||
|
||||
// Add download handlers
|
||||
list.querySelectorAll(".download-skill-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const skillId = (btn as HTMLElement).dataset.skillId;
|
||||
if (skillId) downloadSkill(skillId, btn as HTMLButtonElement);
|
||||
});
|
||||
});
|
||||
resourceListHandlersReady = true;
|
||||
}
|
||||
|
||||
async function downloadSkill(
|
||||
@@ -192,6 +137,7 @@ async function downloadSkill(
|
||||
'<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 {
|
||||
const JSZip = await loadJSZip();
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(skill.id);
|
||||
|
||||
@@ -257,6 +203,8 @@ export async function initSkillsPage(): Promise<void> {
|
||||
const clearFiltersBtn = document.getElementById("clear-filters");
|
||||
const sortSelect = document.getElementById("sort-select") as HTMLSelectElement;
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<SkillsData>("skills.json");
|
||||
if (!data || !data.items) {
|
||||
if (list)
|
||||
@@ -283,7 +231,7 @@ export async function initSkillsPage(): Promise<void> {
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as SortOption;
|
||||
currentSort = sortSelect.value as SkillSortOption;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
|
||||
198
website/src/scripts/pages/tools-render.ts
Normal file
198
website/src/scripts/pages/tools-render.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { escapeHtml } from "../utils";
|
||||
|
||||
export interface RenderableTool {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
featured: boolean;
|
||||
requirements: string[];
|
||||
features: string[];
|
||||
links: {
|
||||
blog?: string;
|
||||
vscode?: string;
|
||||
"vscode-insiders"?: string;
|
||||
"visual-studio"?: string;
|
||||
github?: string;
|
||||
documentation?: string;
|
||||
marketplace?: string;
|
||||
npm?: string;
|
||||
pypi?: string;
|
||||
};
|
||||
configuration?: {
|
||||
type: string;
|
||||
content: string;
|
||||
} | null;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
function formatMultilineText(text: string): string {
|
||||
return escapeHtml(text).replace(/\r?\n/g, "<br>");
|
||||
}
|
||||
|
||||
function sanitizeToolUrl(url: string): string {
|
||||
try {
|
||||
const protocol = new URL(url).protocol;
|
||||
if (
|
||||
protocol === "http:" ||
|
||||
protocol === "https:" ||
|
||||
protocol === "vscode:" ||
|
||||
protocol === "vscode-insiders:"
|
||||
) {
|
||||
return escapeHtml(url);
|
||||
}
|
||||
} catch {
|
||||
return "#";
|
||||
}
|
||||
|
||||
return "#";
|
||||
}
|
||||
|
||||
function getToolActionLink(
|
||||
href: string | undefined,
|
||||
label: string,
|
||||
className: string
|
||||
): string {
|
||||
if (!href) return "";
|
||||
return `<a href="${sanitizeToolUrl(
|
||||
href
|
||||
)}" class="${className}" target="_blank" rel="noopener">${label}</a>`;
|
||||
}
|
||||
|
||||
export function renderToolsHtml(
|
||||
tools: RenderableTool[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = "", highlightTitle } = options;
|
||||
|
||||
if (tools.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No tools found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return tools
|
||||
.map((tool) => {
|
||||
const badges: string[] = [];
|
||||
if (tool.featured) {
|
||||
badges.push('<span class="tool-badge featured">Featured</span>');
|
||||
}
|
||||
badges.push(
|
||||
`<span class="tool-badge category">${escapeHtml(tool.category)}</span>`
|
||||
);
|
||||
|
||||
const features =
|
||||
tool.features && tool.features.length > 0
|
||||
? `<div class="tool-section">
|
||||
<h3>Features</h3>
|
||||
<ul>${tool.features
|
||||
.map((feature) => `<li>${escapeHtml(feature)}</li>`)
|
||||
.join("")}</ul>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const requirements =
|
||||
tool.requirements && tool.requirements.length > 0
|
||||
? `<div class="tool-section">
|
||||
<h3>Requirements</h3>
|
||||
<ul>${tool.requirements
|
||||
.map((requirement) => `<li>${escapeHtml(requirement)}</li>`)
|
||||
.join("")}</ul>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const tags =
|
||||
tool.tags && tool.tags.length > 0
|
||||
? `<div class="tool-tags">
|
||||
${tool.tags
|
||||
.map((tag) => `<span class="tool-tag">${escapeHtml(tag)}</span>`)
|
||||
.join("")}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const config = tool.configuration
|
||||
? `<div class="tool-config">
|
||||
<h3>Configuration</h3>
|
||||
<div class="tool-config-wrapper">
|
||||
<pre><code>${escapeHtml(tool.configuration.content)}</code></pre>
|
||||
</div>
|
||||
<button class="copy-config-btn" data-config="${encodeURIComponent(
|
||||
tool.configuration.content
|
||||
)}">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<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 Configuration
|
||||
</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const actions = [
|
||||
getToolActionLink(tool.links.blog, "📖 Blog", "btn btn-secondary"),
|
||||
getToolActionLink(
|
||||
tool.links.marketplace,
|
||||
"🏪 Marketplace",
|
||||
"btn btn-secondary"
|
||||
),
|
||||
getToolActionLink(tool.links.npm, "📦 npm", "btn btn-secondary"),
|
||||
getToolActionLink(tool.links.pypi, "🐍 PyPI", "btn btn-secondary"),
|
||||
getToolActionLink(
|
||||
tool.links.documentation,
|
||||
"📚 Docs",
|
||||
"btn btn-secondary"
|
||||
),
|
||||
getToolActionLink(tool.links.github, "GitHub", "btn btn-secondary"),
|
||||
getToolActionLink(
|
||||
tool.links.vscode,
|
||||
"Install in VS Code",
|
||||
"btn btn-primary"
|
||||
),
|
||||
getToolActionLink(
|
||||
tool.links["vscode-insiders"],
|
||||
"VS Code Insiders",
|
||||
"btn btn-outline"
|
||||
),
|
||||
getToolActionLink(
|
||||
tool.links["visual-studio"],
|
||||
"Visual Studio",
|
||||
"btn btn-outline"
|
||||
),
|
||||
].filter(Boolean);
|
||||
|
||||
const actionsHtml =
|
||||
actions.length > 0
|
||||
? `<div class="tool-actions">${actions.join("")}</div>`
|
||||
: "";
|
||||
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(tool.name, query)
|
||||
: escapeHtml(tool.name);
|
||||
|
||||
return `
|
||||
<div class="tool-card">
|
||||
<div class="tool-header">
|
||||
<h2>${titleHtml}</h2>
|
||||
<div class="tool-badges">
|
||||
${badges.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<p class="tool-description">${formatMultilineText(tool.description)}</p>
|
||||
${features}
|
||||
${requirements}
|
||||
${config}
|
||||
${tags}
|
||||
${actionsHtml}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* Tools page functionality
|
||||
*/
|
||||
import { FuzzySearch, type SearchableItem } from "../search";
|
||||
import { fetchData, debounce, escapeHtml } from "../utils";
|
||||
import { fetchData, debounce } from "../utils";
|
||||
import { renderToolsHtml } from "./tools-render";
|
||||
|
||||
export interface Tool extends SearchableItem {
|
||||
id: string;
|
||||
@@ -40,15 +41,13 @@ interface ToolsData {
|
||||
}
|
||||
|
||||
let allItems: Tool[] = [];
|
||||
let search: FuzzySearch<Tool>;
|
||||
let search = new FuzzySearch<Tool>();
|
||||
let currentFilters = {
|
||||
categories: [] as string[],
|
||||
query: "",
|
||||
};
|
||||
|
||||
function formatMultilineText(text: string): string {
|
||||
return escapeHtml(text).replace(/\r?\n/g, "<br>");
|
||||
}
|
||||
let copyHandlersReady = false;
|
||||
let initialized = false;
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
const searchInput = document.getElementById(
|
||||
@@ -78,182 +77,50 @@ function applyFiltersAndRender(): void {
|
||||
function renderTools(tools: Tool[], query = ""): void {
|
||||
const container = document.getElementById("tools-list");
|
||||
if (!container) return;
|
||||
|
||||
if (tools.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No tools found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tools
|
||||
.map((tool) => {
|
||||
const badges: string[] = [];
|
||||
if (tool.featured) {
|
||||
badges.push('<span class="tool-badge featured">Featured</span>');
|
||||
}
|
||||
badges.push(
|
||||
`<span class="tool-badge category">${escapeHtml(tool.category)}</span>`
|
||||
);
|
||||
|
||||
const features =
|
||||
tool.features && tool.features.length > 0
|
||||
? `<div class="tool-section">
|
||||
<h3>Features</h3>
|
||||
<ul>${tool.features
|
||||
.map((f) => `<li>${escapeHtml(f)}</li>`)
|
||||
.join("")}</ul>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const requirements =
|
||||
tool.requirements && tool.requirements.length > 0
|
||||
? `<div class="tool-section">
|
||||
<h3>Requirements</h3>
|
||||
<ul>${tool.requirements
|
||||
.map((r) => `<li>${escapeHtml(r)}</li>`)
|
||||
.join("")}</ul>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const tags =
|
||||
tool.tags && tool.tags.length > 0
|
||||
? `<div class="tool-tags">
|
||||
${tool.tags
|
||||
.map((t) => `<span class="tool-tag">${escapeHtml(t)}</span>`)
|
||||
.join("")}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const config = tool.configuration
|
||||
? `<div class="tool-config">
|
||||
<h3>Configuration</h3>
|
||||
<div class="tool-config-wrapper">
|
||||
<pre><code>${escapeHtml(tool.configuration.content)}</code></pre>
|
||||
</div>
|
||||
<button class="copy-config-btn" data-config="${encodeURIComponent(
|
||||
tool.configuration.content
|
||||
)}">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||
<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 Configuration
|
||||
</button>
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
const actions: string[] = [];
|
||||
if (tool.links.blog) {
|
||||
actions.push(
|
||||
`<a href="${tool.links.blog}" class="btn btn-secondary" target="_blank" rel="noopener">📖 Blog</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links.marketplace) {
|
||||
actions.push(
|
||||
`<a href="${tool.links.marketplace}" class="btn btn-secondary" target="_blank" rel="noopener">🏪 Marketplace</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links.npm) {
|
||||
actions.push(
|
||||
`<a href="${tool.links.npm}" class="btn btn-secondary" target="_blank" rel="noopener">📦 npm</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links.pypi) {
|
||||
actions.push(
|
||||
`<a href="${tool.links.pypi}" class="btn btn-secondary" target="_blank" rel="noopener">🐍 PyPI</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links.documentation) {
|
||||
actions.push(
|
||||
`<a href="${tool.links.documentation}" class="btn btn-secondary" target="_blank" rel="noopener">📚 Docs</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links.github) {
|
||||
actions.push(
|
||||
`<a href="${tool.links.github}" class="btn btn-secondary" target="_blank" rel="noopener">GitHub</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links.vscode) {
|
||||
actions.push(
|
||||
`<a href="${tool.links.vscode}" class="btn btn-primary" target="_blank" rel="noopener">Install in VS Code</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links["vscode-insiders"]) {
|
||||
actions.push(
|
||||
`<a href="${tool.links["vscode-insiders"]}" class="btn btn-outline" target="_blank" rel="noopener">VS Code Insiders</a>`
|
||||
);
|
||||
}
|
||||
if (tool.links["visual-studio"]) {
|
||||
actions.push(
|
||||
`<a href="${tool.links["visual-studio"]}" class="btn btn-outline" target="_blank" rel="noopener">Visual Studio</a>`
|
||||
);
|
||||
}
|
||||
|
||||
const actionsHtml =
|
||||
actions.length > 0
|
||||
? `<div class="tool-actions">${actions.join("")}</div>`
|
||||
: "";
|
||||
|
||||
const titleHtml = query
|
||||
? search.highlight(tool.name, query)
|
||||
: escapeHtml(tool.name);
|
||||
const descriptionHtml = formatMultilineText(tool.description);
|
||||
|
||||
return `
|
||||
<div class="tool-card">
|
||||
<div class="tool-header">
|
||||
<h2>${titleHtml}</h2>
|
||||
<div class="tool-badges">
|
||||
${badges.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<p class="tool-description">${descriptionHtml}</p>
|
||||
${features}
|
||||
${requirements}
|
||||
${config}
|
||||
${tags}
|
||||
${actionsHtml}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
setupCopyConfigHandlers();
|
||||
}
|
||||
|
||||
function setupCopyConfigHandlers(): void {
|
||||
document.querySelectorAll(".copy-config-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const button = e.currentTarget as HTMLButtonElement;
|
||||
const config = decodeURIComponent(button.dataset.config || "");
|
||||
try {
|
||||
await navigator.clipboard.writeText(config);
|
||||
button.classList.add("copied");
|
||||
const originalHtml = button.innerHTML;
|
||||
button.innerHTML = `
|
||||
<svg width="14" height="14" viewBox="0 0 16 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(() => {
|
||||
button.classList.remove("copied");
|
||||
button.innerHTML = originalHtml;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
});
|
||||
container.innerHTML = renderToolsHtml(tools, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
}
|
||||
|
||||
function setupCopyConfigHandlers(): void {
|
||||
if (copyHandlersReady) return;
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const button = (event.target as HTMLElement).closest(
|
||||
".copy-config-btn"
|
||||
) as HTMLButtonElement | null;
|
||||
if (!button) return;
|
||||
|
||||
event.stopPropagation();
|
||||
const config = decodeURIComponent(button.dataset.config || "");
|
||||
try {
|
||||
await navigator.clipboard.writeText(config);
|
||||
button.classList.add("copied");
|
||||
const originalHtml = button.innerHTML;
|
||||
button.innerHTML = `
|
||||
<svg width="14" height="14" viewBox="0 0 16 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(() => {
|
||||
button.classList.remove("copied");
|
||||
button.innerHTML = originalHtml;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err);
|
||||
}
|
||||
});
|
||||
|
||||
copyHandlersReady = true;
|
||||
}
|
||||
|
||||
export async function initToolsPage(): Promise<void> {
|
||||
const container = document.getElementById("tools-list");
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
@@ -262,12 +129,9 @@ export async function initToolsPage(): Promise<void> {
|
||||
) as HTMLSelectElement;
|
||||
const clearFiltersBtn = document.getElementById("clear-filters");
|
||||
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="loading">Loading tools...</div>';
|
||||
}
|
||||
|
||||
const data = await fetchData<ToolsData>("tools.json");
|
||||
if (!data || !data.items) {
|
||||
const container = document.getElementById("tools-list");
|
||||
if (container)
|
||||
container.innerHTML =
|
||||
'<div class="empty-state"><h3>Failed to load tools</h3></div>';
|
||||
@@ -289,7 +153,7 @@ export async function initToolsPage(): Promise<void> {
|
||||
'<option value="">All Categories</option>' +
|
||||
data.filters.categories
|
||||
.map(
|
||||
(c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
|
||||
(c) => `<option value="${c}">${c}</option>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
@@ -315,7 +179,7 @@ export async function initToolsPage(): Promise<void> {
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
setupCopyConfigHandlers();
|
||||
}
|
||||
|
||||
// Auto-initialize when DOM is ready
|
||||
|
||||
76
website/src/scripts/pages/workflows-render.ts
Normal file
76
website/src/scripts/pages/workflows-render.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
escapeHtml,
|
||||
getActionButtonsHtml,
|
||||
getGitHubUrl,
|
||||
getLastUpdatedHtml,
|
||||
} from '../utils';
|
||||
|
||||
export interface RenderableWorkflow {
|
||||
title: string;
|
||||
description?: string;
|
||||
path: string;
|
||||
triggers: string[];
|
||||
lastUpdated?: string | null;
|
||||
}
|
||||
|
||||
export type WorkflowSortOption = 'title' | 'lastUpdated';
|
||||
|
||||
export function sortWorkflows<T extends RenderableWorkflow>(
|
||||
items: T[],
|
||||
sort: WorkflowSortOption
|
||||
): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sort === 'lastUpdated') {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderWorkflowsHtml(
|
||||
items: RenderableWorkflow[],
|
||||
options: {
|
||||
query?: string;
|
||||
highlightTitle?: (title: string, query: string) => string;
|
||||
} = {}
|
||||
): string {
|
||||
const { query = '', highlightTitle } = options;
|
||||
|
||||
if (items.length === 0) {
|
||||
return `
|
||||
<div class="empty-state">
|
||||
<h3>No workflows found</h3>
|
||||
<p>Try a different search term or adjust filters</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
const titleHtml =
|
||||
query && highlightTitle
|
||||
? highlightTitle(item.title, query)
|
||||
: escapeHtml(item.title);
|
||||
|
||||
return `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${titleHtml}</div>
|
||||
<div class="resource-description">${escapeHtml(item.description || 'No description')}</div>
|
||||
<div class="resource-meta">
|
||||
${item.triggers.map((trigger) => `<span class="resource-tag tag-trigger">${escapeHtml(trigger)}</span>`).join('')}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -6,15 +6,17 @@ import { FuzzySearch, type SearchItem } from "../search";
|
||||
import {
|
||||
fetchData,
|
||||
debounce,
|
||||
escapeHtml,
|
||||
getGitHubUrl,
|
||||
getActionButtonsHtml,
|
||||
setupActionHandlers,
|
||||
getLastUpdatedHtml,
|
||||
} from "../utils";
|
||||
import { setupModal, openFileModal } from "../modal";
|
||||
import {
|
||||
renderWorkflowsHtml,
|
||||
sortWorkflows,
|
||||
type RenderableWorkflow,
|
||||
type WorkflowSortOption,
|
||||
} from "./workflows-render";
|
||||
|
||||
interface Workflow extends SearchItem {
|
||||
interface Workflow extends SearchItem, RenderableWorkflow {
|
||||
id: string;
|
||||
path: string;
|
||||
triggers: string[];
|
||||
@@ -28,8 +30,6 @@ interface WorkflowsData {
|
||||
};
|
||||
}
|
||||
|
||||
type SortOption = "title" | "lastUpdated";
|
||||
|
||||
const resourceType = "workflow";
|
||||
let allItems: Workflow[] = [];
|
||||
let search = new FuzzySearch<Workflow>();
|
||||
@@ -37,17 +37,11 @@ let triggerSelect: Choices;
|
||||
let currentFilters = {
|
||||
triggers: [] as string[],
|
||||
};
|
||||
let currentSort: SortOption = "title";
|
||||
let currentSort: WorkflowSortOption = "title";
|
||||
let resourceListHandlersReady = false;
|
||||
|
||||
function sortItems(items: Workflow[]): Workflow[] {
|
||||
return [...items].sort((a, b) => {
|
||||
if (currentSort === "lastUpdated") {
|
||||
const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0;
|
||||
const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
return sortWorkflows(items, currentSort);
|
||||
}
|
||||
|
||||
function applyFiltersAndRender(): void {
|
||||
@@ -86,54 +80,32 @@ function renderItems(items: Workflow[], query = ""): void {
|
||||
const list = document.getElementById("resource-list");
|
||||
if (!list) return;
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML =
|
||||
'<div class="empty-state"><h3>No workflows found</h3><p>Try a different search term or adjust filters</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = items
|
||||
.map(
|
||||
(item) => `
|
||||
<div class="resource-item" data-path="${escapeHtml(item.path)}">
|
||||
<div class="resource-info">
|
||||
<div class="resource-title">${
|
||||
query ? search.highlight(item.title, query) : escapeHtml(item.title)
|
||||
}</div>
|
||||
<div class="resource-description">${escapeHtml(
|
||||
item.description || "No description"
|
||||
)}</div>
|
||||
<div class="resource-meta">
|
||||
${item.triggers
|
||||
.map(
|
||||
(t) =>
|
||||
`<span class="resource-tag tag-trigger">${escapeHtml(t)}</span>`
|
||||
)
|
||||
.join("")}
|
||||
${getLastUpdatedHtml(item.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
// Add click handlers for opening modal
|
||||
list.querySelectorAll(".resource-item").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
if ((e.target as HTMLElement).closest(".resource-actions")) return;
|
||||
const path = (el as HTMLElement).dataset.path;
|
||||
if (path) openFileModal(path, resourceType);
|
||||
});
|
||||
list.innerHTML = renderWorkflowsHtml(items, {
|
||||
query,
|
||||
highlightTitle: (title, highlightQuery) =>
|
||||
search.highlight(title, highlightQuery),
|
||||
});
|
||||
}
|
||||
|
||||
function setupResourceListHandlers(list: HTMLElement | null): void {
|
||||
if (!list || resourceListHandlersReady) return;
|
||||
|
||||
list.addEventListener("click", (event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
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;
|
||||
}
|
||||
|
||||
export async function initWorkflowsPage(): Promise<void> {
|
||||
const list = document.getElementById("resource-list");
|
||||
const searchInput = document.getElementById(
|
||||
@@ -144,6 +116,8 @@ export async function initWorkflowsPage(): Promise<void> {
|
||||
"sort-select"
|
||||
) as HTMLSelectElement;
|
||||
|
||||
setupResourceListHandlers(list as HTMLElement | null);
|
||||
|
||||
const data = await fetchData<WorkflowsData>("workflows.json");
|
||||
if (!data || !data.items) {
|
||||
if (list)
|
||||
@@ -171,11 +145,15 @@ export async function initWorkflowsPage(): Promise<void> {
|
||||
});
|
||||
|
||||
sortSelect?.addEventListener("change", () => {
|
||||
currentSort = sortSelect.value as SortOption;
|
||||
currentSort = sortSelect.value as WorkflowSortOption;
|
||||
applyFiltersAndRender();
|
||||
});
|
||||
|
||||
applyFiltersAndRender();
|
||||
const countEl = document.getElementById("results-count");
|
||||
if (countEl) {
|
||||
countEl.textContent = `${allItems.length} of ${allItems.length} workflows`;
|
||||
}
|
||||
|
||||
searchInput?.addEventListener(
|
||||
"input",
|
||||
debounce(() => applyFiltersAndRender(), 200)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getEmbeddedData as getEmbeddedPageData } from "./embedded-data";
|
||||
|
||||
/**
|
||||
* Utility functions for the Awesome Copilot website
|
||||
*/
|
||||
@@ -43,6 +45,9 @@ export function getBasePath(): string {
|
||||
export async function fetchData<T = unknown>(
|
||||
filename: string
|
||||
): Promise<T | null> {
|
||||
const embeddedData = getEmbeddedPageData<T>(filename);
|
||||
if (embeddedData !== null) return embeddedData;
|
||||
|
||||
try {
|
||||
const basePath = getBasePath();
|
||||
const response = await fetch(`${basePath}data/${filename}`);
|
||||
@@ -54,6 +59,17 @@ export async function fetchData<T = unknown>(
|
||||
}
|
||||
}
|
||||
|
||||
let jsZipPromise: Promise<typeof import("./jszip")> | null = null;
|
||||
|
||||
/**
|
||||
* Lazy-load JSZip only when downloads are requested
|
||||
*/
|
||||
export async function loadJSZip() {
|
||||
jsZipPromise ??= import("./jszip");
|
||||
const { default: JSZip } = await jsZipPromise;
|
||||
return JSZip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw file content from GitHub
|
||||
*/
|
||||
@@ -209,9 +225,12 @@ export function debounce<T extends (...args: unknown[]) => void>(
|
||||
* Escape HTML to prevent XSS
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -246,10 +265,8 @@ export function truncate(text: string | undefined, maxLength: number): string {
|
||||
export function getResourceType(filePath: string): string {
|
||||
if (filePath.endsWith(".agent.md")) return "agent";
|
||||
if (filePath.endsWith(".instructions.md")) return "instruction";
|
||||
if (/(^|\/)skills\//.test(filePath) && filePath.endsWith("SKILL.md"))
|
||||
return "skill";
|
||||
if (/(^|\/)hooks\//.test(filePath) && filePath.endsWith("README.md"))
|
||||
return "hook";
|
||||
if (/(^|\/)skills\//.test(filePath)) return "skill";
|
||||
if (/(^|\/)hooks\//.test(filePath)) return "hook";
|
||||
if (/(^|\/)workflows\//.test(filePath) && filePath.endsWith(".md"))
|
||||
return "workflow";
|
||||
// Check for plugin directories (e.g., plugins/<id>, plugins/<id>/)
|
||||
|
||||
Reference in New Issue
Block a user