mirror of
https://github.com/github/awesome-copilot.git
synced 2026-03-23 09:35:13 +00:00
* feat(ui): replace emoji icons with SVG icon system Replace all emoji icons with a consistent SVG icon system to improve: - Visual consistency across platforms - Design token control and theming - Professional appearance Changes: - Add new Icon.astro component with 16 custom SVG icons - Update index.astro to use SVG icons in resource cards - Update index.ts to render SVG icons in search results - Update utils.ts to return icon names instead of emojis - Update global.css with proper SVG icon styling - Remove emoji from Footer component Icons added: robot, document, lightning, hook, workflow, plug, wrench, book, plus action icons: close, copy, download, share, external, plus, search, chevron-down Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ui): enhance hero section, add animations and mobile responsiveness Phase 2 & 3 UI/UX improvements: Hero Section Enhancements: - Add gradient text effect for title (purple to orange gradient) - Add animated floating particles in hero background - Increase hero padding for better visual impact Card Category Colors: - Add category-specific accent colors (AI, docs, power, automation, etc.) - Each category has unique glow effect on hover - Category colors: purple (AI), orange (docs), red (power), etc. Entrance Animations: - Add staggered fade-in animation for cards (50ms delay each) - Cards animate in with translateY + opacity - Add slide-down animation for search results Mobile Responsiveness: - Responsive grid: 4 cols → 2 cols → 1 col - Adjust font sizes for mobile screens - Add safe-area-inset support for notched devices - Ensure touch targets ≥44px Accessibility: - Add prefers-reduced-motion support - Disable animations for users who prefer reduced motion - Smooth scroll with fallback Additional Improvements: - Add arrow indicator on card hover - Add loading animation for count numbers - Enhanced scrollbar styling - Print styles for better printing Co-Authored-By: Claude <noreply@anthropic.com> * feat(ui): add theme toggle, enhanced search, and back to top button Theme Toggle: - Create ThemeToggle.astro component with sun/moon icons - Add theme initialization in Head.astro to prevent flash - Store theme preference in localStorage - Keyboard shortcut: Cmd/Ctrl + Shift + L - Smooth icon transition animation Back to Top Button: - Create BackToTop.astro component - Appears after scrolling 400px - Smooth scroll to top on click - Fixed position bottom-right - Respects reduced motion preference Enhanced Search: - Recent searches functionality with localStorage - Show recent searches on focus when empty - Remove individual items or clear all - Enhanced empty state with icon and hint - Cmd/Ctrl + K keyboard shortcut to focus search - Add search to recent when getting results CSS Enhancements: - Theme toggle container styles - Recent searches section styling - Search empty state with icon - Search loading spinner - Keyboard shortcut hint styles - Print styles for new components Co-Authored-By: Claude <noreply@anthropic.com> * fix(ui): resolve header and theme toggle issues - Add Copilot logo to header via Starlight config with automatic theme switching - Fix theme toggle slider direction (was reversed) - Fix theme toggle active icon highlighting (was backwards) - Change theme toggle from purple circle slider to bold text indicator - Fix theme toggle slider overflow by adding overflow: hidden - Remove duplicate banner image from home page - Clean up conflicting logo CSS rules to prevent duplication The header now displays: [ Copilot Icon ] Awesome GitHub Copilot [ Search ] Theme toggle indicators are now visually clear with bold text for selected theme. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(ui): address feedback on UI/UX improvements - Remove logo from header per brand guidance (logo config and CSS) - Fix back-to-top button visibility by moving to body level and using global styles - Fix modal visibility by adding 'visible' class for CSS animations - Fix theme toggle applying site-wide by using global styles and proper theme initialization - Update icons to use GitHub Primer SVG icons with proper fill-based styling - Fix plugin modal to render SVG icons instead of icon names - Add theme initialization script to prevent flash of unstyled content Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: move modal to body level to fix z-index stacking context issue The modal was nested inside .main-pane which has isolation: isolate, creating a new stacking context. This caused the modal's z-index to be evaluated within that context, unable to stack above the header. This fix moves the modal to be a direct child of body on page load, allowing it to properly cover the entire viewport including navbar. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
641 lines
20 KiB
TypeScript
641 lines
20 KiB
TypeScript
import { getEmbeddedData as getEmbeddedPageData } from "./embedded-data";
|
|
|
|
/**
|
|
* Utility functions for the Awesome Copilot website
|
|
*/
|
|
|
|
const REPO_BASE_URL =
|
|
"https://raw.githubusercontent.com/github/awesome-copilot/main";
|
|
const REPO_GITHUB_URL = "https://github.com/github/awesome-copilot/blob/main";
|
|
|
|
// VS Code install URL configurations
|
|
const VSCODE_INSTALL_CONFIG: Record<
|
|
string,
|
|
{ baseUrl: string; scheme: string }
|
|
> = {
|
|
instructions: {
|
|
baseUrl: "https://aka.ms/awesome-copilot/install/instructions",
|
|
scheme: "chat-instructions",
|
|
},
|
|
instruction: {
|
|
baseUrl: "https://aka.ms/awesome-copilot/install/instructions",
|
|
scheme: "chat-instructions",
|
|
},
|
|
agent: {
|
|
baseUrl: "https://aka.ms/awesome-copilot/install/agent",
|
|
scheme: "chat-agent",
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Get the base path for the site
|
|
*/
|
|
export function getBasePath(): string {
|
|
// In Astro, import.meta.env.BASE_URL is available at build time
|
|
// At runtime, we use a data attribute on the body
|
|
if (typeof document !== "undefined") {
|
|
return document.body.dataset.basePath || "/";
|
|
}
|
|
return "/";
|
|
}
|
|
|
|
/**
|
|
* Fetch JSON data from the data directory
|
|
*/
|
|
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}`);
|
|
if (!response.ok) throw new Error(`Failed to fetch ${filename}`);
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error(`Error fetching ${filename}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export interface ZipDownloadFile {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
function triggerBlobDownload(blob: Blob, filename: string): void {
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
export async function downloadZipBundle(
|
|
bundleName: string,
|
|
files: ZipDownloadFile[]
|
|
): Promise<void> {
|
|
if (files.length === 0) {
|
|
throw new Error("No files found for this download.");
|
|
}
|
|
|
|
const JSZip = await loadJSZip();
|
|
const zip = new JSZip();
|
|
const folder = zip.folder(bundleName);
|
|
|
|
const fetchPromises = files.map(async (file) => {
|
|
try {
|
|
const response = await fetch(getRawGitHubUrl(file.path));
|
|
if (!response.ok) return null;
|
|
|
|
return {
|
|
name: file.name,
|
|
content: await response.text(),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(fetchPromises);
|
|
let addedFiles = 0;
|
|
|
|
for (const result of results) {
|
|
if (result && folder) {
|
|
folder.file(result.name, result.content);
|
|
addedFiles++;
|
|
}
|
|
}
|
|
|
|
if (addedFiles === 0) {
|
|
throw new Error("Failed to fetch any files");
|
|
}
|
|
|
|
const blob = await zip.generateAsync({ type: "blob" });
|
|
triggerBlobDownload(blob, `${bundleName}.zip`);
|
|
}
|
|
|
|
/**
|
|
* Fetch raw file content from GitHub
|
|
*/
|
|
export async function fetchFileContent(
|
|
filePath: string
|
|
): Promise<string | null> {
|
|
try {
|
|
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
|
|
if (!response.ok) throw new Error(`Failed to fetch ${filePath}`);
|
|
return await response.text();
|
|
} catch (error) {
|
|
console.error(`Error fetching file content:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Copy text to clipboard
|
|
*/
|
|
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
} catch {
|
|
// Deprecated fallback for older browsers that lack the async clipboard API.
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = text;
|
|
textarea.style.position = "fixed";
|
|
textarea.style.opacity = "0";
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
const success = document.execCommand("copy");
|
|
document.body.removeChild(textarea);
|
|
return success;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate VS Code install URL
|
|
* @param type - Resource type (agent, instructions)
|
|
* @param filePath - Path to the file
|
|
* @param insiders - Whether to use VS Code Insiders
|
|
*/
|
|
export function getVSCodeInstallUrl(
|
|
type: string,
|
|
filePath: string,
|
|
insiders = false
|
|
): string | null {
|
|
const config = VSCODE_INSTALL_CONFIG[type];
|
|
if (!config) return null;
|
|
|
|
const rawUrl = `${REPO_BASE_URL}/${filePath}`;
|
|
const vscodeScheme = insiders ? "vscode-insiders" : "vscode";
|
|
const innerUrl = `${vscodeScheme}:${
|
|
config.scheme
|
|
}/install?url=${encodeURIComponent(rawUrl)}`;
|
|
|
|
return `${config.baseUrl}?url=${encodeURIComponent(innerUrl)}`;
|
|
}
|
|
|
|
/**
|
|
* Get GitHub URL for a file
|
|
*/
|
|
export function getGitHubUrl(filePath: string): string {
|
|
return `${REPO_GITHUB_URL}/${filePath}`;
|
|
}
|
|
|
|
/**
|
|
* Get raw GitHub URL for a file (for fetching content)
|
|
*/
|
|
export function getRawGitHubUrl(filePath: string): string {
|
|
return `${REPO_BASE_URL}/${filePath}`;
|
|
}
|
|
|
|
/**
|
|
* Download a file from its path
|
|
*/
|
|
export async function downloadFile(filePath: string): Promise<boolean> {
|
|
try {
|
|
const response = await fetch(`${REPO_BASE_URL}/${filePath}`);
|
|
if (!response.ok) throw new Error("Failed to fetch file");
|
|
|
|
const content = await response.text();
|
|
const filename = filePath.split("/").pop() || "file.md";
|
|
|
|
const blob = new Blob([content], { type: "text/markdown" });
|
|
triggerBlobDownload(blob, filename);
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Download failed:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Share/copy link to clipboard (deep link to current page with file hash)
|
|
*/
|
|
export async function shareFile(filePath: string): Promise<boolean> {
|
|
const deepLinkUrl = `${window.location.origin}${
|
|
window.location.pathname
|
|
}#file=${encodeURIComponent(filePath)}`;
|
|
return copyToClipboard(deepLinkUrl);
|
|
}
|
|
|
|
/**
|
|
* Show a toast notification
|
|
*/
|
|
export function showToast(
|
|
message: string,
|
|
type: "success" | "error" = "success"
|
|
): void {
|
|
const existing = document.querySelector(".toast");
|
|
if (existing) existing.remove();
|
|
|
|
const toast = document.createElement("div");
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 3000);
|
|
}
|
|
|
|
/**
|
|
* Debounce function for search input
|
|
*/
|
|
export function debounce<T extends (...args: unknown[]) => void>(
|
|
func: T,
|
|
wait: number
|
|
): (...args: Parameters<T>) => void {
|
|
let timeout: ReturnType<typeof setTimeout>;
|
|
return function executedFunction(...args: Parameters<T>) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Escape HTML to prevent XSS
|
|
*/
|
|
export function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Validate and sanitize URLs to prevent XSS attacks
|
|
* Only allows http/https protocols, returns '#' for invalid URLs
|
|
*/
|
|
export function sanitizeUrl(url: string | null | undefined): string {
|
|
if (!url) return '#';
|
|
try {
|
|
const parsed = new URL(url);
|
|
// Only allow http and https protocols
|
|
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
|
return url;
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
return '#';
|
|
}
|
|
|
|
/**
|
|
* Truncate text with ellipsis
|
|
*/
|
|
export function truncate(text: string | undefined, maxLength: number): string {
|
|
if (!text || text.length <= maxLength) return text || "";
|
|
return text.slice(0, maxLength).trim() + "...";
|
|
}
|
|
|
|
/**
|
|
* Get resource type from file path
|
|
*/
|
|
export function getResourceType(filePath: string): string {
|
|
if (filePath.endsWith(".agent.md")) return "agent";
|
|
if (filePath.endsWith(".instructions.md")) return "instruction";
|
|
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>/)
|
|
if (/(^|\/)plugins\/[^/]+\/?$/.test(filePath)) return "plugin";
|
|
// Check for plugin.json files (e.g., plugins/<id>/.github/plugin/plugin.json)
|
|
if (filePath.endsWith("/.github/plugin/plugin.json")) return "plugin";
|
|
return "unknown";
|
|
}
|
|
|
|
/**
|
|
* Format a resource type for display
|
|
*/
|
|
export function formatResourceType(type: string): string {
|
|
const labels: Record<string, string> = {
|
|
agent: "🤖 Agent",
|
|
instruction: "📋 Instruction",
|
|
skill: "⚡ Skill",
|
|
hook: "🪝 Hook",
|
|
workflow: "⚡ Workflow",
|
|
plugin: "🔌 Plugin",
|
|
};
|
|
return labels[type] || type;
|
|
}
|
|
|
|
/**
|
|
* Get icon for resource type (returns SVG icon name)
|
|
*/
|
|
export function getResourceIcon(type: string): string {
|
|
const icons: Record<string, string> = {
|
|
agent: "robot",
|
|
instruction: "document",
|
|
skill: "lightning",
|
|
hook: "hook",
|
|
workflow: "workflow",
|
|
plugin: "plug",
|
|
};
|
|
return icons[type] || "document";
|
|
}
|
|
|
|
// Icon definitions with fill/stroke type info
|
|
const iconDefs: Record<string, { path: string; fill?: boolean }> = {
|
|
// Agent icon - GitHub Primer's agent-24
|
|
robot: {
|
|
fill: true,
|
|
path: '<path d="M22.5 13.919v-.278a5.097 5.097 0 0 0-4.961-5.086.858.858 0 0 1-.754-.497l-.149-.327A6.414 6.414 0 0 0 10.81 4a6.133 6.133 0 0 0-6.13 6.32l.019.628a.863.863 0 0 1-.67.869A3.263 3.263 0 0 0 1.5 14.996v.108A3.397 3.397 0 0 0 4.896 18.5h1.577a.75.75 0 0 1 0 1.5H4.896A4.896 4.896 0 0 1 0 15.104v-.108a4.761 4.761 0 0 1 3.185-4.493l-.004-.137A7.633 7.633 0 0 1 10.81 2.5a7.911 7.911 0 0 1 7.176 4.58C21.36 7.377 24 10.207 24 13.641v.278a.75.75 0 0 1-1.5 0Z"/><path d="m12.306 11.77 3.374 3.375a.749.749 0 0 1 0 1.061l-3.375 3.375-.057.051a.751.751 0 0 1-1.004-.051.751.751 0 0 1-.051-1.004l.051-.057 2.845-2.845-2.844-2.844a.75.75 0 1 1 1.061-1.061ZM22.5 19.8H18a.75.75 0 0 1 0-1.5h4.5a.75.75 0 0 1 0 1.5Z"/>',
|
|
},
|
|
document: {
|
|
path: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
},
|
|
lightning: {
|
|
path: '<path d="M13 2 4.09 12.11a1.23 1.23 0 0 0 .13 1.72l.16.14a1.23 1.23 0 0 0 1.52 0L13 9.5V22l8.91-10.11a1.23 1.23 0 0 0-.13-1.72l-.16-.14a1.23 1.23 0 0 0-1.52 0L13 14.5V2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
},
|
|
// Hook icon - GitHub Primer's sync-24
|
|
hook: {
|
|
fill: true,
|
|
path: '<path d="M3.38 8A9.502 9.502 0 0 1 12 2.5a9.502 9.502 0 0 1 9.215 7.182.75.75 0 1 0 1.456-.364C21.473 4.539 17.15 1 12 1a10.995 10.995 0 0 0-9.5 5.452V4.75a.75.75 0 0 0-1.5 0V8.5a1 1 0 0 0 1 1h3.75a.75.75 0 0 0 0-1.5H3.38Zm-.595 6.318a.75.75 0 0 0-1.455.364C2.527 19.461 6.85 23 12 23c4.052 0 7.592-2.191 9.5-5.451v1.701a.75.75 0 0 0 1.5 0V15.5a1 1 0 0 0-1-1h-3.75a.75.75 0 0 0 0 1.5h2.37A9.502 9.502 0 0 1 12 21.5c-4.446 0-8.181-3.055-9.215-7.182Z"/>',
|
|
},
|
|
// Workflow icon - GitHub Primer's workflow-24
|
|
workflow: {
|
|
fill: true,
|
|
path: '<path d="M1 3a2 2 0 0 1 2-2h6.5a2 2 0 0 1 2 2v6.5a2 2 0 0 1-2 2H7v4.063C7 16.355 7.644 17 8.438 17H12.5v-2.5a2 2 0 0 1 2-2H21a2 2 0 0 1 2 2V21a2 2 0 0 1-2 2h-6.5a2 2 0 0 1-2-2v-2.5H8.437A2.939 2.939 0 0 1 5.5 15.562V11.5H3a2 2 0 0 1-2-2Zm2-.5a.5.5 0 0 0-.5.5v6.5a.5.5 0 0 0 .5.5h6.5a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5ZM14.5 14a.5.5 0 0 0-.5.5V21a.5.5 0 0 0 .5.5H21a.5.5 0 0 0 .5-.5v-6.5a.5.5 0 0 0-.5-.5Z"/>',
|
|
},
|
|
// Plug icon - GitHub Primer's plug-24
|
|
plug: {
|
|
fill: true,
|
|
path: '<path d="M7 11.5H2.938c-.794 0-1.438.644-1.438 1.437v8.313a.75.75 0 0 1-1.5 0v-8.312A2.939 2.939 0 0 1 2.937 10H7V6.151c0-.897.678-1.648 1.57-1.74l6.055-.626 1.006-1.174A1.752 1.752 0 0 1 16.96 2h1.29c.966 0 1.75.784 1.75 1.75V6h3.25a.75.75 0 0 1 0 1.5H20V14h3.25a.75.75 0 0 1 0 1.5H20v2.25a1.75 1.75 0 0 1-1.75 1.75h-1.29a1.75 1.75 0 0 1-1.329-.611l-1.006-1.174-6.055-.627A1.749 1.749 0 0 1 7 15.348Zm9.77-7.913v.001l-1.201 1.4a.75.75 0 0 1-.492.258l-6.353.657a.25.25 0 0 0-.224.249v9.196a.25.25 0 0 0 .224.249l6.353.657c.191.02.368.112.493.258l1.2 1.401a.252.252 0 0 0 .19.087h1.29a.25.25 0 0 0 .25-.25v-14a.25.25 0 0 0-.25-.25h-1.29a.252.252 0 0 0-.19.087Z"/>',
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Get SVG icon HTML for resource type
|
|
*/
|
|
export function getResourceIconSvg(type: string, size = 20): string {
|
|
const iconName = getResourceIcon(type);
|
|
const icon = iconDefs[iconName] || iconDefs.document;
|
|
const fill = icon.fill ? 'fill="currentColor"' : 'fill="none"';
|
|
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" ${fill} aria-hidden="true">${icon.path}</svg>`;
|
|
}
|
|
|
|
/**
|
|
* Generate HTML for install dropdown button
|
|
*/
|
|
export function getInstallDropdownHtml(
|
|
type: string,
|
|
filePath: string,
|
|
small = false
|
|
): string {
|
|
const vscodeUrl = getVSCodeInstallUrl(type, filePath, false);
|
|
const insidersUrl = getVSCodeInstallUrl(type, filePath, true);
|
|
|
|
if (!vscodeUrl) return "";
|
|
|
|
const sizeClass = small ? "install-dropdown-small" : "";
|
|
const uniqueId = `install-${filePath.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
|
|
|
return `
|
|
<div class="install-dropdown ${sizeClass}" id="${uniqueId}" data-install-scope="list">
|
|
<a href="${vscodeUrl}" class="btn btn-primary ${
|
|
small ? "btn-small" : ""
|
|
} install-btn-main" target="_blank" rel="noopener">
|
|
Install
|
|
</a>
|
|
<button type="button" class="btn btn-primary ${
|
|
small ? "btn-small" : ""
|
|
} install-btn-toggle" aria-label="Install options" aria-expanded="false">
|
|
<svg viewBox="0 0 16 16" width="12" height="12" fill="currentColor">
|
|
<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"/>
|
|
</svg>
|
|
</button>
|
|
<div class="install-dropdown-menu">
|
|
<a href="${vscodeUrl}" target="_blank" rel="noopener">
|
|
VS Code
|
|
</a>
|
|
<a href="${insidersUrl}" target="_blank" rel="noopener">
|
|
VS Code Insiders
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Setup dropdown close handlers for dynamically created dropdowns
|
|
*/
|
|
export function setupDropdownCloseHandlers(): void {
|
|
if (dropdownHandlersReady) return;
|
|
dropdownHandlersReady = true;
|
|
|
|
document.addEventListener(
|
|
"click",
|
|
(e) => {
|
|
const target = e.target as HTMLElement;
|
|
const dropdown = target.closest(
|
|
'.install-dropdown[data-install-scope="list"]'
|
|
);
|
|
const toggle = target.closest(
|
|
".install-btn-toggle"
|
|
) as HTMLButtonElement | null;
|
|
const menuLink = target.closest(
|
|
".install-dropdown-menu a"
|
|
) as HTMLAnchorElement | null;
|
|
|
|
if (dropdown) {
|
|
e.stopPropagation();
|
|
|
|
if (toggle) {
|
|
e.preventDefault();
|
|
const isOpen = dropdown.classList.toggle("open");
|
|
toggle.setAttribute("aria-expanded", String(isOpen));
|
|
return;
|
|
}
|
|
|
|
if (menuLink) {
|
|
dropdown.classList.remove("open");
|
|
const toggleBtn = dropdown.querySelector<HTMLButtonElement>(
|
|
".install-btn-toggle"
|
|
);
|
|
toggleBtn?.setAttribute("aria-expanded", "false");
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
document
|
|
.querySelectorAll('.install-dropdown[data-install-scope="list"].open')
|
|
.forEach((openDropdown) => {
|
|
openDropdown.classList.remove("open");
|
|
const toggleBtn = openDropdown.querySelector<HTMLButtonElement>(
|
|
".install-btn-toggle"
|
|
);
|
|
toggleBtn?.setAttribute("aria-expanded", "false");
|
|
});
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generate HTML for action buttons (download, share) in list view
|
|
*/
|
|
export function getActionButtonsHtml(filePath: string, small = false): string {
|
|
const btnClass = small ? "btn-small" : "";
|
|
const iconSize = small ? 14 : 16;
|
|
|
|
return `
|
|
<button class="btn btn-secondary ${btnClass} action-download" data-path="${escapeHtml(
|
|
filePath
|
|
)}" title="Download file">
|
|
<svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor">
|
|
<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>
|
|
</button>
|
|
<button class="btn btn-secondary ${btnClass} action-share" data-path="${escapeHtml(
|
|
filePath
|
|
)}" title="Copy link">
|
|
<svg viewBox="0 0 16 16" width="${iconSize}" height="${iconSize}" fill="currentColor">
|
|
<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>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Setup global action handlers for download and share buttons
|
|
*/
|
|
export function setupActionHandlers(): void {
|
|
if (actionHandlersReady) return;
|
|
actionHandlersReady = true;
|
|
|
|
document.addEventListener(
|
|
"click",
|
|
async (e) => {
|
|
const target = (e.target as HTMLElement).closest(
|
|
".action-download, .action-share"
|
|
) as HTMLElement | null;
|
|
if (!target) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const path = target.dataset.path;
|
|
if (!path) return;
|
|
|
|
if (target.classList.contains("action-download")) {
|
|
const success = await downloadFile(path);
|
|
showToast(
|
|
success ? "Download started!" : "Download failed",
|
|
success ? "success" : "error"
|
|
);
|
|
return;
|
|
}
|
|
|
|
const success = await shareFile(path);
|
|
showToast(
|
|
success ? "Link copied!" : "Failed to copy link",
|
|
success ? "success" : "error"
|
|
);
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
let dropdownHandlersReady = 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>`;
|
|
}
|