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.
This commit is contained in:
Ben Miller
2025-12-28 20:35:29 -07:00
parent c738ab3ef7
commit d67897aa17
6 changed files with 617 additions and 487 deletions

View File

@ -40,5 +40,9 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
1. Sanitize with `Utilities.newBlob()`. 1. Sanitize with `Utilities.newBlob()`.
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails. 2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
- **Video Previews**: - **Video Previews**:
- HTML5 `<video>` tags often fail with standard Drive download URLs due to auth/codec issues. - **Video Previews**:
- **Strategy**: Use an `<iframe>` embedding the `https://drive.google.com/file/d/{ID}/preview` URL. This leverages Google's native player for reliable auth and transcoding. - Use `document.createElement('video')` to inject video tags. Avoid template strings (`<video src="...">`) as the parser sanitizes them aggressively.
- Fallback to `<iframe>` only if native playback fails.
- **Client-Side Syntax**:
- **ES5 ONLY**: Do not use `class` in client-side HTML files. The Apps Script sanitizer often fails to parse them. Use `function` constructors.

View File

@ -141,3 +141,19 @@ We implemented a "Sidebar-First" architecture for product media to handle the co
- Calculates checksums to avoid re-uploading duplicate images. - Calculates checksums to avoid re-uploading duplicate images.
- Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow. - Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow.
### 8. Apps Script & HTML Service Constraints
When working with `HtmlService` (client-side code), the environment differs significantly from the server-side V8 runtime.
1. **Server-Side (`.ts`/`.gs`)**:
- **Runtime**: V8 Engine.
- **Syntax**: Modern ES6+ (Classes, Arrow Functions, `const`/`let`) is fully supported.
- **Recommendation**: Use standard TypeScript patterns.
2. **Client-Side (`.html` served via `createHtmlOutputFromFile`)**:
- **Runtime**: Legacy Browser Environment / Strict Caja Sanitization.
- **Constraint**: The parser often chokes on ES6 `class` syntax and complex template strings inside HTML attributes.
- **Rule 1**: **NO ES6 CLASSES**. Use ES5 `function` constructors and `prototype` methods.
- **Rule 2**: **NO Complex Template Strings in Attributes**. Do not use `src="${var}"` if the variable contains a URL. Use `document.createElement` and set properties (e.g., `element.src = value`) programmatically.
- **Rule 3**: **Unified Script Tags**. Consolidate scripts into a single block where possible to avoid parser merge errors.
- **Rule 4**: **Var over Let/Const**. Top-level variables should use `var` or explicit `window` assignment to ensure they are accessible to inline HTML handlers (e.g., `onclick="handler()"`).

View File

@ -86,7 +86,8 @@
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px; gap: 12px;
margin-top: 16px; margin-top: 16px;
min-height: 100px; /* Drop target area */ min-height: 100px;
/* Drop target area */
} }
.media-item { .media-item {
@ -127,7 +128,8 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
background: #f8fafc; /* Placeholder bg */ background: #f8fafc;
/* Placeholder bg */
padding: 4px; padding: 4px;
box-sizing: border-box; box-sizing: border-box;
} }
@ -135,28 +137,52 @@
/* Overlays & Badges */ /* Overlays & Badges */
.media-overlay { .media-overlay {
position: absolute; position: absolute;
top: 0; top: auto;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0,0,0,0.4); background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
display: flex; display: flex;
align-items: center; align-items: flex-end;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
padding: 40px 10px 10px 10px;
pointer-events: none;
}
.media-overlay .icon-btn {
pointer-events: auto;
} }
.media-item:hover .media-overlay { .media-item:hover .media-overlay {
opacity: 1; 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 { .badge {
position: absolute; position: absolute;
top: 6px; top: 6px;
right: 6px; right: 6px;
font-size: 10px; /* Text badge */ font-size: 10px;
/* Text badge */
z-index: 10; z-index: 10;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
@ -186,8 +212,13 @@
transform: scale(1.1); transform: scale(1.1);
} }
.btn-delete { color: var(--danger); } .btn-delete {
.btn-view { color: var(--primary); } color: var(--danger);
}
.btn-view {
color: var(--primary);
}
/* Buttons */ /* Buttons */
.btn { .btn {
@ -243,7 +274,10 @@
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8); background: rgba(0, 0, 0, 0.8);
z-index: 100; z-index: 100;
display: none; display: none;
@ -288,14 +322,32 @@
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } @keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Session UI */ /* Session UI */
#photos-session-ui { #photos-session-ui {
animation: slideDown 0.3s ease-out; animation: slideDown 0.3s ease-out;
} }
@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style> </style>
</head> </head>
@ -306,7 +358,8 @@
<div class="card"> <div class="card">
<div style="display:flex; justify-content:space-between; align-items:flex-start;"> <div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div> <div>
<div id="current-title" style="font-weight:600; font-size:16px; margin-bottom:4px; color:var(--text);">Loading... <div id="current-title" style="font-weight:600; font-size:16px; margin-bottom:4px; color:var(--text);">
Loading...
</div> </div>
<span id="current-sku" class="sku-badge">...</span> <span id="current-sku" class="sku-badge">...</span>
</div> </div>
@ -429,131 +482,129 @@
<script> <script>
/** /**
* State Management * State Management & Error Handling
*/ */
class MediaState { window.onerror = function (msg, url, line) {
constructor() { alert("Script Error: " + msg + "\nLine: " + line);
};
// --- ES5 Refactor: MediaState ---
function MediaState() {
this.sku = null; this.sku = null;
this.items = []; // Current UI State this.token = null;
this.initialState = []; // For diffing "isDirty" this.items = [];
this.initialState = [];
} }
setSku(info) { MediaState.prototype.setSku = function (info) {
this.sku = info ? info.sku : null; this.sku = info ? info.sku : null;
this.title = info ? info.title : ""; this.title = info ? info.title : "";
this.items = []; this.items = [];
this.initialState = []; this.initialState = [];
ui.updateSku(this.sku, this.title); ui.updateSku(this.sku, this.title);
} };
setItems(items) { MediaState.prototype.setItems = function (items) {
// items: { id, filename, thumbnail, status, source }
this.items = items || []; this.items = items || [];
this.initialState = JSON.parse(JSON.stringify(this.items)); // Deep copy this.initialState = JSON.parse(JSON.stringify(this.items));
ui.render(this.items); ui.render(this.items);
this.checkDirty(); this.checkDirty();
} };
addItem(item) { MediaState.prototype.addItem = function (item) {
this.items.push(item); this.items.push(item);
ui.render(this.items); ui.render(this.items);
this.checkDirty(); this.checkDirty();
} };
deleteItem(index) { MediaState.prototype.deleteItem = function (index) {
const item = this.items[index]; var item = this.items[index];
if (item.source === 'new') { if (item.source === 'new') {
// Remove entirely if it's a new upload not yet synced
this.items.splice(index, 1); this.items.splice(index, 1);
} else { } else {
// Toggle soft delete for existing items
item._deleted = !item._deleted; item._deleted = !item._deleted;
} }
ui.render(this.items); ui.render(this.items);
this.checkDirty(); this.checkDirty();
} };
reorderItems(newIndices) { MediaState.prototype.reorderItems = function (newIndices) {
// Handled by Sortable onEnd // Handled by Sortable
} };
// Check if current state differs from initial MediaState.prototype.checkDirty = function () {
checkDirty() { var plan = this.calculateDiff();
const plan = this.calculateDiff(); var isDirty = plan.hasChanges;
const isDirty = plan.hasChanges;
ui.toggleSave(isDirty); ui.toggleSave(isDirty);
return plan; return plan;
} };
calculateDiff() { MediaState.prototype.calculateDiff = function () {
const currentIds = new Set(this.items.map(i => i.id)); var currentIds = new Set(this.items.map(function (i) { return i.id; }));
const initialIds = new Set(this.initialState.map(i => i.id)); var initialIds = new Set(this.initialState.map(function (i) { return i.id; }));
const actions = []; var actions = [];
// 1. Deletions (Marked _deleted) this.items.forEach(function (i) {
this.items.forEach(i => { if (i._deleted) actions.push({ type: 'delete', name: i.filename || 'Item' });
if (i._deleted) {
actions.push({ type: 'delete', name: i.filename || 'Item' });
}
}); });
// 2. Additions this.items.forEach(function (i) {
this.items.forEach(i => { if (i._deleted) return;
if (i._deleted) return; // Skip deleted items
if (!initialIds.has(i.id)) { if (!initialIds.has(i.id)) {
actions.push({ type: 'upload', name: i.filename || 'New Item' }); actions.push({ type: 'upload', name: i.filename || 'New Item' });
} else if (i.status === 'drive_only') { } else if (i.status === 'drive_only') {
// Existing drive items to be synced
actions.push({ type: 'sync_upload', name: i.filename || 'Item' }); actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
} }
}); });
// 3. Reorders // 3. Reorders
const activeItems = this.items.filter(i => !i._deleted); var activeItems = this.items.filter(function (i) { return !i._deleted; });
// Check order of common items
// Filter initial state to only items that are still active // Filter initial state to only items that are still active
const initialCommon = this.initialState.filter(i => activeItems.find(c => c.id === i.id)); var initialCommon = this.initialState.filter(function (i) {
const currentCommon = activeItems.filter(i => initialIds.has(i.id)); return activeItems.some(function (c) { return c.id === i.id; });
});
var currentCommon = activeItems.filter(function (i) {
return initialIds.has(i.id);
});
let orderChanged = false; var orderChanged = false;
if (initialCommon.length !== currentCommon.length) { if (initialCommon.length === currentCommon.length) {
// Should match if we filtered correctly for (var k = 0; k < initialCommon.length; k++) {
} else {
for (let k = 0; k < initialCommon.length; k++) {
if (initialCommon[k].id !== currentCommon[k].id) { if (initialCommon[k].id !== currentCommon[k].id) {
orderChanged = true; orderChanged = true;
break; break;
} }
} }
} else {
// If lengths differ despite logic, assume change or weird state
} }
if (orderChanged) { if (orderChanged) {
actions.push({ type: 'reorder', name: 'Reorder Gallery' }); actions.push({ type: 'reorder', name: 'Reorder Gallery' });
} }
const uniqueActions = actions.filter((v, i, a) => a.findIndex(t => (t.type === v.type && t.name === v.name)) === i); var uniqueActions = actions.filter(function (v, i, a) {
return a.findIndex(function (t) { return t.type === v.type && t.name === v.name; }) === i;
});
return { return {
hasChanges: uniqueActions.length > 0, hasChanges: uniqueActions.length > 0,
actions: uniqueActions actions: uniqueActions
}; };
} };
hasNewItems() { MediaState.prototype.hasNewItems = function () {
return this.items.some(i => !i._deleted && (i.status === 'drive_only' || i.source === 'new')); return this.items.some(function (i) {
} return !i._deleted && (i.status === 'drive_only' || i.source === 'new');
} });
};
const state = new MediaState(); var state = new MediaState();
window.state = state;
/** // --- ES5 Refactor: UI ---
* UI Controller function UI() {
*/
class UI {
constructor() {
this.grid = document.getElementById('media-grid'); this.grid = document.getElementById('media-grid');
this.saveBtn = document.getElementById('save-btn'); this.saveBtn = document.getElementById('save-btn');
this.toggleLogBtn = document.getElementById('toggle-log-btn'); this.toggleLogBtn = document.getElementById('toggle-log-btn');
@ -564,93 +615,99 @@
this.shopifyUrl = null; this.shopifyUrl = null;
} }
setDriveLink(url) { this.driveUrl = url; this.renderLinks(); } UI.prototype.setDriveLink = function (url) { this.driveUrl = url; this.renderLinks(); };
setShopifyLink(url) { this.shopifyUrl = url; this.renderLinks(); } UI.prototype.setShopifyLink = function (url) { this.shopifyUrl = url; this.renderLinks(); };
renderLinks() { UI.prototype.renderLinks = function () {
this.linksContainer.innerHTML = ''; 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.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>`; if (this.shopifyUrl) this.linksContainer.innerHTML += '<a href="' + this.shopifyUrl + '" target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>';
} };
toggleLog(forceState) { UI.prototype.toggleLog = function (forceState) {
const isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none'; var isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none';
this.logContainer.style.display = isVisible ? 'none' : 'block'; this.logContainer.style.display = isVisible ? 'none' : 'block';
this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log"; this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log";
} };
updateSku(sku, title) { UI.prototype.updateSku = function (sku, title) {
document.getElementById('current-sku').innerText = sku || '...'; document.getElementById('current-sku').innerText = sku || '...';
document.getElementById('current-title').innerText = title || ''; document.getElementById('current-title').innerText = title || '';
document.getElementById('loading-ui').style.display = 'none'; document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block'; document.getElementById('main-ui').style.display = 'block';
} };
toggleSave(enable) { UI.prototype.toggleSave = function (enable) {
this.saveBtn.disabled = !enable; this.saveBtn.disabled = !enable;
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes"; this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
} };
render(items) { UI.prototype.render = function (items) {
this.grid.innerHTML = ''; this.grid.innerHTML = '';
const activeCount = items.filter(i => !i._deleted).length; var _this = this; // Capture 'this' for callbacks
document.getElementById('item-count').innerText = `(${activeCount})`; var activeCount = items.filter(function (i) { return !i._deleted; }).length;
document.getElementById('item-count').innerText = '(' + activeCount + ')';
if (items.length === 0) { if (items.length === 0) {
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>'; this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
return; return;
} }
items.forEach((item, index) => { items.forEach(function (item, index) {
const el = this.createCard(item, index); var el = _this.createCard(item, index);
this.grid.appendChild(el); _this.grid.appendChild(el);
}); });
// Re-init Sortable
if (this.sortable) this.sortable.destroy(); if (this.sortable) this.sortable.destroy();
this.sortable = new Sortable(this.grid, { this.sortable = new Sortable(this.grid, {
animation: 150, animation: 150,
filter: '.deleted-item', filter: '.deleted-item',
ghostClass: 'sortable-ghost', ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag', dragClass: 'sortable-drag',
onEnd: () => { onEnd: function () {
// Update State Order var newOrderIds = Array.from(_this.grid.children).map(function (el) { return el.dataset.id; });
const newOrderIds = Array.from(this.grid.children).map(el => el.dataset.id); var newItems = newOrderIds.map(function (id) {
// Reorder state.items based on newOrderIds return state.items.find(function (i) { return i.id === id; });
const newItems = newOrderIds.map(id => state.items.find(i => i.id === id)).filter(Boolean); }).filter(Boolean);
state.items = newItems; state.items = newItems;
state.checkDirty(); state.checkDirty();
} }
}); });
} };
setLoadingState(isLoading) { UI.prototype.setLoadingState = function (isLoading) {
if (isLoading) { if (isLoading) {
this.grid.innerHTML = ` this.grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">' +
<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);"> '<div class="spinner" style="margin-bottom: 12px;"></div>' +
<div class="spinner" style="margin-bottom: 12px;"></div> '<div>Connecting to systems...</div></div>';
<div>Connecting to systems...</div>
</div>
`;
}
} }
};
logStatus(step, message, type = 'info') { UI.prototype.logStatus = function (step, message, type) {
const container = this.logContainer; if (!type) type = 'info';
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳'; var container = this.logContainer;
const el = document.createElement('div'); var icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
el.innerHTML = `<span style="margin-right:8px;">${icon}</span> ${message}`; var el = document.createElement('div');
el.innerHTML = '<span style="margin-right:8px;">' + icon + '</span> ' + message;
if (type === 'error') el.style.color = 'var(--error)'; if (type === 'error') el.style.color = 'var(--error)';
container.appendChild(el); container.appendChild(el);
} };
createCard(item, index) { UI.prototype.createCard = function (item, index) {
const div = document.createElement('div'); var div = document.createElement('div');
div.className = `media-item ${item._deleted ? 'deleted-item' : ''}`; div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
div.dataset.id = item.id; div.dataset.id = item.id;
// Badge logic div.onmouseenter = function () {
let badge = ''; 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._deleted) {
if (item.status === 'synced') badge = '<span class="badge" title="Synced" style="background:#dcfce7; color:#166534;">Synced</span>'; 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 === 'drive_only') badge = '<span class="badge" title="Drive Only" style="background:#dbeafe; color:#1e40af;">Drive</span>';
@ -659,148 +716,155 @@
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>'; badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
} }
// Thumbnail // Check Video
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4')); 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 + ", ContentUrl: " + item.contentUrl); if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
const mediaHtml = isVideo
? `<video src="${item.contentUrl || ''}" poster="${item.thumbnail}" muted loop onmouseover="this.play()" onmouseout="this.pause()" class="media-content"></video>`
: `<img src="${item.thumbnail}" class="media-content" loading="lazy">`;
const actionBtn = item._deleted var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">📹</div>' : '';
? `<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 = ` // Content URL
${badge} var contentUrl = item.contentUrl || "";
${mediaHtml} if (isVideo && item.source !== 'shopify_only' && state.token) {
<div class="media-overlay"> contentUrl = "https://www.googleapis.com/drive/v3/files/" + item.id + "?alt=media&access_token=" + state.token;
<button class="icon-btn btn-view" onclick="ui.openPreview('${item.id}')" title="View">👁️</button>
${actionBtn}
</div>
`;
return div;
} }
openPreview(id) { var actionBtn = item._deleted
const item = state.items.find(i => i.id === id); ? '<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; if (!item) return;
const modal = document.getElementById('preview-modal'); var modal = document.getElementById('preview-modal');
const img = document.getElementById('preview-image'); var img = document.getElementById('preview-image');
const vid = document.getElementById('preview-video'); var vid = document.getElementById('preview-video');
const iframe = document.getElementById('preview-iframe'); var iframe = document.getElementById('preview-iframe');
img.style.display = 'none'; img.style.display = 'none';
vid.style.display = 'none'; vid.style.display = 'none';
iframe.style.display = 'none'; iframe.style.display = 'none';
iframe.src = 'about:blank'; // Reset iframe.src = 'about:blank';
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4')); var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
if (isVideo) { if (isVideo) {
// Use Drive Preview Embed URL var previewUrlParam = item.contentUrl || "";
// Note: This assumes item.id corresponds to Drive File ID for drive items. if (item.source !== 'shopify_only' && state.token) {
// (Which is true for 'drive_only' and 'synced' items in MediaService) previewUrlParam = "https://www.googleapis.com/drive/v3/files/" + item.id + "?alt=media&access_token=" + state.token;
let previewUrl = "https://drive.google.com/file/d/" + item.id + "/preview"; }
if (previewUrlParam) {
// If it's a shopify-only video (orphan), we might need a different strategy, vid.src = previewUrlParam;
// but for now focusing on Drive fix.
if (item.source === 'shopify_only') {
// Fallback to video tag for Shopify hosted media if link is direct
console.log("[MediaManager] Shopify Video: " + item.filename);
vid.src = item.contentUrl || item.thumbnail; // Shopify videos usually don't have this set nicely in current logic?
// Actually MediaService for orphans puts originalSrc in thumbnail.
// But originalSrc for video is usually an image.
// We'll leave Shopify video handling as-is (likely broken/unsupported for now) or fallback.
// Effectively this branch executes the OLD logic for non-drive.
vid.src = item.thumbnail; // Risk
vid.style.display = 'block'; vid.style.display = 'block';
vid.play().catch(console.warn);
} else { } else {
console.log("[MediaManager] Opening Drive Embed: " + item.filename + ", URL: " + previewUrl); iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
iframe.src = previewUrl;
iframe.style.display = 'block'; iframe.style.display = 'block';
} }
} else { } else {
// Image img.src = item.thumbnail;
img.src = item.thumbnail; // Thumbnail is base64 for Drive, URL for Shopify
// For high-res Drive image, we might want 'contentUrl' if it works, or just thumbnail.
// Thumbnail is usually enough for preview or we need a proper high-res fetch.
// Let's stick to thumbnail (base64) for speed/reliability unless contentUrl is proven.
img.style.display = 'block'; img.style.display = 'block';
} }
modal.style.display = 'flex'; modal.style.display = 'flex';
} };
closeModal(e) { UI.prototype.closeModal = function (e) {
if (e && e.target !== document.getElementById('preview-modal') && e.target !== document.querySelector('.modal-close')) return; 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-modal').style.display = 'none';
document.getElementById('preview-video').pause(); document.getElementById('preview-video').pause();
document.getElementById('preview-iframe').src = 'about:blank'; // Stop playback document.getElementById('preview-iframe').src = 'about:blank';
} };
// --- Details Modal --- UI.prototype.showDetails = function () {
showDetails() { var plan = state.calculateDiff();
const plan = state.calculateDiff(); var container = document.getElementById('details-content');
const container = document.getElementById('details-content');
if (plan.actions.length === 0) { if (plan.actions.length === 0) {
container.innerHTML = '<div style="text-align:center; padding:20px;">No pending changes.</div>'; container.innerHTML = '<div style="text-align:center; padding:20px;">No pending changes.</div>';
} else { } else {
const html = plan.actions.map((a, i) => { var html = plan.actions.map(function (a, i) {
let icon = '•'; var icon = '•';
if (a.type === 'delete') icon = '🗑️'; if (a.type === 'delete') icon = '🗑️';
if (a.type === 'upload') icon = '📤'; if (a.type === 'upload') icon = '📤';
if (a.type === 'sync_upload') icon = '☁️'; if (a.type === 'sync_upload') icon = '☁️';
if (a.type === 'reorder') icon = '🔢'; if (a.type === 'reorder') icon = '🔢';
let label = ""; var label = "";
if (a.type === 'delete') label = `Delete <b>${a.name}</b>`; if (a.type === 'delete') label = 'Delete <b>' + a.name + '</b>';
if (a.type === 'upload') label = `Upload New <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 === 'sync_upload') label = 'Sync Drive File <b>' + a.name + '</b>';
if (a.type === 'reorder') label = `Update Order`; if (a.type === 'reorder') label = 'Update Order';
return `<div style="margin-bottom:8px;">${i + 1}. ${icon} ${label}</div>`; return '<div style="margin-bottom:8px;">' + (i + 1) + '. ' + icon + ' ' + label + '</div>';
}).join(''); }).join('');
container.innerHTML = html; container.innerHTML = html;
} }
document.getElementById('details-modal').style.display = 'flex'; document.getElementById('details-modal').style.display = 'flex';
} };
closeDetails(e) { UI.prototype.closeDetails = function (e) {
if (e && e.target !== document.getElementById('details-modal') && !e.target.matches('.modal-close, .btn-secondary, .close-btn')) return; 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'; document.getElementById('details-modal').style.display = 'none';
} };
// Photos Session Methods UI.prototype.showPhotoSession = function (url) {
showPhotoSession(url) { var uiEl = document.getElementById('photos-session-ui');
const ui = document.getElementById('photos-session-ui'); var link = document.getElementById('photos-session-link');
const link = document.getElementById('photos-session-link'); var status = document.getElementById('photos-session-status');
const status = document.getElementById('photos-session-status');
ui.style.display = 'block'; uiEl.style.display = 'block';
link.href = url; link.href = url;
link.style.display = 'block'; link.style.display = 'block';
status.innerText = "Waiting for selection..."; status.innerText = "Waiting for selection...";
} };
closePhotoSession() { UI.prototype.closePhotoSession = function () {
document.getElementById('photos-session-ui').style.display = 'none'; document.getElementById('photos-session-ui').style.display = 'none';
} };
updatePhotoStatus(msg) { UI.prototype.updatePhotoStatus = function (msg) {
document.getElementById('photos-session-status').innerText = msg; document.getElementById('photos-session-status').innerText = msg;
} };
}
const ui = new UI(); var ui = new UI();
window.ui = ui;
/** /**
* Data Controller * Data Controller
*/ */
const controller = { var controller = {
init() { init() {
// Start polling for SKU selection // Start polling for SKU selection
setInterval(() => this.checkSku(), 2000); setInterval(() => this.checkSku(), 2000);
@ -864,6 +928,9 @@
ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error'); ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error');
} }
// Capture Token
if (diagnostics.token) state.token = diagnostics.token;
// Shopify Status // Shopify Status
if (diagnostics.shopify.status === 'ok') { 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.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');
@ -1022,7 +1089,7 @@
// --- Google Picker API --- // --- Google Picker API ---
let pickerApiLoaded = false; let pickerApiLoaded = false;
function onApiLoad() { gapi.load('picker', () => { pickerApiLoaded = true; }); } window.onApiLoad = function () { gapi.load('picker', () => { pickerApiLoaded = true; }); };
function createPicker(config) { function createPicker(config) {
const view = new google.picker.DocsView(google.picker.ViewId.DOCS) const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
@ -1048,7 +1115,16 @@
} }
// Init // Init
try {
if (!window.state || !window.ui || !window.controller) {
throw new Error("Core components failed to initialize. Check console for SyntaxError.");
}
controller.init(); controller.init();
window.controller = controller; // Re-assert global access
} catch (e) {
alert("Init Failed: " + e.message);
console.error(e);
}
// Drag & Drop Handlers (Global) // Drag & Drop Handlers (Global)
const dropOverlay = document.getElementById('drop-overlay'); const dropOverlay = document.getElementById('drop-overlay');
@ -1082,4 +1158,5 @@
</script> </script>
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script> <script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
</body> </body>
</html> </html>

View File

@ -107,7 +107,13 @@ export function getMediaDiagnostics(sku: string) {
const shopifyId = product.shopify_id || "" const shopifyId = product.shopify_id || ""
return mediaService.getDiagnostics(sku, shopifyId) const diagnostics = mediaService.getDiagnostics(sku, shopifyId)
// Inject OAuth token for frontend video streaming (Drive API alt=media)
return {
...diagnostics,
token: ScriptApp.getOAuthToken()
}
} }
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) { export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {

View File

@ -139,6 +139,20 @@ export class MediaService {
// Find Shopify Orphans // Find Shopify Orphans
shopifyMedia.forEach(m => { shopifyMedia.forEach(m => {
if (!matchedShopifyIds.has(m.id)) { if (!matchedShopifyIds.has(m.id)) {
let mimeType = 'image/jpeg'; // Default
let contentUrl = "";
if (m.mediaContentType === 'VIDEO' && m.sources) {
// Find MP4
const mp4 = m.sources.find((s: any) => s.mimeType === 'video/mp4')
if (mp4) {
mimeType = mp4.mimeType
contentUrl = mp4.url
}
} else if (m.mediaContentType === 'IMAGE' && m.image) {
contentUrl = m.image.url
}
unifiedState.push({ unifiedState.push({
id: m.id, // Use Shopify ID keys for orphans id: m.id, // Use Shopify ID keys for orphans
driveId: null, driveId: null,
@ -148,7 +162,9 @@ export class MediaService {
source: 'shopify_only', source: 'shopify_only',
thumbnail: m.preview?.image?.originalSrc || "", thumbnail: m.preview?.image?.originalSrc || "",
status: 'active', status: 'active',
galleryOrder: 10000 // End of list galleryOrder: 10000, // End of list
mimeType: mimeType,
contentUrl: contentUrl
}) })
} }
}) })

View File

@ -78,6 +78,17 @@ export class ShopifyMediaService implements IShopifyMediaService {
originalSrc originalSrc
} }
} }
... on Video {
sources {
url
mimeType
}
}
... on MediaImage {
image {
url
}
}
} }
} }
} }