forgebuilder / index.html
DxrkMonteva's picture
Upload index.html
78d474b verified
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ForgeBuilder — ZIP → JAR</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Unbounded:wght@400;700;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0c;
--surface: #111116;
--surface2: #18181f;
--border: #2a2a35;
--border2: #3a3a48;
--accent: #e8572a;
--accent2: #ff7a4d;
--green: #2ecc71;
--yellow: #f39c12;
--red: #e74c3c;
--text: #e8e8f0;
--muted: #6e6e85;
--mono: 'JetBrains Mono', monospace;
--display: 'Unbounded', sans-serif;
}
html { scroll-behavior: smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--mono);
font-size: 14px;
line-height: 1.6;
min-height: 100vh;
}
/* ── NOISE OVERLAY ── */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.6;
}
/* ── LAYOUT ── */
.wrap { max-width: 860px; margin: 0 auto; padding: 0 20px; position: relative; z-index: 1; }
/* ── HEADER ── */
header {
padding: 48px 0 40px;
border-bottom: 1px solid var(--border);
margin-bottom: 48px;
}
.logo {
font-family: var(--display);
font-size: clamp(28px, 6vw, 48px);
font-weight: 900;
letter-spacing: -0.03em;
line-height: 1;
margin-bottom: 12px;
}
.logo span { color: var(--accent); }
.tagline {
color: var(--muted);
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ── CARD ── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
margin-bottom: 20px;
}
.card-title {
font-family: var(--display);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 8px;
}
.card-title::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background: var(--accent);
border-radius: 2px;
}
/* ── VERSION SELECTOR ── */
.version-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.version-btn {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px 20px;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
color: var(--text);
font-family: var(--mono);
}
.version-btn:hover {
border-color: var(--accent);
background: #1a1016;
}
.version-btn.active {
border-color: var(--accent);
background: #1a1016;
box-shadow: 0 0 0 1px var(--accent);
}
.version-btn .ver-num {
font-family: var(--display);
font-size: 20px;
font-weight: 700;
color: var(--accent2);
display: block;
margin-bottom: 4px;
}
.version-btn .ver-label {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.version-btn.active .ver-label { color: var(--accent); }
/* ── DROP ZONE ── */
.dropzone {
border: 2px dashed var(--border2);
border-radius: 10px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
background: var(--surface2);
}
.dropzone:hover, .dropzone.drag-over {
border-color: var(--accent);
background: #14100e;
}
.dropzone input[type=file] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
width: 100%;
height: 100%;
}
.drop-icon {
font-size: 40px;
margin-bottom: 12px;
display: block;
filter: grayscale(0.3);
}
.drop-title {
font-family: var(--display);
font-size: 16px;
font-weight: 700;
margin-bottom: 6px;
}
.drop-sub { color: var(--muted); font-size: 12px; }
.file-selected {
display: none;
align-items: center;
gap: 12px;
background: var(--surface2);
border: 1px solid var(--green);
border-radius: 8px;
padding: 14px 18px;
margin-top: 12px;
}
.file-selected.show { display: flex; }
.file-icon { color: var(--green); font-size: 20px; }
.file-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { color: var(--muted); font-size: 12px; }
/* ── BUILD BUTTON ── */
.btn-build {
width: 100%;
padding: 18px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 10px;
font-family: var(--display);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.05em;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 8px;
text-transform: uppercase;
}
.btn-build:hover:not(:disabled) {
background: var(--accent2);
transform: translateY(-1px);
}
.btn-build:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
/* ── PROGRESS ── */
#progress-section { display: none; }
#progress-section.show { display: block; }
.log-box {
background: #06060a;
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
height: 280px;
overflow-y: auto;
font-size: 12px;
line-height: 1.8;
}
.log-box::-webkit-scrollbar { width: 4px; }
.log-box::-webkit-scrollbar-track { background: transparent; }
.log-box::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
.log-line { display: flex; gap: 12px; }
.log-time { color: var(--muted); min-width: 60px; }
.log-msg { flex: 1; }
.log-msg.ok { color: var(--green); }
.log-msg.err { color: var(--red); }
.log-msg.warn { color: var(--yellow); }
.log-msg.info { color: #89b4fa; }
.progress-bar-wrap {
background: var(--surface2);
border-radius: 4px;
height: 6px;
margin: 16px 0;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
border-radius: 4px;
width: 0%;
transition: width 0.5s ease;
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--muted);
margin-bottom: 8px;
}
/* ── RESULT ── */
#result-section { display: none; }
#result-section.show { display: block; }
.result-success {
background: #0a1a0f;
border: 1px solid var(--green);
border-radius: 10px;
padding: 28px;
text-align: center;
}
.result-icon { font-size: 48px; margin-bottom: 12px; display: block; }
.result-title {
font-family: var(--display);
font-size: 22px;
font-weight: 900;
color: var(--green);
margin-bottom: 8px;
}
.result-sub { color: var(--muted); font-size: 13px; margin-bottom: 24px; }
.btn-download {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 14px 28px;
background: var(--green);
color: #06060a;
border: none;
border-radius: 8px;
font-family: var(--display);
font-size: 14px;
font-weight: 700;
cursor: pointer;
text-decoration: none;
transition: opacity 0.15s;
}
.btn-download:hover { opacity: 0.85; }
.result-error {
background: #1a0a0a;
border: 1px solid var(--red);
border-radius: 10px;
padding: 28px;
text-align: center;
}
.result-error .result-title { color: var(--red); }
.btn-retry {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: transparent;
color: var(--text);
border: 1px solid var(--border2);
border-radius: 8px;
font-family: var(--mono);
font-size: 13px;
cursor: pointer;
margin-top: 16px;
transition: border-color 0.15s;
}
.btn-retry:hover { border-color: var(--accent); }
/* ── INFO BOXES ── */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 8px;
}
.info-item {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.info-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
margin-bottom: 6px;
}
.info-val {
font-size: 13px;
font-weight: 600;
color: var(--accent2);
}
/* ── FOOTER ── */
footer {
border-top: 1px solid var(--border);
margin-top: 60px;
padding: 32px 0;
color: var(--muted);
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
/* ── SPINNER ── */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255,255,255,0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── WARNING BADGE ── */
.warn-badge {
background: #1a1400;
border: 1px solid var(--yellow);
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
color: var(--yellow);
margin-top: 12px;
display: flex;
gap: 8px;
align-items: flex-start;
}
@media (max-width: 500px) {
.version-grid { grid-template-columns: 1fr; }
.card { padding: 20px; }
header { padding: 32px 0 28px; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">Forge<span>Builder</span></div>
<div class="tagline">ZIP исходников → готовый JAR мод // Forge 1.12.2 &amp; 1.20.1</div>
</header>
<!-- STEP 1: VERSION -->
<div class="card">
<div class="card-title">Шаг 1 — Версия Minecraft</div>
<div class="version-grid">
<button class="version-btn active" data-ver="1.12.2" onclick="selectVersion(this)">
<span class="ver-num">1.12.2</span>
<span class="ver-label">Forge 14.23.5.2860 · JDK 8</span>
</button>
<button class="version-btn" data-ver="1.20.1" onclick="selectVersion(this)">
<span class="ver-num">1.20.1</span>
<span class="ver-label">Forge 47.2.0 · JDK 17</span>
</button>
</div>
</div>
<!-- STEP 2: UPLOAD -->
<div class="card">
<div class="card-title">Шаг 2 — Загрузи ZIP с исходниками</div>
<div class="dropzone" id="dropzone">
<input type="file" id="fileInput" accept=".zip" onchange="handleFile(this.files[0])">
<span class="drop-icon">📦</span>
<div class="drop-title">Перетащи ZIP или нажми</div>
<div class="drop-sub">Максимум 50 MB · Должен содержать build.gradle + src/</div>
</div>
<div class="file-selected" id="fileInfo">
<span class="file-icon"></span>
<span class="file-name" id="fileName"></span>
<span class="file-size" id="fileSize"></span>
</div>
<div class="warn-badge">
⚠ Загружай только свой код. Вредоносные файлы блокируются и IP банится автоматически.
</div>
</div>
<!-- STEP 3: BUILD -->
<div class="card">
<div class="card-title">Шаг 3 — Сборка</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">Выбранная версия</div>
<div class="info-val" id="infoVer">1.12.2 Forge</div>
</div>
<div class="info-item">
<div class="info-label">Ожидаемое время</div>
<div class="info-val">3 — 8 минут</div>
</div>
<div class="info-item">
<div class="info-label">Сервер</div>
<div class="info-val">Hugging Face Spaces</div>
</div>
<div class="info-item">
<div class="info-label">JAR хранится</div>
<div class="info-val">1 час после сборки</div>
</div>
</div>
<button class="btn-build" id="buildBtn" onclick="startBuild()" disabled>
Собрать JAR
</button>
</div>
<!-- PROGRESS -->
<div class="card" id="progress-section">
<div class="card-title">Сборка...</div>
<div class="progress-label">
<span id="progressStep">Запуск контейнера</span>
<span id="progressPct">0%</span>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="log-box" id="logBox"></div>
</div>
<!-- RESULT -->
<div id="result-section"></div>
<footer>
<span>ForgeBuilder v1.0 — бесплатно для всех</span>
<span>Сервер: Hugging Face Spaces · 16 GB RAM</span>
</footer>
</div>
<script>
let selectedVersion = '1.12.2';
let selectedFile = null;
let buildJobId = null;
let pollInterval = null;
// ── Version select ──────────────────────────────────────────────────────────
function selectVersion(btn) {
document.querySelectorAll('.version-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedVersion = btn.dataset.ver;
document.getElementById('infoVer').textContent = selectedVersion + ' Forge';
checkReady();
}
// ── File handling ───────────────────────────────────────────────────────────
function handleFile(file) {
if (!file) return;
if (!file.name.endsWith('.zip')) {
alert('Нужен ZIP файл!');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('Файл слишком большой. Максимум 50 MB.');
return;
}
selectedFile = file;
document.getElementById('fileName').textContent = file.name;
document.getElementById('fileSize').textContent = formatSize(file.size);
document.getElementById('fileInfo').classList.add('show');
checkReady();
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/(1024*1024)).toFixed(1) + ' MB';
}
// Drag and drop
const dz = document.getElementById('dropzone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag-over'); });
dz.addEventListener('dragleave', () => dz.classList.remove('drag-over'));
dz.addEventListener('drop', e => {
e.preventDefault();
dz.classList.remove('drag-over');
handleFile(e.dataTransfer.files[0]);
});
// ── Build readiness ─────────────────────────────────────────────────────────
function checkReady() {
document.getElementById('buildBtn').disabled = !selectedFile;
}
// ── Build ───────────────────────────────────────────────────────────────────
async function startBuild() {
if (!selectedFile) return;
// Show progress section
document.getElementById('progress-section').classList.add('show');
document.getElementById('result-section').classList.remove('show');
document.getElementById('result-section').innerHTML = '';
document.getElementById('buildBtn').disabled = true;
document.getElementById('buildBtn').innerHTML = '<span class="spinner"></span>Собирается...';
document.getElementById('logBox').innerHTML = '';
setProgress(0, 'Загрузка файла...');
// Upload
const form = new FormData();
form.append('file', selectedFile);
form.append('version', selectedVersion);
try {
addLog('Отправка ZIP на сервер...', 'info');
const res = await fetch('/build', { method: 'POST', body: form });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Ошибка сервера' }));
throw new Error(err.error || 'Ошибка ' + res.status);
}
const data = await res.json();
buildJobId = data.job_id;
addLog('Job ID: ' + buildJobId, 'info');
setProgress(10, 'Запуск Docker контейнера...');
pollBuild();
} catch (e) {
showError(e.message);
}
}
// ── Polling ─────────────────────────────────────────────────────────────────
function pollBuild() {
pollInterval = setInterval(async () => {
try {
const res = await fetch('/status/' + buildJobId);
const data = await res.json();
// Add new log lines
if (data.logs && data.logs.length > 0) {
data.logs.forEach(line => {
const cls = line.includes('ERROR') ? 'err'
: line.includes('WARN') ? 'warn'
: line.includes('BUILD SUCCESSFUL') ? 'ok'
: '';
addLog(line, cls);
});
}
// Update progress
updateProgressFromStatus(data.status, data.progress || 0);
if (data.status === 'done') {
clearInterval(pollInterval);
showSuccess(data.jar_url, data.jar_name, data.build_time);
} else if (data.status === 'error') {
clearInterval(pollInterval);
showError(data.error || 'Ошибка сборки. Проверь build.gradle.');
}
} catch (e) {
addLog('Ошибка связи с сервером: ' + e.message, 'err');
}
}, 2000);
}
function updateProgressFromStatus(status, pct) {
const steps = {
'uploading': [10, 'Загрузка файла...'],
'extracting': [20, 'Распаковка ZIP...'],
'validating': [25, 'Проверка структуры проекта...'],
'container': [30, 'Запуск Docker контейнера...'],
'deps': [50, 'Загрузка зависимостей Forge...'],
'compiling': [75, 'Компиляция Java...'],
'packaging': [90, 'Создание JAR...'],
'done': [100,'Готово!'],
};
const s = steps[status] || [pct, status];
setProgress(s[0], s[1]);
}
// ── UI helpers ──────────────────────────────────────────────────────────────
function setProgress(pct, label) {
document.getElementById('progressBar').style.width = pct + '%';
document.getElementById('progressPct').textContent = pct + '%';
document.getElementById('progressStep').textContent = label;
}
function addLog(msg, cls) {
const box = document.getElementById('logBox');
const now = new Date();
const t = now.getHours().toString().padStart(2,'0') + ':'
+ now.getMinutes().toString().padStart(2,'0') + ':'
+ now.getSeconds().toString().padStart(2,'0');
const line = document.createElement('div');
line.className = 'log-line';
line.innerHTML = `<span class="log-time">${t}</span><span class="log-msg ${cls||''}">${escHtml(msg)}</span>`;
box.appendChild(line);
box.scrollTop = box.scrollHeight;
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function showSuccess(url, name, time) {
document.getElementById('buildBtn').disabled = false;
document.getElementById('buildBtn').textContent = 'Собрать JAR';
const sec = document.getElementById('result-section');
sec.innerHTML = `
<div class="card">
<div class="result-success">
<span class="result-icon">✅</span>
<div class="result-title">BUILD SUCCESSFUL</div>
<div class="result-sub">Время сборки: ${time || '—'} · Файл удалится через 1 час</div>
<a class="btn-download" href="${url}" download="${name}">
⬇ Скачать ${name}
</a>
</div>
</div>`;
sec.classList.add('show');
sec.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function showError(msg) {
document.getElementById('buildBtn').disabled = false;
document.getElementById('buildBtn').textContent = 'Собрать JAR';
const sec = document.getElementById('result-section');
sec.innerHTML = `
<div class="card">
<div class="result-error">
<span class="result-icon">❌</span>
<div class="result-title">BUILD FAILED</div>
<div class="result-sub">${escHtml(msg)}</div>
<button class="btn-retry" onclick="resetBuild()">↺ Попробовать снова</button>
</div>
</div>`;
sec.classList.add('show');
sec.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function resetBuild() {
document.getElementById('result-section').classList.remove('show');
document.getElementById('result-section').innerHTML = '';
document.getElementById('progress-section').classList.remove('show');
}
</script>
</body>
</html>