// 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();
};
}
})();