/**
* Design2Clothes — app.js
* jQuery logic for the full 4-step workflow
* Step 1: Upload | Step 2: Model | Step 3: Apply | Step 4: Export
*/
/* global D2C, $ */
$(function () {
'use strict';
/* ============================================================
CONFIG & STATE
============================================================ */
const API = {
upload: D2C.basePath + '/api/upload.php',
generate: D2C.basePath + '/api/generate.php',
projects: D2C.basePath + '/api/projects.php',
};
const state = {
step: 1, // current step (1–4)
activeAssetUrl: null, // selected asset URL
activeAssetName: null,
modelUrl: null, // generated/selected model image URL
resultUrl: null, // final composed image URL
selectedModel: null, // predefined model id
history: [], // apply history for undo
};
/* ============================================================
UTILS
============================================================ */
function toast(msg, type = 'info') {
const icons = {
success: 'fa-circle-check',
warning: 'fa-triangle-exclamation',
danger: 'fa-circle-xmark',
info: 'fa-circle-info',
};
const colors = {
success: 'var(--clr-accent-purple)',
warning: 'var(--clr-accent-orange)',
danger: '#e57161',
info: '#a880ff',
};
const $t = $('<div class="toast-d2c">')
.html(`<i class="fa-solid ${icons[type]}" style="color:${colors[type]}"></i> ${msg}`);
$('#toastContainer').append($t);
setTimeout(() => $t.fadeOut(400, () => $t.remove()), 4000);
}
function setApiStatus(status, label) {
const $el = $('#apiStatus');
$el.attr('class', 'api-status api-status--' + status);
$el.html(`<i class="fa-solid fa-circle"></i> ${label}`);
}
function setStep(n) {
state.step = n;
$('.step-pip').each(function () {
const s = parseInt($(this).data('step'));
$(this).removeClass('active done');
if (s < n) $(this).addClass('done').html('<i class="fa-solid fa-check" style="font-size:9px"></i>');
else if (s === n) $(this).addClass('active').text(s);
else $(this).text(s);
});
}
function updateSummary() {
$('#metaType').text($('#garmentType').find(':selected').text() || '—');
$('#metaAsset').text(state.activeAssetName || '—');
$('#metaBlending').text($('#artBlending').find(':selected').text() || '—');
const scale = $('#artScale').val() + '%';
const rot = $('#artRotation').val() + '°';
$('#metaScaleRot').text(`${scale} / ${rot}`);
}
function showViewportLoading(msg = 'Processing...') {
$('#loadingMsg').text(msg);
$('#viewportLoading').show();
$('#viewportEmpty, #viewportContent').hide();
setApiStatus('working', msg);
// Animate progress bar
let pct = 5;
const timer = setInterval(() => {
pct = Math.min(pct + Math.random() * 12, 88);
$('#loadingBar').css('width', pct + '%');
}, 600);
return timer;
}
function hideViewportLoading(timer) {
clearInterval(timer);
$('#loadingBar').css('width', '100%');
setTimeout(() => {
$('#viewportLoading').hide();
$('#loadingBar').css('width', '0%');
}, 400);
setApiStatus('idle', 'Idle');
}
function loadModelIntoCanvas(url) {
state.modelUrl = url;
$('#layerModel').attr('src', url).show();
$('#layerArtwork').hide();
$('#viewportEmpty').hide();
$('#viewportContent').show();
// Enable apply + export buttons
$('#btnApplyArtwork').prop('disabled', !state.activeAssetUrl);
// Update export thumb
updateExportThumb(url);
setStep(3);
toast('Model ready in canvas', 'success');
setApiStatus('done', 'Done');
}
function updateExportThumb(url) {
const $thumb = $('#exportThumb');
if (url) {
$thumb.html(`<img src="${url}" alt="Preview">`);
}
}
/* ============================================================
TABS — Generic handler
============================================================ */
function initTabs($container, activeClass = 'active--purple') {
$container.on('click', '.tab-item', function () {
const tab = $(this).data('tab');
$container.find('.tab-item').removeClass('active--purple active--orange');
$(this).addClass(activeClass);
// Show pane
const $panes = $container.closest('[data-tabs-scope]').find('.tab-pane');
$panes.removeClass('active').hide();
$('#' + tab).addClass('active').show();
});
// Init: show first active pane
$container.find('.tab-pane.active').show();
}
// Asset tabs
initTabs($('#assetTabs'));
// Model tabs
initTabs($('#modelTabs'));
// Also handle tab panes in the left panel (they share same scope)
$('.tab-pane').not('.active').hide();
/* ============================================================
STEP 1 — UPLOAD
============================================================ */
const $zone = $('#uploadZone');
const $input = $('#fileInput');
$('#btnBrowse').on('click', function(e) {
e.stopPropagation(); // Avoid zone catching the button click
$input[0].click(); // Native click, safer than jQuery's .trigger('click') for files
});
$zone.on('click', function (e) {
// If clicking directly on the input or the button, do nothing to avoid loops
if ($(e.target).closest('button, input').length) return;
$input[0].click();
});
// Drag & drop visual
$zone.on('dragover dragenter', e => { e.preventDefault(); $zone.addClass('drag-over'); })
.on('dragleave drop', () => $zone.removeClass('drag-over'));
$zone.on('drop', function (e) {
e.preventDefault();
const files = e.originalEvent.dataTransfer.files;
if (files.length) handleFiles(files);
});
$input.on('change', function () {
if (this.files.length) handleFiles(this.files);
});
function handleFiles(files) {
Array.from(files).forEach(file => uploadFile(file));
}
function uploadFile(file) {
const ext = file.name.split('.').pop().toUpperCase();
const allowed = ['PNG', 'JPG', 'JPEG', 'SVG'];
if (!allowed.includes(ext)) {
toast('Unsupported format: ' + ext, 'danger');
return;
}
if (file.size > 10 * 1024 * 1024) {
toast('File too large (max 10MB)', 'danger');
return;
}
const fd = new FormData();
fd.append('file', file);
// Optimistic: add placeholder to list
const $item = addAssetItem(file.name, ext, null, true);
$.ajax({
url: API.upload,
type: 'POST',
data: fd,
processData: false,
contentType: false,
success: function (r) {
if (r.success) {
$item.data('url', r.url).removeClass('uploading');
$item.find('.file-badge').text(ext);
toast(file.name + ' uploaded', 'success');
setStep(Math.max(state.step, 1));
} else {
$item.remove();
toast(r.error || 'Upload failed', 'danger');
}
},
error: function () {
if (D2C.debug) {
// Mock for dev: use local object URL
const mockUrl = URL.createObjectURL(file);
$item.data('url', mockUrl).removeClass('uploading');
toast('[DEV] ' + file.name + ' mocked', 'warning');
} else {
$item.remove();
toast('Upload error', 'danger');
}
},
});
}
function addAssetItem(name, ext, url, uploading = false) {
$('#emptyHint').hide();
const extClass = ext.toLowerCase() === 'jpg' || ext.toLowerCase() === 'jpeg' ? 'jpg' : ext.toLowerCase();
const $item = $(`
<div class="asset-item ${uploading ? 'uploading' : ''}" data-url="${url || ''}" data-name="${name}">
<div class="d-flex align-center gap-2">
<i class="fa-solid fa-file-image" style="font-size:15px;color:var(--clr-accent-purple)"></i>
<span class="asset-item__name">${name}</span>
</div>
<span class="file-badge file-badge--${extClass}">${uploading ? '…' : ext}</span>
</div>
`);
$('#assetList').append($item);
return $item;
}
// Select active asset
$('#assetList').on('click', '.asset-item', function () {
if ($(this).hasClass('uploading')) return;
$('.asset-item').removeClass('asset-item--active');
$(this).addClass('asset-item--active');
state.activeAssetUrl = $(this).data('url');
state.activeAssetName = $(this).data('name');
// Enable Apply button if model exists
$('#btnApplyArtwork').prop('disabled', !state.modelUrl);
updateSummary();
toast('Asset selected: ' + state.activeAssetName, 'info');
});
/* ============================================================
STEP 2 — GENERATE MODEL
============================================================ */
$('#btnGenerateModel').on('click', function () {
const prompt = $('#modelPrompt').val().trim();
const pose = $('#modelPose').val();
const type = $('#garmentType').val();
const fabric = $('#garmentFabric').val();
const color = $('#garmentColor').val();
const mode = $('#modelTabs .tab-item.active--purple').data('tab');
const predefined = state.selectedModel;
if (mode === 'tabPredefined' && !predefined) {
toast('Select a predefined model first', 'warning');
return;
}
const timer = showViewportLoading('Generating model...');
$(this).addClass('is-loading');
$.ajax({
url: API.generate,
type: 'POST',
dataType: 'json',
data: {
action: 'model',
prompt: prompt,
pose: pose,
type: type,
fabric: fabric,
color: color,
predefined: predefined || '',
},
success: function (r) {
hideViewportLoading(timer);
$('#btnGenerateModel').removeClass('is-loading');
if (r.success) {
loadModelIntoCanvas(r.url);
updateSummary();
} else {
toast(r.error || 'Generation failed', 'danger');
setApiStatus('error', 'Error');
}
},
error: function () {
hideViewportLoading(timer);
$('#btnGenerateModel').removeClass('is-loading');
if (D2C.debug) {
// Mock: use a placeholder for dev
const mockUrl = 'https://placehold.co/600x800/1B1B1B/6F2CFF?text=Model+Mock&font=inter';
loadModelIntoCanvas(mockUrl);
toast('[DEV] Model mocked', 'warning');
} else {
toast('API error. Check n8n connection.', 'danger');
setApiStatus('error', 'Error');
}
},
});
});
// Predefined model selection
$(document).on('click', '.predefined-item', function () {
$('.predefined-item').removeClass('selected');
$(this).addClass('selected');
state.selectedModel = $(this).data('model');
// Auto-load predefined model (local static image or placeholder)
const timer = showViewportLoading('Loading predefined model...');
setTimeout(() => {
hideViewportLoading(timer);
const mockUrl = `https://placehold.co/600x800/1B1B1B/6F2CFF?text=${encodeURIComponent($(this).find('span').text())}&font=inter`;
loadModelIntoCanvas(mockUrl);
updateSummary();
}, 800);
});
/* ============================================================
INSPECTOR — Slider live feedback
============================================================ */
$('#artScale').on('input', function () {
$('#scaleVal').text($(this).val() + '%');
updateArtworkOverlay();
});
$('#artRotation').on('input', function () {
$('#rotVal').text($(this).val() + '°');
updateArtworkOverlay();
});
$('#artOpacity').on('input', function () {
$('#opaVal').text($(this).val() + '%');
$('#layerArtwork').css('opacity', $(this).val() / 100);
});
$('#artBlending').on('change', updateSummary);
$('#garmentType, #garmentFabric').on('change', updateSummary);
function updateArtworkOverlay() {
const scale = parseInt($('#artScale').val()) / 100;
const rot = parseInt($('#artRotation').val());
const opa = parseInt($('#artOpacity').val()) / 100;
$('#layerArtwork').css({
transform: `translate(-50%, -50%) scale(${scale}) rotate(${rot}deg)`,
opacity: opa,
});
updateSummary();
}
/* ============================================================
STEP 3 — APPLY ARTWORK
============================================================ */
$('#btnApplyArtwork').on('click', function () {
if (!state.modelUrl || !state.activeAssetUrl) {
toast('Generate a model and select an asset first', 'warning');
return;
}
// Save to history for undo
if (state.resultUrl) state.history.push(state.resultUrl);
$('#btnUndoApply').prop('disabled', false);
const timer = showViewportLoading('Applying artwork...');
$(this).addClass('is-loading');
$.ajax({
url: API.generate,
type: 'POST',
dataType: 'json',
data: {
action: 'apply',
model_url: state.modelUrl,
asset_url: state.activeAssetUrl,
scale: $('#artScale').val(),
rotation: $('#artRotation').val(),
opacity: $('#artOpacity').val(),
blending: $('#artBlending').val(),
prompt: $('#applyPromptInput').val().trim(),
},
success: function (r) {
hideViewportLoading(timer);
$('#btnApplyArtwork').removeClass('is-loading');
if (r.success) {
state.resultUrl = r.url;
$('#layerModel').attr('src', r.url);
$('#layerArtwork').hide();
updateExportThumb(r.url);
$('#btnExport').prop('disabled', false);
setStep(4);
toast('Artwork applied successfully', 'success');
setApiStatus('done', 'Done');
updateSummary();
} else {
toast(r.error || 'Apply failed', 'danger');
setApiStatus('error', 'Error');
}
},
error: function () {
hideViewportLoading(timer);
$('#btnApplyArtwork').removeClass('is-loading');
if (D2C.debug) {
// Mock
const mockUrl = 'https://placehold.co/600x800/171717/FF6A00?text=Applied+Mock&font=inter';
state.resultUrl = mockUrl;
$('#layerModel').attr('src', mockUrl);
updateExportThumb(mockUrl);
$('#btnExport').prop('disabled', false);
setStep(4);
toast('[DEV] Apply mocked', 'warning');
} else {
toast('API error. Check n8n connection.', 'danger');
setApiStatus('error', 'Error');
}
},
});
});
// Undo last apply
$('#btnUndoApply').on('click', function () {
if (!state.history.length) return;
const prev = state.history.pop();
state.resultUrl = prev;
$('#layerModel').attr('src', prev);
updateExportThumb(prev);
if (!state.history.length) $(this).prop('disabled', true);
toast('Undone', 'info');
});
/* ============================================================
STEP 4 — EXPORT
============================================================ */
$('#btnExport').on('click', function () {
if (!state.resultUrl && !state.modelUrl) {
toast('Nothing to export yet', 'warning');
return;
}
const resolution = $('input[name="exportRes"]:checked').val();
const format = $('input[name="exportFmt"]:checked').val();
const sourceUrl = state.resultUrl || state.modelUrl;
$(this).addClass('is-loading');
setApiStatus('working', 'Exporting...');
$('#exportStatus').show().text('Preparing export...');
$.ajax({
url: API.generate,
type: 'POST',
dataType: 'json',
data: {
action: 'export',
source_url: sourceUrl,
resolution: resolution,
format: format,
},
success: function (r) {
$('#btnExport').removeClass('is-loading');
if (r.success) {
// Trigger download
const a = document.createElement('a');
a.href = r.url;
a.download = r.filename || 'design2clothes_export.' + format;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
$('#exportStatus').text('✓ Exported: ' + (r.filename || 'file'));
setApiStatus('done', 'Exported');
toast('Export ready!', 'success');
} else {
$('#exportStatus').text('Error: ' + (r.error || 'Export failed'));
setApiStatus('error', 'Error');
toast(r.error || 'Export failed', 'danger');
}
},
error: function () {
$('#btnExport').removeClass('is-loading');
if (D2C.debug) {
// Mock: just open the image in new tab
window.open(state.resultUrl || state.modelUrl, '_blank');
$('#exportStatus').text('[DEV] Opened in new tab (mock)');
setApiStatus('done', 'Mock Export');
toast('[DEV] Export mocked', 'warning');
} else {
setApiStatus('error', 'Error');
$('#exportStatus').text('Export API unavailable');
toast('Export API error', 'danger');
}
},
});
});
/* ============================================================
MODALS
============================================================ */
function openModal(id) { $('#' + id).addClass('active'); }
function closeModal(id) { $('#' + id).removeClass('active'); }
$('#btnNewProject').on('click', () => openModal('modalNewProject'));
$('#btnAssetLib').on('click', () => openModal('modalProjectsList'));
$('#btnProjectsList').on('click', () => {
loadProjects();
openModal('modalProjectsList');
});
$('#btnNewFromList').on('click', () => {
closeModal('modalProjectsList');
openModal('modalNewProject');
});
// Close buttons
$(document).on('click', '[data-close-modal]', function () {
closeModal($(this).data('close-modal'));
});
// Close on overlay click
$(document).on('click', '.modal-overlay', function (e) {
if ($(e.target).is('.modal-overlay')) closeModal($(this).attr('id'));
});
// Create project
$('#btnCreateProject').on('click', function () {
const title = $('#newProjectTitle').val().trim();
if (!title) { toast('Enter a project name', 'warning'); return; }
$('#projectTitle').text(title);
$('#projectDesc').text($('#newProjectDesc').val().trim() || '');
closeModal('modalNewProject');
toast('Project "' + title + '" created', 'success');
// Reset state
state.activeAssetUrl = null;
state.modelUrl = null;
state.resultUrl = null;
state.history = [];
$('#assetList').empty();
$('#emptyHint').show();
$('#viewportEmpty').show();
$('#viewportContent').hide();
$('#layerModel, #layerArtwork').attr('src', '');
$('#btnApplyArtwork, #btnExport').prop('disabled', true);
$('#exportThumb').html('<i class="fa-solid fa-image"></i><span>Result preview</span>');
setStep(1);
updateSummary();
});
// Edit title inline
$('#btnEditTitle').on('click', function () {
const $title = $('#projectTitle');
const current = $title.text();
const $input = $('<input type="text" class="field-d2c input-d2c" style="font-size:18px;font-weight:700;">')
.val(current);
$title.replaceWith($input);
$input.focus().select();
$input.on('blur keydown', function (e) {
if (e.type === 'blur' || e.key === 'Enter') {
const v = $(this).val().trim() || current;
$(this).replaceWith(`<div class="project-title" id="projectTitle">${v}</div>`);
toast('Project renamed', 'info');
}
if (e.key === 'Escape') {
$(this).replaceWith(`<div class="project-title" id="projectTitle">${current}</div>`);
}
});
});
/* ─── Projects List ─── */
function loadProjects() {
$.getJSON(API.projects)
.done(function (r) {
const $c = $('#projectsListContent').empty();
if (!r.projects || !r.projects.length) {
$c.html('<div class="empty-hint"><i class="fa-solid fa-folder-open"></i><span>No projects yet</span></div>');
return;
}
r.projects.forEach(p => {
$c.append(`
<div class="asset-item" style="height:auto;padding:12px;">
<div>
<div style="font-size:14px;font-weight:600;color:var(--clr-text-primary)">${p.title}</div>
<div style="font-size:11px;color:var(--clr-text-muted)">${p.created || ''}</div>
</div>
<button class="btn-d2c btn-dark btn-d2c--sm" data-open-project="${p.id}">Open</button>
</div>
`);
});
})
.fail(function () {
if (D2C.debug) {
$('#projectsListContent').html('<div class="empty-hint"><i class="fa-solid fa-plug-circle-xmark"></i><span>[DEV] API not yet connected</span></div>');
}
});
}
/* ============================================================
CANVAS ZOOM
============================================================ */
let zoom = 1;
const $layers = $('#canvasLayers');
$('#btnZoomIn').on('click', () => {
zoom = Math.min(zoom + 0.15, 3);
$layers.css('transform', `scale(${zoom})`);
});
$('#btnZoomOut').on('click', () => {
zoom = Math.max(zoom - 0.15, 0.3);
$layers.css('transform', `scale(${zoom})`);
});
$('#btnZoomReset').on('click', () => {
zoom = 1;
$layers.css('transform', 'scale(1)');
});
/* ============================================================
INIT
============================================================ */
setStep(1);
updateSummary();
if (D2C.debug) {
toast('Design2Clothes ready <small style="opacity:.6">[DEV]</small>', 'info');
}
});