Merge pull request #649 from github/feature/last-updated-dates

feat(website): Add last updated dates for resources
This commit is contained in:
Aaron Powell
2026-02-04 08:30:02 +11:00
committed by GitHub
14 changed files with 360 additions and 15 deletions

View File

@@ -11,6 +11,7 @@
# categor - TypeScript template literal in website/src/scripts/pages/skills.ts:70 (categor${...length > 1 ? "ies" : "y"}) # categor - TypeScript template literal in website/src/scripts/pages/skills.ts:70 (categor${...length > 1 ? "ies" : "y"})
# aline - proper name (Aline Ávila, contributor) # aline - proper name (Aline Ávila, contributor)
# ative - part of "Declarative Agents" in TypeSpec M365 Copilot documentation (collections/typespec-m365-copilot.collection.md) # ative - part of "Declarative Agents" in TypeSpec M365 Copilot documentation (collections/typespec-m365-copilot.collection.md)
ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques # dateA, dateB - variable names used in sorting comparison functions
ignore-words-list = numer,wit,aks,edn,ser,ois,gir,rouge,categor,aline,ative,afterall,deques,dateA,dateB
# Skip certain files and directories # Skip certain files and directories
skip = .git,node_modules,package-lock.json,*.lock,website/build,website/.docusaurus skip = .git,node_modules,package-lock.json,*.lock,website/build,website/.docusaurus

View File

@@ -40,6 +40,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for git-based last updated dates
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -24,6 +24,7 @@ import {
parseSkillMetadata, parseSkillMetadata,
parseYamlFile, parseYamlFile,
} from "./yaml-parser.mjs"; } from "./yaml-parser.mjs";
import { getGitFileDates } from "./utils/git-dates.mjs";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@@ -64,7 +65,7 @@ function extractTitle(filePath, frontmatter) {
/** /**
* Generate agents metadata * Generate agents metadata
*/ */
function generateAgentsData() { function generateAgentsData(gitDates) {
const agents = []; const agents = [];
const files = fs const files = fs
.readdirSync(AGENTS_DIR) .readdirSync(AGENTS_DIR)
@@ -105,6 +106,7 @@ function generateAgentsData() {
: [], : [],
path: relativePath, path: relativePath,
filename: file, filename: file,
lastUpdated: gitDates.get(relativePath) || null,
}); });
} }
@@ -123,7 +125,7 @@ function generateAgentsData() {
/** /**
* Generate prompts metadata * Generate prompts metadata
*/ */
function generatePromptsData() { function generatePromptsData(gitDates) {
const prompts = []; const prompts = [];
const files = fs const files = fs
.readdirSync(PROMPTS_DIR) .readdirSync(PROMPTS_DIR)
@@ -151,6 +153,7 @@ function generatePromptsData() {
tools: tools, tools: tools,
path: relativePath, path: relativePath,
filename: file, filename: file,
lastUpdated: gitDates.get(relativePath) || null,
}); });
} }
@@ -206,7 +209,7 @@ function extractExtensionFromPattern(pattern) {
/** /**
* Generate instructions metadata * Generate instructions metadata
*/ */
function generateInstructionsData() { function generateInstructionsData(gitDates) {
const instructions = []; const instructions = [];
const files = fs const files = fs
.readdirSync(INSTRUCTIONS_DIR) .readdirSync(INSTRUCTIONS_DIR)
@@ -253,6 +256,7 @@ function generateInstructionsData() {
extensions: [...new Set(extensions)], extensions: [...new Set(extensions)],
path: relativePath, path: relativePath,
filename: file, filename: file,
lastUpdated: gitDates.get(relativePath) || null,
}); });
} }
@@ -316,7 +320,7 @@ function categorizeSkill(name, description) {
/** /**
* Generate skills metadata * Generate skills metadata
*/ */
function generateSkillsData() { function generateSkillsData(gitDates) {
const skills = []; const skills = [];
if (!fs.existsSync(SKILLS_DIR)) { if (!fs.existsSync(SKILLS_DIR)) {
@@ -343,6 +347,9 @@ function generateSkillsData() {
// Get all files in the skill folder recursively // Get all files in the skill folder recursively
const files = getSkillFiles(skillPath, relativePath); const files = getSkillFiles(skillPath, relativePath);
// Get last updated from SKILL.md file
const skillFilePath = `${relativePath}/SKILL.md`;
skills.push({ skills.push({
id: folder, id: folder,
name: metadata.name, name: metadata.name,
@@ -356,8 +363,9 @@ function generateSkillsData() {
assetCount: metadata.assets.length, assetCount: metadata.assets.length,
category: category, category: category,
path: relativePath, path: relativePath,
skillFile: `${relativePath}/SKILL.md`, skillFile: skillFilePath,
files: files, files: files,
lastUpdated: gitDates.get(skillFilePath) || null,
}); });
} }
} }
@@ -406,7 +414,7 @@ function getSkillFiles(skillPath, relativePath) {
/** /**
* Generate collections metadata * Generate collections metadata
*/ */
function generateCollectionsData() { function generateCollectionsData(gitDates) {
const collections = []; const collections = [];
if (!fs.existsSync(COLLECTIONS_DIR)) { if (!fs.existsSync(COLLECTIONS_DIR)) {
@@ -447,6 +455,7 @@ function generateCollectionsData() {
})), })),
path: relativePath, path: relativePath,
filename: file, filename: file,
lastUpdated: gitDates.get(relativePath) || null,
}); });
} }
} }
@@ -542,6 +551,7 @@ function generateSearchIndex(
title: agent.title, title: agent.title,
description: agent.description, description: agent.description,
path: agent.path, path: agent.path,
lastUpdated: agent.lastUpdated,
searchText: `${agent.title} ${agent.description} ${agent.tools.join( searchText: `${agent.title} ${agent.description} ${agent.tools.join(
" " " "
)}`.toLowerCase(), )}`.toLowerCase(),
@@ -555,6 +565,7 @@ function generateSearchIndex(
title: prompt.title, title: prompt.title,
description: prompt.description, description: prompt.description,
path: prompt.path, path: prompt.path,
lastUpdated: prompt.lastUpdated,
searchText: `${prompt.title} ${prompt.description}`.toLowerCase(), searchText: `${prompt.title} ${prompt.description}`.toLowerCase(),
}); });
} }
@@ -566,6 +577,7 @@ function generateSearchIndex(
title: instruction.title, title: instruction.title,
description: instruction.description, description: instruction.description,
path: instruction.path, path: instruction.path,
lastUpdated: instruction.lastUpdated,
searchText: `${instruction.title} ${instruction.description} ${ searchText: `${instruction.title} ${instruction.description} ${
instruction.applyTo || "" instruction.applyTo || ""
}`.toLowerCase(), }`.toLowerCase(),
@@ -579,6 +591,7 @@ function generateSearchIndex(
title: skill.title, title: skill.title,
description: skill.description, description: skill.description,
path: skill.skillFile, path: skill.skillFile,
lastUpdated: skill.lastUpdated,
searchText: `${skill.title} ${skill.description}`.toLowerCase(), searchText: `${skill.title} ${skill.description}`.toLowerCase(),
}); });
} }
@@ -591,6 +604,7 @@ function generateSearchIndex(
description: collection.description, description: collection.description,
path: collection.path, path: collection.path,
tags: collection.tags, tags: collection.tags,
lastUpdated: collection.lastUpdated,
searchText: `${collection.name} ${ searchText: `${collection.name} ${
collection.description collection.description
} ${collection.tags.join(" ")}`.toLowerCase(), } ${collection.tags.join(" ")}`.toLowerCase(),
@@ -703,32 +717,40 @@ async function main() {
ensureDataDir(); ensureDataDir();
// Load git dates for all resource files (single efficient git command)
console.log("Loading git history for last updated dates...");
const gitDates = getGitFileDates(
["agents/", "prompts/", "instructions/", "skills/", "collections/"],
ROOT_FOLDER
);
console.log(`✓ Loaded dates for ${gitDates.size} files\n`);
// Generate all data // Generate all data
const agentsData = generateAgentsData(); const agentsData = generateAgentsData(gitDates);
const agents = agentsData.items; const agents = agentsData.items;
console.log( console.log(
`✓ Generated ${agents.length} agents (${agentsData.filters.models.length} models, ${agentsData.filters.tools.length} tools)` `✓ Generated ${agents.length} agents (${agentsData.filters.models.length} models, ${agentsData.filters.tools.length} tools)`
); );
const promptsData = generatePromptsData(); const promptsData = generatePromptsData(gitDates);
const prompts = promptsData.items; const prompts = promptsData.items;
console.log( console.log(
`✓ Generated ${prompts.length} prompts (${promptsData.filters.tools.length} tools)` `✓ Generated ${prompts.length} prompts (${promptsData.filters.tools.length} tools)`
); );
const instructionsData = generateInstructionsData(); const instructionsData = generateInstructionsData(gitDates);
const instructions = instructionsData.items; const instructions = instructionsData.items;
console.log( console.log(
`✓ Generated ${instructions.length} instructions (${instructionsData.filters.extensions.length} extensions)` `✓ Generated ${instructions.length} instructions (${instructionsData.filters.extensions.length} extensions)`
); );
const skillsData = generateSkillsData(); const skillsData = generateSkillsData(gitDates);
const skills = skillsData.items; const skills = skillsData.items;
console.log( console.log(
`✓ Generated ${skills.length} skills (${skillsData.filters.categories.length} categories)` `✓ Generated ${skills.length} skills (${skillsData.filters.categories.length} categories)`
); );
const collectionsData = generateCollectionsData(); const collectionsData = generateCollectionsData(gitDates);
const collections = collectionsData.items; const collections = collectionsData.items;
console.log( console.log(
`✓ Generated ${collections.length} collections (${collectionsData.filters.tags.length} tags)` `✓ Generated ${collections.length} collections (${collectionsData.filters.tags.length} tags)`

103
eng/utils/git-dates.mjs Normal file
View File

@@ -0,0 +1,103 @@
#!/usr/bin/env node
/**
* Utility to extract last modification dates from git history.
* Uses a single git log command for efficiency.
*/
import { execSync } from "child_process";
import path from "path";
/**
* Get the last modification date for all tracked files in specified directories.
* Returns a Map of file path -> ISO date string.
*
* @param {string[]} directories - Array of directory paths to scan
* @param {string} rootDir - Root directory for relative paths
* @returns {Map<string, string>} Map of relative file path to ISO date string
*/
export function getGitFileDates(directories, rootDir) {
const fileDates = new Map();
try {
// Get git log with file names for all specified directories
// Format: ISO date, then file names that were modified in that commit
const gitArgs = [
"--no-pager",
"log",
"--format=%aI", // Author date in ISO 8601 format
"--name-only",
"--diff-filter=ACMR", // Added, Copied, Modified, Renamed
"--",
...directories,
];
const output = execSync(`git ${gitArgs.join(" ")}`, {
encoding: "utf8",
cwd: rootDir,
stdio: ["pipe", "pipe", "pipe"],
});
// Parse the output: alternating date lines and file name lines
// Format is:
// 2026-01-15T10:30:00+00:00
//
// file1.md
// file2.md
//
// 2026-01-14T09:00:00+00:00
// ...
let currentDate = null;
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
// Check if this is a date line (ISO 8601 format)
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
currentDate = trimmed;
} else if (currentDate && trimmed) {
// This is a file path - only set if we haven't seen this file yet
// (first occurrence is the most recent modification)
if (!fileDates.has(trimmed)) {
fileDates.set(trimmed, currentDate);
}
}
}
} catch (error) {
// Git command failed - might not be a git repo or no history
console.warn("Warning: Could not get git dates:", error.message);
}
return fileDates;
}
/**
* Get the last modification date for a single file.
*
* @param {string} filePath - Path to the file (relative to git root)
* @param {string} rootDir - Root directory
* @returns {string|null} ISO date string or null if not found
*/
export function getGitFileDate(filePath, rootDir) {
try {
const output = execSync(
`git --no-pager log -1 --format="%aI" -- "${filePath}"`,
{
encoding: "utf8",
cwd: rootDir,
stdio: ["pipe", "pipe", "pipe"],
}
);
const date = output.trim();
return date || null;
} catch (error) {
return null;
}
}

View File

@@ -1415,6 +1415,14 @@ a:hover {
flex-shrink: 0; flex-shrink: 0;
} }
/* Last Updated */
.last-updated {
font-size: 12px;
color: var(--color-text-muted);
cursor: default;
margin-left: auto;
}
/* Collection Items */ /* Collection Items */
.collection-items { .collection-items {
margin-top: 12px; margin-top: 12px;

View File

@@ -35,6 +35,13 @@ import Modal from '../components/Modal.astro';
Has Handoffs Has Handoffs
</label> </label>
</div> </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> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>

View File

@@ -24,6 +24,13 @@ import Modal from '../components/Modal.astro';
<label for="filter-extension">File Extension:</label> <label for="filter-extension">File Extension:</label>
<select id="filter-extension" multiple aria-label="Filter by file extension"></select> <select id="filter-extension" multiple aria-label="Filter by file extension"></select>
</div> </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> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>

View File

@@ -24,6 +24,13 @@ import Modal from '../components/Modal.astro';
<label for="filter-tool">Tool:</label> <label for="filter-tool">Tool:</label>
<select id="filter-tool" multiple aria-label="Filter by tool"></select> <select id="filter-tool" multiple aria-label="Filter by tool"></select>
</div> </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> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>

View File

@@ -30,6 +30,13 @@ import Modal from '../components/Modal.astro';
Has Bundled Assets Has Bundled Assets
</label> </label>
</div> </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> <button id="clear-filters" class="btn btn-secondary btn-small">Clear Filters</button>
</div> </div>

View File

@@ -3,7 +3,7 @@
*/ */
import { createChoices, getChoicesValues, type Choices } from '../choices'; import { createChoices, getChoicesValues, type Choices } from '../choices';
import { FuzzySearch, SearchItem } from '../search'; import { FuzzySearch, SearchItem } from '../search';
import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers } from '../utils'; import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils';
import { setupModal, openFileModal } from '../modal'; import { setupModal, openFileModal } from '../modal';
interface Agent extends SearchItem { interface Agent extends SearchItem {
@@ -11,6 +11,7 @@ interface Agent extends SearchItem {
model?: string; model?: string;
tools?: string[]; tools?: string[];
hasHandoffs?: boolean; hasHandoffs?: boolean;
lastUpdated?: string | null;
} }
interface AgentsData { interface AgentsData {
@@ -21,11 +22,14 @@ interface AgentsData {
}; };
} }
type SortOption = 'title' | 'lastUpdated';
const resourceType = 'agent'; const resourceType = 'agent';
let allItems: Agent[] = []; let allItems: Agent[] = [];
let search = new FuzzySearch<Agent>(); let search = new FuzzySearch<Agent>();
let modelSelect: Choices; let modelSelect: Choices;
let toolSelect: Choices; let toolSelect: Choices;
let currentSort: SortOption = 'title';
let currentFilters = { let currentFilters = {
models: [] as string[], models: [] as string[],
@@ -33,6 +37,19 @@ let currentFilters = {
hasHandoffs: false, hasHandoffs: false,
}; };
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);
});
}
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById('search-input') as HTMLInputElement;
const countEl = document.getElementById('results-count'); const countEl = document.getElementById('results-count');
@@ -59,6 +76,9 @@ function applyFiltersAndRender(): void {
results = results.filter(item => item.hasHandoffs); results = results.filter(item => item.hasHandoffs);
} }
// Apply sorting
results = sortItems(results);
renderItems(results, query); renderItems(results, query);
const activeFilters: string[] = []; const activeFilters: string[] = [];
@@ -97,6 +117,7 @@ function renderItems(items: Agent[], query = ''): void {
${item.tools?.slice(0, 3).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''} ${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.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>` : ''} ${item.hasHandoffs ? `<span class="resource-tag tag-handoffs">handoffs</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
</div> </div>
</div> </div>
<div class="resource-actions"> <div class="resource-actions">
@@ -123,6 +144,7 @@ export async function initAgentsPage(): Promise<void> {
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById('search-input') as HTMLInputElement;
const handoffsCheckbox = document.getElementById('filter-handoffs') as HTMLInputElement; const handoffsCheckbox = document.getElementById('filter-handoffs') as HTMLInputElement;
const clearFiltersBtn = document.getElementById('clear-filters'); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
const data = await fetchData<AgentsData>('agents.json'); const data = await fetchData<AgentsData>('agents.json');
if (!data || !data.items) { if (!data || !data.items) {
@@ -149,6 +171,12 @@ export async function initAgentsPage(): Promise<void> {
applyFiltersAndRender(); applyFiltersAndRender();
}); });
// Initialize sort select
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as SortOption;
applyFiltersAndRender();
});
applyFiltersAndRender(); applyFiltersAndRender();
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
@@ -160,10 +188,12 @@ export async function initAgentsPage(): Promise<void> {
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { models: [], tools: [], hasHandoffs: false }; currentFilters = { models: [], tools: [], hasHandoffs: false };
currentSort = 'title';
modelSelect.removeActiveItems(); modelSelect.removeActiveItems();
toolSelect.removeActiveItems(); toolSelect.removeActiveItems();
if (handoffsCheckbox) handoffsCheckbox.checked = false; if (handoffsCheckbox) handoffsCheckbox.checked = false;
if (searchInput) searchInput.value = ''; if (searchInput) searchInput.value = '';
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
}); });

View File

@@ -3,13 +3,14 @@
*/ */
import { createChoices, getChoicesValues, type Choices } from '../choices'; import { createChoices, getChoicesValues, type Choices } from '../choices';
import { FuzzySearch, SearchItem } from '../search'; import { FuzzySearch, SearchItem } from '../search';
import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers } from '../utils'; import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils';
import { setupModal, openFileModal } from '../modal'; import { setupModal, openFileModal } from '../modal';
interface Instruction extends SearchItem { interface Instruction extends SearchItem {
path: string; path: string;
applyTo?: string; applyTo?: string;
extensions?: string[]; extensions?: string[];
lastUpdated?: string | null;
} }
interface InstructionsData { interface InstructionsData {
@@ -19,11 +20,25 @@ interface InstructionsData {
}; };
} }
type SortOption = 'title' | 'lastUpdated';
const resourceType = 'instruction'; const resourceType = 'instruction';
let allItems: Instruction[] = []; let allItems: Instruction[] = [];
let search = new FuzzySearch<Instruction>(); let search = new FuzzySearch<Instruction>();
let extensionSelect: Choices; let extensionSelect: Choices;
let currentFilters = { extensions: [] as string[] }; let currentFilters = { extensions: [] as string[] };
let currentSort: SortOption = 'title';
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);
});
}
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById('search-input') as HTMLInputElement;
@@ -41,6 +56,8 @@ function applyFiltersAndRender(): void {
}); });
} }
results = sortItems(results);
renderItems(results, query); renderItems(results, query);
let countText = `${results.length} of ${allItems.length} instructions`; let countText = `${results.length} of ${allItems.length} instructions`;
if (currentFilters.extensions.length > 0) { if (currentFilters.extensions.length > 0) {
@@ -67,6 +84,7 @@ function renderItems(items: Instruction[], query = ''): void {
${item.applyTo ? `<span class="resource-tag">applies to: ${escapeHtml(item.applyTo)}</span>` : ''} ${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?.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>` : ''} ${item.extensions && item.extensions.length > 4 ? `<span class="resource-tag">+${item.extensions.length - 4} more</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
</div> </div>
</div> </div>
<div class="resource-actions"> <div class="resource-actions">
@@ -92,6 +110,7 @@ export async function initInstructionsPage(): Promise<void> {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById('search-input') as HTMLInputElement;
const clearFiltersBtn = document.getElementById('clear-filters'); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
const data = await fetchData<InstructionsData>('instructions.json'); const data = await fetchData<InstructionsData>('instructions.json');
if (!data || !data.items) { if (!data || !data.items) {
@@ -109,13 +128,20 @@ export async function initInstructionsPage(): Promise<void> {
applyFiltersAndRender(); applyFiltersAndRender();
}); });
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as SortOption;
applyFiltersAndRender();
});
applyFiltersAndRender(); applyFiltersAndRender();
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { extensions: [] }; currentFilters = { extensions: [] };
currentSort = 'title';
extensionSelect.removeActiveItems(); extensionSelect.removeActiveItems();
if (searchInput) searchInput.value = ''; if (searchInput) searchInput.value = '';
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
}); });

View File

@@ -3,12 +3,13 @@
*/ */
import { createChoices, getChoicesValues, type Choices } from '../choices'; import { createChoices, getChoicesValues, type Choices } from '../choices';
import { FuzzySearch, SearchItem } from '../search'; import { FuzzySearch, SearchItem } from '../search';
import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers } from '../utils'; import { fetchData, debounce, escapeHtml, getGitHubUrl, getInstallDropdownHtml, setupDropdownCloseHandlers, getActionButtonsHtml, setupActionHandlers, getLastUpdatedHtml } from '../utils';
import { setupModal, openFileModal } from '../modal'; import { setupModal, openFileModal } from '../modal';
interface Prompt extends SearchItem { interface Prompt extends SearchItem {
path: string; path: string;
tools?: string[]; tools?: string[];
lastUpdated?: string | null;
} }
interface PromptsData { interface PromptsData {
@@ -18,11 +19,25 @@ interface PromptsData {
}; };
} }
type SortOption = 'title' | 'lastUpdated';
const resourceType = 'prompt'; const resourceType = 'prompt';
let allItems: Prompt[] = []; let allItems: Prompt[] = [];
let search = new FuzzySearch<Prompt>(); let search = new FuzzySearch<Prompt>();
let toolSelect: Choices; let toolSelect: Choices;
let currentFilters = { tools: [] as string[] }; let currentFilters = { tools: [] as string[] };
let currentSort: SortOption = 'title';
function sortItems(items: Prompt[]): Prompt[] {
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);
});
}
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById('search-input') as HTMLInputElement;
@@ -37,6 +52,8 @@ function applyFiltersAndRender(): void {
); );
} }
results = sortItems(results);
renderItems(results, query); renderItems(results, query);
let countText = `${results.length} of ${allItems.length} prompts`; let countText = `${results.length} of ${allItems.length} prompts`;
if (currentFilters.tools.length > 0) { if (currentFilters.tools.length > 0) {
@@ -62,6 +79,7 @@ function renderItems(items: Prompt[], query = ''): void {
<div class="resource-meta"> <div class="resource-meta">
${item.tools?.slice(0, 4).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''} ${item.tools?.slice(0, 4).map(t => `<span class="resource-tag">${escapeHtml(t)}</span>`).join('') || ''}
${item.tools && item.tools.length > 4 ? `<span class="resource-tag">+${item.tools.length - 4} more</span>` : ''} ${item.tools && item.tools.length > 4 ? `<span class="resource-tag">+${item.tools.length - 4} more</span>` : ''}
${getLastUpdatedHtml(item.lastUpdated)}
</div> </div>
</div> </div>
<div class="resource-actions"> <div class="resource-actions">
@@ -87,6 +105,7 @@ export async function initPromptsPage(): Promise<void> {
const list = document.getElementById('resource-list'); const list = document.getElementById('resource-list');
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById('search-input') as HTMLInputElement;
const clearFiltersBtn = document.getElementById('clear-filters'); const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
const data = await fetchData<PromptsData>('prompts.json'); const data = await fetchData<PromptsData>('prompts.json');
if (!data || !data.items) { if (!data || !data.items) {
@@ -104,13 +123,20 @@ export async function initPromptsPage(): Promise<void> {
applyFiltersAndRender(); applyFiltersAndRender();
}); });
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as SortOption;
applyFiltersAndRender();
});
applyFiltersAndRender(); applyFiltersAndRender();
searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200)); searchInput?.addEventListener('input', debounce(() => applyFiltersAndRender(), 200));
clearFiltersBtn?.addEventListener('click', () => { clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { tools: [] }; currentFilters = { tools: [] };
currentSort = 'title';
toolSelect.removeActiveItems(); toolSelect.removeActiveItems();
if (searchInput) searchInput.value = ''; if (searchInput) searchInput.value = '';
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender(); applyFiltersAndRender();
}); });

View File

@@ -10,6 +10,7 @@ import {
getGitHubUrl, getGitHubUrl,
getRawGitHubUrl, getRawGitHubUrl,
showToast, showToast,
getLastUpdatedHtml,
} from "../utils"; } from "../utils";
import { setupModal, openFileModal } from "../modal"; import { setupModal, openFileModal } from "../modal";
import JSZip from "../jszip"; import JSZip from "../jszip";
@@ -27,6 +28,7 @@ interface Skill extends SearchItem {
hasAssets: boolean; hasAssets: boolean;
assetCount: number; assetCount: number;
files: SkillFile[]; files: SkillFile[];
lastUpdated?: string | null;
} }
interface SkillsData { interface SkillsData {
@@ -36,6 +38,8 @@ interface SkillsData {
}; };
} }
type SortOption = 'title' | 'lastUpdated';
const resourceType = "skill"; const resourceType = "skill";
let allItems: Skill[] = []; let allItems: Skill[] = [];
let search = new FuzzySearch<Skill>(); let search = new FuzzySearch<Skill>();
@@ -44,6 +48,18 @@ let currentFilters = {
categories: [] as string[], categories: [] as string[],
hasAssets: false, hasAssets: false,
}; };
let currentSort: SortOption = 'title';
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);
});
}
function applyFiltersAndRender(): void { function applyFiltersAndRender(): void {
const searchInput = document.getElementById( const searchInput = document.getElementById(
@@ -63,6 +79,8 @@ function applyFiltersAndRender(): void {
results = results.filter((item) => item.hasAssets); results = results.filter((item) => item.hasAssets);
} }
results = sortItems(results);
renderItems(results, query); renderItems(results, query);
const activeFilters: string[] = []; const activeFilters: string[] = [];
if (currentFilters.categories.length > 0) if (currentFilters.categories.length > 0)
@@ -116,6 +134,7 @@ function renderItems(items: Skill[], query = ""): void {
<span class="resource-tag">${item.files.length} file${ <span class="resource-tag">${item.files.length} file${
item.files.length === 1 ? "" : "s" item.files.length === 1 ? "" : "s"
}</span> }</span>
${getLastUpdatedHtml(item.lastUpdated)}
</div> </div>
</div> </div>
<div class="resource-actions"> <div class="resource-actions">
@@ -236,6 +255,7 @@ export async function initSkillsPage(): Promise<void> {
"filter-has-assets" "filter-has-assets"
) as HTMLInputElement; ) as HTMLInputElement;
const clearFiltersBtn = document.getElementById("clear-filters"); const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById("sort-select") as HTMLSelectElement;
const data = await fetchData<SkillsData>("skills.json"); const data = await fetchData<SkillsData>("skills.json");
if (!data || !data.items) { if (!data || !data.items) {
@@ -262,6 +282,11 @@ export async function initSkillsPage(): Promise<void> {
applyFiltersAndRender(); applyFiltersAndRender();
}); });
sortSelect?.addEventListener("change", () => {
currentSort = sortSelect.value as SortOption;
applyFiltersAndRender();
});
applyFiltersAndRender(); applyFiltersAndRender();
searchInput?.addEventListener( searchInput?.addEventListener(
"input", "input",
@@ -275,9 +300,11 @@ export async function initSkillsPage(): Promise<void> {
clearFiltersBtn?.addEventListener("click", () => { clearFiltersBtn?.addEventListener("click", () => {
currentFilters = { categories: [], hasAssets: false }; currentFilters = { categories: [], hasAssets: false };
currentSort = 'title';
categorySelect.removeActiveItems(); categorySelect.removeActiveItems();
if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false; if (hasAssetsCheckbox) hasAssetsCheckbox.checked = false;
if (searchInput) searchInput.value = ""; if (searchInput) searchInput.value = "";
if (sortSelect) sortSelect.value = "title";
applyFiltersAndRender(); applyFiltersAndRender();
}); });

View File

@@ -429,3 +429,75 @@ export function setupActionHandlers(): void {
let dropdownHandlersReady = false; let dropdownHandlersReady = false;
let actionHandlersReady = false; let actionHandlersReady = false;
/**
* Format a date as relative time (e.g., "3 days ago")
* @param isoDate - ISO 8601 date string
* @returns Relative time string
*/
export function formatRelativeTime(isoDate: string | null | undefined): string {
if (!isoDate) return "Unknown";
const date = new Date(isoDate);
if (isNaN(date.getTime())) return "Unknown";
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
const diffYears = Math.floor(diffDays / 365);
if (diffDays === 0) {
if (diffHours === 0) {
if (diffMinutes === 0) return "just now";
return diffMinutes === 1 ? "1 minute ago" : `${diffMinutes} minutes ago`;
}
return diffHours === 1 ? "1 hour ago" : `${diffHours} hours ago`;
}
if (diffDays === 1) return "yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
if (diffWeeks === 1) return "1 week ago";
if (diffWeeks < 4) return `${diffWeeks} weeks ago`;
if (diffMonths === 1) return "1 month ago";
if (diffMonths < 12) return `${diffMonths} months ago`;
if (diffYears === 1) return "1 year ago";
return `${diffYears} years ago`;
}
/**
* Format a date for display (e.g., "January 15, 2026")
* @param isoDate - ISO 8601 date string
* @returns Formatted date string
*/
export function formatFullDate(isoDate: string | null | undefined): string {
if (!isoDate) return "Unknown";
const date = new Date(isoDate);
if (isNaN(date.getTime())) return "Unknown";
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/**
* Generate HTML for displaying last updated time with hover tooltip
* @param isoDate - ISO 8601 date string
* @returns HTML string with relative time and title attribute
*/
export function getLastUpdatedHtml(isoDate: string | null | undefined): string {
const relativeTime = formatRelativeTime(isoDate);
const fullDate = formatFullDate(isoDate);
if (relativeTime === "Unknown") {
return `<span class="last-updated">Updated: Unknown</span>`;
}
return `<span class="last-updated" title="${escapeHtml(fullDate)}">Updated ${relativeTime}</span>`;
}