initial
This commit is contained in:
149
src/index.html
Normal file
149
src/index.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Minecraft/Paper Repositories - Gitea Browser</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="script.js" async defer></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
glass: 'rgba(255, 255, 255, 0.1)',
|
||||
'glass-border': 'rgba(255, 255, 255, 0.2)',
|
||||
'glass-highlight': 'rgba(255, 255, 255, 0.3)',
|
||||
dark: {
|
||||
900: '#0a0a0a',
|
||||
800: '#1a1a1a',
|
||||
700: '#2a2a2a',
|
||||
600: '#3a3a3a',
|
||||
}
|
||||
},
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
sm: '4px',
|
||||
DEFAULT: '8px',
|
||||
md: '12px',
|
||||
lg: '16px',
|
||||
xl: '24px',
|
||||
},
|
||||
boxShadow: {
|
||||
'glass': '0 4px 30px rgba(0, 0, 0, 0.1)',
|
||||
'glass-inset': 'inset 0 4px 30px rgba(0, 0, 0, 0.1)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet", href="style.css">
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="glass-panel shadow-lg">
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white">Minecraft/Paper Repositories</h1>
|
||||
<p class="text-indigo-200">Browse Minecraft-related projects from your Gitea instance</p>
|
||||
</div>
|
||||
<button id="show-config" class="glass-button text-white px-4 py-2 rounded-lg flex items-center gap-2">
|
||||
<i class="fas fa-cog"></i> Configure Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-grow container mx-auto px-4 py-6">
|
||||
<!-- Search and Filter Section -->
|
||||
<div class="glass-panel rounded-xl p-4 mb-6">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-grow relative">
|
||||
<input type="text" id="search" placeholder="Search repositories..."
|
||||
class="w-full px-4 py-2 bg-dark-800 border border-dark-600 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-white placeholder-gray-400">
|
||||
<i class="fas fa-search absolute right-3 top-3 text-gray-400"></i>
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 md:pb-0">
|
||||
<button class="filter-btn px-4 py-2 rounded-lg whitespace-nowrap transition-colors bg-indigo-600 text-white" data-filter="all">
|
||||
All Repos
|
||||
</button>
|
||||
<button class="filter-btn px-4 py-2 rounded-lg whitespace-nowrap transition-colors bg-dark-700 hover:bg-dark-600 text-white" data-filter="success">
|
||||
<i class="fas fa-check-circle text-green-400 mr-1"></i> Success
|
||||
</button>
|
||||
<button class="filter-btn px-4 py-2 rounded-lg whitespace-nowrap transition-colors bg-dark-700 hover:bg-dark-600 text-white" data-filter="failure">
|
||||
<i class="fas fa-times-circle text-red-400 mr-1"></i> Failed
|
||||
</button>
|
||||
<button class="filter-btn px-4 py-2 rounded-lg whitespace-nowrap transition-colors bg-dark-700 hover:bg-dark-600 text-white" data-filter="minecraft">
|
||||
<i class="fas fa-cube text-green-500 mr-1"></i> Minecraft
|
||||
</button>
|
||||
<button class="filter-btn px-4 py-2 rounded-lg whitespace-nowrap transition-colors bg-dark-700 hover:bg-dark-600 text-white" data-filter="paper">
|
||||
<i class="fas fa-scroll text-blue-400 mr-1"></i> Paper
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Container -->
|
||||
<div id="error-container" class="mb-6"></div>
|
||||
|
||||
<!-- Server Configuration Panel -->
|
||||
<div id="server-config" class="glass-panel rounded-xl p-6 mb-6 hidden">
|
||||
<h3 class="text-xl font-bold mb-4 text-white">Server Configuration</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label for="server-address" class="block text-sm font-medium text-gray-300 mb-1">Gitea Server Address</label>
|
||||
<input type="text" id="server-address" placeholder="gitea.example.com"
|
||||
class="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label for="http-port" class="block text-sm font-medium text-gray-300 mb-1">HTTP Port</label>
|
||||
<input type="text" id="http-port" placeholder="3000"
|
||||
class="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500 text-white">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ssh-port" class="block text-sm font-medium text-gray-300 mb-1">SSH Port</label>
|
||||
<input type="text" id="ssh-port" placeholder="22"
|
||||
class="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500 text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="access-token" class="block text-sm font-medium text-gray-300 mb-1">Access Token</label>
|
||||
<div class="relative">
|
||||
<input type="password" id="access-token" placeholder="Required for private repositories"
|
||||
class="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500 text-white pr-10">
|
||||
<button id="toggle-token" class="absolute right-3 top-2 text-gray-400 hover:text-gray-200">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Create a token in your Gitea account settings</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button id="cancel-config" class="px-4 py-2 bg-dark-700 hover:bg-dark-600 text-white rounded-md transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="save-config" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition-colors">
|
||||
Save & Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repositories Container -->
|
||||
<div id="plugins-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="glass-panel py-4">
|
||||
<div class="container mx-auto px-4 text-center text-gray-400 text-sm">
|
||||
<p>Gitea Kotlin Repo Index © <span id="current-year"></span> | Powered by Gitea API</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
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;
|
||||
72
src/style.css
Normal file
72
src/style.css
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user