initial
This commit is contained in:
551
src/script.js
Normal file
551
src/script.js
Normal file
@@ -0,0 +1,551 @@
|
||||
// Set current year in footer
|
||||
document.getElementById('current-year').textContent = new Date().getFullYear();
|
||||
|
||||
// Default configuration
|
||||
let config = {
|
||||
server: localStorage.getItem('giteaServer') || '',
|
||||
port: localStorage.getItem('giteaPort') || '3000',
|
||||
sshPort: localStorage.getItem('giteaSSHPort') || '22',
|
||||
token: localStorage.getItem('giteaToken') || ''
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
const pluginsContainer = document.getElementById('plugins-container');
|
||||
const searchInput = document.getElementById('search');
|
||||
const filterButtons = document.querySelectorAll('.filter-btn');
|
||||
const errorContainer = document.getElementById('error-container');
|
||||
const toggleTokenBtn = document.getElementById('toggle-token');
|
||||
|
||||
// State
|
||||
let allRepos = [];
|
||||
let currentFilter = 'all';
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadConfig();
|
||||
setupEventListeners();
|
||||
|
||||
// Toggle token visibility
|
||||
toggleTokenBtn.addEventListener('click', () => {
|
||||
const tokenInput = document.getElementById('access-token');
|
||||
if (tokenInput.type === 'password') {
|
||||
tokenInput.type = 'text';
|
||||
toggleTokenBtn.innerHTML = '<i class="fas fa-eye-slash"></i>';
|
||||
} else {
|
||||
tokenInput.type = 'password';
|
||||
toggleTokenBtn.innerHTML = '<i class="fas fa-eye"></i>';
|
||||
}
|
||||
});
|
||||
|
||||
// If we have a server configured, fetch repos immediately
|
||||
if (config.server) {
|
||||
fetchRepositories();
|
||||
} else {
|
||||
// Show config panel if no server is configured
|
||||
document.getElementById('server-config').classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
function loadConfig() {
|
||||
document.getElementById('server-address').value = config.server;
|
||||
document.getElementById('http-port').value = config.port;
|
||||
document.getElementById('ssh-port').value = config.sshPort;
|
||||
document.getElementById('access-token').value = config.token;
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// Search functionality
|
||||
searchInput.addEventListener('input', debounce(filterRepositories, 300));
|
||||
|
||||
// Filter buttons
|
||||
filterButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
filterButtons.forEach(btn => {
|
||||
btn.classList.remove('bg-indigo-600', 'text-white');
|
||||
btn.classList.add('bg-dark-700', 'hover:bg-dark-600', 'text-white');
|
||||
});
|
||||
button.classList.remove('bg-dark-700', 'hover:bg-dark-600');
|
||||
button.classList.add('bg-indigo-600', 'text-white');
|
||||
currentFilter = button.dataset.filter;
|
||||
filterRepositories();
|
||||
});
|
||||
});
|
||||
|
||||
// Server configuration panel
|
||||
document.getElementById('show-config').addEventListener('click', () => {
|
||||
document.getElementById('server-config').classList.remove('hidden');
|
||||
loadConfig();
|
||||
});
|
||||
|
||||
document.getElementById('cancel-config').addEventListener('click', () => {
|
||||
document.getElementById('server-config').classList.add('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('save-config').addEventListener('click', () => {
|
||||
// Save new configuration
|
||||
config = {
|
||||
server: document.getElementById('server-address').value.trim(),
|
||||
port: document.getElementById('http-port').value.trim(),
|
||||
sshPort: document.getElementById('ssh-port').value.trim(),
|
||||
token: document.getElementById('access-token').value.trim()
|
||||
};
|
||||
|
||||
// Validate required fields
|
||||
if (!config.server) {
|
||||
showError('Server address is required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('giteaServer', config.server);
|
||||
localStorage.setItem('giteaPort', config.port);
|
||||
localStorage.setItem('giteaSSHPort', config.sshPort);
|
||||
localStorage.setItem('giteaToken', config.token);
|
||||
|
||||
// Hide config panel
|
||||
document.getElementById('server-config').classList.add('hidden');
|
||||
|
||||
// Reload repositories
|
||||
pluginsContainer.innerHTML = `
|
||||
<div class="col-span-full text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500 mb-4"></div>
|
||||
<p class="text-gray-400">Loading repositories...</p>
|
||||
</div>
|
||||
`;
|
||||
errorContainer.innerHTML = '';
|
||||
fetchRepositories();
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRepositories() {
|
||||
if (!config.server) {
|
||||
showError('Server address is not configured. Please configure your Gitea server first.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBase = `${window.location.protocol}//${config.server}:${config.port}/api/v1`;
|
||||
const fetchOptions = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
...(config.token ? { 'Authorization': `token ${config.token}` } : {})
|
||||
}
|
||||
};
|
||||
|
||||
// First get all repositories (paginated)
|
||||
let allRepositories = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
let reposUrl = `${apiBase}/repos/search?limit=50&page=${page}`;
|
||||
if (config.token) {
|
||||
reposUrl += '&mode=all'; // Include private repos if authenticated
|
||||
}
|
||||
|
||||
const response = await fetch(reposUrl, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.ok || !Array.isArray(data.data)) {
|
||||
throw new Error('Invalid response format from Gitea API');
|
||||
}
|
||||
|
||||
if (data.data.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
allRepositories = allRepositories.concat(data.data);
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
if (allRepositories.length === 0) {
|
||||
throw new Error('No repositories found on this Gitea instance.');
|
||||
}
|
||||
|
||||
// Process repositories in batches to avoid overwhelming the browser
|
||||
const batchSize = 5;
|
||||
allRepos = [];
|
||||
|
||||
for (let i = 0; i < allRepositories.length; i += batchSize) {
|
||||
const batch = allRepositories.slice(i, i + batchSize);
|
||||
const processedBatch = await Promise.all(batch.map(repo => processRepository(repo, apiBase, fetchOptions)));
|
||||
|
||||
// Filter out null values and add to allRepos
|
||||
allRepos = allRepos.concat(processedBatch.filter(repo => repo !== null));
|
||||
|
||||
// Update UI with progress
|
||||
renderProgress(allRepositories.length, allRepos.length);
|
||||
}
|
||||
|
||||
if (allRepos.length === 0) {
|
||||
throw new Error('No Minecraft/Paper repositories found. Try adjusting your search criteria or check your access token.');
|
||||
}
|
||||
|
||||
renderRepositories(allRepos);
|
||||
} catch (error) {
|
||||
console.error('Error fetching repositories:', error);
|
||||
showError(`Failed to fetch repositories: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function processRepository(repo, apiBase, fetchOptions) {
|
||||
try {
|
||||
// Check if this repository has "minecraft" or "paper" as a topic
|
||||
const hasMinecraftTopic = await checkRepositoryTopics(repo, ['minecraft', 'paper'], apiBase, fetchOptions);
|
||||
if (!hasMinecraftTopic) return null;
|
||||
|
||||
// Get README for description
|
||||
let description = repo.description || 'No description available';
|
||||
try {
|
||||
const readmeContent = await getReadmeContent(repo, apiBase, fetchOptions);
|
||||
if (readmeContent) {
|
||||
description = readmeContent;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting README for ${repo.full_name}:`, error);
|
||||
}
|
||||
|
||||
// Get build status if Actions are enabled
|
||||
let buildStatus = null;
|
||||
let latestArtifactUrl = null;
|
||||
let buildTimestamp = null;
|
||||
|
||||
// Only check actions if we have a token (required for private repos)
|
||||
if (config.token) {
|
||||
try {
|
||||
const runsUrl = `${apiBase}/repos/${repo.owner.login || repo.owner.username}/${repo.name}/actions/runs`;
|
||||
const runsResponse = await fetch(runsUrl, fetchOptions);
|
||||
|
||||
if (runsResponse.ok) {
|
||||
const runsData = await runsResponse.json();
|
||||
if (runsData.workflow_runs && runsData.workflow_runs.length > 0) {
|
||||
const latestRun = runsData.workflow_runs[0];
|
||||
buildStatus = latestRun.status === 'completed' ?
|
||||
(latestRun.conclusion || null) :
|
||||
latestRun.status;
|
||||
buildTimestamp = latestRun.updated_at || latestRun.created_at;
|
||||
|
||||
if (buildStatus === 'success') {
|
||||
const artifactsUrl = `${apiBase}/repos/${repo.owner.login || repo.owner.username}/${repo.name}/actions/runs/${latestRun.id}/artifacts`;
|
||||
const artifactsResponse = await fetch(artifactsUrl, fetchOptions);
|
||||
|
||||
if (artifactsResponse.ok) {
|
||||
const artifactsData = await artifactsResponse.json();
|
||||
if (artifactsData.artifacts && artifactsData.artifacts.length > 0) {
|
||||
latestArtifactUrl = `${apiBase}/repos/${repo.owner.login || repo.owner.username}/${repo.name}/actions/artifacts/${artifactsData.artifacts[0].id}/zip`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking workflows for ${repo.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...repo,
|
||||
buildStatus,
|
||||
latestArtifactUrl,
|
||||
buildTimestamp,
|
||||
description,
|
||||
cloneUrl: `ssh://git@${config.server}:${config.sshPort}/${repo.owner.login || repo.owner.username}/${repo.name}.git`,
|
||||
httpCloneUrl: `${window.location.protocol}//${config.server}:${config.port}/${repo.owner.login || repo.owner.username}/${repo.name}.git`,
|
||||
html_url: repo.html_url || `${window.location.protocol}//${config.server}:${config.port}/${repo.owner.login || repo.owner.username}/${repo.name}`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error processing repository ${repo.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRepositoryTopics(repo, topics, apiBase, fetchOptions) {
|
||||
try {
|
||||
const topicsUrl = `${apiBase}/repos/${repo.owner.login || repo.owner.username}/${repo.name}/topics`;
|
||||
const response = await fetch(topicsUrl, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.topics || !Array.isArray(data.topics)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any of the required topics are present
|
||||
const hasTopic = topics.some(topic => data.topics.includes(topic));
|
||||
|
||||
// Add topics to repo object for filtering later
|
||||
repo.topics = data.topics;
|
||||
|
||||
return hasTopic;
|
||||
} catch (error) {
|
||||
console.error(`Error checking topics for ${repo.name}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getReadmeContent(repo, apiBase, fetchOptions) {
|
||||
try {
|
||||
const contentsUrl = `${apiBase}/repos/${repo.owner.login || repo.owner.username}/${repo.name}/contents`;
|
||||
const contentsResponse = await fetch(contentsUrl, fetchOptions);
|
||||
|
||||
if (!contentsResponse.ok) return null;
|
||||
|
||||
const contentsData = await contentsResponse.json();
|
||||
const readmeItem = contentsData.find(item =>
|
||||
item.name.toLowerCase().startsWith('readme')
|
||||
);
|
||||
|
||||
if (!readmeItem) return null;
|
||||
|
||||
const readmeResponse = await fetch(readmeItem.url, fetchOptions);
|
||||
if (!readmeResponse.ok) return null;
|
||||
|
||||
const readmeData = await readmeResponse.json();
|
||||
if (!readmeData.content || readmeData.encoding !== 'base64') return null;
|
||||
|
||||
const decodedContent = atob(readmeData.content);
|
||||
const firstParagraph = decodedContent.split('\n\n')[0]
|
||||
.replace(/^#+ .*$/m, '')
|
||||
.replace(/\[.*?\]\(.*?\)/g, '') // Remove markdown links
|
||||
.replace(/`.*?`/g, '') // Remove code tags
|
||||
.replace(/\*\*.*?\*\*/g, '') // Remove bold text
|
||||
.trim();
|
||||
|
||||
return firstParagraph && firstParagraph.length > 10 ? firstParagraph : null;
|
||||
} catch (error) {
|
||||
console.error(`Error getting README for ${repo.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderProgress(total, processed) {
|
||||
if (processed === 0) return;
|
||||
|
||||
const percent = Math.round((processed / total) * 100);
|
||||
pluginsContainer.innerHTML = `
|
||||
<div class="col-span-full text-center py-8">
|
||||
<div class="w-full bg-dark-700 rounded-full h-4 mb-4 max-w-md mx-auto">
|
||||
<div class="bg-indigo-600 h-4 rounded-full" style="width: ${percent}%"></div>
|
||||
</div>
|
||||
<p class="text-gray-400">Processing repositories... ${percent}% (${processed}/${total})</p>
|
||||
<p class="text-sm text-gray-500 mt-2">Checking for Minecraft/Paper topics and build status</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRepositories(repositories) {
|
||||
pluginsContainer.innerHTML = '';
|
||||
|
||||
if (repositories.length === 0) {
|
||||
pluginsContainer.innerHTML = `
|
||||
<div class="col-span-full text-center py-8">
|
||||
<i class="fas fa-inbox text-4xl text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-400">No Minecraft/Paper repositories found matching your criteria.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
repositories.forEach(repo => {
|
||||
const repoCard = document.createElement('div');
|
||||
repoCard.className = 'glass-panel rounded-xl overflow-hidden fade-in hover:shadow-lg transition-shadow';
|
||||
|
||||
let statusBadge = '';
|
||||
|
||||
// Only show status badge if we have build status information
|
||||
if (repo.buildStatus) {
|
||||
let statusColor = 'bg-yellow-500';
|
||||
let statusIcon = 'fa-question-circle';
|
||||
let statusText = 'Unknown';
|
||||
|
||||
if (repo.buildStatus === 'success') {
|
||||
statusColor = 'bg-green-500';
|
||||
statusIcon = 'fa-check-circle';
|
||||
statusText = 'Success';
|
||||
} else if (repo.buildStatus === 'failure') {
|
||||
statusColor = 'bg-red-500';
|
||||
statusIcon = 'fa-times-circle';
|
||||
statusText = 'Failed';
|
||||
} else if (repo.buildStatus === 'running') {
|
||||
statusColor = 'bg-blue-500 badge-pulse';
|
||||
statusIcon = 'fa-sync-alt fa-spin';
|
||||
statusText = 'Running';
|
||||
}
|
||||
|
||||
statusBadge = `
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold text-white ${statusColor} flex items-center gap-1">
|
||||
<i class="fas ${statusIcon}"></i> ${statusText}
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const lastUpdated = repo.buildTimestamp ?
|
||||
new Date(repo.buildTimestamp).toLocaleDateString() :
|
||||
new Date(repo.updated_at || repo.created_at).toLocaleDateString();
|
||||
|
||||
repoCard.innerHTML = `
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h3 class="text-xl font-bold text-white truncate">${repo.name}</h3>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-1">${repo.owner.login || repo.owner.username}</p>
|
||||
<p class="text-gray-300 my-4 line-clamp-3">${repo.description}</p>
|
||||
|
||||
<div class="flex justify-between text-sm text-gray-500 mb-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-star mr-1"></i> ${repo.stargazers_count || repo.stars_count || 0}
|
||||
</div>
|
||||
<div>
|
||||
<i class="far fa-clock mr-1"></i> ${lastUpdated}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<a href="${repo.html_url}" target="_blank"
|
||||
class="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white text-center py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-external-link-alt"></i> View
|
||||
</a>
|
||||
${repo.latestArtifactUrl ?
|
||||
`<a href="${repo.latestArtifactUrl}"
|
||||
class="flex-1 bg-green-600 hover:bg-green-700 text-white text-center py-2 px-4 rounded-lg transition-colors flex items-center justify-center gap-2">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>` :
|
||||
`<button onclick="copyCloneUrl('${repo.httpCloneUrl}')"
|
||||
class="flex-1 glass-button text-white text-center py-2 px-4 rounded-lg flex items-center justify-center gap-2">
|
||||
<i class="fas fa-copy"></i> Clone
|
||||
</button>`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
pluginsContainer.appendChild(repoCard);
|
||||
});
|
||||
}
|
||||
|
||||
function filterRepositories() {
|
||||
const searchTerm = searchInput.value.toLowerCase();
|
||||
|
||||
const filteredRepos = allRepos.filter(repo => {
|
||||
const matchesSearch = repo.name.toLowerCase().includes(searchTerm) ||
|
||||
(repo.description && repo.description.toLowerCase().includes(searchTerm)) ||
|
||||
(repo.owner.login || repo.owner.username).toLowerCase().includes(searchTerm);
|
||||
|
||||
const matchesFilter =
|
||||
(currentFilter === 'all') ||
|
||||
(currentFilter === 'success' && repo.buildStatus === 'success') ||
|
||||
(currentFilter === 'failure' && repo.buildStatus === 'failure') ||
|
||||
(currentFilter === 'minecraft' && repo.topics?.includes('minecraft')) ||
|
||||
(currentFilter === 'paper' && repo.topics?.includes('paper'));
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
renderRepositories(filteredRepos);
|
||||
}
|
||||
|
||||
function copyCloneUrl(url) {
|
||||
navigator.clipboard.writeText(url)
|
||||
.then(() => {
|
||||
showToast('Clone URL copied to clipboard!', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error copying text: ', err);
|
||||
showToast('Failed to copy URL. Please copy it manually.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
let bgColor = 'bg-indigo-600';
|
||||
|
||||
if (type === 'success') bgColor = 'bg-green-600';
|
||||
if (type === 'error') bgColor = 'bg-red-600';
|
||||
|
||||
toast.className = `fixed bottom-4 right-4 ${bgColor} text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 z-50`;
|
||||
toast.innerHTML = `
|
||||
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'}"></i>
|
||||
${message}
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.add('opacity-0', 'transition-opacity', 'duration-300');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorContainer.innerHTML = `
|
||||
<div class="bg-red-900 bg-opacity-50 border-l-4 border-red-500 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-exclamation-circle text-red-400 mt-1"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-200">Error</h3>
|
||||
<div class="mt-2 text-sm text-red-100">
|
||||
<p>${message}</p>
|
||||
<ul class="list-disc pl-5 space-y-1 mt-2">
|
||||
<li>Verify that your Gitea server (${config.server || 'not configured'}) is running</li>
|
||||
<li>Check if authentication is required (you may need an access token)</li>
|
||||
<li>Ensure CORS is properly configured on your Gitea server</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button onclick="retryFetch()"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-red-100 bg-red-800 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
<i class="fas fa-sync-alt mr-2"></i> Retry
|
||||
</button>
|
||||
<button onclick="document.getElementById('server-config').classList.remove('hidden')"
|
||||
class="ml-3 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<i class="fas fa-cog mr-2"></i> Configure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
pluginsContainer.innerHTML = `
|
||||
<div class="col-span-full text-center py-8">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-4"></i>
|
||||
<p class="text-gray-400">Failed to load repositories.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function retryFetch() {
|
||||
errorContainer.innerHTML = '';
|
||||
pluginsContainer.innerHTML = `
|
||||
<div class="col-span-full text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500 mb-4"></div>
|
||||
<p class="text-gray-400">Loading repositories...</p>
|
||||
</div>
|
||||
`;
|
||||
fetchRepositories();
|
||||
}
|
||||
|
||||
// Utility function to debounce rapid events
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function() {
|
||||
const context = this, args = arguments;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
window.copyCloneUrl = copyCloneUrl;
|
||||
window.retryFetch = retryFetch;
|
||||
Reference in New Issue
Block a user