- Resolved persistent 'SyntaxError: Unexpected token class' by refactoring 'MediaState' and 'UI' classes in MediaManager.html to standard ES5 function constructors. - Resolved 'SyntaxError: Unexpected identifier src' by rewriting 'createCard' to use 'document.createElement' instead of template strings for dynamic media elements. - Consolidated script tags in MediaManager.html to prevent Apps Script parser merge issues. - Updated docs/ARCHITECTURE.md and MEMORY.md to formally document client-side constraints (No ES6 classes, strict DOM manipulation for media). - Note: Google Drive video animate-on-hover functionality is implemented but currently pending verification/fix.
1162 lines
42 KiB
HTML
1162 lines
42 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: 16px;
|
||
}
|
||
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
h2 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
color: var(--text);
|
||
}
|
||
|
||
.sku-badge {
|
||
background: #eff6ff;
|
||
color: var(--primary);
|
||
padding: 4px 10px;
|
||
border-radius: 9999px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
border: 1px solid #dbeafe;
|
||
}
|
||
|
||
/* Upload Zone */
|
||
.upload-zone {
|
||
border: 2px dashed var(--border);
|
||
border-radius: 8px;
|
||
padding: 32px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.upload-zone:hover,
|
||
.upload-zone.dragover {
|
||
border-color: var(--primary);
|
||
background: #eff6ff;
|
||
}
|
||
|
||
/* Grid */
|
||
.media-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 16px;
|
||
min-height: 100px;
|
||
/* Drop target area */
|
||
}
|
||
|
||
.media-item {
|
||
position: relative;
|
||
background: var(--surface);
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border);
|
||
overflow: hidden;
|
||
aspect-ratio: 1;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
cursor: grab;
|
||
}
|
||
|
||
.media-item:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.media-item.sortable-ghost {
|
||
opacity: 0.4;
|
||
background: #cbd5e1;
|
||
}
|
||
|
||
.media-item.sortable-drag {
|
||
cursor: grabbing;
|
||
opacity: 1;
|
||
background: var(--surface);
|
||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.media-item.deleted-item {
|
||
opacity: 0.5;
|
||
filter: grayscale(100%);
|
||
}
|
||
|
||
/* Media Content */
|
||
.media-content {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
background: #f8fafc;
|
||
/* Placeholder bg */
|
||
padding: 4px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* Overlays & Badges */
|
||
.media-overlay {
|
||
position: absolute;
|
||
top: auto;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 40px 10px 10px 10px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.media-overlay .icon-btn {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.media-item:hover .media-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
.type-badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
left: 6px;
|
||
z-index: 10;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
color: white;
|
||
border-radius: 4px;
|
||
padding: 4px;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
|
||
}
|
||
|
||
.badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
font-size: 10px;
|
||
/* Text badge */
|
||
z-index: 10;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.1);
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.icon-btn {
|
||
background: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
width: 32px;
|
||
height: 32px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
transition: transform 0.1s;
|
||
}
|
||
|
||
.icon-btn:hover {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.btn-delete {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.btn-view {
|
||
color: var(--primary);
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn {
|
||
background-color: var(--primary);
|
||
color: white;
|
||
border: none;
|
||
padding: 10px 16px;
|
||
border-radius: 8px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
font-size: 14px;
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.btn:hover {
|
||
background-color: var(--primary-hover);
|
||
}
|
||
|
||
.btn:disabled {
|
||
background-color: var(--text-secondary);
|
||
cursor: not-allowed;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.btn-secondary {
|
||
background-color: white;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background-color: #f8fafc;
|
||
}
|
||
|
||
.action-bar {
|
||
position: sticky;
|
||
bottom: 0;
|
||
background: var(--surface);
|
||
padding: 16px;
|
||
border-top: 1px solid var(--border);
|
||
margin: 0 -16px -16px -16px;
|
||
border-bottom-left-radius: 12px;
|
||
border-bottom-right-radius: 12px;
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
/* Modal */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.8);
|
||
z-index: 100;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
}
|
||
|
||
.modal-content {
|
||
max-width: 90%;
|
||
max-height: 90%;
|
||
position: relative;
|
||
}
|
||
|
||
.modal-close {
|
||
position: absolute;
|
||
top: -40px;
|
||
right: 0;
|
||
color: white;
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* Empty State */
|
||
.empty-state {
|
||
text-align: center;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
padding: 40px 0;
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.spinner {
|
||
display: inline-block;
|
||
width: 20px;
|
||
height: 20px;
|
||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||
border-left-color: var(--primary);
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
/* Session UI */
|
||
#photos-session-ui {
|
||
animation: slideDown 0.3s ease-out;
|
||
}
|
||
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-10px);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div id="main-ui" style="display:none">
|
||
<!-- Header Card -->
|
||
<!-- Product Info Card -->
|
||
<div class="card">
|
||
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||
<div>
|
||
<div id="current-title" style="font-weight:600; font-size:16px; margin-bottom:4px; color:var(--text);">
|
||
Loading...
|
||
</div>
|
||
<span id="current-sku" class="sku-badge">...</span>
|
||
</div>
|
||
<!-- Optional: Could put metadata here or small status -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Upload Options Card -->
|
||
<div class="card">
|
||
<div class="header" style="margin-bottom: 12px;">
|
||
<h3 style="margin:0; font-size:14px; color:var(--text);">Add Photos/Videos from...</h3>
|
||
</div>
|
||
<div style="display: flex; gap: 8px; width: 100%;">
|
||
<button onclick="controller.openPicker()" class="btn btn-secondary"
|
||
style="flex: 1; font-size: 13px; white-space: nowrap;">
|
||
Google Drive
|
||
</button>
|
||
<button onclick="controller.startPhotoSession()" class="btn btn-secondary"
|
||
style="flex: 1; font-size: 13px; white-space: nowrap;">
|
||
Google Photos
|
||
</button>
|
||
<button onclick="document.getElementById('file-input').click()" class="btn btn-secondary"
|
||
style="flex: 1; font-size: 13px;">
|
||
Your Computer
|
||
</button>
|
||
</div>
|
||
<input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)">
|
||
</div>
|
||
|
||
<!-- Photos Session UI -->
|
||
<div id="photos-session-ui"
|
||
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||
<span style="font-weight:600; font-size:12px; color:#0369a1;">Photo Picker Session</span>
|
||
<button onclick="ui.closePhotoSession()"
|
||
style="background:none; border:none; color:#0369a1; cursor:pointer; font-size:16px;">×</button>
|
||
</div>
|
||
<a id="photos-session-link" href="#" target="_blank" class="btn"
|
||
style="background:#0ea5e9; text-decoration:none; margin-bottom:8px;">
|
||
Open Google Photos ↗
|
||
</a>
|
||
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="padding-bottom: 0;">
|
||
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
|
||
<div style="display:flex; align-items:baseline; gap:12px;">
|
||
<h2 style="margin:0;">Gallery <span id="item-count"
|
||
style="font-weight:400; color:var(--text-secondary); font-size:12px;">(0)</span></h2>
|
||
<div id="quick-links" style="font-size:12px; display:flex; gap:8px;"></div>
|
||
</div>
|
||
<div style="display: flex; gap: 8px;">
|
||
<button id="toggle-log-btn" onclick="ui.toggleLog()"
|
||
style="display:none; background:none; border:none; cursor:pointer; font-size:12px; color:var(--primary);">View
|
||
Log</button>
|
||
<button onclick="controller.loadMedia()" title="Refresh"
|
||
style="background:none; border:none; cursor:pointer; font-size:18px; color:var(--text-secondary);">↻</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Status Log -->
|
||
<div id="status-log-container"
|
||
style="padding:16px; background:#f8fafc; border-bottom:1px solid var(--border); font-family:monospace; font-size:12px; line-height:1.6; display:none;">
|
||
</div>
|
||
|
||
<div id="media-grid" class="media-grid">
|
||
<!-- Rendered Items -->
|
||
</div>
|
||
<!-- Action Footer -->
|
||
<div class="action-bar" style="display:flex; gap:8px;">
|
||
<button id="details-btn" onclick="ui.showDetails()" class="btn btn-secondary" style="flex:1;">
|
||
Show Plan
|
||
</button>
|
||
<button id="save-btn" onclick="controller.saveChanges()" class="btn" style="flex:2;" disabled>
|
||
Save Changes
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading Screen -->
|
||
<div id="loading-ui" style="text-align:center; padding-top: 100px;">
|
||
<div class="spinner" style="width: 32px; height: 32px; border-width: 3px;"></div>
|
||
<div style="margin-top:16px; color: var(--text-secondary); font-weight: 500;">Connecting...</div>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
|
||
<div id="drop-overlay"
|
||
style="position: fixed; top:0; left:0; right:0; bottom:0; background: rgba(37, 99, 235, 0.9); z-index: 200; display: none; flex-direction: column; align-items: center; justify-content: center; color: white;">
|
||
<div style="font-size: 48px; margin-bottom: 16px;">☁️</div>
|
||
<div style="font-size: 24px; font-weight: 600;">Drop files to Upload</div>
|
||
</div>
|
||
|
||
<script>
|
||
/**
|
||
* State Management & Error Handling
|
||
*/
|
||
window.onerror = function (msg, url, line) {
|
||
alert("Script Error: " + msg + "\nLine: " + line);
|
||
};
|
||
|
||
// --- ES5 Refactor: MediaState ---
|
||
function MediaState() {
|
||
this.sku = null;
|
||
this.token = null;
|
||
this.items = [];
|
||
this.initialState = [];
|
||
}
|
||
|
||
MediaState.prototype.setSku = function (info) {
|
||
this.sku = info ? info.sku : null;
|
||
this.title = info ? info.title : "";
|
||
this.items = [];
|
||
this.initialState = [];
|
||
ui.updateSku(this.sku, this.title);
|
||
};
|
||
|
||
MediaState.prototype.setItems = function (items) {
|
||
this.items = items || [];
|
||
this.initialState = JSON.parse(JSON.stringify(this.items));
|
||
ui.render(this.items);
|
||
this.checkDirty();
|
||
};
|
||
|
||
MediaState.prototype.addItem = function (item) {
|
||
this.items.push(item);
|
||
ui.render(this.items);
|
||
this.checkDirty();
|
||
};
|
||
|
||
MediaState.prototype.deleteItem = function (index) {
|
||
var item = this.items[index];
|
||
if (item.source === 'new') {
|
||
this.items.splice(index, 1);
|
||
} else {
|
||
item._deleted = !item._deleted;
|
||
}
|
||
ui.render(this.items);
|
||
this.checkDirty();
|
||
};
|
||
|
||
MediaState.prototype.reorderItems = function (newIndices) {
|
||
// Handled by Sortable
|
||
};
|
||
|
||
MediaState.prototype.checkDirty = function () {
|
||
var plan = this.calculateDiff();
|
||
var isDirty = plan.hasChanges;
|
||
ui.toggleSave(isDirty);
|
||
return plan;
|
||
};
|
||
|
||
MediaState.prototype.calculateDiff = function () {
|
||
var currentIds = new Set(this.items.map(function (i) { return i.id; }));
|
||
var initialIds = new Set(this.initialState.map(function (i) { return i.id; }));
|
||
|
||
var actions = [];
|
||
|
||
this.items.forEach(function (i) {
|
||
if (i._deleted) actions.push({ type: 'delete', name: i.filename || 'Item' });
|
||
});
|
||
|
||
this.items.forEach(function (i) {
|
||
if (i._deleted) return;
|
||
if (!initialIds.has(i.id)) {
|
||
actions.push({ type: 'upload', name: i.filename || 'New Item' });
|
||
} else if (i.status === 'drive_only') {
|
||
actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
|
||
}
|
||
});
|
||
|
||
// 3. Reorders
|
||
var activeItems = this.items.filter(function (i) { return !i._deleted; });
|
||
// Filter initial state to only items that are still active
|
||
var initialCommon = this.initialState.filter(function (i) {
|
||
return activeItems.some(function (c) { return c.id === i.id; });
|
||
});
|
||
var currentCommon = activeItems.filter(function (i) {
|
||
return initialIds.has(i.id);
|
||
});
|
||
|
||
var orderChanged = false;
|
||
if (initialCommon.length === currentCommon.length) {
|
||
for (var k = 0; k < initialCommon.length; k++) {
|
||
if (initialCommon[k].id !== currentCommon[k].id) {
|
||
orderChanged = true;
|
||
break;
|
||
}
|
||
}
|
||
} else {
|
||
// If lengths differ despite logic, assume change or weird state
|
||
}
|
||
|
||
if (orderChanged) {
|
||
actions.push({ type: 'reorder', name: 'Reorder Gallery' });
|
||
}
|
||
|
||
var uniqueActions = actions.filter(function (v, i, a) {
|
||
return a.findIndex(function (t) { return t.type === v.type && t.name === v.name; }) === i;
|
||
});
|
||
|
||
return {
|
||
hasChanges: uniqueActions.length > 0,
|
||
actions: uniqueActions
|
||
};
|
||
};
|
||
|
||
MediaState.prototype.hasNewItems = function () {
|
||
return this.items.some(function (i) {
|
||
return !i._deleted && (i.status === 'drive_only' || i.source === 'new');
|
||
});
|
||
};
|
||
|
||
var state = new MediaState();
|
||
window.state = state;
|
||
|
||
// --- ES5 Refactor: UI ---
|
||
function UI() {
|
||
this.grid = document.getElementById('media-grid');
|
||
this.saveBtn = document.getElementById('save-btn');
|
||
this.toggleLogBtn = document.getElementById('toggle-log-btn');
|
||
this.logContainer = document.getElementById('status-log-container');
|
||
this.linksContainer = document.getElementById('quick-links');
|
||
this.sortable = null;
|
||
this.driveUrl = null;
|
||
this.shopifyUrl = null;
|
||
}
|
||
|
||
UI.prototype.setDriveLink = function (url) { this.driveUrl = url; this.renderLinks(); };
|
||
UI.prototype.setShopifyLink = function (url) { this.shopifyUrl = url; this.renderLinks(); };
|
||
|
||
UI.prototype.renderLinks = function () {
|
||
this.linksContainer.innerHTML = '';
|
||
if (this.driveUrl) this.linksContainer.innerHTML += '<a href="' + this.driveUrl + '" target="_blank" style="color:var(--primary); text-decoration:none;">Drive ↗</a>';
|
||
if (this.shopifyUrl) this.linksContainer.innerHTML += '<a href="' + this.shopifyUrl + '" target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>';
|
||
};
|
||
|
||
UI.prototype.toggleLog = function (forceState) {
|
||
var isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none';
|
||
this.logContainer.style.display = isVisible ? 'none' : 'block';
|
||
this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log";
|
||
};
|
||
|
||
UI.prototype.updateSku = function (sku, title) {
|
||
document.getElementById('current-sku').innerText = sku || '...';
|
||
document.getElementById('current-title').innerText = title || '';
|
||
document.getElementById('loading-ui').style.display = 'none';
|
||
document.getElementById('main-ui').style.display = 'block';
|
||
};
|
||
|
||
UI.prototype.toggleSave = function (enable) {
|
||
this.saveBtn.disabled = !enable;
|
||
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
|
||
};
|
||
|
||
UI.prototype.render = function (items) {
|
||
this.grid.innerHTML = '';
|
||
var _this = this; // Capture 'this' for callbacks
|
||
var activeCount = items.filter(function (i) { return !i._deleted; }).length;
|
||
document.getElementById('item-count').innerText = '(' + activeCount + ')';
|
||
|
||
if (items.length === 0) {
|
||
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
|
||
return;
|
||
}
|
||
|
||
items.forEach(function (item, index) {
|
||
var el = _this.createCard(item, index);
|
||
_this.grid.appendChild(el);
|
||
});
|
||
|
||
if (this.sortable) this.sortable.destroy();
|
||
this.sortable = new Sortable(this.grid, {
|
||
animation: 150,
|
||
filter: '.deleted-item',
|
||
ghostClass: 'sortable-ghost',
|
||
dragClass: 'sortable-drag',
|
||
onEnd: function () {
|
||
var newOrderIds = Array.from(_this.grid.children).map(function (el) { return el.dataset.id; });
|
||
var newItems = newOrderIds.map(function (id) {
|
||
return state.items.find(function (i) { return i.id === id; });
|
||
}).filter(Boolean);
|
||
state.items = newItems;
|
||
state.checkDirty();
|
||
}
|
||
});
|
||
};
|
||
|
||
UI.prototype.setLoadingState = function (isLoading) {
|
||
if (isLoading) {
|
||
this.grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">' +
|
||
'<div class="spinner" style="margin-bottom: 12px;"></div>' +
|
||
'<div>Connecting to systems...</div></div>';
|
||
}
|
||
};
|
||
|
||
UI.prototype.logStatus = function (step, message, type) {
|
||
if (!type) type = 'info';
|
||
var container = this.logContainer;
|
||
var icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
|
||
var el = document.createElement('div');
|
||
el.innerHTML = '<span style="margin-right:8px;">' + icon + '</span> ' + message;
|
||
if (type === 'error') el.style.color = 'var(--error)';
|
||
container.appendChild(el);
|
||
};
|
||
|
||
UI.prototype.createCard = function (item, index) {
|
||
var div = document.createElement('div');
|
||
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
|
||
div.dataset.id = item.id;
|
||
|
||
div.onmouseenter = function () {
|
||
var v = div.querySelector('video');
|
||
if (v) v.play();
|
||
};
|
||
div.onmouseleave = function () {
|
||
var v = div.querySelector('video');
|
||
if (v) v.pause();
|
||
};
|
||
|
||
var badge = '';
|
||
if (!item._deleted) {
|
||
if (item.status === 'synced') badge = '<span class="badge" title="Synced" style="background:#dcfce7; color:#166534;">Synced</span>';
|
||
else if (item.status === 'drive_only') badge = '<span class="badge" title="Drive Only" style="background:#dbeafe; color:#1e40af;">Drive</span>';
|
||
else if (item.status === 'shopify_only') badge = '<span class="badge" title="Shopify Only" style="background:#fce7f3; color:#9d174d;">Shopify</span>';
|
||
} else {
|
||
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
|
||
}
|
||
|
||
// Check Video
|
||
var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
|
||
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
|
||
|
||
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">📹</div>' : '';
|
||
|
||
// Content URL
|
||
var contentUrl = item.contentUrl || "";
|
||
if (isVideo && item.source !== 'shopify_only' && state.token) {
|
||
contentUrl = "https://www.googleapis.com/drive/v3/files/" + item.id + "?alt=media&access_token=" + state.token;
|
||
}
|
||
|
||
var actionBtn = item._deleted
|
||
? '<button class="icon-btn" onclick="state.deleteItem(' + index + ')" title="Restore">↩️</button>'
|
||
: '<button class="icon-btn btn-delete" onclick="state.deleteItem(' + index + ')" title="Delete">🗑️</button>';
|
||
|
||
div.innerHTML =
|
||
badge +
|
||
videoBadgeIcon +
|
||
'<div class="media-overlay">' +
|
||
'<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item.id + '\')" title="View">👁️</button>' +
|
||
actionBtn +
|
||
'</div>';
|
||
|
||
// Create Media Element Programmatically
|
||
var mediaEl;
|
||
if (isVideo) {
|
||
mediaEl = document.createElement('video');
|
||
mediaEl.src = contentUrl;
|
||
mediaEl.poster = item.thumbnail || "";
|
||
mediaEl.muted = true;
|
||
mediaEl.loop = true;
|
||
mediaEl.style.objectFit = 'cover';
|
||
} else {
|
||
mediaEl = document.createElement('img');
|
||
mediaEl.src = item.thumbnail || "";
|
||
mediaEl.loading = "lazy";
|
||
}
|
||
mediaEl.className = 'media-content';
|
||
|
||
var overlay = div.querySelector('.media-overlay');
|
||
div.insertBefore(mediaEl, overlay);
|
||
|
||
return div;
|
||
};
|
||
|
||
UI.prototype.openPreview = function (id) {
|
||
var item = state.items.find(function (i) { return i.id === id; });
|
||
if (!item) return;
|
||
|
||
var modal = document.getElementById('preview-modal');
|
||
var img = document.getElementById('preview-image');
|
||
var vid = document.getElementById('preview-video');
|
||
var iframe = document.getElementById('preview-iframe');
|
||
|
||
img.style.display = 'none';
|
||
vid.style.display = 'none';
|
||
iframe.style.display = 'none';
|
||
iframe.src = 'about:blank';
|
||
|
||
var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
|
||
|
||
if (isVideo) {
|
||
var previewUrlParam = item.contentUrl || "";
|
||
if (item.source !== 'shopify_only' && state.token) {
|
||
previewUrlParam = "https://www.googleapis.com/drive/v3/files/" + item.id + "?alt=media&access_token=" + state.token;
|
||
}
|
||
if (previewUrlParam) {
|
||
vid.src = previewUrlParam;
|
||
vid.style.display = 'block';
|
||
vid.play().catch(console.warn);
|
||
} else {
|
||
iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
|
||
iframe.style.display = 'block';
|
||
}
|
||
} else {
|
||
img.src = item.thumbnail;
|
||
img.style.display = 'block';
|
||
}
|
||
|
||
modal.style.display = 'flex';
|
||
};
|
||
|
||
UI.prototype.closeModal = function (e) {
|
||
if (e && e.target !== document.getElementById('preview-modal') && e.target !== document.querySelector('.modal-close')) return;
|
||
document.getElementById('preview-modal').style.display = 'none';
|
||
document.getElementById('preview-video').pause();
|
||
document.getElementById('preview-iframe').src = 'about:blank';
|
||
};
|
||
|
||
UI.prototype.showDetails = function () {
|
||
var plan = state.calculateDiff();
|
||
var container = document.getElementById('details-content');
|
||
|
||
if (plan.actions.length === 0) {
|
||
container.innerHTML = '<div style="text-align:center; padding:20px;">No pending changes.</div>';
|
||
} else {
|
||
var html = plan.actions.map(function (a, i) {
|
||
var icon = '•';
|
||
if (a.type === 'delete') icon = '🗑️';
|
||
if (a.type === 'upload') icon = '📤';
|
||
if (a.type === 'sync_upload') icon = '☁️';
|
||
if (a.type === 'reorder') icon = '🔢';
|
||
|
||
var label = "";
|
||
if (a.type === 'delete') label = 'Delete <b>' + a.name + '</b>';
|
||
if (a.type === 'upload') label = 'Upload New <b>' + a.name + '</b>';
|
||
if (a.type === 'sync_upload') label = 'Sync Drive File <b>' + a.name + '</b>';
|
||
if (a.type === 'reorder') label = 'Update Order';
|
||
|
||
return '<div style="margin-bottom:8px;">' + (i + 1) + '. ' + icon + ' ' + label + '</div>';
|
||
}).join('');
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
document.getElementById('details-modal').style.display = 'flex';
|
||
};
|
||
|
||
UI.prototype.closeDetails = function (e) {
|
||
if (e && e.target !== document.getElementById('details-modal') && !e.target.matches('.modal-close, .btn-secondary, .close-btn')) return;
|
||
document.getElementById('details-modal').style.display = 'none';
|
||
};
|
||
|
||
UI.prototype.showPhotoSession = function (url) {
|
||
var uiEl = document.getElementById('photos-session-ui');
|
||
var link = document.getElementById('photos-session-link');
|
||
var status = document.getElementById('photos-session-status');
|
||
|
||
uiEl.style.display = 'block';
|
||
link.href = url;
|
||
link.style.display = 'block';
|
||
status.innerText = "Waiting for selection...";
|
||
};
|
||
|
||
UI.prototype.closePhotoSession = function () {
|
||
document.getElementById('photos-session-ui').style.display = 'none';
|
||
};
|
||
|
||
UI.prototype.updatePhotoStatus = function (msg) {
|
||
document.getElementById('photos-session-status').innerText = msg;
|
||
};
|
||
|
||
var ui = new UI();
|
||
window.ui = ui;
|
||
|
||
/**
|
||
* Data Controller
|
||
*/
|
||
var controller = {
|
||
init() {
|
||
// Start polling for SKU selection
|
||
setInterval(() => this.checkSku(), 2000);
|
||
this.checkSku();
|
||
},
|
||
|
||
checkSku() {
|
||
google.script.run
|
||
.withSuccessHandler(info => {
|
||
// Info is now { sku, title } or null
|
||
const sku = info ? info.sku : null;
|
||
|
||
if (sku && sku !== state.sku) {
|
||
state.setSku(info); // Pass whole object
|
||
this.loadMedia();
|
||
}
|
||
})
|
||
.getSelectedProductInfo();
|
||
},
|
||
|
||
loadMedia(preserveLogs = false) {
|
||
// Resolve SKU/Title - prefer state, fallback to DOM
|
||
let sku = state.sku;
|
||
let title = state.title;
|
||
|
||
if (!sku) {
|
||
const domSku = document.getElementById('current-sku').innerText;
|
||
if (domSku && domSku !== '...') sku = domSku;
|
||
|
||
const domTitle = document.getElementById('current-title').innerText;
|
||
if (domTitle && domTitle !== 'Loading...') title = domTitle;
|
||
}
|
||
|
||
// Show Main UI immediately so logs are visible
|
||
document.getElementById('loading-ui').style.display = 'none';
|
||
document.getElementById('main-ui').style.display = 'block';
|
||
|
||
// Set Inline Loading State
|
||
ui.setLoadingState(true);
|
||
|
||
// Reset State (this calls ui.updateSku)
|
||
state.setSku({ sku, title });
|
||
|
||
if (!preserveLogs) {
|
||
document.getElementById('status-log-container').innerHTML = '';
|
||
}
|
||
ui.toggleLogBtn.style.display = 'inline-block';
|
||
ui.toggleLog(true); // Force Show Log to see progress
|
||
|
||
// 1. Run Diagnostics
|
||
// 1. Run Diagnostics
|
||
ui.logStatus('init', 'Initializing access...', 'info');
|
||
|
||
google.script.run
|
||
.withSuccessHandler(function (diagnostics) {
|
||
// Drive Status
|
||
if (diagnostics.drive.status === 'ok') {
|
||
ui.logStatus('drive', `Drive Folder: ok (${diagnostics.drive.fileCount} files) <a href="${diagnostics.drive.folderUrl}" target="_blank" style="margin-left:8px;">Open Folder ↗</a>`, 'success');
|
||
ui.setDriveLink(diagnostics.drive.folderUrl);
|
||
} else {
|
||
ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error');
|
||
}
|
||
|
||
// Capture Token
|
||
if (diagnostics.token) state.token = diagnostics.token;
|
||
|
||
// Shopify Status
|
||
if (diagnostics.shopify.status === 'ok') {
|
||
ui.logStatus('shopify', `Shopify Product: ok (${diagnostics.shopify.mediaCount} media) (ID: ${diagnostics.shopify.id}) <a href="${diagnostics.shopify.adminUrl}" target="_blank" style="margin-left:8px;">Open Admin ↗</a>`, 'success');
|
||
ui.setShopifyLink(diagnostics.shopify.adminUrl);
|
||
} else if (diagnostics.shopify.status === 'skipped') {
|
||
ui.logStatus('shopify', 'Shopify Product: Not linked/Found', 'info');
|
||
} else {
|
||
ui.logStatus('shopify', `Shopify Check Failed: ${diagnostics.shopify.error}`, 'error');
|
||
}
|
||
|
||
ui.logStatus('fetch', 'Fetching full media state (this may take a moment)...', 'info');
|
||
|
||
// 2. Load Full Media
|
||
google.script.run
|
||
.withSuccessHandler(function (items) {
|
||
// Normalize items
|
||
const normalized = items.map(i => ({
|
||
...i,
|
||
id: i.id || Math.random().toString(36).substr(2, 9),
|
||
status: i.source || 'drive_only', // Fix: Use source as status
|
||
source: i.source,
|
||
_deleted: false // Init soft delete flag
|
||
}));
|
||
|
||
state.setItems(normalized);
|
||
document.getElementById('loading-ui').style.display = 'none';
|
||
document.getElementById('main-ui').style.display = 'block';
|
||
|
||
ui.logStatus('done', 'Finished loading.', 'success');
|
||
setTimeout(() => ui.toggleLog(false), 1000); // Auto hide after 1s
|
||
|
||
})
|
||
.withFailureHandler(function (err) {
|
||
ui.logStatus('fatal', `Failed to load media: ${err.message}`, 'error');
|
||
})
|
||
.getMediaForSku(sku);
|
||
|
||
})
|
||
.withFailureHandler(function (err) {
|
||
ui.logStatus('fatal', `Diagnostics failed: ${err.message}`, 'error');
|
||
})
|
||
.getMediaDiagnostics(sku, "");
|
||
},
|
||
|
||
saveChanges() {
|
||
ui.toggleSave(false);
|
||
ui.saveBtn.innerText = "Saving...";
|
||
|
||
ui.saveBtn.innerText = "Saving...";
|
||
|
||
// Filter out deleted items so they are actually removed
|
||
const activeItems = state.items.filter(i => !i._deleted);
|
||
|
||
// Send final state array to backend
|
||
google.script.run
|
||
.withSuccessHandler((logs) => {
|
||
ui.saveBtn.innerText = "Saved!";
|
||
|
||
// Verify logs is an array (backward compatibility check)
|
||
if (Array.isArray(logs)) {
|
||
document.getElementById('status-log-container').innerHTML = '';
|
||
logs.forEach(l => ui.logStatus('save', l, 'info'));
|
||
ui.toggleLog(true); // Force show
|
||
} else {
|
||
// Fallback for old backend
|
||
alert("Changes Saved & Synced!");
|
||
}
|
||
|
||
// Reload to get fresh IDs/State, preserving the save logs
|
||
setTimeout(() => this.loadMedia(true), 1500);
|
||
})
|
||
.withFailureHandler(e => {
|
||
alert(`Save Failed: ${e.message}`);
|
||
ui.toggleSave(true);
|
||
})
|
||
.saveMediaChanges(state.sku, activeItems);
|
||
},
|
||
|
||
handleFiles(fileList) {
|
||
Array.from(fileList).forEach(file => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const data = e.target.result.split(',')[1]; // Base64
|
||
|
||
google.script.run
|
||
.withSuccessHandler(() => {
|
||
this.loadMedia();
|
||
})
|
||
.saveFileToDrive(state.sku, file.name, file.type, data);
|
||
};
|
||
reader.readAsDataURL(file);
|
||
});
|
||
},
|
||
|
||
// --- Picker ---
|
||
openPicker() {
|
||
if (!pickerApiLoaded) return alert("API Loading...");
|
||
google.script.run.withSuccessHandler(c => createPicker(c)).getPickerConfig();
|
||
},
|
||
|
||
importFromPicker(fileId, mime, name, url) {
|
||
google.script.run
|
||
.withSuccessHandler(() => this.loadMedia())
|
||
.importFromPicker(state.sku, fileId, mime, name, url);
|
||
},
|
||
|
||
// --- Photos ---
|
||
startPhotoSession() {
|
||
ui.updatePhotoStatus("Starting session...");
|
||
google.script.run
|
||
.withSuccessHandler(session => {
|
||
ui.showPhotoSession(session.pickerUri);
|
||
this.pollPhotoSession(session.id);
|
||
})
|
||
.createPhotoSession();
|
||
},
|
||
|
||
pollPhotoSession(sessionId) {
|
||
let processing = false;
|
||
const check = () => {
|
||
if (processing) return;
|
||
google.script.run
|
||
.withSuccessHandler(res => {
|
||
if (res.status === 'complete') {
|
||
processing = true;
|
||
ui.updatePhotoStatus("Importing photos...");
|
||
controller.processPhotoItems(res.mediaItems);
|
||
} else if (res.status === 'error') {
|
||
ui.updatePhotoStatus("Error: " + res.message);
|
||
} else {
|
||
setTimeout(check, 2000);
|
||
}
|
||
})
|
||
.checkPhotoSession(sessionId);
|
||
};
|
||
check();
|
||
},
|
||
|
||
processPhotoItems(items) {
|
||
let done = 0;
|
||
items.forEach(item => {
|
||
const url = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
|
||
google.script.run
|
||
.withSuccessHandler(() => {
|
||
done++;
|
||
if (done === items.length) {
|
||
ui.updatePhotoStatus("Done!");
|
||
controller.loadMedia();
|
||
setTimeout(() => ui.closePhotoSession(), 2000);
|
||
}
|
||
})
|
||
.importFromPicker(state.sku, null, item.mimeType, item.filename, url);
|
||
});
|
||
}
|
||
};
|
||
|
||
// --- Google Picker API ---
|
||
let pickerApiLoaded = false;
|
||
window.onApiLoad = function () { gapi.load('picker', () => { pickerApiLoaded = true; }); };
|
||
|
||
function createPicker(config) {
|
||
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
|
||
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
|
||
.setIncludeFolders(true)
|
||
.setSelectFolderEnabled(false);
|
||
const photosView = new google.picker.PhotosView();
|
||
|
||
new google.picker.PickerBuilder()
|
||
.addView(view)
|
||
.addView(photosView)
|
||
.setOAuthToken(config.token)
|
||
.setDeveloperKey(config.apiKey)
|
||
.setCallback(data => {
|
||
if (data.action == google.picker.Action.PICKED) {
|
||
const doc = data.docs[0];
|
||
const url = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
|
||
controller.importFromPicker(doc.id, doc.mimeType, doc.name, url);
|
||
}
|
||
})
|
||
.build()
|
||
.setVisible(true);
|
||
}
|
||
|
||
// Init
|
||
try {
|
||
if (!window.state || !window.ui || !window.controller) {
|
||
throw new Error("Core components failed to initialize. Check console for SyntaxError.");
|
||
}
|
||
controller.init();
|
||
window.controller = controller; // Re-assert global access
|
||
} catch (e) {
|
||
alert("Init Failed: " + e.message);
|
||
console.error(e);
|
||
}
|
||
|
||
// Drag & Drop Handlers (Global)
|
||
const dropOverlay = document.getElementById('drop-overlay');
|
||
let dragCounter = 0;
|
||
|
||
document.addEventListener('dragenter', (e) => {
|
||
e.preventDefault();
|
||
dragCounter++;
|
||
dropOverlay.style.display = 'flex';
|
||
});
|
||
|
||
document.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
dragCounter--;
|
||
if (dragCounter === 0) {
|
||
dropOverlay.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
document.addEventListener('dragover', (e) => { e.preventDefault(); });
|
||
|
||
document.addEventListener('drop', (e) => {
|
||
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> |