Compare commits

...

5 Commits

Author SHA1 Message Date
55d18138b7 feat: handle missing SKU in Media Manager
- Added UI and logic to handle cases where the Media Manager is opened for a row without a SKU.
- Displays a user-friendly error message with a Close button.
- Fixed an issue where the Gallery card was not properly hidden in the error state.
2025-12-29 00:21:02 -07:00
945fb610f9 Fix: Prevent drag-drop overlay during internal reordering in Media Manager
Updated drag event listeners in MediaManager.html to check for 'Files' in dataTransfer.types. This ensures the upload overlay only appears when files are dragged from the OS, preventing interference with SortableJS reordering.
2025-12-28 21:13:02 -07:00
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
c738ab3ef7 Refactor Media Manager UI and Fix Infinite Loop
- **UI Refactor**:
  - Split Media Manager header into two distinct cards: 'Product Info' and 'Upload Options'.
  - 'Product Info' now displays the Product Title and SKU.
  - Renamed upload buttons to 'Google Drive', 'Google Photos', and 'Your Computer' for clarity.
  - Added global drag-and-drop support with overlay.
  - Replaced full-screen 'Connecting' overlay with an inline spinner for better UX and log visibility.

- **Backend**:
  - Renamed getSelectedSku to getSelectedProductInfo in mediaHandlers.ts to fetch and return both SKU and Title.
  - Updated global.ts exports and mediaHandlers.test.ts to support the new signature.

- **Fixes**:
  - Resolved an infinite loop issue in loadMedia caused by incorrect SKU state handling.
2025-12-28 16:34:02 -07:00
d9d884e1fc Improve Media Manager loading state visibility
- Removed the full-screen 'Connecting' overlay in MediaManager.html

- Implemented an inline loading spinner control

- Ensure log container is visible immediately during initialization so users can track progress
2025-12-28 16:02:56 -07:00
8 changed files with 726 additions and 488 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,44 +322,73 @@
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>
<body>
<div id="main-ui" style="display:none">
<!-- Header Card -->
<!-- Product Info Card -->
<div class="card">
<div class="header">
<h2>Media Manager</h2>
<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>
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div style="font-size: 32px; margin-bottom: 8px;">☁️</div>
<div style="font-size: 14px; font-weight: 500;">Drop files or click to upload</div>
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">
Direct to Drive • JPG, PNG, MP4
<!-- 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>
<div style="display: flex; gap: 8px; margin-top: 12px;">
<button onclick="controller.openPicker()" class="btn btn-secondary" style="flex: 1; font-size: 12px;">
📂 Drive Picker
</button>
<button onclick="controller.startPhotoSession()" class="btn btn-secondary" style="flex: 1; font-size: 12px;">
📸 Google Photos
</button>
</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;">
@ -340,7 +403,6 @@
</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;">
@ -384,6 +446,17 @@
<div style="margin-top:16px; color: var(--text-secondary); font-weight: 500;">Connecting...</div>
</div>
<!-- Error UI -->
<div id="error-ui"
style="display:none; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding-top: 80px;">
<div style="font-size: 48px; margin-bottom: 20px;">⚠️</div>
<h3 style="margin: 0 0 8px 0; color: var(--text);">No SKU Found</h3>
<p style="color: var(--text-secondary); max-width: 300px; margin-bottom: 24px; line-height: 1.5;">
This row does not appear to have a valid SKU. Please ensure the product is set up correctly before managing media.
</p>
<button onclick="google.script.host.close()" class="btn" style="width: auto;">Close</button>
</div>
<!-- Preview Modal -->
<div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)">
<div class="modal-content">
@ -411,132 +484,137 @@
</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
* State Management & Error Handling
*/
class MediaState {
constructor() {
this.sku = null;
this.items = []; // Current UI State
this.initialState = []; // For diffing "isDirty"
}
window.onerror = function (msg, url, line) {
alert("Script Error: " + msg + "\nLine: " + line);
};
setSku(sku) {
this.sku = sku;
// --- ES5 Refactor: MediaState ---
function MediaState() {
this.sku = null;
this.token = null;
this.items = [];
this.initialState = [];
ui.updateSku(sku);
}
setItems(items) {
// items: { id, filename, thumbnail, status, source }
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)); // 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');
@ -547,81 +625,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) {
document.getElementById('current-sku').innerText = sku;
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();
}
});
}
};
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.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);
}
};
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>';
@ -630,148 +726,159 @@
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">`;
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 logic (Only relevant for Shopify where we have a direct public link)
var contentUrl = item.contentUrl || "";
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
// RULE: Only create <video> for Shopify-hosted videos (public).
// Drive videos use static thumbnail + Iframe Preview.
var mediaEl;
if (isVideo && item.source === 'shopify_only' && contentUrl) {
mediaEl = document.createElement('video');
mediaEl.src = contentUrl;
mediaEl.poster = item.thumbnail || "";
mediaEl.muted = true;
mediaEl.loop = true;
mediaEl.style.objectFit = 'cover';
} else {
// Static Image for Drive videos or regular images
mediaEl = document.createElement('img');
mediaEl.src = item.thumbnail || "";
mediaEl.loading = "lazy";
}
mediaEl.className = 'media-content';
openPreview(id) {
const item = state.items.find(i => i.id === id);
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
vid.style.display = 'block';
} else {
console.log("[MediaManager] Opening Drive Embed: " + item.filename + ", URL: " + previewUrl);
iframe.src = previewUrl;
// Drive Video -> Iframe
if (item.source !== 'shopify_only') {
iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
iframe.style.display = 'block';
}
// Shopify Video -> Direct Player
else {
var previewUrlParam = item.contentUrl || "";
if (previewUrlParam) {
vid.src = previewUrlParam;
vid.style.display = 'block';
vid.play().catch(console.warn);
} 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.
// Fallback if URL missing
console.warn("Missing contentUrl for Shopify video");
}
}
} else {
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);
@ -780,27 +887,56 @@
checkSku() {
google.script.run
.withSuccessHandler(sku => {
.withSuccessHandler(info => {
// Info is now { sku, title } or null
const sku = info ? info.sku : null;
if (sku && sku !== state.sku) {
state.setSku(sku);
state.setSku(info); // Pass whole object
this.loadMedia();
} else if (!sku && !state.sku) {
// If we don't have a SKU and haven't shown error yet
if (document.getElementById('error-ui').style.display !== 'flex') {
this.loadMedia();
}
}
})
.getSelectedSku();
.getSelectedProductInfo();
},
loadMedia(preserveLogs = false) {
const sku = document.getElementById('current-sku').innerText;
// Ensure Loading UI is visible and Main UI is hidden until ready
document.getElementById('loading-ui').style.display = 'block';
document.getElementById('main-ui').style.display = 'none';
// Resolve SKU/Title - prefer state, fallback to DOM
let sku = state.sku;
let title = state.title;
// Reset State (this calls ui.updateSku which might show main-ui, so we re-toggle below if needed)
state.setSku(sku);
if (!sku) {
const domSku = document.getElementById('current-sku').innerText;
if (domSku && domSku !== '...') sku = domSku;
}
// Enforce Loading State Again (in case setSku reset it)
document.getElementById('loading-ui').style.display = 'block';
// CHECK FOR MISSING SKU
if (!sku || sku === '...') {
console.warn("No SKU found. Showing error.");
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'none';
document.getElementById('error-ui').style.display = 'flex';
return;
}
if (!title) {
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 = '';
@ -822,6 +958,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');
@ -980,7 +1119,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)
@ -1006,19 +1145,59 @@
}
// 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 for upload zone (Visual only)
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); });
dropZone.addEventListener('drop', (e) => {
// Drag & Drop Handlers (Global)
const dropOverlay = document.getElementById('drop-overlay');
let dragCounter = 0;
// Check if the drag involves files
function isFileDrag(e) {
return e.dataTransfer.types && Array.from(e.dataTransfer.types).includes('Files');
}
document.addEventListener('dragenter', (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
dropZone.classList.remove('dragover');
dragCounter++;
dropOverlay.style.display = 'flex';
});
document.addEventListener('dragleave', (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
dragCounter--;
if (dragCounter === 0) {
dropOverlay.style.display = 'none';
}
});
document.addEventListener('dragover', (e) => {
if (!isFileDrag(e)) return;
e.preventDefault();
});
document.addEventListener('drop', (e) => {
if (!isFileDrag(e)) return;
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>

View File

@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers"
import { showMediaManager, getSelectedSku, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
import { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore
@ -52,7 +52,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).reconcileSalesHandler = reconcileSalesHandler
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
;(global as any).showMediaManager = showMediaManager
;(global as any).getSelectedSku = getSelectedSku
;(global as any).getSelectedProductInfo = getSelectedProductInfo
;(global as any).getMediaForSku = getMediaForSku
;(global as any).saveFileToDrive = saveFileToDrive
;(global as any).saveMediaChanges = saveMediaChanges

View File

@ -1,5 +1,5 @@
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
import { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
@ -47,7 +47,11 @@ jest.mock("./services/GASSpreadsheetService", () => {
return {
GASSpreadsheetService: jest.fn().mockImplementation(() => {
return {
getCellValueByColumnName: jest.fn().mockReturnValue("TEST-SKU")
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
if (col === "sku") return "TEST-SKU"
if (col === "title") return "Test Product Title"
return null
})
}
})
}
@ -336,9 +340,9 @@ describe("mediaHandlers", () => {
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
})
test("getSelectedSku should return sku from sheet", () => {
const sku = getSelectedSku()
expect(sku).toBe("TEST-SKU")
test("getSelectedProductInfo should return sku and title from sheet", () => {
const info = getSelectedProductInfo()
expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
})
test("getPickerConfig should return config", () => {

View File

@ -15,7 +15,7 @@ export function showMediaManager() {
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
}
export function getSelectedSku(): string | null {
export function getSelectedProductInfo(): { sku: string, title: string } | null {
const ss = new GASSpreadsheetService()
const sheet = SpreadsheetApp.getActiveSheet()
if (sheet.getName() !== "product_inventory") return null
@ -24,7 +24,9 @@ export function getSelectedSku(): string | null {
if (row <= 1) return null // Header
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
return sku ? String(sku) : null
const title = ss.getCellValueByColumnName("product_inventory", row, "title")
return sku ? { sku: String(sku), title: String(title || "") } : null
}
export function getPickerConfig() {
@ -105,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
}
}
}
}
}