Newer
Older
clothes / js / app.js
@airebros airebros 1 day ago 21 KB a
/**
 * 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');
  }
});