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()`. 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,8 +274,11 @@
/* Modal */ /* Modal */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0;
background: rgba(0,0,0,0.8); left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 100; z-index: 100;
display: none; display: none;
align-items: center; align-items: center;
@ -288,44 +322,73 @@
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>
<body> <body>
<div id="main-ui" style="display:none"> <div id="main-ui" style="display:none">
<!-- Header Card --> <!-- Header Card -->
<!-- Product Info Card -->
<div class="card"> <div class="card">
<div class="header"> <div style="display:flex; justify-content:space-between; align-items:flex-start;">
<h2>Media Manager</h2> <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> <span id="current-sku" class="sku-badge">...</span>
</div> </div>
<!-- Optional: Could put metadata here or small status -->
</div>
</div>
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()"> <!-- Upload Options Card -->
<div style="font-size: 32px; margin-bottom: 8px;">☁️</div> <div class="card">
<div style="font-size: 14px; font-weight: 500;">Drop files or click to upload</div> <div class="header" style="margin-bottom: 12px;">
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;"> <h3 style="margin:0; font-size:14px; color:var(--text);">Add Photos/Videos from...</h3>
Direct to Drive • JPG, PNG, MP4 </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> </div>
<input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)"> <input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)">
</div> </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 --> <!-- Photos Session UI -->
<div id="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;"> style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
@ -340,7 +403,6 @@
</a> </a>
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div> <div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
</div> </div>
</div>
<div class="card" style="padding-bottom: 0;"> <div class="card" style="padding-bottom: 0;">
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;"> <div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
@ -366,24 +428,35 @@
<div id="media-grid" class="media-grid"> <div id="media-grid" class="media-grid">
<!-- Rendered Items --> <!-- Rendered Items -->
</div> </div>
<!-- Action Footer --> <!-- Action Footer -->
<div class="action-bar" style="display:flex; gap:8px;"> <div class="action-bar" style="display:flex; gap:8px;">
<button id="details-btn" onclick="ui.showDetails()" class="btn btn-secondary" style="flex:1;"> <button id="details-btn" onclick="ui.showDetails()" class="btn btn-secondary" style="flex:1;">
Show Plan Show Plan
</button> </button>
<button id="save-btn" onclick="controller.saveChanges()" class="btn" style="flex:2;" disabled> <button id="save-btn" onclick="controller.saveChanges()" class="btn" style="flex:2;" disabled>
Save Changes Save Changes
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Loading Screen --> <!-- Loading Screen -->
<div id="loading-ui" style="text-align:center; padding-top: 100px;"> <div id="loading-ui" style="text-align:center; padding-top: 100px;">
<div class="spinner" style="width: 32px; height: 32px; border-width: 3px;"></div> <div class="spinner" style="width: 32px; height: 32px; border-width: 3px;"></div>
<div style="margin-top:16px; color: var(--text-secondary); font-weight: 500;">Connecting...</div> <div style="margin-top:16px; color: var(--text-secondary); font-weight: 500;">Connecting...</div>
</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 --> <!-- Preview Modal -->
<div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)"> <div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)">
<div class="modal-content"> <div class="modal-content">
@ -411,132 +484,137 @@
</div> </div>
</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> <script>
/** /**
* State Management * State Management & Error Handling
*/ */
class MediaState { window.onerror = function (msg, url, line) {
constructor() { alert("Script Error: " + msg + "\nLine: " + line);
this.sku = null; };
this.items = []; // Current UI State
this.initialState = []; // For diffing "isDirty"
}
setSku(sku) { // --- ES5 Refactor: MediaState ---
this.sku = sku; function MediaState() {
this.sku = null;
this.token = null;
this.items = []; this.items = [];
this.initialState = []; this.initialState = [];
ui.updateSku(sku);
} }
setItems(items) { MediaState.prototype.setSku = function (info) {
// items: { id, filename, thumbnail, status, source } 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.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');
@ -547,81 +625,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) { 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('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();
} }
}); });
} };
logStatus(step, message, type = 'info') { UI.prototype.setLoadingState = function (isLoading) {
const container = this.logContainer; if (isLoading) {
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳'; this.grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">' +
const el = document.createElement('div'); '<div class="spinner" style="margin-bottom: 12px;"></div>' +
el.innerHTML = `<span style="margin-right:8px;">${icon}</span> ${message}`; '<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)'; 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>';
@ -630,148 +726,159 @@
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>'; badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
} }
// Thumbnail var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4')); if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
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">`;
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 logic (Only relevant for Shopify where we have a direct public link)
${badge} var contentUrl = item.contentUrl || "";
${mediaHtml}
<div class="media-overlay"> var actionBtn = item._deleted
<button class="icon-btn btn-view" onclick="ui.openPreview('${item.id}')" title="View">👁</button> ? '<button class="icon-btn" onclick="state.deleteItem(' + index + ')" title="Restore"></button>'
${actionBtn} : '<button class="icon-btn btn-delete" onclick="state.deleteItem(' + index + ')" title="Delete">🗑️</button>';
</div>
`; div.innerHTML =
return div; 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) { var overlay = div.querySelector('.media-overlay');
const item = state.items.find(i => i.id === id); 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 // Drive Video -> Iframe
// Note: This assumes item.id corresponds to Drive File ID for drive items. if (item.source !== 'shopify_only') {
// (Which is true for 'drive_only' and 'synced' items in MediaService) iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
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;
iframe.style.display = 'block'; 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 { } else {
// Image // Fallback if URL missing
img.src = item.thumbnail; // Thumbnail is base64 for Drive, URL for Shopify console.warn("Missing contentUrl for Shopify video");
// 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. } else {
img.src = item.thumbnail;
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);
@ -780,27 +887,56 @@
checkSku() { checkSku() {
google.script.run 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) { 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(); this.loadMedia();
} }
}
}) })
.getSelectedSku(); .getSelectedProductInfo();
}, },
loadMedia(preserveLogs = false) { loadMedia(preserveLogs = false) {
const sku = document.getElementById('current-sku').innerText; // Resolve SKU/Title - prefer state, fallback to DOM
// Ensure Loading UI is visible and Main UI is hidden until ready let sku = state.sku;
document.getElementById('loading-ui').style.display = 'block'; let title = state.title;
document.getElementById('main-ui').style.display = 'none';
// Reset State (this calls ui.updateSku which might show main-ui, so we re-toggle below if needed) if (!sku) {
state.setSku(sku); const domSku = document.getElementById('current-sku').innerText;
if (domSku && domSku !== '...') sku = domSku;
}
// Enforce Loading State Again (in case setSku reset it) // CHECK FOR MISSING SKU
document.getElementById('loading-ui').style.display = 'block'; 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('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) { if (!preserveLogs) {
document.getElementById('status-log-container').innerHTML = ''; document.getElementById('status-log-container').innerHTML = '';
@ -822,6 +958,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');
@ -980,7 +1119,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)
@ -1006,19 +1145,59 @@
} }
// 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 for upload zone (Visual only) // Drag & Drop Handlers (Global)
const dropZone = document.getElementById('drop-zone'); const dropOverlay = document.getElementById('drop-overlay');
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); let dragCounter = 0;
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); });
dropZone.addEventListener('drop', (e) => { // 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(); 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); controller.handleFiles(e.dataTransfer.files);
}
}); });
</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

@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar" import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync" import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
import { installSalesSyncTrigger } from "./triggers" 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" import { runSystemDiagnostics } from "./verificationSuite"
// prettier-ignore // prettier-ignore
@ -52,7 +52,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).reconcileSalesHandler = reconcileSalesHandler ;(global as any).reconcileSalesHandler = reconcileSalesHandler
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger ;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
;(global as any).showMediaManager = showMediaManager ;(global as any).showMediaManager = showMediaManager
;(global as any).getSelectedSku = getSelectedSku ;(global as any).getSelectedProductInfo = getSelectedProductInfo
;(global as any).getMediaForSku = getMediaForSku ;(global as any).getMediaForSku = getMediaForSku
;(global as any).saveFileToDrive = saveFileToDrive ;(global as any).saveFileToDrive = saveFileToDrive
;(global as any).saveMediaChanges = saveMediaChanges ;(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 { Config } from "./config"
import { GASDriveService } from "./services/GASDriveService" import { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService" import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
@ -47,7 +47,11 @@ jest.mock("./services/GASSpreadsheetService", () => {
return { return {
GASSpreadsheetService: jest.fn().mockImplementation(() => { GASSpreadsheetService: jest.fn().mockImplementation(() => {
return { 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") expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
}) })
test("getSelectedSku should return sku from sheet", () => { test("getSelectedProductInfo should return sku and title from sheet", () => {
const sku = getSelectedSku() const info = getSelectedProductInfo()
expect(sku).toBe("TEST-SKU") expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
}) })
test("getPickerConfig should return config", () => { test("getPickerConfig should return config", () => {

View File

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