Files
product_inventory/src/MediaManager.html
Ben Miller 8d780d2fcb feat(media-manager): link media filenames to preview pages in match wizard
- Updates the 'Link Media' wizard and confirmation modal to make filenames clickable.
- Links Drive files to their view page.
- Links Shopify files to the Admin Content > Files page, derived from the product admin URL.
- Applies primary theme color to links for better visibility.
2026-01-01 05:37:53 -07:00

2559 lines
93 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: 8px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
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 0 -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: 8px;
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);
}
/* Selection Styles */
.media-item.selected {
border: 3px solid var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
.icon-btn.active {
background-color: var(--primary);
color: white;
}
.btn-link {
background-color: var(--primary);
color: white;
}
.btn-link:hover {
background-color: var(--primary-hover);
}
/* Combined Card Styles */
.combined-item {
display: flex;
flex-direction: column;
background: #f0f9ff;
/* Light blue tint */
border: 2px solid var(--primary);
}
.combined-images {
display: flex;
flex: 1;
overflow: hidden;
border-bottom: 1px solid var(--border);
}
.combined-part {
flex: 1;
position: relative;
border-right: 1px solid var(--border);
}
.combined-part:last-child {
border-right: none;
}
.combined-part .media-content {
aspect-ratio: auto;
/* Allow filling height */
height: 100px;
}
.unlink-btn {
width: 100%;
background: white;
border: none;
padding: 6px;
color: var(--danger);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 11px;
font-weight: 500;
transition: background 0.1s;
}
.unlink-btn:hover {
background: #fee2e2;
}
</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:center; 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>
<button id="btn-link-selected" onclick="controller.linkSelectedMedia()" class="btn btn-link"
style="display:none; width:auto; padding: 4px 12px; font-size:12px; height:28px;">
Link These!
</button>
</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 id="match-modal-title" style="margin-top:0;">Link Media?</h3>
<p id="match-modal-text" 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 = [];
this.selectedIds = new Set();
this.tentativeLinks = [];
}
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));
this.selectedIds.clear();
this.tentativeLinks = [];
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.toggleSelection = function (id) {
var item = this.items.find(function (i) { return i.id === id; });
if (!item) return;
var isSelected = this.selectedIds.has(id);
var affectedIds = [id];
if (isSelected) {
this.selectedIds.delete(id);
} else {
// Enforce one-pair rule: Max one Drive, one Shopify
var isDrive = (item.source === 'drive_only');
var isShopify = (item.source === 'shopify_only');
if (isDrive) {
// Clear other Drive selections
var _this = this;
this.items.forEach(function (i) {
// Simplified clearing logic
if (i.source === 'drive_only' && _this.selectedIds.has(i.id) && i.id !== id) {
_this.selectedIds.delete(i.id);
affectedIds.push(i.id);
}
});
} else if (isShopify) {
// Clear other Shopify selections
var _this = this;
this.items.forEach(function (i) {
if (i.source === 'shopify_only' && _this.selectedIds.has(i.id) && i.id !== id) {
_this.selectedIds.delete(i.id);
affectedIds.push(i.id);
}
});
}
this.selectedIds.add(id);
}
// Targeted updates
affectedIds.forEach(function (aid) { ui.updateCardState(aid); });
ui.updateLinkButtonState();
};
MediaState.prototype.clearSelection = function () {
var affectedIds = Array.from(this.selectedIds);
this.selectedIds.clear();
affectedIds.forEach(function (aid) { ui.updateCardState(aid); });
ui.updateLinkButtonState();
};
MediaState.prototype.addItem = function (item) {
this.items.push(item);
ui.render(this.items);
this.checkDirty();
};
MediaState.prototype.deleteItem = function (id) {
var item = this.items.find(function (i) { return i.id === id; });
if (!item) {
console.warn("[MediaState] Item not found for deletion:", id);
return;
}
if (item.source === 'new') {
var index = this.items.indexOf(item);
if (index !== -1) this.items.splice(index, 1);
ui.render(this.items); // Full render only for actual removal
} else {
item._deleted = !item._deleted;
ui.updateCardState(id); // Targeted update for toggle
}
this.checkDirty();
};
MediaState.prototype.reorderItems = function (newIndices) {
// Handled by Sortable
};
MediaState.prototype.unlinkMedia = function (driveId, shopifyId) {
this.tentativeLinks = this.tentativeLinks.filter(function (l) {
return !(l.driveId === driveId && l.shopifyId === shopifyId);
});
ui.render(this.items);
this.checkDirty();
};
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 = [];
// Collect IDs involved in tentative links to exclude them from individual actions
var linkedIds = new Set();
this.tentativeLinks.forEach(function (l) {
linkedIds.add(l.driveId);
linkedIds.add(l.shopifyId);
});
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;
// Skip items that are pending link (they will be handled by 'link' action)
if (linkedIds.has(i.id)) 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' });
} else if (i.status === 'shopify_only') {
actions.push({ type: 'adopt', name: i.filename || 'Item' });
}
});
// Tentative Links
var _this = this;
this.tentativeLinks.forEach(function (link) {
var dItem = _this.items.find(function (i) { return i.id === link.driveId; });
var sItem = _this.items.find(function (i) { return i.id === link.shopifyId; });
if (dItem && sItem) {
actions.push({
type: 'link',
name: 'Link Sync: ' + (dItem.filename || 'Item'),
driveId: link.driveId,
shopifyId: link.shopifyId
});
}
});
// 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();
state.sku = "<?!= initialSku ?>";
state.title = "<?!= initialTitle ?>";
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.updateLinkButtonState = function () {
var btnLink = document.getElementById('btn-link-selected');
if (btnLink) {
var items = state.items || [];
var selectedItems = items.filter(function (i) { return state.selectedIds.has(i.id); });
var hasDrive = selectedItems.some(function (i) { return i.source === 'drive_only'; });
var hasShopify = selectedItems.some(function (i) { return i.source === 'shopify_only'; });
if (hasDrive && hasShopify) {
btnLink.style.display = 'block';
} else {
btnLink.style.display = 'none';
}
}
};
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';
}
// Link Selected button logic
this.updateLinkButtonState();
if (items.length === 0) {
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
return;
}
// Pre-process items for Tentative Links (Visual Merge)
var displayItems = [];
var handledIds = new Set();
items.forEach(function (item) {
if (handledIds.has(item.id)) return;
// Check if part of tentative link
var link = state.tentativeLinks.find(function (l) { return l.driveId === item.id || l.shopifyId === item.id; });
if (link) {
var dItem = items.find(function (i) { return i.id === link.driveId; });
var sItem = items.find(function (i) { return i.id === link.shopifyId; });
if (dItem && sItem) {
// Create merged display item
displayItems.push({
type: 'combined',
id: 'link-' + link.driveId + '-' + link.shopifyId,
drive: dItem,
shopify: sItem,
_deleted: false // Combined item is alive unless constituent parts deleted? (Shouldn't be deleted if linked)
});
handledIds.add(dItem.id);
handledIds.add(sItem.id);
return;
}
}
displayItems.push(item);
handledIds.add(item.id);
});
displayItems.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', // Simplified filter
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function () {
// Reordering Logic needs update for combined items?
// If we drag a combined item, what happens?
// The sortable list contains IDs.
// If we drag 'link-d1-s1', we can't easily map back to single item reorder.
// Ideally, we treat it as reordering the associated Drive file (and sync Shopify).
// But 'state.items' expects flat list.
// Complexity: Reordering combined items.
// Logic: Update state.items order based on displayItems order.
var newOrderIds = Array.from(_this.grid.children).map(function (el) { return el.dataset.id; });
var newItems = [];
newOrderIds.forEach(function (id) {
if (id.startsWith('link-')) {
// Find the combined item in displayItems (or reconstruct)
var parts = id.replace('link-', '').split('-');
// DriveID is parts[0] ? No, id might contain hyphens?
// Use lookup map or careful logic.
// Actually, we tracked it in displayItems.
var dItem = state.items.find(i => i.id === parts[0]); // dangerous if ID has hyphen
// Better: find in cached displayItems?
// Let's rely on finding in state.items by matching tentativeLinks.
var link = state.tentativeLinks.find(l => 'link-' + l.driveId + '-' + l.shopifyId === id);
if (link) {
var d = state.items.find(i => i.id === link.driveId);
var s = state.items.find(i => i.id === link.shopifyId);
if (d) newItems.push(d);
if (s) newItems.push(s);
}
} else {
var item = state.items.find(i => i.id === id);
if (item) newItems.push(item);
}
});
// Append any items that were not in grid (e.g. filtered out? No, all active shown)
// What about handledIds?
// Simplest: just map the specific moved items.
// Reordering combined items is tricky.
// Let's assume for now user drags combined item, we place both d&s in that slot in correct relative order?
// Or just place Drive item there, and Shopify item follows?
// For simplicity: We reconstruct state.items based on new grid order.
state.items = newItems.concat(state.items.filter(i => !newItems.includes(i))); // Append missing?
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) {
// Combined Card (Tentative Link)
if (item.type === 'combined') {
var div = document.createElement('div');
div.className = 'media-item combined-item';
div.dataset.id = item.id;
var parts = '<div class="combined-images">';
// Drive Part
parts += '<div class="combined-part">';
parts += '<img class="media-content" src="' + (item.drive.thumbnail || "") + '">';
parts += '</div>';
// Shopify Part
parts += '<div class="combined-part">';
parts += '<img class="media-content" src="' + (item.shopify.thumbnail || "") + '">';
parts += '</div>';
parts += '</div>'; // end combined-images
parts += '<button class="unlink-btn" onclick="state.unlinkMedia(\'' + item.drive.id + '\', \'' + item.shopify.id + '\')">Unlink</button>';
div.innerHTML = parts;
return div;
}
var div = document.createElement('div');
var isSelected = state.selectedIds.has(item.id);
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '') + (isSelected ? ' selected' : '');
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.source === 'synced') badge = '<span class="badge" title="Synced" style="background:#dcfce7; color:#166534;">Synced</span>';
else if (item.source === 'drive_only') badge = '<span class="badge" title="Drive Only" style="background:#dbeafe; color:#1e40af;">Drive</span>';
else if (item.source === '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 || "";
// Link selection button
var linkSelectionBtn = '';
if (!item._deleted && (item.source === 'drive_only' || item.source === 'shopify_only')) {
linkSelectionBtn = '<button id="link-btn-' + item.id + '" class="icon-btn' + (isSelected ? ' active' : '') + '" onclick="event.stopPropagation(); state.toggleSelection(\'' + item.id + '\')" title="Select for linking">🔗</button>';
}
var actionBtn = item._deleted
? '<button class="icon-btn" onclick="state.deleteItem(\'' + item.id + '\')" title="Restore">↩️</button>'
: '<button class="icon-btn btn-delete" onclick="state.deleteItem(\'' + item.id + '\')" 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>' +
linkSelectionBtn +
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/zfyAwNtf3PAgAE4D2Rbb5BKBNrvc9EQAmAxOLbPNkYFKu9z0RAGYAg4ps82BgRq73PR4A4vH4l8BaYHCRffcwsDYej3+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+woAgK8S3REqql9/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.updateCardState = function (id) {
var item = state.items.find(function (i) { return i.id === id; });
var el = this.grid.querySelector('[data-id="' + id + '"]');
if (!item || !el) return;
var isSelected = state.selectedIds.has(item.id);
// 1. Update Container Class
// Combined Items don't use this update path (they are re-rendered if unlinked)
el.className = 'media-item ' + (item._deleted ? 'deleted-item' : '') + (isSelected ? ' selected' : '');
if (item.isProcessing) el.className += ' processing-card';
// 2. Update Badge
var badgeEl = el.querySelector('.badge');
if (badgeEl) {
if (!item._deleted) {
if (item.source === 'synced') {
badgeEl.innerText = 'Synced';
badgeEl.title = 'Synced';
badgeEl.style.background = '#dcfce7';
badgeEl.style.color = '#166534';
} else if (item.source === 'drive_only') {
badgeEl.innerText = 'Drive';
badgeEl.title = 'Drive Only';
badgeEl.style.background = '#dbeafe';
badgeEl.style.color = '#1e40af';
} else if (item.source === 'shopify_only') {
badgeEl.innerText = 'Shopify';
badgeEl.title = 'Shopify Only';
badgeEl.style.background = '#fce7f3';
badgeEl.style.color = '#9d174d';
}
} else {
badgeEl.innerText = 'Deleted';
badgeEl.title = '';
badgeEl.style.background = '#fee2e2';
badgeEl.style.color = '#991b1b';
}
}
// 3. Update Overlay Buttons
var overlay = el.querySelector('.media-overlay');
if (overlay) {
// Remove existing link button if it exists
var oldLinkBtn = el.querySelector('[id="link-btn-' + id + '"]');
if (oldLinkBtn) oldLinkBtn.remove();
// Add link button back if NOT deleted
if (!item._deleted && (item.source === 'drive_only' || item.source === 'shopify_only')) {
var linkHtml = '<button id="link-btn-' + item.id + '" class="icon-btn' + (isSelected ? ' active' : '') + '" onclick="event.stopPropagation(); state.toggleSelection(\'' + item.id + '\')" title="Select for linking">🔗</button>';
var viewBtn = overlay.querySelector('.btn-view');
if (viewBtn) {
viewBtn.insertAdjacentHTML('afterend', linkHtml);
} else {
overlay.insertAdjacentHTML('afterbegin', linkHtml);
}
}
// Update Delete/Restore button
var actionBtn = overlay.querySelector('.btn-delete') || overlay.querySelector('[title="Restore"]');
if (actionBtn) {
if (item._deleted) {
actionBtn.className = 'icon-btn';
actionBtn.innerHTML = '↩️';
actionBtn.title = 'Restore';
} else {
actionBtn.className = 'icon-btn btn-delete';
actionBtn.innerHTML = '🗑️';
actionBtn.title = 'Delete';
}
}
}
// 4. Update global item count
var activeCount = state.items.filter(function (i) { return !i._deleted; }).length;
var countEl = document.getElementById('item-count');
if (countEl) countEl.innerText = '(' + activeCount + ')';
};
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 = '🔢';
if (a.type === 'link') icon = '🔗';
if (a.type === 'adopt') 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';
if (a.type === 'link') label = '<b>' + a.name + '</b>';
if (a.type === 'adopt') label = 'Adopt <b>' + a.name + '</b> from Shopify';
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() {
if (state.sku) {
// If we already have the SKU from the template, show the UI shell immediately
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
ui.updateSku(state.sku, state.title);
ui.setLoadingState(true); // Loading... spinner inside the grid
}
this.loadMedia();
},
loadMedia(preserveLogs = false) {
let sku = state.sku;
let title = state.title;
// Visual optimization: Show loading immediately
ui.setLoadingState(true);
google.script.run
.withSuccessHandler(response => {
const { sku: serverSku, title: serverTitle, diagnostics, media, token } = response;
if (!serverSku) {
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;
}
// Update State
state.setSku({ sku: serverSku, title: serverTitle });
state.token = token;
// Update UI
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
if (!preserveLogs) {
document.getElementById('status-log-container').innerHTML = ''; // Reset log
ui.logStatus('ready', 'Ready.', 'info');
}
ui.logStatus('init', 'Initializing access...', 'info');
// Handle Diagnostics
if (diagnostics) {
// Check Resumption
if (diagnostics.activeJobId) {
ui.logStatus('resume', 'Resuming active background job...', 'info');
ui.toggleSave(false);
ui.saveBtn.innerText = "Saving in background...";
this.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');
}
// 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');
}
}
// Handle Media Items
if (media) {
ui.logStatus('fetch', 'Fetched full media state.', 'success');
const normalized = media.map(function (i) {
var source = i.source || (i.shopifyId && i.driveId ? 'synced' : (i.shopifyId ? 'shopify_only' : 'drive_only'));
return {
...i,
id: i.id || Math.random().toString(36).substr(2, 9),
status: source,
source: source,
_deleted: false
};
});
state.setItems(normalized);
if (!this.hasRunMatching) {
this.hasRunMatching = true;
this.checkMatches(normalized);
} else {
this.showGallery();
}
}
ui.setLoadingState(false);
})
.withFailureHandler(err => {
console.error("Initial load failed", err);
ui.logStatus('fatal', `Failed to load initialization data: ${err.message}`, 'error');
ui.setLoadingState(false);
})
.getMediaManagerInitialState(sku, title);
},
linkSelectedMedia() {
var selectedItems = state.items.filter(function (i) { return state.selectedIds.has(i.id); });
var driveItem = selectedItems.find(function (i) { return i.source === 'drive_only'; });
var shopifyItem = selectedItems.find(function (i) { return i.source === 'shopify_only'; });
if (!driveItem || !shopifyItem) {
alert("Please select exactly one Drive item and one Shopify item.");
return;
}
// Queue Link
state.tentativeLinks.push({ driveId: driveItem.id, shopifyId: shopifyItem.id });
state.selectedIds.clear();
ui.render(state.items);
state.checkDirty();
},
saveChanges() {
// Intercept for Tentative Links (Batch Manual Linking)
if (state.tentativeLinks.length > 0) {
var manualMatches = [];
state.tentativeLinks.forEach(function (l) {
var d = state.items.find(function (i) { return i.id === l.driveId; });
var s = state.items.find(function (i) { return i.id === l.shopifyId; });
if (d && s) {
manualMatches.push({ drive: d, shopify: s });
}
});
if (manualMatches.length > 0) {
this.matches = manualMatches;
this.currentMatchIndex = 0;
this.postMatchAction = 'save';
ui.logStatus('info', 'Processing ' + manualMatches.length + ' pending links before saving...', 'info');
// Update Modal Text
document.getElementById('match-modal-title').innerText = "Confirm Manual Links";
document.getElementById('match-modal-text').innerText = "Please confirm the links you selected.";
this.startMatching();
return;
}
}
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');
// Default text for automatic matching
document.getElementById('match-modal-title').innerText = "Link Media?";
document.getElementById('match-modal-text').innerText = "We found a matching file in Shopify. Should these be linked?";
// 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 = 'block';
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;
// Link to Drive Preview
var driveLink = "https://drive.google.com/file/d/" + match.drive.id + "/view";
document.getElementById('match-drive-name').innerHTML = '<a href="' + driveLink + '" target="_blank" style="color:var(--primary); text-decoration:underline;">' + match.drive.filename + '</a>';
// Link to Shopify Admin Media
var shopifyLink = match.shopify.contentUrl || "#";
if (ui.shopifyUrl) {
// Pattern: .../admin/content/files/{id}?selectedView=all
// We derive the base admin URL from the product URL
var adminBase = ui.shopifyUrl.split('/products/')[0];
var mediaId = match.shopify.id.split('/').pop();
shopifyLink = adminBase + "/content/files/" + mediaId + "?selectedView=all";
}
document.getElementById('match-shopify-name').innerHTML = '<a href="' + shopifyLink + '" target="_blank" style="color:var(--primary); text-decoration:underline;">' + match.shopify.filename + '</a>';
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');
// Update Local State to prevent save conflicts
var d = state.items.find(function (i) { return i.id === match.drive.id; });
var s = state.items.find(function (i) { return i.id === match.shopify.id; });
if (d && s) {
var dIdx = state.items.indexOf(d);
var sIdx = state.items.indexOf(s);
if (dIdx !== -1 && sIdx !== -1) {
var targetIdx = Math.min(dIdx, sIdx);
// Update d
d.source = 'synced';
d.shopifyId = s.id;
d.status = 'synced';
// Remove both from current positions
// We filter by reference to handle duplicate ID edge cases safely, though ID should be unique
// But 'indexOf' found specific objects.
// Easiest is to splice high then low to avoid index shift?
// Or just filter out s then move d?
// Logic:
// 1. Remove s.
// 2. If s was before d, d's index decreased.
// 3. Move d to targetIdx.
// Simpler: Filter both out, then insert d at targetIdx.
state.items = state.items.filter(function (i) { return i !== d && i !== s; });
state.items.splice(targetIdx, 0, d);
}
}
_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';
// Clear tentative links as they are now processed (linked or skipped)
state.tentativeLinks = [];
// Check Post-Match Action
if (this.postMatchAction === 'save') {
this.postMatchAction = null;
ui.logStatus('info', 'Matching complete. Proceeding to save...', 'info');
this.saveChanges();
return;
}
ui.logStatus('info', 'Matching complete. Finalizing gallery...', 'info');
// Show main UI immediately if not already showing
document.getElementById('main-ui').style.display = 'block';
document.getElementById('loading-ui').style.display = 'none';
// 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>