Files
product_inventory/src/MediaManager.html
Ben Miller d9fe81f282 feat: Use Shopify thumbnail and playback URL for synced media
- Update \MediaService.ts\ to populate \	humbnail\ and \contentUrl\ from Shopify media when a Drive file is synced.
- Enable \synced\ videos to use the Shopify video URL for playback/hover.
- Update \MediaManager.html\ to allow \synced\ items to render as \<video>\ tags if they have a valid \contentUrl\.
- Add regression tests in \MediaService.test.ts\ for thumbnail and video sync behavior.
2025-12-29 01:26:18 -07:00

1396 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<!-- SortableJS for Drag and Drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<style>
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--surface: #ffffff;
--background: #f1f5f9;
--text: #0f172a;
--text-secondary: #64748b;
--border: #e2e8f0;
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
}
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 16px;
background-color: var(--background);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
/* Cards & Layout */
.card {
background: var(--surface);
border: 1px solid var(--border);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--text);
}
.sku-badge {
background: #eff6ff;
color: var(--primary);
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
border: 1px solid #dbeafe;
}
/* Upload Zone */
.upload-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 32px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: #f8fafc;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--primary);
background: #eff6ff;
}
/* Grid */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
margin-top: 16px;
min-height: 100px;
/* Drop target area */
}
.media-item {
position: relative;
background: var(--surface);
border-radius: 8px;
border: 1px solid var(--border);
overflow: hidden;
aspect-ratio: 1;
transition: transform 0.2s, box-shadow 0.2s;
cursor: grab;
}
.media-item:active {
cursor: grabbing;
}
.media-item.sortable-ghost {
opacity: 0.4;
background: #cbd5e1;
}
.media-item.sortable-drag {
cursor: grabbing;
opacity: 1;
background: var(--surface);
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
transform: scale(1.05);
}
.media-item.deleted-item {
opacity: 0.5;
filter: grayscale(100%);
}
/* Media Content */
.media-content {
width: 100%;
height: 100%;
object-fit: contain;
background: #f8fafc;
/* Placeholder bg */
padding: 4px;
box-sizing: border-box;
}
/* Overlays & Badges */
.media-overlay {
position: absolute;
top: auto;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
opacity: 0;
transition: opacity 0.2s;
display: flex;
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 */
z-index: 10;
padding: 2px 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
font-weight: 600;
text-transform: uppercase;
}
.icon-btn {
background: white;
border: none;
border-radius: 6px;
width: 32px;
height: 32px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: transform 0.1s;
}
.icon-btn:hover {
transform: scale(1.1);
}
.btn-delete {
color: var(--danger);
}
.btn-view {
color: var(--primary);
}
/* Buttons */
.btn {
background-color: var(--primary);
color: white;
border: none;
padding: 10px 16px;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
font-size: 14px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn:hover {
background-color: var(--primary-hover);
}
.btn:disabled {
background-color: var(--text-secondary);
cursor: not-allowed;
opacity: 0.7;
}
.btn-secondary {
background-color: white;
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background-color: #f8fafc;
}
.action-bar {
position: sticky;
bottom: 0;
background: var(--surface);
padding: 16px;
border-top: 1px solid var(--border);
margin: 0 -16px -16px -16px;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
display: flex;
gap: 8px;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 100;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-content {
max-width: 90%;
max-height: 90%;
position: relative;
}
.modal-close {
position: absolute;
top: -40px;
right: 0;
color: white;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
}
/* Empty State */
.empty-state {
text-align: center;
color: var(--text-secondary);
font-size: 13px;
padding: 40px 0;
grid-column: 1 / -1;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@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);
}
}
</style>
</head>
<body>
<div id="main-ui" style="display:none">
<!-- Header Card -->
<!-- Product Info Card -->
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<div id="current-title" style="font-weight:600; font-size:16px; margin-bottom:4px; color:var(--text);">
Loading...
</div>
<span id="current-sku" class="sku-badge">...</span>
</div>
<!-- Optional: Could put metadata here or small status -->
</div>
</div>
<!-- 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>
<!-- 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;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<span style="font-weight:600; font-size:12px; color:#0369a1;">Photo Picker Session</span>
<button onclick="ui.closePhotoSession()"
style="background:none; border:none; color:#0369a1; cursor:pointer; font-size:16px;">×</button>
</div>
<a id="photos-session-link" href="#" target="_blank" class="btn"
style="background:#0ea5e9; text-decoration:none; margin-bottom:8px;">
Open Google Photos ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</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;">
<div style="display:flex; align-items:baseline; gap:12px;">
<h2 style="margin:0;">Gallery <span id="item-count"
style="font-weight:400; color:var(--text-secondary); font-size:12px;">(0)</span></h2>
<div id="quick-links" style="font-size:12px; display:flex; gap:8px;"></div>
</div>
<div style="display: flex; gap: 8px;">
<button id="toggle-log-btn" onclick="ui.toggleLog()"
style="display:none; background:none; border:none; cursor:pointer; font-size:12px; color:var(--primary);">View
Log</button>
<button onclick="controller.loadMedia()" title="Refresh"
style="background:none; border:none; cursor:pointer; font-size:18px; color:var(--text-secondary);"></button>
</div>
</div>
<!-- Status Log -->
<div id="status-log-container"
style="padding:16px; background:#f8fafc; border-bottom:1px solid var(--border); font-family:monospace; font-size:12px; line-height:1.6; display:none;">
</div>
<div id="media-grid" class="media-grid">
<!-- Rendered Items -->
</div>
<!-- Action Footer -->
<div class="action-bar" style="display:flex; gap:8px;">
<button id="details-btn" onclick="ui.showDetails()" class="btn btn-secondary" style="flex:1;">
Show Plan
</button>
<button id="save-btn" onclick="controller.saveChanges()" class="btn" style="flex:2;" disabled>
Save Changes
</button>
</div>
</div>
</div>
<!-- Loading Screen -->
<div id="loading-ui" style="text-align:center; padding-top: 100px;">
<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>
<!-- 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">
<button class="modal-close" onclick="ui.closeModal()">×</button>
<img id="preview-image" style="max-width:100%; max-height:80vh; border-radius:8px; display:none;">
<video id="preview-video" controls style="max-width:100%; max-height:80vh; border-radius:8px; display:none;"></video>
<iframe id="preview-iframe" style="width:100%; height:60vh; border:none; border-radius:8px; display:none;"
allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div>
</div>
<!-- Details Modal -->
<div id="details-modal" class="modal-overlay" onclick="ui.closeDetails(event)">
<div class="card"
style="width: 300px; max-height: 80vh; overflow-y: auto; margin: auto; position: relative; padding: 20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h3 style="margin:0; font-size:16px;">Pending Actions</h3>
<button onclick="ui.closeDetails()"
style="border:none; background:none; font-size:20px; cursor:pointer;">&times;</button>
</div>
<div id="details-content" style="font-size:13px; color:var(--text-secondary); line-height:1.5;">
<!-- Injected by JS -->
</div>
<button onclick="ui.closeDetails()" class="btn btn-secondary" style="margin-top:16px;">Close</button>
</div>
</div>
<!-- Matching Modal -->
<div id="matching-modal" class="modal-overlay" style="z-index: 150;">
<div class="card"
style="width: 600px; max-width: 90%; text-align: center; padding: 24px; position: relative; background: #fff;">
<h3 style="margin-top:0;">Link Media?</h3>
<p style="color:var(--text-secondary); margin-bottom: 24px;">
We found a matching file in Shopify. Should these be linked?
</p>
<div style="display: flex; justify-content: center; gap: 24px; margin-bottom: 24px;">
<!-- Drive Side -->
<div style="flex: 1;">
<div style="font-size: 12px; font-weight: 600; margin-bottom: 8px;">Drive File</div>
<img id="match-drive-img"
style="width: 100%; height: 200px; object-fit: contain; border: 1px solid var(--border); border-radius: 8px; background: #f8fafc;">
<div id="match-drive-name"
style="font-size: 11px; margin-top: 4px; color: var(--text-secondary); word-break: break-all;">filename.jpg
</div>
</div>
<!-- Icon -->
<div style="display: flex; align-items: center; font-size: 24px; color: var(--text-secondary);">🔗</div>
<!-- Shopify Side -->
<div style="flex: 1;">
<div style="font-size: 12px; font-weight: 600; margin-bottom: 8px;">Shopify Media</div>
<img id="match-shopify-img"
style="width: 100%; height: 200px; object-fit: contain; border: 1px solid var(--border); border-radius: 8px; background: #f8fafc;">
<div id="match-shopify-name"
style="font-size: 11px; margin-top: 4px; color: var(--text-secondary); word-break: break-all;">filename.jpg
</div>
</div>
</div>
<div style="display: flex; gap: 12px; justify-content: center;">
<button id="btn-match-skip" onclick="controller.skipLink()" class="btn btn-secondary" style="width: 100px;">No,
Skip</button>
<button id="btn-match-confirm" onclick="controller.confirmLink()" class="btn" style="width: 100px;">Yes,
Link</button>
</div>
<div style="margin-top: 12px; font-size: 12px; color: var(--text-secondary);">
Match <span id="match-index">1</span> of <span id="match-total">1</span>
</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>
/**
* State Management & Error Handling
*/
window.onerror = function (msg, url, line) {
alert("Script Error: " + msg + "\nLine: " + line);
};
// --- ES5 Refactor: MediaState ---
function MediaState() {
this.sku = null;
this.token = null;
this.items = [];
this.initialState = [];
}
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));
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.addItem = function (item) {
this.items.push(item);
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.deleteItem = function (index) {
var item = this.items[index];
if (item.source === 'new') {
this.items.splice(index, 1);
} else {
item._deleted = !item._deleted;
}
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.reorderItems = function (newIndices) {
// Handled by Sortable
};
MediaState.prototype.checkDirty = function () {
var plan = this.calculateDiff();
var isDirty = plan.hasChanges;
ui.toggleSave(isDirty);
return plan;
};
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; }));
var actions = [];
this.items.forEach(function (i) {
if (i._deleted) actions.push({ type: 'delete', name: i.filename || 'Item' });
});
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') {
actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
}
});
// 3. Reorders
var activeItems = this.items.filter(function (i) { return !i._deleted; });
// Filter initial state to only items that are still active
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);
});
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' });
}
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
};
};
MediaState.prototype.hasNewItems = function () {
return this.items.some(function (i) {
return !i._deleted && (i.status === 'drive_only' || i.source === 'new');
});
};
var state = new MediaState();
window.state = state;
// --- ES5 Refactor: UI ---
function UI() {
this.grid = document.getElementById('media-grid');
this.saveBtn = document.getElementById('save-btn');
this.toggleLogBtn = document.getElementById('toggle-log-btn');
this.logContainer = document.getElementById('status-log-container');
this.linksContainer = document.getElementById('quick-links');
this.sortable = null;
this.driveUrl = null;
this.shopifyUrl = null;
}
UI.prototype.setDriveLink = function (url) { this.driveUrl = url; this.renderLinks(); };
UI.prototype.setShopifyLink = function (url) { this.shopifyUrl = url; this.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>';
};
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";
};
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';
};
UI.prototype.toggleSave = function (enable) {
this.saveBtn.disabled = !enable;
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
};
UI.prototype.render = function (items) {
this.grid.innerHTML = '';
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(function (item, index) {
var el = _this.createCard(item, index);
_this.grid.appendChild(el);
});
if (this.sortable) this.sortable.destroy();
this.sortable = new Sortable(this.grid, {
animation: 150,
filter: '.deleted-item',
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
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();
}
});
};
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);
};
UI.prototype.createCard = function (item, index) {
var div = document.createElement('div');
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
div.dataset.id = item.id;
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>';
else if (item.status === 'shopify_only') badge = '<span class="badge" title="Shopify Only" style="background:#fce7f3; color:#9d174d;">Shopify</span>';
} else {
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
}
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);
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">🎞️</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;
// Allow Shopify-only OR Synced items with valid contentUrl (Shopify Video URL) to use <video> tag
if (isVideo && (item.source === 'shopify_only' || item.source === 'synced') && 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';
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;
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';
var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
if (isVideo) {
// 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 {
// Fallback if URL missing
console.warn("Missing contentUrl for Shopify video");
}
}
} else {
img.src = item.thumbnail;
img.style.display = 'block';
}
modal.style.display = 'flex';
};
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';
};
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 {
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 = '🔢';
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>';
}).join('');
container.innerHTML = html;
}
document.getElementById('details-modal').style.display = 'flex';
};
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';
};
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');
uiEl.style.display = 'block';
link.href = url;
link.style.display = 'block';
status.innerText = "Waiting for selection...";
};
UI.prototype.closePhotoSession = function () {
document.getElementById('photos-session-ui').style.display = 'none';
};
UI.prototype.updatePhotoStatus = function (msg) {
document.getElementById('photos-session-status').innerText = msg;
};
var ui = new UI();
window.ui = ui;
/**
* Data Controller
*/
var controller = {
init() {
// Start polling for SKU selection
setInterval(() => this.checkSku(), 2000);
this.checkSku();
},
checkSku() {
google.script.run
.withSuccessHandler(info => {
// Info is now { sku, title } or null
const sku = info ? info.sku : null;
if (sku && sku !== state.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();
}
}
})
.getSelectedProductInfo();
},
loadMedia(preserveLogs = false) {
// Resolve SKU/Title - prefer state, fallback to DOM
let sku = state.sku;
let title = state.title;
if (!sku) {
const domSku = document.getElementById('current-sku').innerText;
if (domSku && domSku !== '...') sku = domSku;
}
// 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 = '';
}
ui.toggleLogBtn.style.display = 'inline-block';
ui.toggleLog(true); // Force Show Log to see progress
// 1. Run Diagnostics
// 1. Run Diagnostics
ui.logStatus('init', 'Initializing access...', 'info');
google.script.run
.withSuccessHandler(function (diagnostics) {
// Drive Status
if (diagnostics.drive.status === 'ok') {
ui.logStatus('drive', `Drive Folder: ok (${diagnostics.drive.fileCount} files) <a href="${diagnostics.drive.folderUrl}" target="_blank" style="margin-left:8px;">Open Folder ↗</a>`, 'success');
ui.setDriveLink(diagnostics.drive.folderUrl);
} else {
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');
ui.setShopifyLink(diagnostics.shopify.adminUrl);
} else if (diagnostics.shopify.status === 'skipped') {
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
} else {
ui.logStatus('shopify', `Shopify Check Failed: ${diagnostics.shopify.error}`, 'error');
}
ui.logStatus('fetch', 'Fetching full media state (this may take a moment)...', 'info');
// 2. Load Full Media
google.script.run
.withSuccessHandler(function (items) {
// Normalize items
const normalized = items.map(i => ({
...i,
id: i.id || Math.random().toString(36).substr(2, 9),
status: i.source || 'drive_only', // Fix: Use source as status
source: i.source,
_deleted: false // Init soft delete flag
}));
state.setItems(normalized);
if (!controller.hasRunMatching) {
controller.hasRunMatching = true;
controller.checkMatches(normalized);
} else {
controller.showGallery();
}
})
.withFailureHandler(function (err) {
ui.logStatus('fatal', `Failed to load media: ${err.message}`, 'error');
})
.getMediaForSku(sku);
})
.withFailureHandler(function (err) {
ui.logStatus('fatal', `Diagnostics failed: ${err.message}`, 'error');
})
.getMediaDiagnostics(sku, "");
},
saveChanges() {
ui.toggleSave(false);
ui.saveBtn.innerText = "Saving...";
ui.saveBtn.innerText = "Saving...";
// Filter out deleted items so they are actually removed
const activeItems = state.items.filter(i => !i._deleted);
// Send final state array to backend
google.script.run
.withSuccessHandler((logs) => {
ui.saveBtn.innerText = "Saved!";
// Verify logs is an array (backward compatibility check)
if (Array.isArray(logs)) {
document.getElementById('status-log-container').innerHTML = '';
logs.forEach(l => ui.logStatus('save', l, 'info'));
ui.toggleLog(true); // Force show
} else {
// Fallback for old backend
alert("Changes Saved & Synced!");
}
// Reload to get fresh IDs/State, preserving the save logs
setTimeout(() => this.loadMedia(true), 1500);
})
.withFailureHandler(e => {
alert(`Save Failed: ${e.message}`);
ui.toggleSave(true);
})
.saveMediaChanges(state.sku, activeItems);
},
handleFiles(fileList) {
Array.from(fileList).forEach(file => {
const reader = new FileReader();
reader.onload = (e) => {
const data = e.target.result.split(',')[1]; // Base64
google.script.run
.withSuccessHandler(() => {
this.loadMedia();
})
.saveFileToDrive(state.sku, file.name, file.type, data);
};
reader.readAsDataURL(file);
});
},
// --- Picker ---
openPicker() {
if (!pickerApiLoaded) return alert("API Loading...");
google.script.run.withSuccessHandler(c => createPicker(c)).getPickerConfig();
},
importFromPicker(fileId, mime, name, url) {
google.script.run
.withSuccessHandler(() => this.loadMedia())
.importFromPicker(state.sku, fileId, mime, name, url);
},
// --- Photos ---
startPhotoSession() {
ui.updatePhotoStatus("Starting session...");
google.script.run
.withSuccessHandler(session => {
ui.showPhotoSession(session.pickerUri);
this.pollPhotoSession(session.id);
})
.createPhotoSession();
},
pollPhotoSession(sessionId) {
let processing = false;
const check = () => {
if (processing) return;
google.script.run
.withSuccessHandler(res => {
if (res.status === 'complete') {
processing = true;
ui.updatePhotoStatus("Importing photos...");
controller.processPhotoItems(res.mediaItems);
} else if (res.status === 'error') {
ui.updatePhotoStatus("Error: " + res.message);
} else {
setTimeout(check, 2000);
}
})
.checkPhotoSession(sessionId);
};
check();
},
processPhotoItems(items) {
let done = 0;
items.forEach(item => {
const url = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
google.script.run
.withSuccessHandler(() => {
done++;
if (done === items.length) {
ui.updatePhotoStatus("Done!");
controller.loadMedia();
setTimeout(() => ui.closePhotoSession(), 2000);
}
})
.importFromPicker(state.sku, null, item.mimeType, item.filename, url);
});
},
// --- Compatibility / Matching Logic ---
matches: [],
currentMatchIndex: 0,
hasRunMatching: false,
checkMatches(items) {
// Filter candidates
var driveOnly = items.filter(function (i) { return i.status === 'drive_only'; });
var shopifyOnly = items.filter(function (i) { return i.source === 'shopify_only'; }); // source check is safer for shopify items
var newMatches = [];
driveOnly.forEach(function (d) {
// Find match by filename
// Note: Backend might return "Orphaned Media" if extraction failed, ignore those.
if (!d.filename || d.filename === 'Orphaned Media') return;
var match = shopifyOnly.find(function (s) {
return s.filename === d.filename; // Exact match
});
if (match) {
newMatches.push({ drive: d, shopify: match });
}
});
if (newMatches.length > 0) {
this.matches = newMatches;
this.currentMatchIndex = 0;
ui.logStatus('info', 'Found ' + newMatches.length + ' potential matches. Starting matching wizard...', 'info');
this.startMatching();
} else {
// No matches, show UI
this.showGallery();
}
},
startMatching() {
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'none';
document.getElementById('matching-modal').style.display = 'flex';
this.renderMatch();
},
renderMatch() {
var match = this.matches[this.currentMatchIndex];
// Reset Buttons
var btnConfirm = document.getElementById('btn-match-confirm');
var btnSkip = document.getElementById('btn-match-skip');
if (btnConfirm) {
btnConfirm.disabled = false;
btnConfirm.innerText = "Yes, Link";
}
if (btnSkip) {
btnSkip.disabled = false;
btnSkip.innerText = "No, Skip";
}
var dImg = document.getElementById('match-drive-img');
var sImg = document.getElementById('match-shopify-img');
// Reset visual state safely
dImg.style.transition = 'none';
dImg.style.opacity = '0';
sImg.style.transition = 'none';
sImg.style.opacity = '0';
// Clear source to blank pixel to ensure old image is gone
var blank = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
dImg.src = blank;
sImg.src = blank;
document.getElementById('match-drive-name').innerText = match.drive.filename;
document.getElementById('match-shopify-name').innerText = match.shopify.filename;
document.getElementById('match-index').innerText = this.currentMatchIndex + 1;
document.getElementById('match-total').innerText = this.matches.length;
// Load new images
setTimeout(function () {
dImg.style.transition = 'opacity 0.3s ease';
sImg.style.transition = 'opacity 0.3s ease';
dImg.onload = function () { dImg.style.opacity = '1'; };
sImg.onload = function () { sImg.style.opacity = '1'; };
dImg.src = match.drive.thumbnail;
sImg.src = match.shopify.thumbnail;
}, 50);
},
confirmLink() {
var match = this.matches[this.currentMatchIndex];
var _this = this;
document.getElementById('btn-match-confirm').disabled = true;
document.getElementById('btn-match-confirm').innerText = "Linking...";
document.getElementById('btn-match-skip').disabled = true;
// ui.logStatus('link', 'Linking ' + match.drive.filename + '...', 'info');
google.script.run
.withSuccessHandler(function () {
// ui.logStatus('link', 'Linked ' + match.drive.filename, 'success');
_this.nextMatch();
})
.withFailureHandler(function (e) {
alert("Failed to link: " + e.message);
document.getElementById('btn-match-confirm').disabled = false;
document.getElementById('btn-match-confirm').innerText = "Yes, Link";
document.getElementById('btn-match-skip').disabled = false;
})
.linkDriveFileToShopifyMedia(state.sku, match.drive.id, match.shopify.id);
},
skipLink() {
document.getElementById('btn-match-skip').innerText = "Skipping...";
document.getElementById('btn-match-skip').disabled = true;
document.getElementById('btn-match-confirm').disabled = true;
setTimeout(() => this.nextMatch(), 200);
},
nextMatch() {
this.currentMatchIndex++;
if (this.currentMatchIndex < this.matches.length) {
this.renderMatch();
} else {
// Done
document.getElementById('matching-modal').style.display = 'none';
ui.logStatus('info', 'Matching complete. Reloading...', 'info');
document.getElementById('loading-ui').style.display = 'block';
// Reload to get fresh state. Since hasRunMatching is true, it shouldn't trigger again.
this.loadMedia(true);
}
},
showGallery() {
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
ui.logStatus('done', 'Finished loading.', 'success');
setTimeout(function () { ui.toggleLog(false); }, 1000);
}
};
// --- Google Picker API ---
let pickerApiLoaded = false;
window.onApiLoad = function () { gapi.load('picker', () => { pickerApiLoaded = true; }); };
function createPicker(config) {
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
.setIncludeFolders(true)
.setSelectFolderEnabled(false);
const photosView = new google.picker.PhotosView();
new google.picker.PickerBuilder()
.addView(view)
.addView(photosView)
.setOAuthToken(config.token)
.setDeveloperKey(config.apiKey)
.setCallback(data => {
if (data.action == google.picker.Action.PICKED) {
const doc = data.docs[0];
const url = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
controller.importFromPicker(doc.id, doc.mimeType, doc.name, url);
}
})
.build()
.setVisible(true);
}
// Init
try {
if (!window.state || !window.ui || !window.controller) {
throw new Error("Core components failed to initialize. Check console for SyntaxError.");
}
controller.init();
window.controller = controller; // Re-assert global access
} catch (e) {
alert("Init Failed: " + e.message);
console.error(e);
}
// Drag & Drop Handlers (Global)
const dropOverlay = document.getElementById('drop-overlay');
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();
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>