Files
product_inventory/src/MediaManager.html
Ben Miller d67897aa17 Fix Media Manager critical syntax errors and enforce ES5 architecture
- Resolved persistent 'SyntaxError: Unexpected token class' by refactoring 'MediaState' and 'UI' classes in MediaManager.html to standard ES5 function constructors.

- Resolved 'SyntaxError: Unexpected identifier src' by rewriting 'createCard' to use 'document.createElement' instead of template strings for dynamic media elements.

- Consolidated script tags in MediaManager.html to prevent Apps Script parser merge issues.

- Updated docs/ARCHITECTURE.md and MEMORY.md to formally document client-side constraints (No ES6 classes, strict DOM manipulation for media).

- Note: Google Drive video animate-on-hover functionality is implemented but currently pending verification/fix.
2025-12-28 20:35:29 -07:00

1162 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<!-- SortableJS for Drag and Drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<style>
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--surface: #ffffff;
--background: #f1f5f9;
--text: #0f172a;
--text-secondary: #64748b;
--border: #e2e8f0;
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
}
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 16px;
background-color: var(--background);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
/* Cards & Layout */
.card {
background: var(--surface);
border: 1px solid var(--border);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--text);
}
.sku-badge {
background: #eff6ff;
color: var(--primary);
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
border: 1px solid #dbeafe;
}
/* Upload Zone */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 32px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #f8fafc;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--primary);
background: #eff6ff;
}
/* Grid */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 16px;
min-height: 100px;
/* Drop target area */
}
.media-item {
position: relative;
background: var(--surface);
border-radius: 8px;
border: 1px solid var(--border);
overflow: hidden;
aspect-ratio: 1;
transition: transform 0.2s, box-shadow 0.2s;
cursor: grab;
}
.media-item:active {
cursor: grabbing;
}
.media-item.sortable-ghost {
opacity: 0.4;
background: #cbd5e1;
}
.media-item.sortable-drag {
cursor: grabbing;
opacity: 1;
background: var(--surface);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
transform: scale(1.05);
}
.media-item.deleted-item {
opacity: 0.5;
filter: grayscale(100%);
}
/* Media Content */
.media-content {
width: 100%;
height: 100%;
object-fit: contain;
background: #f8fafc;
/* Placeholder bg */
padding: 4px;
box-sizing: border-box;
}
/* Overlays & Badges */
.media-overlay {
position: absolute;
top: auto;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
opacity: 0;
transition: opacity 0.2s;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 8px;
padding: 40px 10px 10px 10px;
pointer-events: none;
}
.media-overlay .icon-btn {
pointer-events: auto;
}
.media-item:hover .media-overlay {
opacity: 1;
}
.type-badge {
position: absolute;
top: 6px;
left: 6px;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
color: white;
border-radius: 4px;
padding: 4px;
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
}
.badge {
position: absolute;
top: 6px;
right: 6px;
font-size: 10px;
/* Text badge */
z-index: 10;
padding: 2px 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
font-weight: 600;
text-transform: uppercase;
}
.icon-btn {
background: white;
border: none;
border-radius: 6px;
width: 32px;
height: 32px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: transform 0.1s;
}
.icon-btn:hover {
transform: scale(1.1);
}
.btn-delete {
color: var(--danger);
}
.btn-view {
color: var(--primary);
}
/* Buttons */
.btn {
background-color: var(--primary);
color: white;
border: none;
padding: 10px 16px;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
font-size: 14px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn:hover {
background-color: var(--primary-hover);
}
.btn:disabled {
background-color: var(--text-secondary);
cursor: not-allowed;
opacity: 0.7;
}
.btn-secondary {
background-color: white;
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background-color: #f8fafc;
}
.action-bar {
position: sticky;
bottom: 0;
background: var(--surface);
padding: 16px;
border-top: 1px solid var(--border);
margin: 0 -16px -16px -16px;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
display: flex;
gap: 8px;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
max-width: 90%;
max-height: 90%;
position: relative;
}
.modal-close {
position: absolute;
top: -40px;
right: 0;
color: white;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
}
/* Empty State */
.empty-state {
text-align: center;
color: var(--text-secondary);
font-size: 13px;
padding: 40px 0;
grid-column: 1 / -1;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Session UI */
#photos-session-ui {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div id="main-ui" style="display:none">
<!-- Header Card -->
<!-- Product Info Card -->
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<div id="current-title" style="font-weight:600; font-size:16px; margin-bottom:4px; color:var(--text);">
Loading...
</div>
<span id="current-sku" class="sku-badge">...</span>
</div>
<!-- Optional: Could put metadata here or small status -->
</div>
</div>
<!-- Upload Options Card -->
<div class="card">
<div class="header" style="margin-bottom: 12px;">
<h3 style="margin:0; font-size:14px; color:var(--text);">Add Photos/Videos from...</h3>
</div>
<div style="display: flex; gap: 8px; width: 100%;">
<button onclick="controller.openPicker()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Drive
</button>
<button onclick="controller.startPhotoSession()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Photos
</button>
<button onclick="document.getElementById('file-input').click()" class="btn btn-secondary"
style="flex: 1; font-size: 13px;">
Your Computer
</button>
</div>
<input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)">
</div>
<!-- Photos Session UI -->
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="font-weight:600; font-size:12px; color:#0369a1;">Photo Picker Session</span>
<button onclick="ui.closePhotoSession()"
style="background:none; border:none; color:#0369a1; cursor:pointer; font-size:16px;">×</button>
</div>
<a id="photos-session-link" href="#" target="_blank" class="btn"
style="background:#0ea5e9; text-decoration:none; margin-bottom:8px;">
Open Google Photos ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
</div>
</div>
<div class="card" style="padding-bottom: 0;">
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
<div style="display:flex; align-items:baseline; gap:12px;">
<h2 style="margin:0;">Gallery <span id="item-count"
style="font-weight:400; color:var(--text-secondary); font-size:12px;">(0)</span></h2>
<div id="quick-links" style="font-size:12px; display:flex; gap:8px;"></div>
</div>
<div style="display: flex; gap: 8px;">
<button id="toggle-log-btn" onclick="ui.toggleLog()"
style="display:none; background:none; border:none; cursor:pointer; font-size:12px; color:var(--primary);">View
Log</button>
<button onclick="controller.loadMedia()" title="Refresh"
style="background:none; border:none; cursor:pointer; font-size:18px; color:var(--text-secondary);"></button>
</div>
</div>
<!-- Status Log -->
<div id="status-log-container"
style="padding:16px; background:#f8fafc; border-bottom:1px solid var(--border); font-family:monospace; font-size:12px; line-height:1.6; display:none;">
</div>
<div id="media-grid" class="media-grid">
<!-- Rendered Items -->
</div>
<!-- Action Footer -->
<div class="action-bar" style="display:flex; gap:8px;">
<button id="details-btn" onclick="ui.showDetails()" class="btn btn-secondary" style="flex:1;">
Show Plan
</button>
<button id="save-btn" onclick="controller.saveChanges()" class="btn" style="flex:2;" disabled>
Save Changes
</button>
</div>
</div>
</div>
<!-- Loading Screen -->
<div id="loading-ui" style="text-align:center; padding-top: 100px;">
<div class="spinner" style="width: 32px; height: 32px; border-width: 3px;"></div>
<div style="margin-top:16px; color: var(--text-secondary); font-weight: 500;">Connecting...</div>
</div>
<!-- Preview Modal -->
<div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)">
<div class="modal-content">
<button class="modal-close" onclick="ui.closeModal()">×</button>
<img id="preview-image" style="max-width:100%; max-height:80vh; border-radius:8px; display:none;">
<video id="preview-video" controls style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video>
<iframe id="preview-iframe" style="width:100%; height:60vh; border:none; border-radius:8px; display:none;"
allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div>
</div>
<!-- Details Modal -->
<div id="details-modal" class="modal-overlay" onclick="ui.closeDetails(event)">
<div class="card"
style="width: 300px; max-height: 80vh; overflow-y: auto; margin: auto; position: relative; padding: 20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 style="margin:0; font-size:16px;">Pending Actions</h3>
<button onclick="ui.closeDetails()"
style="border:none; background:none; font-size:20px; cursor:pointer;">&times;</button>
</div>
<div id="details-content" style="font-size:13px; color:var(--text-secondary); line-height:1.5;">
<!-- Injected by JS -->
</div>
<button onclick="ui.closeDetails()" class="btn btn-secondary" style="margin-top:16px;">Close</button>
</div>
</div>
<div id="drop-overlay"
style="position: fixed; top:0; left:0; right:0; bottom:0; background: rgba(37, 99, 235, 0.9); z-index: 200; display: none; flex-direction: column; align-items: center; justify-content: center; color: white;">
<div style="font-size: 48px; margin-bottom: 16px;">☁️</div>
<div style="font-size: 24px; font-weight: 600;">Drop files to Upload</div>
</div>
<script>
/**
* State Management & Error Handling
*/
window.onerror = function (msg, url, line) {
alert("Script Error: " + msg + "\nLine: " + line);
};
// --- ES5 Refactor: MediaState ---
function MediaState() {
this.sku = null;
this.token = null;
this.items = [];
this.initialState = [];
}
MediaState.prototype.setSku = function (info) {
this.sku = info ? info.sku : null;
this.title = info ? info.title : "";
this.items = [];
this.initialState = [];
ui.updateSku(this.sku, this.title);
};
MediaState.prototype.setItems = function (items) {
this.items = items || [];
this.initialState = JSON.parse(JSON.stringify(this.items));
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.addItem = function (item) {
this.items.push(item);
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.deleteItem = function (index) {
var item = this.items[index];
if (item.source === 'new') {
this.items.splice(index, 1);
} else {
item._deleted = !item._deleted;
}
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.reorderItems = function (newIndices) {
// Handled by Sortable
};
MediaState.prototype.checkDirty = function () {
var plan = this.calculateDiff();
var isDirty = plan.hasChanges;
ui.toggleSave(isDirty);
return plan;
};
MediaState.prototype.calculateDiff = function () {
var currentIds = new Set(this.items.map(function (i) { return i.id; }));
var initialIds = new Set(this.initialState.map(function (i) { return i.id; }));
var actions = [];
this.items.forEach(function (i) {
if (i._deleted) actions.push({ type: 'delete', name: i.filename || 'Item' });
});
this.items.forEach(function (i) {
if (i._deleted) return;
if (!initialIds.has(i.id)) {
actions.push({ type: 'upload', name: i.filename || 'New Item' });
} else if (i.status === 'drive_only') {
actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
}
});
// 3. Reorders
var activeItems = this.items.filter(function (i) { return !i._deleted; });
// Filter initial state to only items that are still active
var initialCommon = this.initialState.filter(function (i) {
return activeItems.some(function (c) { return c.id === i.id; });
});
var currentCommon = activeItems.filter(function (i) {
return initialIds.has(i.id);
});
var orderChanged = false;
if (initialCommon.length === currentCommon.length) {
for (var k = 0; k < initialCommon.length; k++) {
if (initialCommon[k].id !== currentCommon[k].id) {
orderChanged = true;
break;
}
}
} else {
// If lengths differ despite logic, assume change or weird state
}
if (orderChanged) {
actions.push({ type: 'reorder', name: 'Reorder Gallery' });
}
var uniqueActions = actions.filter(function (v, i, a) {
return a.findIndex(function (t) { return t.type === v.type && t.name === v.name; }) === i;
});
return {
hasChanges: uniqueActions.length > 0,
actions: uniqueActions
};
};
MediaState.prototype.hasNewItems = function () {
return this.items.some(function (i) {
return !i._deleted && (i.status === 'drive_only' || i.source === 'new');
});
};
var state = new MediaState();
window.state = state;
// --- ES5 Refactor: UI ---
function UI() {
this.grid = document.getElementById('media-grid');
this.saveBtn = document.getElementById('save-btn');
this.toggleLogBtn = document.getElementById('toggle-log-btn');
this.logContainer = document.getElementById('status-log-container');
this.linksContainer = document.getElementById('quick-links');
this.sortable = null;
this.driveUrl = null;
this.shopifyUrl = null;
}
UI.prototype.setDriveLink = function (url) { this.driveUrl = url; this.renderLinks(); };
UI.prototype.setShopifyLink = function (url) { this.shopifyUrl = url; this.renderLinks(); };
UI.prototype.renderLinks = function () {
this.linksContainer.innerHTML = '';
if (this.driveUrl) this.linksContainer.innerHTML += '<a href="' + this.driveUrl + '" target="_blank" style="color:var(--primary); text-decoration:none;">Drive ↗</a>';
if (this.shopifyUrl) this.linksContainer.innerHTML += '<a href="' + this.shopifyUrl + '" target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>';
};
UI.prototype.toggleLog = function (forceState) {
var isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none';
this.logContainer.style.display = isVisible ? 'none' : 'block';
this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log";
};
UI.prototype.updateSku = function (sku, title) {
document.getElementById('current-sku').innerText = sku || '...';
document.getElementById('current-title').innerText = title || '';
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
};
UI.prototype.toggleSave = function (enable) {
this.saveBtn.disabled = !enable;
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
};
UI.prototype.render = function (items) {
this.grid.innerHTML = '';
var _this = this; // Capture 'this' for callbacks
var activeCount = items.filter(function (i) { return !i._deleted; }).length;
document.getElementById('item-count').innerText = '(' + activeCount + ')';
if (items.length === 0) {
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
return;
}
items.forEach(function (item, index) {
var el = _this.createCard(item, index);
_this.grid.appendChild(el);
});
if (this.sortable) this.sortable.destroy();
this.sortable = new Sortable(this.grid, {
animation: 150,
filter: '.deleted-item',
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function () {
var newOrderIds = Array.from(_this.grid.children).map(function (el) { return el.dataset.id; });
var newItems = newOrderIds.map(function (id) {
return state.items.find(function (i) { return i.id === id; });
}).filter(Boolean);
state.items = newItems;
state.checkDirty();
}
});
};
UI.prototype.setLoadingState = function (isLoading) {
if (isLoading) {
this.grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">' +
'<div class="spinner" style="margin-bottom: 12px;"></div>' +
'<div>Connecting to systems...</div></div>';
}
};
UI.prototype.logStatus = function (step, message, type) {
if (!type) type = 'info';
var container = this.logContainer;
var icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
var el = document.createElement('div');
el.innerHTML = '<span style="margin-right:8px;">' + icon + '</span> ' + message;
if (type === 'error') el.style.color = 'var(--error)';
container.appendChild(el);
};
UI.prototype.createCard = function (item, index) {
var div = document.createElement('div');
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
div.dataset.id = item.id;
div.onmouseenter = function () {
var v = div.querySelector('video');
if (v) v.play();
};
div.onmouseleave = function () {
var v = div.querySelector('video');
if (v) v.pause();
};
var badge = '';
if (!item._deleted) {
if (item.status === 'synced') badge = '<span class="badge" title="Synced" style="background:#dcfce7; color:#166534;">Synced</span>';
else if (item.status === 'drive_only') badge = '<span class="badge" title="Drive Only" style="background:#dbeafe; color:#1e40af;">Drive</span>';
else if (item.status === 'shopify_only') badge = '<span class="badge" title="Shopify Only" style="background:#fce7f3; color:#9d174d;">Shopify</span>';
} else {
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
}
// Check Video
var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">📹</div>' : '';
// Content URL
var contentUrl = item.contentUrl || "";
if (isVideo && item.source !== 'shopify_only' && state.token) {
contentUrl = "https://www.googleapis.com/drive/v3/files/" + item.id + "?alt=media&access_token=" + state.token;
}
var actionBtn = item._deleted
? '<button class="icon-btn" onclick="state.deleteItem(' + index + ')" title="Restore">↩️</button>'
: '<button class="icon-btn btn-delete" onclick="state.deleteItem(' + index + ')" title="Delete">🗑️</button>';
div.innerHTML =
badge +
videoBadgeIcon +
'<div class="media-overlay">' +
'<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item.id + '\')" title="View">👁️</button>' +
actionBtn +
'</div>';
// Create Media Element Programmatically
var mediaEl;
if (isVideo) {
mediaEl = document.createElement('video');
mediaEl.src = contentUrl;
mediaEl.poster = item.thumbnail || "";
mediaEl.muted = true;
mediaEl.loop = true;
mediaEl.style.objectFit = 'cover';
} else {
mediaEl = document.createElement('img');
mediaEl.src = item.thumbnail || "";
mediaEl.loading = "lazy";
}
mediaEl.className = 'media-content';
var overlay = div.querySelector('.media-overlay');
div.insertBefore(mediaEl, overlay);
return div;
};
UI.prototype.openPreview = function (id) {
var item = state.items.find(function (i) { return i.id === id; });
if (!item) return;
var modal = document.getElementById('preview-modal');
var img = document.getElementById('preview-image');
var vid = document.getElementById('preview-video');
var iframe = document.getElementById('preview-iframe');
img.style.display = 'none';
vid.style.display = 'none';
iframe.style.display = 'none';
iframe.src = 'about:blank';
var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
if (isVideo) {
var previewUrlParam = item.contentUrl || "";
if (item.source !== 'shopify_only' && state.token) {
previewUrlParam = "https://www.googleapis.com/drive/v3/files/" + item.id + "?alt=media&access_token=" + state.token;
}
if (previewUrlParam) {
vid.src = previewUrlParam;
vid.style.display = 'block';
vid.play().catch(console.warn);
} else {
iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
iframe.style.display = 'block';
}
} else {
img.src = item.thumbnail;
img.style.display = 'block';
}
modal.style.display = 'flex';
};
UI.prototype.closeModal = function (e) {
if (e && e.target !== document.getElementById('preview-modal') && e.target !== document.querySelector('.modal-close')) return;
document.getElementById('preview-modal').style.display = 'none';
document.getElementById('preview-video').pause();
document.getElementById('preview-iframe').src = 'about:blank';
};
UI.prototype.showDetails = function () {
var plan = state.calculateDiff();
var container = document.getElementById('details-content');
if (plan.actions.length === 0) {
container.innerHTML = '<div style="text-align:center; padding:20px;">No pending changes.</div>';
} else {
var html = plan.actions.map(function (a, i) {
var icon = '•';
if (a.type === 'delete') icon = '🗑️';
if (a.type === 'upload') icon = '📤';
if (a.type === 'sync_upload') icon = '☁️';
if (a.type === 'reorder') icon = '🔢';
var label = "";
if (a.type === 'delete') label = 'Delete <b>' + a.name + '</b>';
if (a.type === 'upload') label = 'Upload New <b>' + a.name + '</b>';
if (a.type === 'sync_upload') label = 'Sync Drive File <b>' + a.name + '</b>';
if (a.type === 'reorder') label = 'Update Order';
return '<div style="margin-bottom:8px;">' + (i + 1) + '. ' + icon + ' ' + label + '</div>';
}).join('');
container.innerHTML = html;
}
document.getElementById('details-modal').style.display = 'flex';
};
UI.prototype.closeDetails = function (e) {
if (e && e.target !== document.getElementById('details-modal') && !e.target.matches('.modal-close, .btn-secondary, .close-btn')) return;
document.getElementById('details-modal').style.display = 'none';
};
UI.prototype.showPhotoSession = function (url) {
var uiEl = document.getElementById('photos-session-ui');
var link = document.getElementById('photos-session-link');
var status = document.getElementById('photos-session-status');
uiEl.style.display = 'block';
link.href = url;
link.style.display = 'block';
status.innerText = "Waiting for selection...";
};
UI.prototype.closePhotoSession = function () {
document.getElementById('photos-session-ui').style.display = 'none';
};
UI.prototype.updatePhotoStatus = function (msg) {
document.getElementById('photos-session-status').innerText = msg;
};
var ui = new UI();
window.ui = ui;
/**
* Data Controller
*/
var controller = {
init() {
// Start polling for SKU selection
setInterval(() => this.checkSku(), 2000);
this.checkSku();
},
checkSku() {
google.script.run
.withSuccessHandler(info => {
// Info is now { sku, title } or null
const sku = info ? info.sku : null;
if (sku && sku !== state.sku) {
state.setSku(info); // Pass whole object
this.loadMedia();
}
})
.getSelectedProductInfo();
},
loadMedia(preserveLogs = false) {
// Resolve SKU/Title - prefer state, fallback to DOM
let sku = state.sku;
let title = state.title;
if (!sku) {
const domSku = document.getElementById('current-sku').innerText;
if (domSku && domSku !== '...') sku = domSku;
const domTitle = document.getElementById('current-title').innerText;
if (domTitle && domTitle !== 'Loading...') title = domTitle;
}
// Show Main UI immediately so logs are visible
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
// Set Inline Loading State
ui.setLoadingState(true);
// Reset State (this calls ui.updateSku)
state.setSku({ sku, title });
if (!preserveLogs) {
document.getElementById('status-log-container').innerHTML = '';
}
ui.toggleLogBtn.style.display = 'inline-block';
ui.toggleLog(true); // Force Show Log to see progress
// 1. Run Diagnostics
// 1. Run Diagnostics
ui.logStatus('init', 'Initializing access...', 'info');
google.script.run
.withSuccessHandler(function (diagnostics) {
// Drive Status
if (diagnostics.drive.status === 'ok') {
ui.logStatus('drive', `Drive Folder: ok (${diagnostics.drive.fileCount} files) <a href="${diagnostics.drive.folderUrl}" target="_blank" style="margin-left:8px;">Open Folder ↗</a>`, 'success');
ui.setDriveLink(diagnostics.drive.folderUrl);
} else {
ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error');
}
// Capture Token
if (diagnostics.token) state.token = diagnostics.token;
// Shopify Status
if (diagnostics.shopify.status === 'ok') {
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success');
ui.setShopifyLink(diagnostics.shopify.adminUrl);
} else if (diagnostics.shopify.status === 'skipped') {
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
} else {
ui.logStatus('shopify', `Shopify Check Failed: ${diagnostics.shopify.error}`, 'error');
}
ui.logStatus('fetch', 'Fetching full media state (this may take a moment)...', 'info');
// 2. Load Full Media
google.script.run
.withSuccessHandler(function (items) {
// Normalize items
const normalized = items.map(i => ({
...i,
id: i.id || Math.random().toString(36).substr(2, 9),
status: i.source || 'drive_only', // Fix: Use source as status
source: i.source,
_deleted: false // Init soft delete flag
}));
state.setItems(normalized);
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
ui.logStatus('done', 'Finished loading.', 'success');
setTimeout(() => ui.toggleLog(false), 1000); // Auto hide after 1s
})
.withFailureHandler(function (err) {
ui.logStatus('fatal', `Failed to load media: ${err.message}`, 'error');
})
.getMediaForSku(sku);
})
.withFailureHandler(function (err) {
ui.logStatus('fatal', `Diagnostics failed: ${err.message}`, 'error');
})
.getMediaDiagnostics(sku, "");
},
saveChanges() {
ui.toggleSave(false);
ui.saveBtn.innerText = "Saving...";
ui.saveBtn.innerText = "Saving...";
// Filter out deleted items so they are actually removed
const activeItems = state.items.filter(i => !i._deleted);
// Send final state array to backend
google.script.run
.withSuccessHandler((logs) => {
ui.saveBtn.innerText = "Saved!";
// Verify logs is an array (backward compatibility check)
if (Array.isArray(logs)) {
document.getElementById('status-log-container').innerHTML = '';
logs.forEach(l => ui.logStatus('save', l, 'info'));
ui.toggleLog(true); // Force show
} else {
// Fallback for old backend
alert("Changes Saved & Synced!");
}
// Reload to get fresh IDs/State, preserving the save logs
setTimeout(() => this.loadMedia(true), 1500);
})
.withFailureHandler(e => {
alert(`Save Failed: ${e.message}`);
ui.toggleSave(true);
})
.saveMediaChanges(state.sku, activeItems);
},
handleFiles(fileList) {
Array.from(fileList).forEach(file => {
const reader = new FileReader();
reader.onload = (e) => {
const data = e.target.result.split(',')[1]; // Base64
google.script.run
.withSuccessHandler(() => {
this.loadMedia();
})
.saveFileToDrive(state.sku, file.name, file.type, data);
};
reader.readAsDataURL(file);
});
},
// --- Picker ---
openPicker() {
if (!pickerApiLoaded) return alert("API Loading...");
google.script.run.withSuccessHandler(c => createPicker(c)).getPickerConfig();
},
importFromPicker(fileId, mime, name, url) {
google.script.run
.withSuccessHandler(() => this.loadMedia())
.importFromPicker(state.sku, fileId, mime, name, url);
},
// --- Photos ---
startPhotoSession() {
ui.updatePhotoStatus("Starting session...");
google.script.run
.withSuccessHandler(session => {
ui.showPhotoSession(session.pickerUri);
this.pollPhotoSession(session.id);
})
.createPhotoSession();
},
pollPhotoSession(sessionId) {
let processing = false;
const check = () => {
if (processing) return;
google.script.run
.withSuccessHandler(res => {
if (res.status === 'complete') {
processing = true;
ui.updatePhotoStatus("Importing photos...");
controller.processPhotoItems(res.mediaItems);
} else if (res.status === 'error') {
ui.updatePhotoStatus("Error: " + res.message);
} else {
setTimeout(check, 2000);
}
})
.checkPhotoSession(sessionId);
};
check();
},
processPhotoItems(items) {
let done = 0;
items.forEach(item => {
const url = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
google.script.run
.withSuccessHandler(() => {
done++;
if (done === items.length) {
ui.updatePhotoStatus("Done!");
controller.loadMedia();
setTimeout(() => ui.closePhotoSession(), 2000);
}
})
.importFromPicker(state.sku, null, item.mimeType, item.filename, url);
});
}
};
// --- Google Picker API ---
let pickerApiLoaded = false;
window.onApiLoad = function () { gapi.load('picker', () => { pickerApiLoaded = true; }); };
function createPicker(config) {
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
.setIncludeFolders(true)
.setSelectFolderEnabled(false);
const photosView = new google.picker.PhotosView();
new google.picker.PickerBuilder()
.addView(view)
.addView(photosView)
.setOAuthToken(config.token)
.setDeveloperKey(config.apiKey)
.setCallback(data => {
if (data.action == google.picker.Action.PICKED) {
const doc = data.docs[0];
const url = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
controller.importFromPicker(doc.id, doc.mimeType, doc.name, url);
}
})
.build()
.setVisible(true);
}
// Init
try {
if (!window.state || !window.ui || !window.controller) {
throw new Error("Core components failed to initialize. Check console for SyntaxError.");
}
controller.init();
window.controller = controller; // Re-assert global access
} catch (e) {
alert("Init Failed: " + e.message);
console.error(e);
}
// Drag & Drop Handlers (Global)
const dropOverlay = document.getElementById('drop-overlay');
let dragCounter = 0;
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dragCounter++;
dropOverlay.style.display = 'flex';
});
document.addEventListener('dragleave', (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter === 0) {
dropOverlay.style.display = 'none';
}
});
document.addEventListener('dragover', (e) => { e.preventDefault(); });
document.addEventListener('drop', (e) => {
e.preventDefault();
dragCounter = 0;
dropOverlay.style.display = 'none';
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
controller.handleFiles(e.dataTransfer.files);
}
});
</script>
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
</body>
</html>