|
|
|
|
@ -86,7 +86,8 @@
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
min-height: 100px; /* Drop target area */
|
|
|
|
|
min-height: 100px;
|
|
|
|
|
/* Drop target area */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.media-item {
|
|
|
|
|
@ -127,7 +128,8 @@
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
background: #f8fafc; /* Placeholder bg */
|
|
|
|
|
background: #f8fafc;
|
|
|
|
|
/* Placeholder bg */
|
|
|
|
|
padding: 4px;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
@ -135,28 +137,52 @@
|
|
|
|
|
/* Overlays & Badges */
|
|
|
|
|
.media-overlay {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
top: auto;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
background: rgba(0,0,0,0.4);
|
|
|
|
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
transition: opacity 0.2s;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
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 */
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
/* Text badge */
|
|
|
|
|
z-index: 10;
|
|
|
|
|
padding: 2px 6px;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
@ -186,8 +212,13 @@
|
|
|
|
|
transform: scale(1.1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn-delete { color: var(--danger); }
|
|
|
|
|
.btn-view { color: var(--primary); }
|
|
|
|
|
.btn-delete {
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn-view {
|
|
|
|
|
color: var(--primary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Buttons */
|
|
|
|
|
.btn {
|
|
|
|
|
@ -243,8 +274,11 @@
|
|
|
|
|
/* Modal */
|
|
|
|
|
.modal-overlay {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
|
|
|
background: rgba(0,0,0,0.8);
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
background: rgba(0, 0, 0, 0.8);
|
|
|
|
|
z-index: 100;
|
|
|
|
|
display: none;
|
|
|
|
|
align-items: center;
|
|
|
|
|
@ -288,25 +322,44 @@
|
|
|
|
|
animation: spin 1s linear infinite;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
|
|
|
@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); } }
|
|
|
|
|
|
|
|
|
|
@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 -->
|
|
|
|
|
<!-- 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 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>
|
|
|
|
|
@ -376,20 +429,20 @@
|
|
|
|
|
<div id="media-grid" class="media-grid">
|
|
|
|
|
<!-- Rendered Items -->
|
|
|
|
|
</div>
|
|
|
|
|
<!-- Action Footer -->
|
|
|
|
|
<div class="action-bar" style="display:flex; gap:8px;">
|
|
|
|
|
<!-- 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>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Loading Screen -->
|
|
|
|
|
<div id="loading-ui" style="text-align:center; padding-top: 100px;">
|
|
|
|
|
<!-- 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>
|
|
|
|
|
@ -429,131 +482,129 @@
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
/**
|
|
|
|
|
* State Management
|
|
|
|
|
* State Management & Error Handling
|
|
|
|
|
*/
|
|
|
|
|
class MediaState {
|
|
|
|
|
constructor() {
|
|
|
|
|
window.onerror = function (msg, url, line) {
|
|
|
|
|
alert("Script Error: " + msg + "\nLine: " + line);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// --- ES5 Refactor: MediaState ---
|
|
|
|
|
function MediaState() {
|
|
|
|
|
this.sku = null;
|
|
|
|
|
this.items = []; // Current UI State
|
|
|
|
|
this.initialState = []; // For diffing "isDirty"
|
|
|
|
|
this.token = null;
|
|
|
|
|
this.items = [];
|
|
|
|
|
this.initialState = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSku(info) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setItems(items) {
|
|
|
|
|
// items: { id, filename, thumbnail, status, source }
|
|
|
|
|
MediaState.prototype.setItems = function (items) {
|
|
|
|
|
this.items = items || [];
|
|
|
|
|
this.initialState = JSON.parse(JSON.stringify(this.items)); // Deep copy
|
|
|
|
|
this.initialState = JSON.parse(JSON.stringify(this.items));
|
|
|
|
|
ui.render(this.items);
|
|
|
|
|
this.checkDirty();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
addItem(item) {
|
|
|
|
|
MediaState.prototype.addItem = function (item) {
|
|
|
|
|
this.items.push(item);
|
|
|
|
|
ui.render(this.items);
|
|
|
|
|
this.checkDirty();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
deleteItem(index) {
|
|
|
|
|
const item = this.items[index];
|
|
|
|
|
MediaState.prototype.deleteItem = function (index) {
|
|
|
|
|
var item = this.items[index];
|
|
|
|
|
if (item.source === 'new') {
|
|
|
|
|
// Remove entirely if it's a new upload not yet synced
|
|
|
|
|
this.items.splice(index, 1);
|
|
|
|
|
} else {
|
|
|
|
|
// Toggle soft delete for existing items
|
|
|
|
|
item._deleted = !item._deleted;
|
|
|
|
|
}
|
|
|
|
|
ui.render(this.items);
|
|
|
|
|
this.checkDirty();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
reorderItems(newIndices) {
|
|
|
|
|
// Handled by Sortable onEnd
|
|
|
|
|
}
|
|
|
|
|
MediaState.prototype.reorderItems = function (newIndices) {
|
|
|
|
|
// Handled by Sortable
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Check if current state differs from initial
|
|
|
|
|
checkDirty() {
|
|
|
|
|
const plan = this.calculateDiff();
|
|
|
|
|
const isDirty = plan.hasChanges;
|
|
|
|
|
MediaState.prototype.checkDirty = function () {
|
|
|
|
|
var plan = this.calculateDiff();
|
|
|
|
|
var isDirty = plan.hasChanges;
|
|
|
|
|
ui.toggleSave(isDirty);
|
|
|
|
|
return plan;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
calculateDiff() {
|
|
|
|
|
const currentIds = new Set(this.items.map(i => i.id));
|
|
|
|
|
const initialIds = new Set(this.initialState.map(i => i.id));
|
|
|
|
|
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; }));
|
|
|
|
|
|
|
|
|
|
const actions = [];
|
|
|
|
|
var actions = [];
|
|
|
|
|
|
|
|
|
|
// 1. Deletions (Marked _deleted)
|
|
|
|
|
this.items.forEach(i => {
|
|
|
|
|
if (i._deleted) {
|
|
|
|
|
actions.push({ type: 'delete', name: i.filename || 'Item' });
|
|
|
|
|
}
|
|
|
|
|
this.items.forEach(function (i) {
|
|
|
|
|
if (i._deleted) actions.push({ type: 'delete', name: i.filename || 'Item' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. Additions
|
|
|
|
|
this.items.forEach(i => {
|
|
|
|
|
if (i._deleted) return; // Skip deleted items
|
|
|
|
|
|
|
|
|
|
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') {
|
|
|
|
|
// Existing drive items to be synced
|
|
|
|
|
actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 3. Reorders
|
|
|
|
|
const activeItems = this.items.filter(i => !i._deleted);
|
|
|
|
|
|
|
|
|
|
// Check order of common items
|
|
|
|
|
var activeItems = this.items.filter(function (i) { return !i._deleted; });
|
|
|
|
|
// Filter initial state to only items that are still active
|
|
|
|
|
const initialCommon = this.initialState.filter(i => activeItems.find(c => c.id === i.id));
|
|
|
|
|
const currentCommon = activeItems.filter(i => initialIds.has(i.id));
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let orderChanged = false;
|
|
|
|
|
if (initialCommon.length !== currentCommon.length) {
|
|
|
|
|
// Should match if we filtered correctly
|
|
|
|
|
} else {
|
|
|
|
|
for (let k = 0; k < initialCommon.length; k++) {
|
|
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const uniqueActions = actions.filter((v, i, a) => a.findIndex(t => (t.type === v.type && t.name === v.name)) === i);
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
hasNewItems() {
|
|
|
|
|
return this.items.some(i => !i._deleted && (i.status === 'drive_only' || i.source === 'new'));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
MediaState.prototype.hasNewItems = function () {
|
|
|
|
|
return this.items.some(function (i) {
|
|
|
|
|
return !i._deleted && (i.status === 'drive_only' || i.source === 'new');
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const state = new MediaState();
|
|
|
|
|
var state = new MediaState();
|
|
|
|
|
window.state = state;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* UI Controller
|
|
|
|
|
*/
|
|
|
|
|
class UI {
|
|
|
|
|
constructor() {
|
|
|
|
|
// --- ES5 Refactor: UI ---
|
|
|
|
|
function UI() {
|
|
|
|
|
this.grid = document.getElementById('media-grid');
|
|
|
|
|
this.saveBtn = document.getElementById('save-btn');
|
|
|
|
|
this.toggleLogBtn = document.getElementById('toggle-log-btn');
|
|
|
|
|
@ -564,93 +615,99 @@
|
|
|
|
|
this.shopifyUrl = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setDriveLink(url) { this.driveUrl = url; this.renderLinks(); }
|
|
|
|
|
setShopifyLink(url) { this.shopifyUrl = url; this.renderLinks(); }
|
|
|
|
|
UI.prototype.setDriveLink = function (url) { this.driveUrl = url; this.renderLinks(); };
|
|
|
|
|
UI.prototype.setShopifyLink = function (url) { this.shopifyUrl = url; this.renderLinks(); };
|
|
|
|
|
|
|
|
|
|
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>`;
|
|
|
|
|
}
|
|
|
|
|
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>';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
toggleLog(forceState) {
|
|
|
|
|
const isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none';
|
|
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updateSku(sku, title) {
|
|
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
toggleSave(enable) {
|
|
|
|
|
UI.prototype.toggleSave = function (enable) {
|
|
|
|
|
this.saveBtn.disabled = !enable;
|
|
|
|
|
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
render(items) {
|
|
|
|
|
UI.prototype.render = function (items) {
|
|
|
|
|
this.grid.innerHTML = '';
|
|
|
|
|
const activeCount = items.filter(i => !i._deleted).length;
|
|
|
|
|
document.getElementById('item-count').innerText = `(${activeCount})`;
|
|
|
|
|
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((item, index) => {
|
|
|
|
|
const el = this.createCard(item, index);
|
|
|
|
|
this.grid.appendChild(el);
|
|
|
|
|
items.forEach(function (item, index) {
|
|
|
|
|
var el = _this.createCard(item, index);
|
|
|
|
|
_this.grid.appendChild(el);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Re-init Sortable
|
|
|
|
|
if (this.sortable) this.sortable.destroy();
|
|
|
|
|
this.sortable = new Sortable(this.grid, {
|
|
|
|
|
animation: 150,
|
|
|
|
|
filter: '.deleted-item',
|
|
|
|
|
ghostClass: 'sortable-ghost',
|
|
|
|
|
dragClass: 'sortable-drag',
|
|
|
|
|
onEnd: () => {
|
|
|
|
|
// Update State Order
|
|
|
|
|
const newOrderIds = Array.from(this.grid.children).map(el => el.dataset.id);
|
|
|
|
|
// Reorder state.items based on newOrderIds
|
|
|
|
|
const newItems = newOrderIds.map(id => state.items.find(i => i.id === id)).filter(Boolean);
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setLoadingState(isLoading) {
|
|
|
|
|
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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
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>';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
logStatus(step, message, type = 'info') {
|
|
|
|
|
const container = this.logContainer;
|
|
|
|
|
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
|
|
|
|
|
const el = document.createElement('div');
|
|
|
|
|
el.innerHTML = `<span style="margin-right:8px;">${icon}</span> ${message}`;
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
createCard(item, index) {
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.className = `media-item ${item._deleted ? 'deleted-item' : ''}`;
|
|
|
|
|
UI.prototype.createCard = function (item, index) {
|
|
|
|
|
var div = document.createElement('div');
|
|
|
|
|
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
|
|
|
|
|
div.dataset.id = item.id;
|
|
|
|
|
|
|
|
|
|
// Badge logic
|
|
|
|
|
let badge = '';
|
|
|
|
|
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>';
|
|
|
|
|
@ -659,148 +716,155 @@
|
|
|
|
|
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Thumbnail
|
|
|
|
|
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4'));
|
|
|
|
|
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename + ", ContentUrl: " + item.contentUrl);
|
|
|
|
|
const mediaHtml = isVideo
|
|
|
|
|
? `<video src="${item.contentUrl || ''}" poster="${item.thumbnail}" muted loop onmouseover="this.play()" onmouseout="this.pause()" class="media-content"></video>`
|
|
|
|
|
: `<img src="${item.thumbnail}" class="media-content" loading="lazy">`;
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
const 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>`;
|
|
|
|
|
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">📹</div>' : '';
|
|
|
|
|
|
|
|
|
|
div.innerHTML = `
|
|
|
|
|
${badge}
|
|
|
|
|
${mediaHtml}
|
|
|
|
|
<div class="media-overlay">
|
|
|
|
|
<button class="icon-btn btn-view" onclick="ui.openPreview('${item.id}')" title="View">👁️</button>
|
|
|
|
|
${actionBtn}
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
return 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
openPreview(id) {
|
|
|
|
|
const item = state.items.find(i => i.id === id);
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
const modal = document.getElementById('preview-modal');
|
|
|
|
|
const img = document.getElementById('preview-image');
|
|
|
|
|
const vid = document.getElementById('preview-video');
|
|
|
|
|
const iframe = document.getElementById('preview-iframe');
|
|
|
|
|
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'; // Reset
|
|
|
|
|
iframe.src = 'about:blank';
|
|
|
|
|
|
|
|
|
|
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4'));
|
|
|
|
|
var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
|
|
|
|
|
|
|
|
|
|
if (isVideo) {
|
|
|
|
|
// Use Drive Preview Embed URL
|
|
|
|
|
// Note: This assumes item.id corresponds to Drive File ID for drive items.
|
|
|
|
|
// (Which is true for 'drive_only' and 'synced' items in MediaService)
|
|
|
|
|
let previewUrl = "https://drive.google.com/file/d/" + item.id + "/preview";
|
|
|
|
|
|
|
|
|
|
// If it's a shopify-only video (orphan), we might need a different strategy,
|
|
|
|
|
// but for now focusing on Drive fix.
|
|
|
|
|
if (item.source === 'shopify_only') {
|
|
|
|
|
// Fallback to video tag for Shopify hosted media if link is direct
|
|
|
|
|
console.log("[MediaManager] Shopify Video: " + item.filename);
|
|
|
|
|
vid.src = item.contentUrl || item.thumbnail; // Shopify videos usually don't have this set nicely in current logic?
|
|
|
|
|
// Actually MediaService for orphans puts originalSrc in thumbnail.
|
|
|
|
|
// But originalSrc for video is usually an image.
|
|
|
|
|
// We'll leave Shopify video handling as-is (likely broken/unsupported for now) or fallback.
|
|
|
|
|
// Effectively this branch executes the OLD logic for non-drive.
|
|
|
|
|
vid.src = item.thumbnail; // Risk
|
|
|
|
|
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 {
|
|
|
|
|
console.log("[MediaManager] Opening Drive Embed: " + item.filename + ", URL: " + previewUrl);
|
|
|
|
|
iframe.src = previewUrl;
|
|
|
|
|
iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
|
|
|
|
|
iframe.style.display = 'block';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
// Image
|
|
|
|
|
img.src = item.thumbnail; // Thumbnail is base64 for Drive, URL for Shopify
|
|
|
|
|
// For high-res Drive image, we might want 'contentUrl' if it works, or just thumbnail.
|
|
|
|
|
// Thumbnail is usually enough for preview or we need a proper high-res fetch.
|
|
|
|
|
// Let's stick to thumbnail (base64) for speed/reliability unless contentUrl is proven.
|
|
|
|
|
img.src = item.thumbnail;
|
|
|
|
|
img.style.display = 'block';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
modal.style.display = 'flex';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
closeModal(e) {
|
|
|
|
|
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'; // Stop playback
|
|
|
|
|
}
|
|
|
|
|
document.getElementById('preview-iframe').src = 'about:blank';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// --- Details Modal ---
|
|
|
|
|
showDetails() {
|
|
|
|
|
const plan = state.calculateDiff();
|
|
|
|
|
const container = document.getElementById('details-content');
|
|
|
|
|
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 {
|
|
|
|
|
const html = plan.actions.map((a, i) => {
|
|
|
|
|
let icon = '•';
|
|
|
|
|
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 = '🔢';
|
|
|
|
|
|
|
|
|
|
let 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`;
|
|
|
|
|
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>`;
|
|
|
|
|
return '<div style="margin-bottom:8px;">' + (i + 1) + '. ' + icon + ' ' + label + '</div>';
|
|
|
|
|
}).join('');
|
|
|
|
|
container.innerHTML = html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById('details-modal').style.display = 'flex';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
closeDetails(e) {
|
|
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Photos Session Methods
|
|
|
|
|
showPhotoSession(url) {
|
|
|
|
|
const ui = document.getElementById('photos-session-ui');
|
|
|
|
|
const link = document.getElementById('photos-session-link');
|
|
|
|
|
const status = document.getElementById('photos-session-status');
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
|
|
ui.style.display = 'block';
|
|
|
|
|
uiEl.style.display = 'block';
|
|
|
|
|
link.href = url;
|
|
|
|
|
link.style.display = 'block';
|
|
|
|
|
status.innerText = "Waiting for selection...";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
closePhotoSession() {
|
|
|
|
|
UI.prototype.closePhotoSession = function () {
|
|
|
|
|
document.getElementById('photos-session-ui').style.display = 'none';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
updatePhotoStatus(msg) {
|
|
|
|
|
UI.prototype.updatePhotoStatus = function (msg) {
|
|
|
|
|
document.getElementById('photos-session-status').innerText = msg;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ui = new UI();
|
|
|
|
|
var ui = new UI();
|
|
|
|
|
window.ui = ui;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Data Controller
|
|
|
|
|
*/
|
|
|
|
|
const controller = {
|
|
|
|
|
var controller = {
|
|
|
|
|
init() {
|
|
|
|
|
// Start polling for SKU selection
|
|
|
|
|
setInterval(() => this.checkSku(), 2000);
|
|
|
|
|
@ -864,6 +928,9 @@
|
|
|
|
|
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');
|
|
|
|
|
@ -1022,7 +1089,7 @@
|
|
|
|
|
|
|
|
|
|
// --- Google Picker API ---
|
|
|
|
|
let pickerApiLoaded = false;
|
|
|
|
|
function onApiLoad() { gapi.load('picker', () => { pickerApiLoaded = true; }); }
|
|
|
|
|
window.onApiLoad = function () { gapi.load('picker', () => { pickerApiLoaded = true; }); };
|
|
|
|
|
|
|
|
|
|
function createPicker(config) {
|
|
|
|
|
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
|
|
|
|
|
@ -1048,7 +1115,16 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
@ -1082,4 +1158,5 @@
|
|
|
|
|
</script>
|
|
|
|
|
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
|
|
|
|
|
</body>
|
|
|
|
|
|
|
|
|
|
</html>
|