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