commit e439c08d391506c0e15e55ff96cfd54b94aff359 Author: rattatwinko Date: Sun May 11 14:09:21 2025 +0200 initial diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..281ed72 --- /dev/null +++ b/src/index.html @@ -0,0 +1,149 @@ + + + + + Minecraft/Paper Repositories - Gitea Browser + + + + + + + + +
+
+
+
+

Minecraft/Paper Repositories

+

Browse Minecraft-related projects from your Gitea instance

+
+ +
+
+
+ + +
+ +
+
+
+ + +
+
+ + + + + +
+
+
+ + +
+ + + + + +
+
+
+

Loading repositories...

+
+
+
+ + + + + \ No newline at end of file diff --git a/src/script.js b/src/script.js new file mode 100644 index 0000000..732e4f4 --- /dev/null +++ b/src/script.js @@ -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 = ''; +} else { +tokenInput.type = 'password'; +toggleTokenBtn.innerHTML = ''; +} +}); + +// 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 = ` +
+
+

Loading repositories...

+
+ `; +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 = ` +
+
+
+
+

Processing repositories... ${percent}% (${processed}/${total})

+

Checking for Minecraft/Paper topics and build status

+
+ `; +} + +function renderRepositories(repositories) { +pluginsContainer.innerHTML = ''; + +if (repositories.length === 0) { +pluginsContainer.innerHTML = ` +
+ +

No Minecraft/Paper repositories found matching your criteria.

+
+ `; +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 = ` + + ${statusText} + + `; +} + +const lastUpdated = repo.buildTimestamp ? +new Date(repo.buildTimestamp).toLocaleDateString() : +new Date(repo.updated_at || repo.created_at).toLocaleDateString(); + +repoCard.innerHTML = ` +
+
+

${repo.name}

+ ${statusBadge} +
+

${repo.owner.login || repo.owner.username}

+

${repo.description}

+ +
+
+ ${repo.stargazers_count || repo.stars_count || 0} +
+
+ ${lastUpdated} +
+
+ +
+ + View + + ${repo.latestArtifactUrl ? +` + Download + ` : +``} +
+
+ `; + +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 = ` + + ${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 = ` +
+
+
+ +
+
+

Error

+
+

${message}

+
    +
  • Verify that your Gitea server (${config.server || 'not configured'}) is running
  • +
  • Check if authentication is required (you may need an access token)
  • +
  • Ensure CORS is properly configured on your Gitea server
  • +
+
+
+ + +
+
+
+
+ `; + +pluginsContainer.innerHTML = ` +
+ +

Failed to load repositories.

+
+ `; +} + +function retryFetch() { +errorContainer.innerHTML = ''; +pluginsContainer.innerHTML = ` +
+
+

Loading repositories...

+
+ `; +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; \ No newline at end of file diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..a8419ef --- /dev/null +++ b/src/style.css @@ -0,0 +1,72 @@ +body { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + background-attachment: fixed; + color: #e2e8f0; +} + +.glass-panel { + background: rgba(26, 26, 46, 0.7); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1); +} + +.glass-button { + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); + border: 1px solid rgba(255, 255, 255, 0.2); + transition: all 0.3s ease; +} + +.glass-button:hover { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.fade-in { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.badge-pulse { + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } +} + +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} \ No newline at end of file