Compare commits
5 Commits
243f7057b7
...
55d18138b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d18138b7 | |||
| 945fb610f9 | |||
| d67897aa17 | |||
| c738ab3ef7 | |||
| d9d884e1fc |
@@ -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.
|
||||
|
||||
|
||||
@@ -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()"`).
|
||||
|
||||
+413
-234
@@ -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>
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
+11
-3
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -78,6 +78,17 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
||||
originalSrc
|
||||
}
|
||||
}
|
||||
... on Video {
|
||||
sources {
|
||||
url
|
||||
mimeType
|
||||
}
|
||||
}
|
||||
... on MediaImage {
|
||||
image {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user