// Riaslink Port Builder — frontend logic. // No framework, no build step: this is the entire client-side behavior for // the build form, talking to the plain FastAPI routes in app.py. (() => { 'use strict'; const form = document.getElementById('build-form'); const buildBtn = document.getElementById('build-btn'); const buildBtnLabel = buildBtn.querySelector('.build-btn-label'); const statusPill = document.getElementById('status-pill'); const logOutput = document.getElementById('log-output'); const copyLogBtn = document.getElementById('copy-log-btn'); const copyLogIcon = document.getElementById('copy-log-icon'); const downloadArea = document.getElementById('download-area'); const downloadLink = document.getElementById('download-link'); const fileInput = document.getElementById('www_zip'); const fileDrop = document.getElementById('file-drop'); const fileDropLabel = document.getElementById('file-drop-label'); const uploadRing = document.getElementById('upload-ring'); const uploadRingProgress = document.getElementById('upload-ring-progress'); const uploadRingLabel = document.getElementById('upload-ring-label'); const accentColor = document.getElementById('accent_hex'); const accentColorText = document.getElementById('accent_hex_text'); // ------------------------------------------------------------------- // File drop UX (native still handles the actual // selection — this just adds drag-over styling and a filename label). // ------------------------------------------------------------------- fileInput.addEventListener('change', () => { updateFileDropLabel(); }); ['dragenter', 'dragover'].forEach((evt) => { fileDrop.addEventListener(evt, (e) => { e.preventDefault(); fileDrop.classList.add('drag-over'); }); }); ['dragleave', 'drop'].forEach((evt) => { fileDrop.addEventListener(evt, (e) => { e.preventDefault(); fileDrop.classList.remove('drag-over'); }); }); fileDrop.addEventListener('drop', (e) => { const dropped = e.dataTransfer?.files; if (dropped && dropped.length > 0) { fileInput.files = dropped; updateFileDropLabel(); } }); function updateFileDropLabel() { const file = fileInput.files[0]; if (file) { fileDropLabel.textContent = file.name; fileDropLabel.classList.add('has-file'); } else { fileDropLabel.textContent = 'Dosya seç ya da buraya sürükle'; fileDropLabel.classList.remove('has-file'); } } // Matches the fixed stroke-dasharray in style.css (2 * pi * r, r=15.5). const CIRCLE_CIRCUMFERENCE = 97.389; function showUploadRing() { fileDrop.classList.add('uploading'); uploadRing.classList.remove('hidden'); setUploadProgress(0); } function hideUploadRing() { fileDrop.classList.remove('uploading'); uploadRing.classList.add('hidden'); } function setUploadProgress(fraction) { const clamped = Math.max(0, Math.min(1, fraction)); const offset = CIRCLE_CIRCUMFERENCE * (1 - clamped); uploadRingProgress.style.strokeDashoffset = String(offset); uploadRingLabel.textContent = `${Math.round(clamped * 100)}%`; } // ------------------------------------------------------------------- // Color picker <-> hex text field, kept in sync both directions. // ------------------------------------------------------------------- accentColor.addEventListener('input', () => { accentColorText.value = accentColor.value; }); accentColorText.addEventListener('input', () => { const value = accentColorText.value.trim(); if (/^#[0-9a-fA-F]{6}$/.test(value)) { accentColor.value = value; } }); // ------------------------------------------------------------------- // Log rendering helpers // ------------------------------------------------------------------- let logHasContent = false; function clearLog() { logOutput.innerHTML = ''; logHasContent = false; } function appendLogLine(text, kind) { if (!logHasContent) { logOutput.innerHTML = ''; logHasContent = true; } const line = document.createElement('div'); if (kind) { line.className = `log-line-${kind}`; } line.textContent = text; logOutput.appendChild(line); logOutput.scrollTop = logOutput.scrollHeight; } function setStatus(state, label) { statusPill.dataset.state = state; statusPill.textContent = label; } function getLogText() { // Each log line is its own
(see appendLogLine) — reading // logOutput.textContent directly would run every line together // with no separator, so each line's own text is collected and // joined with real newlines instead. return Array.from(logOutput.children) .map((line) => line.textContent) .join('\n'); } copyLogBtn.addEventListener('click', async () => { const text = getLogText(); if (!text) return; try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { // navigator.clipboard is only available in secure contexts // (HTTPS) — this covers plain-HTTP deployments with the // older document.execCommand approach instead. const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); } showCopyFeedback(true); } catch (err) { showCopyFeedback(false); } }); let copyFeedbackTimeout = null; function showCopyFeedback(success) { clearTimeout(copyFeedbackTimeout); copyLogBtn.classList.toggle('copied', success); copyLogIcon.textContent = success ? '✅' : '⚠️'; copyLogBtn.title = success ? 'Kopyalandı' : 'Kopyalanamadı'; copyFeedbackTimeout = setTimeout(() => { copyLogBtn.classList.remove('copied'); copyLogIcon.textContent = '📋'; copyLogBtn.title = 'Günlüğü kopyala'; }, 1600); } // ------------------------------------------------------------------- // Form submission // ------------------------------------------------------------------- form.addEventListener('submit', async (e) => { e.preventDefault(); if (!fileInput.files[0]) { appendLogLine('Önce bir www.zip dosyası seç.', 'error'); return; } buildBtn.disabled = true; buildBtnLabel.textContent = 'Başlatılıyor...'; downloadArea.classList.add('hidden'); clearLog(); setStatus('running', 'Çalışıyor'); const formData = new FormData(form); // FormData already grabs www_zip from the , and // the checkbox only appears in FormData when checked — FastAPI's // Form(bool) parses "on"/"true"/"1" etc., but an absent field would // be missing entirely rather than "false", so it's added explicitly // here to always send a real value either way. formData.set('fps_default_visible', document.getElementById('fps_default_visible').checked ? 'true' : 'false'); let buildId; try { const data = await uploadWithProgress('/api/build', formData); buildId = data.build_id; } catch (err) { appendLogLine(`Build başlatılamadı: ${err.message}`, 'error'); setStatus('failed', 'Hata'); hideUploadRing(); resetButton(); return; } hideUploadRing(); buildBtnLabel.textContent = 'Build çalışıyor...'; streamBuildLog(buildId); }); // fetch() has no upload-progress event — only XMLHttpRequest exposes // xhr.upload.onprogress, which is what drives the circular ring below. // Wrapped in a Promise so the call site can stay a plain `await`. function uploadWithProgress(url, formData) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.upload.onprogress = (event) => { if (event.lengthComputable) { if (uploadRing.classList.contains('hidden')) { showUploadRing(); } setUploadProgress(event.loaded / event.total); } }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch (err) { reject(new Error('Sunucu yanıtı okunamadı.')); } } else { reject(new Error(`Sunucu hatası (${xhr.status}): ${xhr.responseText}`)); } }; xhr.onerror = () => reject(new Error('Ağ hatası — yükleme başarısız oldu.')); xhr.send(formData); }); } function resetButton() { buildBtn.disabled = false; buildBtnLabel.textContent = 'APK Oluştur'; } // ------------------------------------------------------------------- // Server-Sent Events: live log streaming from /api/build/{id}/stream // ------------------------------------------------------------------- function streamBuildLog(buildId) { const source = new EventSource(`/api/build/${buildId}/stream`); source.onmessage = (event) => { let payload; try { payload = JSON.parse(event.data); } catch { return; } if (payload.type === 'log') { appendLogLine(payload.line); } else if (payload.type === 'error') { appendLogLine(payload.message, 'error'); setStatus('failed', 'Hata'); resetButton(); source.close(); } else if (payload.type === 'done') { source.close(); resetButton(); if (payload.status === 'success') { setStatus('success', 'Tamamlandı'); appendLogLine('✅ Build tamamlandı.', 'success'); if (payload.download_url) { downloadLink.href = payload.download_url; downloadArea.classList.remove('hidden'); } } else { setStatus('failed', 'Başarısız'); if (payload.error_message) { appendLogLine(`❌ ${payload.error_message}`, 'error'); } } } }; source.onerror = () => { // EventSource auto-retries on transient network errors, so this // only means something is actually wrong (server gone, build id // invalid) if the connection doesn't recover — close it and let // the person retry manually rather than retrying forever. source.close(); if (statusPill.dataset.state === 'running') { appendLogLine('Bağlantı kesildi. Sayfayı yenileyip tekrar dene.', 'error'); setStatus('failed', 'Bağlantı Hatası'); } resetButton(); }; } })();