Files
product_inventory/src/MediaManager.html
Ben Miller fc25e877f1 Disable grid interactions during save operations
- Added .grid-disabled CSS class to prevent pointer events and provide visual feedback (grayscale/opacity) during save.
- Implemented UI.prototype.setSavingState to toggle grid interaction and disable SortableJS reordering.
- Integrated setSavingState into controller.saveChanges to block edits while saving is in progress.
- Added loading message updates ('Refreshing media...' and 'Loading media...') for better UX.
2025-12-31 09:11:57 -07:00

2105 lines
75 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
padding: 4px;
pointer-events: auto;
}
.media-item:hover .media-overlay {
opacity: 1;
}
/* Processing State */
.media-item.processing-card {
background-color: #334155 !important;
position: relative;
}
.media-item.processing-card .media-content {
display: block !important;
opacity: 0.8;
filter: grayscale(30%);
width: 100%;
height: 100%;
object-fit: contain;
}
.processing-icon {
position: absolute;
bottom: 6px;
right: 6px;
font-size: 20px;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.6s ease-in-out;
/* Remove fixed width/height so it fits content */
}
/* .flipping removed, handled by JS inline style */
.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);
}
}
/* Log Card Styles */
.log-card {
background: var(--surface);
color: var(--text);
border-radius: 8px;
margin-top: 16px;
font-family: monospace;
font-size: 11px;
overflow: hidden;
transition: all 0.2s ease;
border: 1px solid var(--border);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
}
.log-header {
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
color: var(--text-secondary);
font-weight: 500;
}
.log-content {
padding: 12px;
/* ~1 line */
max-height: 16px;
overflow-y: auto;
transition: max-height 0.3s ease;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--surface);
}
.log-card.expanded .log-content {
/* ~20 lines */
max-height: 300px;
}
.log-entry {
line-height: 1.4;
border-bottom: 1px solid #f1f5f9;
padding-bottom: 2px;
}
.log-entry:last-child {
border-bottom: none;
}
/* Scrollbar for log */
.log-content::-webkit-scrollbar {
width: 6px;
}
.log-content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
/* Transfer Session UI */
.transfer-session {
margin-top: 12px;
padding: 16px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 8px;
}
.progress-track {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.3s ease;
}
.transfer-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
/* Grid Overlay */
.grid-loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(1px);
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-weight: 500;
border-radius: 8px;
}
.grid-disabled {
pointer-events: none;
opacity: 0.7;
filter: grayscale(0.5);
}
</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>
<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>
<!-- Processing Warning Banner -->
<div id="processing-banner"
style="display:none; background-color:#fffbeb; color:#92400e; padding:12px; border-radius:8px; margin: 0 16px 12px 16px; font-size:13px; border:1px solid #fcd34d; align-items:flex-start; gap:8px;">
<span style="font-size:16px; line-height:1;"></span>
<div>
Some videos are still being processed. The video preview might not work yet, but they can still be saved,
reordered, or deleted.
</div>
</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 id="upload-section" style="display:none;">
<!-- 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 id="btn-upload-drive" onclick="controller.openPicker()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Drive
</button>
<button id="btn-upload-photos" onclick="controller.startPhotoSession()" class="btn btn-secondary"
style="flex: 1; font-size: 13px; white-space: nowrap;">
Google Photos
</button>
<button id="btn-upload-computer" 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)">
<!-- Unified Transfer Session UI -->
<div id="transfer-session-ui" class="transfer-session" style="display:none;">
<!-- Instructions (Top) -->
<div id="transfer-desc" style="font-size:13px; color:var(--text); margin-bottom:12px; line-height:1.4;">
<!-- Dynamic Helper Text -->
</div>
<!-- Progress Section (Middle) -->
<div id="transfer-progress-container">
<div
style="display:flex; justify-content:space-between; font-size:11px; color:var(--text-secondary); margin-bottom: 4px;">
<span id="transfer-status-text">Processing...</span>
<span id="transfer-count"></span>
</div>
<div class="progress-track">
<div id="transfer-progress-bar" class="progress-fill"></div>
</div>
</div>
<!-- Footer Buttons (Bottom) -->
<div class="transfer-footer">
<button id="btn-transfer-reopen" class="btn btn-secondary" style="font-size:12px;" disabled>
Re-open Popup ↗
</button>
<button id="btn-transfer-cancel" onclick="controller.cancelTransfer()" class="btn btn-secondary"
style="font-size:12px;">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Permanent Log Card -->
<div id="log-card" class="log-card">
<div class="log-header" onclick="ui.toggleLogExpand()">
<span style="font-weight:600;">Activity Log</span>
<span id="log-toggle-icon"></span>
</div>
<div id="status-log-container" class="log-content">
<div class="log-entry" style="color: #94a3b8;">Ready.</div>
</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'); // Removed
this.logContainer = document.getElementById('status-log-container');
this.linksContainer = document.getElementById('quick-links');
this.logCard = document.getElementById('log-card');
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.toggleLogExpand = function () {
this.logCard.classList.toggle('expanded');
var icon = this.logCard.querySelector('#log-toggle-icon');
icon.innerText = this.logCard.classList.contains('expanded') ? '▼' : '▲';
};
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.showTransferSession = function (mode, serviceName) {
var el = document.getElementById('transfer-session-ui');
var desc = document.getElementById('transfer-desc');
var statusText = document.getElementById('transfer-status-text');
var bar = document.getElementById('transfer-progress-bar');
var btnReopen = document.getElementById('btn-transfer-reopen');
var btnCancel = document.getElementById('btn-transfer-cancel');
el.style.display = 'block';
bar.style.width = '0%';
statusText.innerText = 'Initializing...';
document.getElementById('transfer-count').innerText = '';
// Reset Buttons
btnCancel.disabled = false;
btnReopen.disabled = true; // Default
if (mode === 'waiting') {
desc.innerText = "Select items from " + (serviceName || "the service") + " in the popup window. Click 'Done' when finished.";
statusText.innerText = "Waiting for selection...";
} else {
desc.innerText = "Importing media from " + (serviceName || "source") + "...";
}
};
UI.prototype.updateTransferProgress = function (current, total, statusMsg) {
var bar = document.getElementById('transfer-progress-bar');
var statusText = document.getElementById('transfer-status-text');
var countText = document.getElementById('transfer-count');
// Disable Reopen once transfer starts
const btnReopen = document.getElementById('btn-transfer-reopen');
if (btnReopen) btnReopen.disabled = true;
if (total > 0) {
var pct = Math.round((current / total) * 100);
bar.style.width = pct + '%';
countText.innerText = current + ' / ' + total;
} else {
// Indeterminate
bar.style.width = '100%';
countText.innerText = '';
}
if (statusMsg) statusText.innerText = statusMsg;
// If done, disable cancel
if (current === total && total > 0) {
const btnCancel = document.getElementById('btn-transfer-cancel');
if (btnCancel) btnCancel.disabled = true;
}
};
UI.prototype.hideTransferSession = function () {
document.getElementById('transfer-session-ui').style.display = 'none';
};
UI.prototype.setupReopenButton = function (url) {
var btn = document.getElementById('btn-transfer-reopen');
if (!url) {
btn.disabled = true;
return;
}
const width = 1200;
const height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const params = `width=${width},height=${height},top=${top},left=${left}`;
btn.onclick = function (e) {
e.preventDefault();
window.open(url, 'googlePhotos', params);
};
btn.disabled = false;
};
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 + ')';
// processing check
var hasProcessing = items.some(function (i) { return !i._deleted && i.isProcessing; });
var banner = document.getElementById('processing-banner');
if (banner) {
banner.style.display = hasProcessing ? 'flex' : 'none';
}
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 loading, ensure saving state is cleared (visual conflict)
// but actually we might want saving state to take precedence or exist.
// Loading typically replaces everything or adds overlay.
var overlay = document.getElementById('grid-loading-overlay');
if (isLoading) {
// Clear disabled state if moving to loading (refreshing)
this.grid.classList.remove('grid-disabled');
// Check if we have items
var hasItems = this.grid.children.length > 0 && !this.grid.querySelector('.empty-state');
if (hasItems) {
// Create overlay if not exists
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'grid-loading-overlay';
overlay.className = 'grid-loading-overlay';
overlay.innerHTML = '<div class="spinner" style="margin-bottom: 12px;"></div><div>Refreshing media...</div>';
this.grid.style.position = 'relative'; // Ensure positioning context
this.grid.appendChild(overlay);
}
} else {
// Standard empty state loading
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>Loading media...</div></div>';
}
} else {
// Clear overlay
if (overlay) overlay.remove();
}
};
UI.prototype.setSavingState = function (isSaving) {
if (isSaving) {
this.grid.classList.add('grid-disabled');
if (this.sortable) this.sortable.option('disabled', true);
// Disable action buttons explicitly if needed (pointer-events handles most, but keyboard nav?)
var btns = this.grid.querySelectorAll('button');
btns.forEach(function (b) { b.disabled = true; });
} else {
this.grid.classList.remove('grid-disabled');
if (this.sortable) this.sortable.option('disabled', false);
var btns = this.grid.querySelectorAll('button');
btns.forEach(function (b) { b.disabled = false; });
}
};
UI.prototype.logStatus = function (step, message, type) {
if (!type) type = 'info';
var container = this.logContainer;
// Auto-clear "Ready"
if (container.children.length === 1 && container.children[0].innerText === "Ready.") {
container.innerHTML = "";
}
var icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '';
var el = document.createElement('div');
el.className = 'log-entry';
el.innerHTML = '<span style="margin-right:8px; opacity:0.7;">' + icon + '</span> ' + message;
if (type === 'error') el.style.color = 'var(--danger)';
if (type === 'success') el.style.color = 'var(--success)';
container.appendChild(el);
container.scrollTop = container.scrollHeight; // Auto-scroll
};
UI.prototype.createCard = function (item, index) {
var div = document.createElement('div');
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
div.dataset.id = item.id;
// Processing Class
if (item.isProcessing) {
div.className += ' processing-card';
}
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>' : '';
// Processing Badge REMOVED (Handled by center icon now)
var centerIcon = '';
if (item.isProcessing) {
centerIcon = '<div class="processing-icon">⏳</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 +
centerIcon +
'<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.referrerPolicy = "no-referrer";
mediaEl.onerror = function () {
var currentSrc = this.src;
console.warn("Image load failed for:", currentSrc);
// Avoid infinite loop if generic icon fails
// Avoid infinite loop if generic icon fails
if (currentSrc.startsWith("data:image")) {
this.style.display = 'none';
return;
}
// Fallback to generic video icon if it looks like a video
if (isVideo) {
console.log("Falling back to generic video icon...");
// Base64 Video Icon (Blue) to avoid network issues
this.src = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABmJLR0QA/wD/AP+gvaeTAAAHGUlEQVR42u2beWwUVRzHP293W2i33JRSChQoBAQkQkFO4Q8ET4wKBI0KCJ6JEARPUC5FjIkKCJFDoCIiNwoioCIiBExEIlEKBQu0hRZarcvutttd/xwz7fR2d3ZmO7uz2d9kM7Mz7733/T7z+733ezMHKlSokA/lYg9QDiwG5gPdwAigH9AP6Oow9gL7gX3ALqAZqANqgS/i8fi+XJ9IrgG4BFgMLAP6u4wVwH7gI6AKeD3XJ5RLABYAS4BvgWH5+nI9MBV4D6gHGrN9QDYBuBR4Hfi8gL5/AAuBp7J5WDYAuAp4GxhWZF8/A68Cl2fbsKwCcCHwIfBskX37BDCR9I1clDUArgXWASOL7NOTwDqy476sAHANsA4YWmRfPgKsJRvuMwvAdcBHwLAi++4RYCPZcd9mAdjE4/E9wO9F9uXfwB4S3qAvEwBcAXwGjCiy7x4FNpId92UcgI3AIsBXZN8tAkbke49kFIDLgdeBMUX23WPAO8Cl+faRDgAbSM/9w0X23cPAWnI7B6QDwKvAkCL76lFgGbnlQDIAXAa8AnQsso8eA94ALs71HkgGgI+A/kW2+SPg41zvgUQAGAP0LLLNjwJjcr3/iQAwFuhRZJt3AqNzve+JADAAyL+XtwuMzvW+JwJAd6B7kW3eHeia631PBIBuRe65QNeCAhAAChAAChAAChAAChAAChAAChCArG+C7wS8B4wHOgM9gF5AF6AL4HEZ8wHbgb1AI7AT2A68H4/H/8sGAJcAS4C3c5y/DXgdWJ2rzS8IAOYCz2U592ngmWy2IAgA3gSGZjn3MOClbLYgCAA+B0ZkOfco8Gk2WxAEAMuBrlnOvRzoymYLogD4kPTd38gM5x8JjCmyDZwIAH7S6/x84O0i28GJAPD7ac7fD/xWZBvoMwADyI+zfyAwNtf7nggAE4D2Rbb5BKBNrvc9EQAmAxOLbPNkYFKu9z0RAGYAg4ps82BgRq73PR4A4vH4l8BaYHCRffcwsDYej3+Zaz2gU4j3Jd0ADy+y7x4ClpL7YtAAANcCK4AhRfbhQ8BKcuu+DQAwFXgXSM0uL3QeA94BpqTrI1k+BD8KrCV9U1xMHgXWkg33mQUgHo/vAd4mcxex0PkbeMds92cSgDiwHni5yL58GVhPdtxnFQAA64BJwPsd1K9fSfrh+MlsHpYNAIi/tHwLeK8T+nUFsI7siL8gAAAcBhYA0zupX88D35ON9GcFgPhLzLPAx8C4Ivv0aeB9siP+ggIAYAfwIvB0J/brRWAH2XGfFwAAf5D+QjO+k/v1BOmH4x/l68t8AcBvF4+SfmnqW6IB+AKYQnbE7ysAcdJfa/YD04D3gQuK7Ot9pH8bWEv6j9uGfH3Z7gE5/N/4G+kXm507oF8/AX4iG9nPeQAAO4FngGcz/H//QfoB+X9kI/25AADA18By0n9J6dAB/foa6YfjH+Xy5EIBAPD76hHS/93TJR3QryuB5WQj/bkCAOD31zLSf+3r0AH92kT64XibEwAAO0j/l75Lh/TrLtIPx9ucAiAej+8BviT9X/6K6te/ST8cb3MKAAA/ATNI/7eXovr1M+mH421OAQDwO+0l0v/1p6h+vUX64XibkwAAuAX4hPR/+SuqX38m/XC8zUkAADwArCT933+K6tdK0g/H25wGAMB1wFekd4CK6teXpB+OtzkNQJz0g/BcopP79RzpB+S2XJ5c6D0gHo9vAeaS3hEqql/nkB3x+woAgK8S3REqql9fkh3x+w4AgBtJ7wgV1a8bSY/4CwoAgB8S3REqql9/kB7xFxQAAD8kuiNUVD/8JD3idyQAAG4guiNUVD/cSHrE71gAANxAdEeoeP/wkPSIv6AAALiO6I5Q8f6xnPSIv6AAALie6I5Q8f5xPekRf8EBAPD78DqiO0LF+8cPpEf8rgAA+J34I9EdoeL940fSI35XAABcS3RHqHj/uJb0iN+1AAD4nfhzojtCxXvH/wCwAPQCOgGdgG5AF8CbhP0A8DPwF+l/8Xg8vrcQAAi63yfdAA8rwn/vIWApuS8GQf8L+iHpR/yTivDf+wnpiN/Vl6CjgLeA74GLi/Dfuxj4FniL9I6w0H8CBgHPAp8DI4rw3z0KfAbMIHu3gKAAxEn/l/4U8E4R/nufAt4GppC9W0CQAAC4AHgB+BwYVoT/7mHA56R3hM+CagBx0v+lrwXeAMYX4b83HniD9I6wNswGEIf0C80LwAfAmCL898YBHyC9I2wJuwE0AReR/u/9OrCoCP+9xcA60jvClsq0/wD1uJ+s6hC8IQAAAABJRU5ErkJggg==";
// Ensure visibility if processing
if (item.isProcessing) {
this.style.opacity = "0.5";
}
} else {
this.style.display = 'none'; // Hide if failed image
}
};
}
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';
};
var ui = new UI();
window.ui = ui;
/**
* Data Controller
*/
var controller = {
init() {
// Initialize by checking SKU once
// Since this is a modal, the selection cannot change during the session.
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);
this.loadMedia();
} else if (!sku && !state.sku) {
if (document.getElementById('error-ui').style.display !== 'flex') {
this.loadMedia(); // Likely to trigger error UI
}
}
})
.withFailureHandler(e => {
console.warn("SKU check failed", e);
// If it fails once at startup, we probably should alert or retry once,
// but for now let's just leave it. If it fails, the UI might hang on "Loading..."
// potentially better to trigger error UI?
if (document.getElementById('loading-ui').style.display !== 'none') {
alert("Failed to load product info: " + e.message);
}
})
.getSelectedProductInfo();
},
loadMedia(preserveLogs = false) {
// ... (Resolving SKU/Title Logic preserved below implicitly or we verify we didn't clip it)
// Actually, let's keep the resolving logic safe.
// We are replacing lines 1120-1191 roughly.
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;
}
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
ui.setLoadingState(true);
state.setSku({ sku, title });
if (!preserveLogs) {
document.getElementById('status-log-container').innerHTML = ''; // Reset log
ui.logStatus('ready', 'Ready.', 'info');
} else {
// We might want to clear "Ready" if we are preserving logs
}
// ui.toggleLogBtn.style.display = 'inline-block'; // Removed
ui.logStatus('init', 'Initializing access...', 'info');
// 1. Diagnostics (Parallel)
google.script.run
.withSuccessHandler((diagnostics) => {
// Check Resumption
if (diagnostics.activeJobId) {
ui.logStatus('resume', 'Resuming active background job...', 'info');
ui.toggleSave(false);
ui.saveBtn.innerText = "Saving in background...";
controller.startLogPolling(diagnostics.activeJobId);
if (!ui.logCard.classList.contains('expanded')) ui.toggleLogExpand();
}
// 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');
}
})
.withFailureHandler(function (err) {
ui.logStatus('fatal', `Diagnostics failed: ${err.message}`, 'error');
})
.getMediaDiagnostics(sku, "");
// 2. Load Full Media (Parallel)
ui.logStatus('fetch', 'Fetching full media state...', 'info');
google.script.run
.withSuccessHandler(function (items) {
// Normalize items
const normalized = items.map(function (i) {
return {
...i,
id: i.id || Math.random().toString(36).substr(2, 9),
status: i.source || 'drive_only',
source: i.source,
_deleted: false
};
});
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');
ui.setLoadingState(false);
})
.getMediaForSku(sku);
},
saveChanges() {
ui.toggleSave(false);
ui.saveBtn.innerText = "Saving...";
ui.setSavingState(true);
// Generate Job ID
const jobId = Math.random().toString(36).substring(2) + Date.now().toString(36);
// Start Polling
this.startLogPolling(jobId);
// Filter out deleted items
const activeItems = state.items.filter(i => !i._deleted);
// Send final state array to backend
google.script.run
.withSuccessHandler((logs) => {
ui.saveBtn.innerText = "Saved!";
this.stopLogPolling(); // Stop polling
// Final sync of logs (in case polling missed the very end)
// But usually the returned logs are the full set or summary?
// The backend returns the full array. Let's merge or just ensure we show "Complete".
// Since we were polling, we might have partials.
// Let's just trust the stream has been showing progress.
// We can log a completion message.
ui.logStatus('save', 'Process Completed Successfully.', 'success');
// Reload to get fresh IDs/State, preserving the save logs
setTimeout(() => {
// The refresh will clear the saving state implicitly via setLoadingState(true) -> remove disabled
// But let's be clean
ui.setSavingState(false);
this.loadMedia(true);
}, 1500);
})
.withFailureHandler(e => {
this.stopLogPolling();
alert(`Save Failed: ${e.message}`);
ui.logStatus('fatal', `Save Failed: ${e.message}`, 'error');
ui.toggleSave(true);
ui.setSavingState(false);
})
.saveMediaChanges(state.sku, activeItems, jobId);
},
logPollInterval: null,
knownLogCount: 0,
startLogPolling(jobId) {
if (this.logPollInterval) clearInterval(this.logPollInterval);
this.knownLogCount = 0;
this.logPollInterval = setInterval(() => {
google.script.run
.withSuccessHandler(logs => {
if (!logs || logs.length === 0) return;
// Append ONLY new logs
// Simple approach: standard loop since we know count
if (logs.length > this.knownLogCount) {
const newLogs = logs.slice(this.knownLogCount);
newLogs.forEach(l => ui.logStatus('stream', l));
this.knownLogCount = logs.length;
}
})
.pollJobLogs(jobId);
}, 1000); // Poll every second
},
stopLogPolling() {
if (this.logPollInterval) {
clearInterval(this.logPollInterval);
this.logPollInterval = null;
}
},
handleFiles(fileList) {
if (!fileList || fileList.length === 0) return;
this.setPickerState(true);
this.initTransferState();
ui.showTransferSession('transferring', 'Your Computer');
const total = fileList.length;
let done = 0;
Array.from(fileList).forEach((file, i) => {
if (this.transferState.shouldCancel) return;
const reader = new FileReader();
reader.onload = (e) => {
if (this.transferState.shouldCancel) return;
const data = e.target.result.split(',')[1]; // Base64
ui.updateTransferProgress(done, total, `Uploading ${file.name}...`);
google.script.run
.withSuccessHandler(() => {
if (this.transferState.shouldCancel) return;
done++;
ui.updateTransferProgress(done, total);
if (done === total) {
ui.updateTransferProgress(total, total, "Done!");
this.finishTransfer();
}
})
.withFailureHandler(err => {
console.error(err);
// Allow continuing even if one fails
done++;
ui.updateTransferProgress(done, total, "Error on " + file.name);
if (done === total) this.finishTransfer();
})
.saveFileToDrive(state.sku, file.name, file.type, data);
};
reader.readAsDataURL(file);
});
},
setPickerState(isActive) {
const btnDrive = document.getElementById('btn-upload-drive');
const btnPhotos = document.getElementById('btn-upload-photos');
const btnComp = document.getElementById('btn-upload-computer');
[btnDrive, btnPhotos, btnComp].forEach(btn => {
if (btn) {
btn.disabled = isActive;
btn.style.opacity = isActive ? '0.5' : '1';
btn.style.cursor = isActive ? 'not-allowed' : 'pointer';
}
});
},
openPicker() {
if (!pickerApiLoaded) return alert("API Loading...");
this.setPickerState(true);
// We don't show the full session UI for Drive picker config loading,
// just disable buttons. The picker is its own UI.
google.script.run
.withSuccessHandler(c => createPicker(c))
.withFailureHandler(e => {
alert("Failed to load picker: " + e.message);
this.setPickerState(false);
})
.getPickerConfig();
},
importFromPicker(fileId, mime, name, url) {
// Drive Picker Result
this.initTransferState();
ui.showTransferSession('transferring', 'Google Drive');
// Single Item, so 0/1 -> 1/1
ui.updateTransferProgress(0, 1, "Importing " + name + "...");
google.script.run
.withSuccessHandler(() => {
if (this.transferState.shouldCancel) return;
ui.updateTransferProgress(1, 1, "Done!");
this.finishTransfer();
})
.withFailureHandler(e => {
alert("Import failed: " + e.message);
this.finishTransfer(); // Cleanup
})
.importFromPicker(state.sku, fileId, mime, name, url);
},
// --- Transfer State & Logic ---
transferState: {
isTransferring: false,
shouldCancel: false,
pollingId: null
},
initTransferState() {
this.transferState = { isTransferring: true, shouldCancel: false, pollingId: null };
},
cancelTransfer() {
this.transferState.shouldCancel = true;
this.transferState.isTransferring = false;
if (this.transferState.pollingId) {
clearTimeout(this.transferState.pollingId); // Note: using setTimeout in pollPhotoSession
this.transferState.pollingId = null;
}
ui.hideTransferSession();
this.setPickerState(false);
ui.logStatus('info', 'Transfer cancelled by user.', 'info');
},
finishTransfer() {
if (this.transferState.shouldCancel) return; // Already cleaned up
this.transferState.isTransferring = false;
this.loadMedia();
this.setPickerState(false);
setTimeout(() => ui.hideTransferSession(), 2000);
},
startPhotoSession() {
this.setPickerState(true);
this.initTransferState();
ui.showTransferSession('waiting', 'Google Photos');
google.script.run
.withSuccessHandler(session => {
if (this.transferState.shouldCancel) return; // Cancelled during init
// Setup popup logic
const width = 1200, height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const params = `width=${width},height=${height},top=${top},left=${left}`;
// Auto open
window.open(session.pickerUri, 'googlePhotos', params);
// Setup reopen link
ui.setupReopenButton(session.pickerUri);
this.pollPhotoSession(session.id);
})
.withFailureHandler(e => {
alert("Failed to start session: " + e.message);
this.setPickerState(false);
ui.hideTransferSession();
})
.createPhotoSession();
},
pollPhotoSession(sessionId) {
if (this.transferState.shouldCancel) return;
const check = () => {
if (this.transferState.shouldCancel) return;
google.script.run
.withSuccessHandler(res => {
if (this.transferState.shouldCancel) return;
if (res.status === 'complete') {
// Transition to transferring
ui.updateTransferProgress(0, 0, "Importing photos...");
this.processPhotoItems(res.mediaItems);
} else if (res.status === 'error') {
alert("Photo Picker Error: " + res.message);
this.cancelTransfer();
} else {
// Keep polling
this.transferState.pollingId = setTimeout(check, 2000);
}
})
.withFailureHandler(e => {
console.error(e);
// Retry? Or fail. Let's fail after error.
alert("Polling Error: " + e.message);
this.cancelTransfer();
})
.checkPhotoSession(sessionId);
};
check();
},
processPhotoItems(items) {
let done = 0;
const total = items.length;
ui.updateTransferProgress(0, total, `Found ${total} items. Starting import...`);
// Process sequentially or parallel? Parallel is fine for GAS calls usually,
// but let's count them accurately.
items.forEach(item => {
if (this.transferState.shouldCancel) return;
const mediaFile = item.mediaFile || item;
const url = mediaFile.baseUrl || item.baseUrl;
const filename = mediaFile.filename || item.filename;
let mimeType = mediaFile.mimeType || item.mimeType;
if (item.mediaMetadata && item.mediaMetadata.video) {
mimeType = 'video/mp4';
}
google.script.run
.withSuccessHandler(() => {
if (this.transferState.shouldCancel) return;
done++;
ui.updateTransferProgress(done, total);
if (done === total) this.finishTransfer();
})
.withFailureHandler(e => {
console.error(e);
done++;
ui.updateTransferProgress(done, total, "Error importing item");
if (done === total) this.finishTransfer();
})
.importFromPicker(state.sku, null, mimeType, filename, url);
});
},
// --- Legacy Photos Session (Removed in favor of Embedded Picker) ---
// startPhotoSession() { ... }
// --- 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');
// Preload Images
newMatches.forEach(function (m) {
if (m.drive && m.drive.thumbnail) new Image().src = m.drive.thumbnail;
if (m.shopify && m.shopify.thumbnail) new Image().src = m.shopify.thumbnail;
});
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);
},
pendingLinks: 0,
confirmLink() {
var match = this.matches[this.currentMatchIndex];
var _this = this;
// Async Fire & Forget (from UI perspective)
this.pendingLinks++;
ui.logStatus('link', 'Linking ' + match.drive.filename + '...', 'info');
google.script.run
.withSuccessHandler(function () {
ui.logStatus('link', 'Linked ' + match.drive.filename, 'success');
_this.pendingLinks--;
_this.checkMatchingDone();
})
.withFailureHandler(function (e) {
ui.logStatus('link', 'Failed to link ' + match.drive.filename + ': ' + e.message, 'error');
_this.pendingLinks--;
_this.checkMatchingDone();
})
.linkDriveFileToShopifyMedia(state.sku, match.drive.id, match.shopify.id);
// Move to next immediately
this.nextMatch();
},
skipLink() {
// No async op needed for skip
this.nextMatch();
},
nextMatch() {
this.currentMatchIndex++;
if (this.currentMatchIndex < this.matches.length) {
this.renderMatch();
} else {
this.checkMatchingDone();
}
},
checkMatchingDone() {
// 1. Are we visually done?
if (this.currentMatchIndex < this.matches.length) return;
// 2. Are background jobs done?
if (this.pendingLinks > 0) {
// Show "Waiting" state in Modal
document.getElementById('matching-modal').style.display = 'flex'; // Ensure open
document.getElementById('btn-match-confirm').disabled = true;
document.getElementById('btn-match-skip').disabled = true;
// Re-purpose the modal content temporarily or overlay?
// Let's just update the title/status
var title = document.querySelector('#matching-modal h3');
if (title) title.innerText = "Finalizing...";
var p = document.querySelector('#matching-modal p');
if (p) p.innerText = "Finishing " + this.pendingLinks + " pending link operations...";
return;
}
// All 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);
},
// Sand Animation (Global Loop)
sandInterval: null,
startSandAnimation() {
if (this.sandInterval) return;
this.sandInterval = setInterval(() => {
const icons = document.querySelectorAll('.processing-icon');
icons.forEach(icon => {
// Only swap if NOT currently flipping to avoid visual glitch
if (!icon.classList.contains('flipping')) {
icon.innerText = icon.innerText === '⏳' ? '⌛' : '⏳';
}
});
}, 1000);
},
showGallery() {
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
document.getElementById('upload-section').style.display = 'block';
ui.logStatus('done', 'Finished loading.', 'success');
// setTimeout(function () { ui.toggleLog(false); }, 1000); // Removed auto-hide
// Start Polling for Processing Items
this.pollProcessingItems();
},
pollInterval: null,
pollProcessingItems() {
if (this.pollInterval) clearInterval(this.pollInterval);
const hasProcessing = state.items.some(function (i) { return i.isProcessing; });
if (!hasProcessing) return;
console.log("[MediaManager] Items are processing. Starting poll...");
// Ensure sand animation is running
this.startSandAnimation();
var _this = this;
this.pollInterval = setInterval(function () {
var processingItems = state.items.filter(function (i) { return i.isProcessing; });
if (processingItems.length === 0) {
clearInterval(_this.pollInterval);
return;
}
// Visual Trigger: Rotate 180deg CW (Cumulative)
const icons = document.querySelectorAll('.processing-icon');
icons.forEach(el => {
let currentRot = parseInt(el.dataset.rotation || '0');
currentRot += 180;
el.style.transform = 'rotate(' + currentRot + 'deg)';
el.dataset.rotation = currentRot;
});
// No timeout needed, we stay at new rotation
// Poll backend silently
google.script.run
.withSuccessHandler(function (items) {
// Update items relative to current state
// We only want to update the 'isProcessing' status and thumbnail of existing items
// to avoid jarring re-renders or losing unsaved reordering.
let changed = false;
items.forEach(function (newItem) {
// Find existing
var idx = state.items.findIndex(function (cur) { return cur.id === newItem.id || (newItem.source === 'drive_only' && cur.driveId === newItem.id); });
// Note: backend 'id' is driveId for drive items.
if (idx !== -1) {
var item = state.items[idx];
if (item.isProcessing) {
// Check if it's done now
// The backend logic for 'isProcessing' in getUnifiedMediaState checks if getThumbnail fails.
// If it succeeds now, isProcessing will be false (undefined/false).
// Update our local item
// CAUTION: The normalized structure in loadMedia sets defaults.
// We need to match that.
const stillProcessing = newItem.isProcessing === true;
if (!stillProcessing) {
console.log("[MediaManager] Processing complete for " + item.filename);
item.isProcessing = false;
item.thumbnail = newItem.thumbnail;
item.contentUrl = newItem.contentUrl; // Propagate URL
item.source = newItem.source; // Propagate source update (synced)
changed = true;
}
}
}
});
if (changed) {
ui.render(state.items);
// If none left, stop
if (!state.items.some(function (i) { return i.isProcessing; })) {
clearInterval(_this.pollInterval);
console.log("[MediaManager] All processing complete. Stopping poll.");
}
}
})
.getMediaForSku(state.sku);
}, 15000); // 15 seconds
}
};
// --- 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 builder = new google.picker.PickerBuilder();
builder.addView(view)
.setOAuthToken(config.token)
.setDeveloperKey(config.apiKey)
.setOrigin(google.script.host.origin || (window.location.protocol + '//' + window.location.host))
.setCallback(data => {
if (data.action == google.picker.Action.PICKED) {
const doc = data.docs[0];
const isDrive = doc.serviceId === 'docs';
// Drive File (Always, since we removed Photos view)
controller.importFromPicker(doc.id, doc.mimeType, doc.name, null);
} else if (data.action == google.picker.Action.CANCEL) {
console.log("Picker cancelled");
controller.setPickerState(false);
}
})
.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>