Files
MCPIndex/src/script.js
rattatwinko e439c08d39 initial
2025-05-11 14:09:21 +02:00

551 lines
19 KiB
JavaScript

// 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;