mirror of
https://github.com/github/awesome-copilot.git
synced 2026-03-13 04:35: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:
19
website/src/components/EmbeddedPageData.astro
Normal file
19
website/src/components/EmbeddedPageData.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import {
|
||||
getEmbeddedDataElementId,
|
||||
serializeEmbeddedData,
|
||||
} from "../scripts/embedded-data";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const { filename, data } = Astro.props;
|
||||
---
|
||||
|
||||
<script
|
||||
id={getEmbeddedDataElementId(filename)}
|
||||
type="application/json"
|
||||
set:html={serializeEmbeddedData(data)}
|
||||
></script>
|
||||
@@ -2,57 +2,220 @@
|
||||
// Modal component for viewing file contents
|
||||
---
|
||||
|
||||
<div id="file-modal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<div
|
||||
id="file-modal"
|
||||
class="modal hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-title">File</h3>
|
||||
<div class="modal-actions">
|
||||
<button id="copy-btn" class="btn btn-secondary" aria-label="Copy to clipboard">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/>
|
||||
<path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/>
|
||||
</svg>
|
||||
<span aria-hidden="true">Copy</span>
|
||||
</button>
|
||||
<button id="download-btn" class="btn btn-secondary" aria-label="Download file">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06l3.75 3.75ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z"/>
|
||||
</svg>
|
||||
<span aria-hidden="true">Download</span>
|
||||
</button>
|
||||
<button id="share-btn" class="btn btn-secondary" aria-label="Copy link to clipboard">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25zm-.025 5.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25z"/>
|
||||
</svg>
|
||||
<span aria-hidden="true">Share</span>
|
||||
</button>
|
||||
<div id="install-dropdown" class="install-dropdown" style="display: none;">
|
||||
<a id="install-btn-main" class="btn btn-primary install-btn-main" target="_blank" rel="noopener">
|
||||
Install
|
||||
</a>
|
||||
<button type="button" class="btn btn-primary install-btn-toggle" aria-label="Install options" aria-expanded="false" aria-haspopup="true">
|
||||
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor" aria-hidden="true">
|
||||
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
|
||||
<div class="modal-header-top">
|
||||
<h3 id="modal-title">File</h3>
|
||||
<div class="modal-actions">
|
||||
<div id="modal-file-switcher" class="modal-file-switcher hidden">
|
||||
<span class="modal-file-switcher-label">File</span>
|
||||
<div id="modal-file-dropdown" class="modal-file-dropdown">
|
||||
<button
|
||||
type="button"
|
||||
id="modal-file-button"
|
||||
class="btn btn-secondary modal-file-button"
|
||||
aria-label="Select file to preview"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span id="modal-file-button-label">SKILL.md</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="modal-file-toggle"
|
||||
class="btn btn-secondary modal-file-toggle"
|
||||
aria-label="Open file menu"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="modal-file-menu" class="modal-file-menu" role="menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="raw-btn"
|
||||
class="btn btn-secondary hidden"
|
||||
aria-label="View raw file"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2.3 3.2a.75.75 0 0 1 1.06 0L8 7.94l4.64-4.72a.75.75 0 1 1 1.06 1.06L9.06 8l4.64 4.72a.75.75 0 1 1-1.06 1.06L8 8.06l-4.64 4.72a.75.75 0 1 1-1.06-1.06L6.94 8 2.3 3.28a.75.75 0 0 1 0-1.06z"
|
||||
></path>
|
||||
</svg>
|
||||
<span aria-hidden="true">Raw</span>
|
||||
</button>
|
||||
<button
|
||||
id="render-btn"
|
||||
class="btn btn-secondary"
|
||||
aria-label="Render file"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M2 2h12v1H2V2zm0 3h12v1H2V5zm0 3h12v1H2V8zm0 3h12v1H2v-1z"
|
||||
></path>
|
||||
</svg>
|
||||
<span aria-hidden="true">Render</span>
|
||||
</button>
|
||||
<button
|
||||
id="copy-btn"
|
||||
class="btn btn-secondary"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"
|
||||
></path>
|
||||
<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"
|
||||
></path>
|
||||
</svg>
|
||||
<span aria-hidden="true">Copy</span>
|
||||
</button>
|
||||
<button
|
||||
id="download-btn"
|
||||
class="btn btn-secondary"
|
||||
aria-label="Download file"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7.47 10.78a.75.75 0 0 0 1.06 0l3.75-3.75a.75.75 0 0 0-1.06-1.06L8.75 8.44V1.75a.75.75 0 0 0-1.5 0v6.69L4.78 5.97a.75.75 0 0 0-1.06 1.06l3.75 3.75ZM3.75 13a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-8.5Z"
|
||||
></path>
|
||||
</svg>
|
||||
<span aria-hidden="true">Download</span>
|
||||
</button>
|
||||
<button
|
||||
id="share-btn"
|
||||
class="btn btn-secondary"
|
||||
aria-label="Copy link to clipboard"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M7.775 3.275a.75.75 0 0 0 1.06 1.06l1.25-1.25a2 2 0 1 1 2.83 2.83l-2.5 2.5a2 2 0 0 1-2.83 0 .75.75 0 0 0-1.06 1.06 3.5 3.5 0 0 0 4.95 0l2.5-2.5a3.5 3.5 0 0 0-4.95-4.95l-1.25 1.25zm-.025 5.45a.75.75 0 0 0-1.06-1.06l-1.25 1.25a2 2 0 1 1-2.83-2.83l2.5-2.5a2 2 0 0 1 2.83 0 .75.75 0 0 0 1.06-1.06 3.5 3.5 0 0 0-4.95 0l-2.5 2.5a3.5 3.5 0 0 0 4.95 4.95l1.25-1.25z"
|
||||
></path>
|
||||
</svg>
|
||||
<span aria-hidden="true">Share</span>
|
||||
</button>
|
||||
<div
|
||||
id="install-dropdown"
|
||||
class="install-dropdown"
|
||||
style="display: none;"
|
||||
>
|
||||
<a
|
||||
id="install-btn-main"
|
||||
class="btn btn-primary install-btn-main"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Install
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary install-btn-toggle"
|
||||
aria-label="Install options"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="install-dropdown-menu" role="menu">
|
||||
<a
|
||||
id="install-vscode"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
role="menuitem"
|
||||
>
|
||||
VS Code
|
||||
</a>
|
||||
<a
|
||||
id="install-insiders"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
role="menuitem"
|
||||
>
|
||||
VS Code Insiders
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="close-modal"
|
||||
class="btn btn-icon"
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="install-dropdown-menu" role="menu">
|
||||
<a id="install-vscode" target="_blank" rel="noopener" role="menuitem">
|
||||
VS Code
|
||||
</a>
|
||||
<a id="install-insiders" target="_blank" rel="noopener" role="menuitem">
|
||||
VS Code Insiders
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button id="close-modal" class="btn btn-icon" aria-label="Close dialog">
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" fill="currentColor" aria-hidden="true">
|
||||
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="modal-content"><code></code></pre>
|
||||
<pre id="modal-content"><code /></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import agentsData from '../../public/data/agents.json';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import { renderAgentsHtml, sortAgents } from '../scripts/pages/agents-render';
|
||||
|
||||
const initialItems = sortAgents(agentsData.items, 'title');
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Custom Agents', description: 'Specialized agents that enhance GitHub Copilot for specific technologies, workflows, and domains', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
@@ -11,47 +16,48 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
<div class="page-content">
|
||||
<div class="container">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search agents</label>
|
||||
<input type="text" id="search-input" placeholder="Search agents..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-model">Model:</label>
|
||||
<select id="filter-model" multiple aria-label="Filter by model"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-tool">Tool:</label>
|
||||
<select id="filter-tool" multiple aria-label="Filter by tool"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-handoffs">
|
||||
Has Handoffs
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite"></div>
|
||||
<div class="resource-list" id="resource-list" role="list">
|
||||
<div class="loading" aria-live="polite">Loading agents...</div>
|
||||
<div class="listing-toolbar">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search agents</label>
|
||||
<input type="text" id="search-input" placeholder="Search agents..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-model">Model:</label>
|
||||
<select id="filter-model" multiple aria-label="Filter by model"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-tool">Tool:</label>
|
||||
<select id="filter-tool" multiple aria-label="Filter by tool"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-handoffs">
|
||||
Has Handoffs
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} agents</div>
|
||||
<div class="resource-list" id="resource-list" role="list" set:html={renderAgentsHtml(initialItems)}></div>
|
||||
<ContributeCTA resourceType="agents" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="agents.json" data={agentsData} />
|
||||
|
||||
<script>
|
||||
import '../scripts/pages/agents';
|
||||
|
||||
@@ -3,6 +3,11 @@ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import hooksData from '../../public/data/hooks.json';
|
||||
import { renderHooksHtml, sortHooks } from '../scripts/pages/hooks-render';
|
||||
|
||||
const initialItems = sortHooks(hooksData.items, 'title');
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Hooks', description: 'Automated workflows triggered by Copilot coding agent events', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
@@ -11,34 +16,34 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
<div class="page-content">
|
||||
<div class="container">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search hooks</label>
|
||||
<input type="text" id="search-input" placeholder="Search hooks..." autocomplete="off">
|
||||
<div class="listing-toolbar">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search hooks</label>
|
||||
<input type="text" id="search-input" placeholder="Search hooks..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-hook">Hook Event:</label>
|
||||
<select id="filter-hook" multiple aria-label="Filter by hook event"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-tag">Tag:</label>
|
||||
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-hook">Hook Event:</label>
|
||||
<select id="filter-hook" multiple aria-label="Filter by hook event"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-tag">Tag:</label>
|
||||
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite"></div>
|
||||
<div class="resource-list" id="resource-list" role="list">
|
||||
<div class="loading" aria-live="polite">Loading hooks...</div>
|
||||
</div>
|
||||
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} hooks</div>
|
||||
<div class="resource-list" id="resource-list" role="list" set:html={renderHooksHtml(initialItems)}></div>
|
||||
<ContributeCTA resourceType="hooks" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,6 +51,7 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
<Modal />
|
||||
|
||||
<EmbeddedPageData filename="hooks.json" data={hooksData} />
|
||||
<script>
|
||||
import '../scripts/pages/hooks';
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import instructionsData from '../../public/data/instructions.json';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import { renderInstructionsHtml, sortInstructions } from '../scripts/pages/instructions-render';
|
||||
|
||||
const initialItems = sortInstructions(instructionsData.items, 'title');
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Instructions', description: 'Coding standards and best practices for GitHub Copilot', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
@@ -11,36 +16,37 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
<div class="page-content">
|
||||
<div class="container">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search instructions</label>
|
||||
<input type="text" id="search-input" placeholder="Search instructions..." autocomplete="off">
|
||||
<div class="listing-toolbar">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search instructions</label>
|
||||
<input type="text" id="search-input" placeholder="Search instructions..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-extension">File Extension:</label>
|
||||
<select id="filter-extension" multiple aria-label="Filter by file extension"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-extension">File Extension:</label>
|
||||
<select id="filter-extension" multiple aria-label="Filter by file extension"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite"></div>
|
||||
<div class="resource-list" id="resource-list" role="list">
|
||||
<div class="loading" aria-live="polite">Loading instructions...</div>
|
||||
</div>
|
||||
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} instructions</div>
|
||||
<div class="resource-list" id="resource-list" role="list" set:html={renderInstructionsHtml(initialItems)}></div>
|
||||
<ContributeCTA resourceType="instructions" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="instructions.json" data={instructionsData} />
|
||||
|
||||
<script>
|
||||
import '../scripts/pages/instructions';
|
||||
|
||||
@@ -1,37 +1,63 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import samplesData from '../../../../public/data/samples.json';
|
||||
import Modal from '../../../components/Modal.astro';
|
||||
import EmbeddedPageData from '../../../components/EmbeddedPageData.astro';
|
||||
import {
|
||||
getRecipeResultsCountText,
|
||||
renderCookbookSectionsHtml,
|
||||
} from '../../../scripts/pages/samples-render';
|
||||
|
||||
const initialRecipeMatches = samplesData.cookbooks.flatMap((cookbook) =>
|
||||
cookbook.recipes.map((recipe) => ({ cookbook, recipe }))
|
||||
);
|
||||
|
||||
const languageOptions = Array.from(
|
||||
new Map(
|
||||
samplesData.cookbooks.flatMap((cookbook) =>
|
||||
cookbook.languages.map((language) => [language.id, language])
|
||||
)
|
||||
).values()
|
||||
);
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Cookbook', description: 'Code samples, recipes, and examples for building with GitHub Copilot' }}>
|
||||
<div class="cookbook-page">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search recipes</label>
|
||||
<input type="text" id="search-input" placeholder="Search recipes..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-language">Language:</label>
|
||||
<select id="filter-language" aria-label="Filter by language">
|
||||
<option value="">All Languages</option>
|
||||
</select>
|
||||
<div class="listing-toolbar">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search recipes</label>
|
||||
<input type="text" id="search-input" placeholder="Search recipes..." autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-tag">Tags:</label>
|
||||
<select id="filter-tag" multiple aria-label="Filter by tags"></select>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-language">Language:</label>
|
||||
<select id="filter-language" aria-label="Filter by language">
|
||||
<option value="">All Languages</option>
|
||||
{languageOptions.map((language) => (
|
||||
<option value={language.id}>{language.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-tag">Tags:</label>
|
||||
<select id="filter-tag" multiple aria-label="Filter by tags">
|
||||
{samplesData.filters.tags.map((tag) => (
|
||||
<option value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite"></div>
|
||||
<div class="results-count" id="results-count" aria-live="polite">{getRecipeResultsCountText(samplesData.totalRecipes, samplesData.totalRecipes)}</div>
|
||||
|
||||
<div id="samples-list" role="list">
|
||||
<div class="loading" aria-live="polite">Loading samples...</div>
|
||||
</div>
|
||||
<div id="samples-list" role="list" set:html={renderCookbookSectionsHtml(initialRecipeMatches)}></div>
|
||||
</div>
|
||||
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="samples.json" data={samplesData} />
|
||||
|
||||
<style is:global>
|
||||
/* Cookbook Section */
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import pluginsData from '../../public/data/plugins.json';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import { renderPluginsHtml } from '../scripts/pages/plugins-render';
|
||||
|
||||
const initialItems = pluginsData.items;
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Plugins', description: 'Curated plugins of agents, hooks, and skills for specific workflows', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
@@ -20,35 +25,30 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
<p>Install any plugin with: <code>copilot plugin install <plugin-name>@awesome-copilot</code></p>
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search plugins</label>
|
||||
<input type="text" id="search-input" placeholder="Search plugins..." autocomplete="off">
|
||||
<div class="listing-toolbar">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search plugins</label>
|
||||
<input type="text" id="search-input" placeholder="Search plugins..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-tag">Tag:</label>
|
||||
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-tag">Tag:</label>
|
||||
<select id="filter-tag" multiple aria-label="Filter by tag"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-featured">
|
||||
Featured Only
|
||||
</label>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite"></div>
|
||||
<div class="resource-list" id="resource-list" role="list">
|
||||
<div class="loading" aria-live="polite">Loading plugins...</div>
|
||||
</div>
|
||||
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} plugins</div>
|
||||
<div class="resource-list" id="resource-list" role="list" set:html={renderPluginsHtml(initialItems)}></div>
|
||||
<ContributeCTA resourceType="plugins" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="plugins.json" data={pluginsData} />
|
||||
|
||||
<script>
|
||||
import '../scripts/pages/plugins';
|
||||
|
||||
@@ -3,6 +3,11 @@ import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import skillsData from '../../public/data/skills.json';
|
||||
import { renderSkillsHtml, sortSkills } from '../scripts/pages/skills-render';
|
||||
|
||||
const initialItems = sortSkills(skillsData.items, 'title');
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Skills', description: 'Self-contained agent skills with instructions and bundled resources', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
@@ -11,36 +16,36 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
<div class="page-content">
|
||||
<div class="container">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search skills</label>
|
||||
<input type="text" id="search-input" placeholder="Search skills..." autocomplete="off">
|
||||
<div class="listing-toolbar">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search skills</label>
|
||||
<input type="text" id="search-input" placeholder="Search skills..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" multiple aria-label="Filter by category"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-has-assets">
|
||||
Has Bundled Assets
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" multiple aria-label="Filter by category"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="filter-has-assets">
|
||||
Has Bundled Assets
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite"></div>
|
||||
<div class="resource-list" id="resource-list" role="list">
|
||||
<div class="loading" aria-live="polite">Loading skills...</div>
|
||||
</div>
|
||||
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} skills</div>
|
||||
<div class="resource-list" id="resource-list" role="list" set:html={renderSkillsHtml(initialItems)}></div>
|
||||
<ContributeCTA resourceType="skills" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,6 +53,7 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
<Modal />
|
||||
|
||||
<EmbeddedPageData filename="skills.json" data={skillsData} />
|
||||
<script>
|
||||
import '../scripts/pages/skills';
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import toolsData from '../../public/data/tools.json';
|
||||
import ContributeCTA from "../components/ContributeCTA.astro";
|
||||
import EmbeddedPageData from "../components/EmbeddedPageData.astro";
|
||||
import PageHeader from "../components/PageHeader.astro";
|
||||
import { renderToolsHtml } from "../scripts/pages/tools-render";
|
||||
|
||||
const initialItems = toolsData.items.map((item) => ({
|
||||
...item,
|
||||
title: item.name,
|
||||
}));
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Tools', description: 'MCP servers and developer tools for GitHub Copilot', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
@@ -24,15 +32,18 @@ import PageHeader from "../components/PageHeader.astro";
|
||||
<label for="filter-category" class="sr-only">Filter by category</label>
|
||||
<select id="filter-category" class="filter-select" aria-label="Filter by category">
|
||||
<option value="">All Categories</option>
|
||||
{toolsData.filters.categories.map((category) => (
|
||||
<option value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small"
|
||||
>Clear</button
|
||||
>
|
||||
</div>
|
||||
<div id="results-count" class="results-count" aria-live="polite"></div>
|
||||
<div id="results-count" class="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} tools</div>
|
||||
</div>
|
||||
|
||||
<div id="tools-list" role="list"></div>
|
||||
<div id="tools-list" role="list" set:html={renderToolsHtml(initialItems)}></div>
|
||||
|
||||
<div class="coming-soon">
|
||||
<h2>More Tools Coming Soon</h2>
|
||||
@@ -46,6 +57,8 @@ import PageHeader from "../components/PageHeader.astro";
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<EmbeddedPageData filename="tools.json" data={toolsData} />
|
||||
|
||||
<style is:global>
|
||||
.search-section {
|
||||
margin-bottom: 24px;
|
||||
@@ -94,12 +107,6 @@ import PageHeader from "../components/PageHeader.astro";
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
@@ -299,7 +306,6 @@ import PageHeader from "../components/PageHeader.astro";
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { initToolsPage } from "../scripts/pages/tools";
|
||||
initToolsPage();
|
||||
import '../scripts/pages/tools';
|
||||
</script>
|
||||
</StarlightPage>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
---
|
||||
import StarlightPage from '@astrojs/starlight/components/StarlightPage.astro';
|
||||
import workflowsData from '../../public/data/workflows.json';
|
||||
import Modal from '../components/Modal.astro';
|
||||
import ContributeCTA from '../components/ContributeCTA.astro';
|
||||
import EmbeddedPageData from '../components/EmbeddedPageData.astro';
|
||||
import PageHeader from '../components/PageHeader.astro';
|
||||
import { renderWorkflowsHtml, sortWorkflows } from '../scripts/pages/workflows-render';
|
||||
|
||||
const initialItems = sortWorkflows(workflowsData.items, 'title');
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={{ title: 'Agentic Workflows', description: 'AI-powered repository automations that run coding agents in GitHub Actions', template: 'splash', prev: false, next: false, editUrl: false }}>
|
||||
@@ -13,36 +18,37 @@ import PageHeader from '../components/PageHeader.astro';
|
||||
|
||||
<div class="page-content">
|
||||
<div class="container">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search workflows</label>
|
||||
<input type="text" id="search-input" placeholder="Search workflows..." autocomplete="off">
|
||||
<div class="listing-toolbar">
|
||||
<div class="search-bar">
|
||||
<label for="search-input" class="sr-only">Search workflows</label>
|
||||
<input type="text" id="search-input" placeholder="Search workflows..." autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-trigger">Trigger:</label>
|
||||
<select id="filter-trigger" multiple aria-label="Filter by trigger"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar" id="filters-bar">
|
||||
<div class="filter-group">
|
||||
<label for="filter-trigger">Trigger:</label>
|
||||
<select id="filter-trigger" multiple aria-label="Filter by trigger"></select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="sort-select">Sort:</label>
|
||||
<select id="sort-select" aria-label="Sort by">
|
||||
<option value="title">Name (A-Z)</option>
|
||||
<option value="lastUpdated">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="results-count" id="results-count" aria-live="polite"></div>
|
||||
<div class="resource-list" id="resource-list" role="list">
|
||||
<div class="loading" aria-live="polite">Loading workflows...</div>
|
||||
</div>
|
||||
<div class="results-count" id="results-count" aria-live="polite">{initialItems.length} of {initialItems.length} workflows</div>
|
||||
<div class="resource-list" id="resource-list" role="list" set:html={renderWorkflowsHtml(initialItems)}></div>
|
||||
<ContributeCTA resourceType="workflows" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Modal />
|
||||
<EmbeddedPageData filename="workflows.json" data={workflowsData} />
|
||||
|
||||
<script>
|
||||
import '../scripts/pages/workflows';
|
||||
|
||||
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>/)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/* Nerd Fonts for programming language icons */
|
||||
@font-face {
|
||||
font-family: 'Monaspace Argon NF';
|
||||
src: url('../fonts/MonaspaceArgonNF-Regular.woff2') format('woff2');
|
||||
src: url('../../public/fonts/MonaspaceArgonNF-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -740,7 +740,7 @@ body:has(#main-content) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
.modal.hidden, .hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -759,27 +759,165 @@ body:has(#main-content) {
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-glass-border);
|
||||
background: var(--color-glass);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
.modal-header-top {
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-header-top h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-emphasis);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-file-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modal-file-switcher.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-file-switcher label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.modal-file-switcher-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.modal-file-dropdown {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
min-width: min(320px, 100%);
|
||||
max-width: min(420px, 100%);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-file-button {
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.modal-file-button span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modal-file-toggle {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
padding: 10px;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.modal-file-toggle svg {
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
|
||||
.modal-file-dropdown.open .modal-file-toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.modal-file-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all var(--transition);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-file-dropdown.open .modal-file-menu {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-file-menu-item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-file-menu-item:hover,
|
||||
.modal-file-menu-item:focus-visible {
|
||||
background: var(--color-bg-tertiary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-file-menu-item.active {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
color: var(--color-text-emphasis);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-file-menu-item:first-child {
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
}
|
||||
|
||||
.modal-file-menu-item:last-child {
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-actions button, .modal-actions div {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
@@ -799,6 +937,29 @@ body:has(#main-content) {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.modal-rendered-content {
|
||||
padding: 24px;
|
||||
min-height: 200px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.modal-rendered-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.modal-rendered-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.modal-code-content .shiki {
|
||||
margin: 0 !important;
|
||||
padding: 24px;
|
||||
min-height: 200px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Collection Modal View */
|
||||
.collection-view {
|
||||
padding: 20px 24px;
|
||||
@@ -967,25 +1128,28 @@ body:has(#main-content) {
|
||||
|
||||
/* Page Layouts */
|
||||
.page-header {
|
||||
padding: 56px 0 40px;
|
||||
padding: 40px 0 28px;
|
||||
border-bottom: 1px solid var(--color-glass-border);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 36px;
|
||||
font-size: clamp(2rem, 4vw, 2.25rem);
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.page-header-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
@@ -999,9 +1163,9 @@ body:has(#main-content) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
margin-top: 0;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
@@ -1027,23 +1191,36 @@ body:has(#main-content) {
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 40px 0 80px;
|
||||
padding: 24px 0 80px;
|
||||
}
|
||||
|
||||
/* Search and Filter Bar */
|
||||
.search-bar {
|
||||
.listing-toolbar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
padding: 14px 18px;
|
||||
font-size: 15px;
|
||||
background: var(--color-glass);
|
||||
flex: 1 1 220px;
|
||||
min-width: 220px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
color: var(--color-text);
|
||||
@@ -1059,42 +1236,30 @@ body:has(#main-content) {
|
||||
|
||||
/* Filters Bar */
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: 18px 20px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
.filters-bar .filter-group > label:not(.checkbox-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 8px 14px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-glass-border);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text);
|
||||
min-width: 150px;
|
||||
min-width: 130px;
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.filter-group select:focus {
|
||||
@@ -1103,6 +1268,10 @@ body:has(#main-content) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.filters-bar button {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1122,13 +1291,14 @@ body:has(#main-content) {
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Choices.js Theme Overrides */
|
||||
.filter-group .choices {
|
||||
min-width: 200px;
|
||||
min-width: 170px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.choices {
|
||||
@@ -1138,17 +1308,17 @@ body:has(#main-content) {
|
||||
.choices__inner {
|
||||
background-color: var(--color-bg);
|
||||
border-color: var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
min-height: 42px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
border-radius: var(--border-radius-lg) !important;
|
||||
min-height: 38px;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.choices__input {
|
||||
background-color: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 14px;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.choices__input::placeholder {
|
||||
@@ -1266,10 +1436,16 @@ body:has(#main-content) {
|
||||
.results-count {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.listing-toolbar {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Resource List */
|
||||
.resource-list {
|
||||
display: flex;
|
||||
@@ -1361,6 +1537,10 @@ body:has(#main-content) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resource-actions button {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
/* Last Updated */
|
||||
.last-updated {
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user