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()`.
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
- **Video Previews**:
- HTML5 `<video>` tags often fail with standard Drive download URLs due to auth/codec issues.
- **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.
- **Video Previews**:
- 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.
- 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));
gap: 12px;
margin-top: 16px;
min-height: 100px; /* Drop target area */
min-height: 100px;
/* Drop target area */
}
.media-item {
@ -127,7 +128,8 @@
width: 100%;
height: 100%;
object-fit: contain;
background: #f8fafc; /* Placeholder bg */
background: #f8fafc;
/* Placeholder bg */
padding: 4px;
box-sizing: border-box;
}
@ -135,28 +137,52 @@
/* Overlays & Badges */
.media-overlay {
position: absolute;
top: 0;
top: auto;
left: 0;
right: 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;
transition: opacity 0.2s;
display: flex;
align-items: center;
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 */
font-size: 10px;
/* Text badge */
z-index: 10;
padding: 2px 6px;
border-radius: 4px;
@ -186,8 +212,13 @@
transform: scale(1.1);
}
.btn-delete { color: var(--danger); }
.btn-view { color: var(--primary); }
.btn-delete {
color: var(--danger);
}
.btn-view {
color: var(--primary);
}
/* Buttons */
.btn {
@ -243,7 +274,10 @@
/* Modal */
.modal-overlay {
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);
z-index: 100;
display: none;
@ -288,14 +322,32 @@
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 */
#photos-session-ui {
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>
</head>
@ -306,7 +358,8 @@
<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 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>
@ -429,131 +482,129 @@
<script>
/**
* State Management
* State Management & Error Handling
*/
class MediaState {
constructor() {
window.onerror = function (msg, url, line) {
alert("Script Error: " + msg + "\nLine: " + line);
};
// --- ES5 Refactor: MediaState ---
function MediaState() {
this.sku = null;
this.items = []; // Current UI State
this.initialState = []; // For diffing "isDirty"
this.token = null;
this.items = [];
this.initialState = [];
}
setSku(info) {
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);
}
};
setItems(items) {
// items: { id, filename, thumbnail, status, source }
MediaState.prototype.setItems = function (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);
this.checkDirty();
}
};
addItem(item) {
MediaState.prototype.addItem = function (item) {
this.items.push(item);
ui.render(this.items);
this.checkDirty();
}
};
deleteItem(index) {
const item = this.items[index];
MediaState.prototype.deleteItem = function (index) {
var item = this.items[index];
if (item.source === 'new') {
// Remove entirely if it's a new upload not yet synced
this.items.splice(index, 1);
} else {
// Toggle soft delete for existing items
item._deleted = !item._deleted;
}
ui.render(this.items);
this.checkDirty();
}
};
reorderItems(newIndices) {
// Handled by Sortable onEnd
}
MediaState.prototype.reorderItems = function (newIndices) {
// Handled by Sortable
};
// Check if current state differs from initial
checkDirty() {
const plan = this.calculateDiff();
const isDirty = plan.hasChanges;
MediaState.prototype.checkDirty = function () {
var plan = this.calculateDiff();
var isDirty = plan.hasChanges;
ui.toggleSave(isDirty);
return plan;
}
};
calculateDiff() {
const currentIds = new Set(this.items.map(i => i.id));
const initialIds = new Set(this.initialState.map(i => i.id));
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; }));
const actions = [];
var actions = [];
// 1. Deletions (Marked _deleted)
this.items.forEach(i => {
if (i._deleted) {
actions.push({ type: 'delete', name: i.filename || 'Item' });
}
this.items.forEach(function (i) {
if (i._deleted) actions.push({ type: 'delete', name: i.filename || 'Item' });
});
// 2. Additions
this.items.forEach(i => {
if (i._deleted) return; // Skip deleted items
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') {
// Existing drive items to be synced
actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
}
});
// 3. Reorders
const activeItems = this.items.filter(i => !i._deleted);
// Check order of common items
var activeItems = this.items.filter(function (i) { return !i._deleted; });
// Filter initial state to only items that are still active
const initialCommon = this.initialState.filter(i => activeItems.find(c => c.id === i.id));
const currentCommon = activeItems.filter(i => initialIds.has(i.id));
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);
});
let orderChanged = false;
if (initialCommon.length !== currentCommon.length) {
// Should match if we filtered correctly
} else {
for (let k = 0; k < initialCommon.length; k++) {
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' });
}
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 {
hasChanges: uniqueActions.length > 0,
actions: uniqueActions
};
}
};
hasNewItems() {
return this.items.some(i => !i._deleted && (i.status === 'drive_only' || i.source === 'new'));
}
}
MediaState.prototype.hasNewItems = function () {
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;
/**
* UI Controller
*/
class UI {
constructor() {
// --- ES5 Refactor: UI ---
function UI() {
this.grid = document.getElementById('media-grid');
this.saveBtn = document.getElementById('save-btn');
this.toggleLogBtn = document.getElementById('toggle-log-btn');
@ -564,93 +615,99 @@
this.shopifyUrl = null;
}
setDriveLink(url) { this.driveUrl = url; this.renderLinks(); }
setShopifyLink(url) { this.shopifyUrl = url; this.renderLinks(); }
UI.prototype.setDriveLink = function (url) { this.driveUrl = url; this.renderLinks(); };
UI.prototype.setShopifyLink = function (url) { this.shopifyUrl = url; this.renderLinks(); };
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>`;
}
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>';
};
toggleLog(forceState) {
const isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none';
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";
}
};
updateSku(sku, title) {
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';
}
};
toggleSave(enable) {
UI.prototype.toggleSave = function (enable) {
this.saveBtn.disabled = !enable;
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
}
};
render(items) {
UI.prototype.render = function (items) {
this.grid.innerHTML = '';
const activeCount = items.filter(i => !i._deleted).length;
document.getElementById('item-count').innerText = `(${activeCount})`;
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((item, index) => {
const el = this.createCard(item, index);
this.grid.appendChild(el);
items.forEach(function (item, index) {
var el = _this.createCard(item, index);
_this.grid.appendChild(el);
});
// Re-init Sortable
if (this.sortable) this.sortable.destroy();
this.sortable = new Sortable(this.grid, {
animation: 150,
filter: '.deleted-item',
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: () => {
// Update State Order
const newOrderIds = Array.from(this.grid.children).map(el => el.dataset.id);
// Reorder state.items based on newOrderIds
const newItems = newOrderIds.map(id => state.items.find(i => i.id === id)).filter(Boolean);
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();
}
});
}
};
setLoadingState(isLoading) {
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>
`;
}
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>';
}
};
logStatus(step, message, type = 'info') {
const container = this.logContainer;
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
const el = document.createElement('div');
el.innerHTML = `<span style="margin-right:8px;">${icon}</span> ${message}`;
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);
}
};
createCard(item, index) {
const div = document.createElement('div');
div.className = `media-item ${item._deleted ? 'deleted-item' : ''}`;
UI.prototype.createCard = function (item, index) {
var div = document.createElement('div');
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
div.dataset.id = item.id;
// Badge logic
let badge = '';
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>';
@ -659,148 +716,155 @@
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
}
// Thumbnail
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4'));
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename + ", ContentUrl: " + item.contentUrl);
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">`;
// 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);
const 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>`;
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">📹</div>' : '';
div.innerHTML = `
${badge}
${mediaHtml}
<div class="media-overlay">
<button class="icon-btn btn-view" onclick="ui.openPreview('${item.id}')" title="View">👁️</button>
${actionBtn}
</div>
`;
return 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;
}
openPreview(id) {
const item = state.items.find(i => i.id === id);
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;
const modal = document.getElementById('preview-modal');
const img = document.getElementById('preview-image');
const vid = document.getElementById('preview-video');
const iframe = document.getElementById('preview-iframe');
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'; // 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) {
// Use Drive Preview Embed URL
// Note: This assumes item.id corresponds to Drive File ID for drive items.
// (Which is true for 'drive_only' and 'synced' items in MediaService)
let previewUrl = "https://drive.google.com/file/d/" + item.id + "/preview";
// If it's a shopify-only video (orphan), we might need a different strategy,
// 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
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 {
console.log("[MediaManager] Opening Drive Embed: " + item.filename + ", URL: " + previewUrl);
iframe.src = previewUrl;
iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
iframe.style.display = 'block';
}
} else {
// Image
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.src = item.thumbnail;
img.style.display = 'block';
}
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;
document.getElementById('preview-modal').style.display = 'none';
document.getElementById('preview-video').pause();
document.getElementById('preview-iframe').src = 'about:blank'; // Stop playback
}
document.getElementById('preview-iframe').src = 'about:blank';
};
// --- Details Modal ---
showDetails() {
const plan = state.calculateDiff();
const container = document.getElementById('details-content');
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 {
const html = plan.actions.map((a, i) => {
let icon = '•';
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 = '🔢';
let 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`;
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>`;
return '<div style="margin-bottom:8px;">' + (i + 1) + '. ' + icon + ' ' + label + '</div>';
}).join('');
container.innerHTML = html;
}
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;
document.getElementById('details-modal').style.display = 'none';
}
};
// Photos Session Methods
showPhotoSession(url) {
const ui = document.getElementById('photos-session-ui');
const link = document.getElementById('photos-session-link');
const status = document.getElementById('photos-session-status');
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');
ui.style.display = 'block';
uiEl.style.display = 'block';
link.href = url;
link.style.display = 'block';
status.innerText = "Waiting for selection...";
}
};
closePhotoSession() {
UI.prototype.closePhotoSession = function () {
document.getElementById('photos-session-ui').style.display = 'none';
}
};
updatePhotoStatus(msg) {
UI.prototype.updatePhotoStatus = function (msg) {
document.getElementById('photos-session-status').innerText = msg;
}
}
};
const ui = new UI();
var ui = new UI();
window.ui = ui;
/**
* Data Controller
*/
const controller = {
var controller = {
init() {
// Start polling for SKU selection
setInterval(() => this.checkSku(), 2000);
@ -864,6 +928,9 @@
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');
@ -1022,7 +1089,7 @@
// --- Google Picker API ---
let pickerApiLoaded = false;
function onApiLoad() { gapi.load('picker', () => { pickerApiLoaded = true; }); }
window.onApiLoad = function () { gapi.load('picker', () => { pickerApiLoaded = true; }); };
function createPicker(config) {
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
@ -1048,7 +1115,16 @@
}
// 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');
@ -1082,4 +1158,5 @@
</script>
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
</body>
</html>

View File

@ -107,7 +107,13 @@ export function getMediaDiagnostics(sku: string) {
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) {

View File

@ -139,6 +139,20 @@ export class MediaService {
// Find Shopify Orphans
shopifyMedia.forEach(m => {
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({
id: m.id, // Use Shopify ID keys for orphans
driveId: null,
@ -148,7 +162,9 @@ export class MediaService {
source: 'shopify_only',
thumbnail: m.preview?.image?.originalSrc || "",
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
}
}
... on Video {
sources {
url
mimeType
}
}
... on MediaImage {
image {
url
}
}
}
}
}