← back to SmokeShop

public/js/app.js

730 lines

/**
 * SmokeShop Admin Dashboard — Frontend
 * All user content is escaped via esc() before DOM insertion
 */

const API = '';
const AUTH = 'Basic ' + btoa('admin:OsamaMario1999');

const headers = () => ({
  'Content-Type': 'application/json',
  'Authorization': AUTH,
});

let posts = [];
let currentPost = null;
let draggedPostId = null;

// ─── API Helpers ─────────────────────────────────────────
async function api(path, opts = {}) {
  const res = await fetch(API + path, {
    ...opts,
    headers: { ...headers(), ...(opts.headers || {}) },
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: res.statusText }));
    throw new Error(err.error || 'API error');
  }
  return res.json();
}

// ─── Toast ───────────────────────────────────────────────
function toast(msg, type = 'info') {
  const container = document.getElementById('toast-container');
  const el = document.createElement('div');
  el.className = 'toast ' + type;
  el.textContent = msg;
  container.appendChild(el);
  setTimeout(() => el.remove(), 4000);
}

// ─── Safe HTML Escape ────────────────────────────────────
function esc(str) {
  if (!str) return '';
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

// ─── Tab Switching ───────────────────────────────────────
function switchTab(tabName) {
  document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
  document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tabName));
  if (tabName === 'calendar') renderCalendar();
  if (tabName === 'posts') renderPostList();
  if (tabName === 'settings') loadSettings();
}

// ─── Load Data ───────────────────────────────────────────
async function loadPosts() {
  try {
    posts = await api('/api/posts');
    renderStats();
    renderCalendar();
    renderPostList();
  } catch (err) {
    toast('Failed to load posts: ' + err.message, 'error');
  }
}

// ─── Stats ───────────────────────────────────────────────
function renderStats() {
  const total = posts.length;
  const draft = posts.filter(p => p.status === 'draft').length;
  const scheduled = posts.filter(p => p.status === 'scheduled').length;
  const posted = posts.filter(p => p.status === 'posted').length;
  const withImage = posts.filter(p => p.image_path).length;

  document.getElementById('stat-total').textContent = total;
  document.getElementById('stat-draft').textContent = draft;
  document.getElementById('stat-scheduled').textContent = scheduled;
  document.getElementById('stat-posted').textContent = posted;
  document.getElementById('stat-images').textContent = withImage;
}

// ─── Calendar View ───────────────────────────────────────
function renderCalendar() {
  const grid = document.getElementById('calendar-grid');
  if (!grid) return;

  // Clear grid safely
  while (grid.firstChild) grid.removeChild(grid.firstChild);

  const scheduledPosts = posts.filter(p => p.scheduled_at);
  if (scheduledPosts.length === 0) {
    const msg = document.createElement('div');
    msg.style.cssText = 'grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)';
    msg.textContent = 'No posts scheduled yet. Generate a calendar first.';
    grid.appendChild(msg);
    return;
  }

  // Find date range
  const dates = scheduledPosts.map(p => new Date(p.scheduled_at));
  const minDate = new Date(Math.min(...dates));
  const maxDate = new Date(Math.max(...dates));

  const start = new Date(minDate);
  start.setDate(start.getDate() - start.getDay());
  start.setHours(0, 0, 0, 0);

  const end = new Date(maxDate);
  end.setDate(end.getDate() + (6 - end.getDay()));
  end.setHours(23, 59, 59);

  // Day headers
  const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
  dayNames.forEach(d => {
    const header = document.createElement('div');
    header.className = 'calendar-day-header';
    header.textContent = d;
    grid.appendChild(header);
  });

  // Days
  const current = new Date(start);
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  while (current <= end) {
    const dateStr = current.toISOString().split('T')[0];
    const dayPosts = scheduledPosts.filter(p => p.scheduled_at && p.scheduled_at.startsWith(dateStr));
    const isToday = current.getTime() === today.getTime();

    const dayEl = document.createElement('div');
    dayEl.className = 'calendar-day';
    if (isToday) dayEl.style.borderColor = 'var(--accent)';

    const dayNum = document.createElement('div');
    dayNum.className = 'day-number';
    if (isToday) { dayNum.style.color = 'var(--accent)'; dayNum.style.fontWeight = 'bold'; }
    dayNum.textContent = current.getDate();
    dayEl.appendChild(dayNum);

    dayPosts.forEach(p => {
      const postEl = document.createElement('div');
      postEl.className = 'calendar-post ' + (p.category || '');
      postEl.addEventListener('click', () => openPost(p.id));

      // Thumbnail image
      if (p.image_path) {
        const img = document.createElement('img');
        img.className = 'calendar-post-img';
        img.src = '/api/preview/' + p.id;
        img.alt = p.title;
        img.loading = 'lazy';
        img.onerror = function() { this.style.display = 'none'; };
        postEl.appendChild(img);
      }

      const titleEl = document.createElement('div');
      titleEl.className = 'post-title';
      titleEl.textContent = p.title;
      postEl.appendChild(titleEl);

      const statusEl = document.createElement('div');
      statusEl.className = 'post-status';
      statusEl.textContent = p.status;
      postEl.appendChild(statusEl);

      dayEl.appendChild(postEl);
    });

    grid.appendChild(dayEl);
    current.setDate(current.getDate() + 1);
  }
}

// ─── Post List View ─────────────────────────────────────
function renderPostList() {
  const list = document.getElementById('post-list');
  if (!list) return;

  while (list.firstChild) list.removeChild(list.firstChild);

  if (posts.length === 0) {
    const msg = document.createElement('div');
    msg.style.cssText = 'text-align:center;padding:40px;color:var(--text-muted)';
    msg.textContent = 'No posts yet. Generate a calendar to get started.';
    list.appendChild(msg);
    return;
  }

  posts.forEach(p => {
    const item = document.createElement('div');
    item.className = 'post-item';
    item.addEventListener('click', () => openPost(p.id));

    // Thumbnail
    const thumb = document.createElement('div');
    thumb.className = 'post-thumb';
    if (p.image_path) {
      const img = document.createElement('img');
      img.src = '/api/preview/' + p.id;
      img.alt = p.title;
      img.onerror = function() { this.parentElement.textContent = 'No image'; };
      thumb.appendChild(img);
    } else {
      const noImg = document.createElement('div');
      noImg.className = 'no-img';
      noImg.textContent = 'No image';
      thumb.appendChild(noImg);
    }
    item.appendChild(thumb);

    // Info
    const info = document.createElement('div');
    info.className = 'post-info';
    const title = document.createElement('div');
    title.className = 'post-title';
    title.textContent = p.title;
    const meta = document.createElement('div');
    meta.className = 'post-meta';
    meta.textContent = p.scheduled_at ? formatDate(p.scheduled_at) : 'Not scheduled';
    info.appendChild(title);
    info.appendChild(meta);
    item.appendChild(info);

    // Category badge
    const badge = document.createElement('span');
    badge.className = 'badge ' + (p.category || '');
    badge.textContent = p.category || 'none';
    item.appendChild(badge);

    // Status badge
    const status = document.createElement('span');
    status.className = 'status-badge ' + (p.status || '');
    status.textContent = p.status || 'draft';
    item.appendChild(status);

    // Actions
    const actions = document.createElement('div');
    if (!p.image_path) {
      const genBtn = document.createElement('button');
      genBtn.className = 'btn btn-sm btn-primary';
      genBtn.textContent = 'Generate';
      genBtn.addEventListener('click', (e) => { e.stopPropagation(); generateImage(p.id); });
      actions.appendChild(genBtn);
    }
    item.appendChild(actions);

    list.appendChild(item);
  });
}

// ─── Post Editor Modal ──────────────────────────────────
function openPost(id) {
  currentPost = posts.find(p => p.id === id);
  if (!currentPost) return;

  document.getElementById('edit-title').value = currentPost.title || '';
  document.getElementById('edit-caption').value = currentPost.caption || '';
  document.getElementById('edit-template').value = currentPost.template || 'a';
  document.getElementById('edit-category').value = currentPost.category || 'product';
  document.getElementById('edit-schedule').value = currentPost.scheduled_at ? currentPost.scheduled_at.slice(0, 16) : '';
  document.getElementById('edit-status').value = currentPost.status || 'draft';

  const previewImg = document.getElementById('preview-img');
  while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);

  if (currentPost.image_path) {
    const img = document.createElement('img');
    img.src = '/api/preview/' + currentPost.id + '?t=' + Date.now();
    img.alt = 'Preview';
    previewImg.appendChild(img);
  } else {
    const ph = document.createElement('div');
    ph.className = 'placeholder';
    ph.textContent = 'No image generated yet. Click "Generate Image" to create one with Gemini AI.';
    previewImg.appendChild(ph);
  }

  document.getElementById('modal-overlay').classList.add('open');
}

function openNewPost() {
  currentPost = null;
  document.getElementById('edit-title').value = '';
  document.getElementById('edit-caption').value = '';
  document.getElementById('edit-template').value = 'a';
  document.getElementById('edit-category').value = 'product';
  document.getElementById('edit-schedule').value = '';
  document.getElementById('edit-status').value = 'draft';

  const previewImg = document.getElementById('preview-img');
  while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
  const ph = document.createElement('div');
  ph.className = 'placeholder';
  ph.textContent = 'Create the post first, then generate an image.';
  previewImg.appendChild(ph);

  document.getElementById('modal-overlay').classList.add('open');
}

function closeModal() {
  document.getElementById('modal-overlay').classList.remove('open');
  currentPost = null;
}

async function savePost() {
  const data = {
    title: document.getElementById('edit-title').value,
    caption: document.getElementById('edit-caption').value,
    template: document.getElementById('edit-template').value,
    category: document.getElementById('edit-category').value,
    scheduled_at: document.getElementById('edit-schedule').value || null,
    status: document.getElementById('edit-status').value,
  };

  if (!data.title) return toast('Title is required', 'error');

  try {
    if (currentPost) {
      await api('/api/posts/' + currentPost.id, { method: 'PUT', body: JSON.stringify(data) });
      toast('Post updated', 'success');
    } else {
      const created = await api('/api/posts', { method: 'POST', body: JSON.stringify(data) });
      currentPost = created;
      toast('Post created', 'success');
    }
    await loadPosts();
    if (currentPost) openPost(currentPost.id);
  } catch (err) {
    toast('Save failed: ' + err.message, 'error');
  }
}

async function deletePost() {
  if (!currentPost) return;
  if (!confirm('Delete this post?')) return;

  try {
    await api('/api/posts/' + currentPost.id, { method: 'DELETE' });
    toast('Post deleted', 'success');
    closeModal();
    await loadPosts();
  } catch (err) {
    toast('Delete failed: ' + err.message, 'error');
  }
}

// ─── Gemini Image Generation ─────────────────────────────
async function generateImage(postId) {
  const id = postId || (currentPost && currentPost.id);
  if (!id) return toast('Save the post first', 'error');

  const previewImg = document.getElementById('preview-img');
  if (previewImg && !postId) {
    while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
    const overlay = document.createElement('div');
    overlay.className = 'generating-overlay';
    const spinner = document.createElement('div');
    spinner.className = 'loading';
    overlay.appendChild(spinner);
    const label = document.createElement('div');
    label.style.cssText = 'color:var(--text-secondary);font-size:13px';
    label.textContent = 'Generating with Gemini AI...';
    overlay.appendChild(label);
    previewImg.appendChild(overlay);
  }

  try {
    toast('Generating image with Gemini AI...', 'info');
    await api('/api/posts/' + id + '/generate', { method: 'POST', body: JSON.stringify({}) });
    toast('Image generated!', 'success');
    await loadPosts();
    if (currentPost && !postId) {
      while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
      const img = document.createElement('img');
      img.src = '/api/preview/' + id + '?t=' + Date.now();
      img.alt = 'Preview';
      previewImg.appendChild(img);
    }
  } catch (err) {
    toast('Generation failed: ' + err.message, 'error');
    if (previewImg && !postId) {
      while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
      const ph = document.createElement('div');
      ph.className = 'placeholder';
      ph.textContent = 'Generation failed. Try again.';
      previewImg.appendChild(ph);
    }
  }
}

async function generateCaption() {
  if (!currentPost) return toast('Save the post first', 'error');

  try {
    toast('Generating caption with Gemini AI...', 'info');
    const result = await api('/api/posts/' + currentPost.id + '/generate-caption', { method: 'POST' });
    document.getElementById('edit-caption').value = result.caption;
    toast('Caption generated!', 'success');
  } catch (err) {
    toast('Caption generation failed: ' + err.message, 'error');
  }
}

async function publishPost() {
  if (!currentPost) return;

  // Check if API is connected
  let apiConnected = false;
  try {
    const status = await api('/api/instagram/status');
    apiConnected = status.connected;
  } catch {}

  if (apiConnected) {
    // Auto-publish via Meta Graph API
    if (!confirm('Publish this post to Instagram now via API?')) return;
    try {
      toast('Publishing to Instagram...', 'info');
      await api('/api/posts/' + currentPost.id + '/publish', { method: 'POST' });
      toast('Published to Instagram!', 'success');
      await loadPosts();
      closeModal();
    } catch (err) {
      toast('Publish failed: ' + err.message, 'error');
    }
  } else {
    // Manual publish — copy caption + open IG
    manualPublish();
  }
}

function manualPublish() {
  if (!currentPost) return;

  // Copy caption to clipboard
  const caption = document.getElementById('edit-caption').value || currentPost.caption || '';
  if (caption) {
    navigator.clipboard.writeText(caption).then(() => {
      toast('Caption copied to clipboard!', 'success');
    }).catch(() => {
      // Fallback for older browsers
      const ta = document.createElement('textarea');
      ta.value = caption;
      document.body.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      document.body.removeChild(ta);
      toast('Caption copied to clipboard!', 'success');
    });
  }

  // If there's an image, open it in new tab so user can download/share
  if (currentPost.image_path) {
    window.open('/api/preview/' + currentPost.id, '_blank');
  }

  // Open Instagram
  setTimeout(() => {
    window.open('https://www.instagram.com/', '_blank', 'noopener,noreferrer');
    toast('Instagram opened — paste your caption and upload the image!', 'info');
  }, 500);

  // Mark as posted
  api('/api/posts/' + currentPost.id, {
    method: 'PUT',
    body: JSON.stringify({ status: 'posted' }),
  }).then(() => loadPosts()).catch(() => {});
}

// ─── Quick Photo (Main Screen) ────────────────────────────
async function quickPhoto(input) {
  if (!input.files || !input.files[0]) return;

  toast('Creating post from photo...', 'info');

  try {
    // 1. Create a new post
    const post = await api('/api/posts', {
      method: 'POST',
      body: JSON.stringify({ title: 'New Photo Post', category: 'product', template: 'a' }),
    });

    // 2. Upload the photo
    const formData = new FormData();
    formData.append('image', input.files[0]);
    const uploadRes = await fetch('/api/posts/' + post.id + '/upload-photo', {
      method: 'POST',
      headers: { 'Authorization': AUTH },
      body: formData,
    });
    if (!uploadRes.ok) throw new Error('Upload failed');

    toast('Photo uploaded! Generating title & caption with AI...', 'info');

    // 3. AI-generate title + caption from the photo
    const aiRes = await api('/api/posts/' + post.id + '/ai-from-photo', { method: 'POST' });
    if (aiRes.title) {
      await api('/api/posts/' + post.id, {
        method: 'PUT',
        body: JSON.stringify({ title: aiRes.title, caption: aiRes.caption, category: aiRes.category || 'product' }),
      });
    }

    toast('Post created from photo!', 'success');
    await loadPosts();
    openPost(post.id);
  } catch (err) {
    toast('Failed: ' + err.message, 'error');
  }
  input.value = '';
}

// ─── Photo Upload (Modal) ─────────────────────────────────
async function uploadPhoto(input) {
  if (!input.files || !input.files[0]) return;
  const id = currentPost && currentPost.id;
  if (!id) return toast('Save the post first', 'error');

  const formData = new FormData();
  formData.append('image', input.files[0]);

  const previewImg = document.getElementById('preview-img');
  while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
  const overlay = document.createElement('div');
  overlay.className = 'generating-overlay';
  const spinner = document.createElement('div');
  spinner.className = 'loading';
  overlay.appendChild(spinner);
  const label = document.createElement('div');
  label.style.cssText = 'color:var(--text-secondary);font-size:13px';
  label.textContent = 'Uploading photo...';
  overlay.appendChild(label);
  previewImg.appendChild(overlay);

  try {
    const res = await fetch('/api/posts/' + id + '/upload-photo', {
      method: 'POST',
      headers: { 'Authorization': AUTH },
      body: formData,
    });
    if (!res.ok) throw new Error((await res.json()).error || 'Upload failed');
    toast('Photo uploaded! Generating AI title & caption...', 'info');
    await loadPosts();
    while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
    const img = document.createElement('img');
    img.src = '/api/preview/' + id + '?t=' + Date.now();
    img.alt = 'Preview';
    previewImg.appendChild(img);

    // AI analyze the photo for title + caption
    try {
      const aiRes = await api('/api/posts/' + id + '/ai-from-photo', { method: 'POST' });
      if (aiRes.title) document.getElementById('edit-title').value = aiRes.title;
      if (aiRes.caption) document.getElementById('edit-caption').value = aiRes.caption;
      if (aiRes.category) document.getElementById('edit-category').value = aiRes.category;
      toast('AI filled in title & caption!', 'success');
    } catch (aiErr) {
      toast('Photo saved, but AI analysis failed: ' + aiErr.message, 'error');
    }
  } catch (err) {
    toast('Upload failed: ' + err.message, 'error');
    while (previewImg.firstChild) previewImg.removeChild(previewImg.firstChild);
    const ph = document.createElement('div');
    ph.className = 'placeholder';
    ph.textContent = 'Upload failed. Try again.';
    previewImg.appendChild(ph);
  }
  input.value = '';
}

// ─── Calendar Generation ─────────────────────────────────
async function generateCalendar() {
  const clearExisting = confirm('Clear existing draft posts and generate a fresh 8-week calendar?');

  try {
    toast('Generating 24-post content calendar...', 'info');
    const result = await api('/api/posts/generate-calendar', {
      method: 'POST',
      body: JSON.stringify({ clearExisting }),
    });
    toast('Generated ' + result.generated + ' posts!', 'success');
    await loadPosts();
  } catch (err) {
    toast('Calendar generation failed: ' + err.message, 'error');
  }
}

async function generateAllImages() {
  if (!confirm('Generate Gemini AI images for all posts without images? This may take a while.')) return;

  try {
    toast('Generating all images with Gemini AI...', 'info');
    const result = await api('/api/posts/generate-all-images', { method: 'POST' });
    const success = result.results.filter(r => r.success).length;
    const failed = result.results.filter(r => !r.success).length;
    toast('Generated ' + success + ' images' + (failed ? ', ' + failed + ' failed' : ''), success ? 'success' : 'error');
    await loadPosts();
  } catch (err) {
    toast('Bulk generation failed: ' + err.message, 'error');
  }
}

// ─── Instagram Connect ───────────────────────────────────
function showIGOptions() {
  document.getElementById('ig-connect-overlay').classList.add('open');
}
function closeIGModal() {
  document.getElementById('ig-connect-overlay').classList.remove('open');
}
function igLoginStandard() {
  closeIGModal();
  window.open('https://www.instagram.com/', '_blank', 'noopener,noreferrer');
  // Mark as standard-login connected
  sessionStorage.setItem('ig_standard_login', '1');
  const igEl = document.querySelector('.ig-status');
  const statusText = document.querySelector('.ig-status span:last-child');
  if (igEl) igEl.classList.add('connected');
  if (statusText) statusText.textContent = 'Logged In (Manual)';
  toast('Instagram opened — log in, then come back and hit Publish to copy & post!', 'info');
}
async function igConnectAPI() {
  closeIGModal();
  try {
    const status = await api('/api/instagram/status');
    if (status.connected) {
      toast('Instagram API is already connected!', 'success');
      return;
    }
    if (!status.configured) {
      toast('Configure Meta App ID and App Secret in Settings first', 'error');
      switchTab('settings');
      return;
    }
    window.location.href = '/auth/instagram';
  } catch (err) {
    toast('Failed to check Instagram status: ' + err.message, 'error');
  }
}

async function checkIGStatus() {
  try {
    const status = await api('/api/instagram/status');
    const igEl = document.querySelector('.ig-status');
    const statusText = document.querySelector('.ig-status span:last-child');
    if (status.connected) {
      igEl.classList.add('connected');
      if (statusText) statusText.textContent = 'Connected (API)';
    } else if (sessionStorage.getItem('ig_standard_login')) {
      if (igEl) igEl.classList.add('connected');
      if (statusText) statusText.textContent = 'Logged In (Manual)';
    } else if (status.configured) {
      if (statusText) statusText.textContent = 'Not Connected';
    } else {
      if (statusText) statusText.textContent = 'Ready to Connect';
    }
  } catch (err) {}
}

// ─── Settings ────────────────────────────────────────────
async function loadSettings() {
  try {
    const settings = await api('/api/settings');
    Object.entries(settings).forEach(([key, value]) => {
      const el = document.getElementById('setting-' + key);
      if (el) el.value = value || '';
    });
  } catch (err) {
    toast('Failed to load settings', 'error');
  }
}

async function saveSettings() {
  const keys = ['store_name', 'store_address', 'app_url', 'ig_app_id', 'ig_app_secret'];
  const data = {};
  keys.forEach(k => {
    const el = document.getElementById('setting-' + k);
    if (el) data[k] = el.value;
  });

  try {
    await api('/api/settings', { method: 'PUT', body: JSON.stringify(data) });
    toast('Settings saved', 'success');
  } catch (err) {
    toast('Save failed: ' + err.message, 'error');
  }
}

// ─── Helpers ─────────────────────────────────────────────
function formatDate(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', weekday: 'short' }) +
    ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}

// ─── Init ────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
  loadPosts();
  checkIGStatus();

  const params = new URLSearchParams(window.location.search);
  if (params.get('ig') === 'connected') {
    toast('Instagram connected successfully!', 'success');
    document.querySelector('.ig-status').classList.add('connected');
    const statusText = document.querySelector('.ig-status span:last-child');
    if (statusText) statusText.textContent = 'Connected';
    window.history.replaceState({}, '', '/');
  }
  if (params.get('ig') === 'error') {
    toast('Instagram connection failed: ' + (params.get('msg') || 'Unknown error'), 'error');
    window.history.replaceState({}, '', '/');
  }

  document.getElementById('modal-overlay').addEventListener('click', (e) => {
    if (e.target === e.currentTarget) closeModal();
  });

  document.getElementById('ig-connect-overlay').addEventListener('click', (e) => {
    if (e.target === e.currentTarget) closeIGModal();
  });

  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { closeModal(); closeIGModal(); }
  });
});