- 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.
2559 lines
93 KiB
HTML
2559 lines
93 KiB
HTML
<!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;">×</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> |