Compare commits
5 Commits
243f7057b7
...
55d18138b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d18138b7 | |||
| 945fb610f9 | |||
| d67897aa17 | |||
| c738ab3ef7 | |||
| d9d884e1fc |
@ -40,5 +40,9 @@ This project (`product_inventory`) integrates Google Sheets with Shopify. It ser
|
|||||||
1. Sanitize with `Utilities.newBlob()`.
|
1. Sanitize with `Utilities.newBlob()`.
|
||||||
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
|
2. Fallback to **Advanced Drive Service** (`Drive.Files.create` / `v3`) if standard creation fails.
|
||||||
- **Video Previews**:
|
- **Video Previews**:
|
||||||
- HTML5 `<video>` tags often fail with standard Drive download URLs due to auth/codec issues.
|
- **Video Previews**:
|
||||||
- **Strategy**: Use an `<iframe>` embedding the `https://drive.google.com/file/d/{ID}/preview` URL. This leverages Google's native player for reliable auth and transcoding.
|
- Use `document.createElement('video')` to inject video tags. Avoid template strings (`<video src="...">`) as the parser sanitizes them aggressively.
|
||||||
|
- Fallback to `<iframe>` only if native playback fails.
|
||||||
|
- **Client-Side Syntax**:
|
||||||
|
- **ES5 ONLY**: Do not use `class` in client-side HTML files. The Apps Script sanitizer often fails to parse them. Use `function` constructors.
|
||||||
|
|
||||||
|
|||||||
@ -141,3 +141,19 @@ We implemented a "Sidebar-First" architecture for product media to handle the co
|
|||||||
- Calculates checksums to avoid re-uploading duplicate images.
|
- Calculates checksums to avoid re-uploading duplicate images.
|
||||||
- Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow.
|
- Uses Shopify's "Staged Uploads" -> "Create Media" mutation flow.
|
||||||
|
|
||||||
|
### 8. Apps Script & HTML Service Constraints
|
||||||
|
|
||||||
|
When working with `HtmlService` (client-side code), the environment differs significantly from the server-side V8 runtime.
|
||||||
|
|
||||||
|
1. **Server-Side (`.ts`/`.gs`)**:
|
||||||
|
- **Runtime**: V8 Engine.
|
||||||
|
- **Syntax**: Modern ES6+ (Classes, Arrow Functions, `const`/`let`) is fully supported.
|
||||||
|
- **Recommendation**: Use standard TypeScript patterns.
|
||||||
|
|
||||||
|
2. **Client-Side (`.html` served via `createHtmlOutputFromFile`)**:
|
||||||
|
- **Runtime**: Legacy Browser Environment / Strict Caja Sanitization.
|
||||||
|
- **Constraint**: The parser often chokes on ES6 `class` syntax and complex template strings inside HTML attributes.
|
||||||
|
- **Rule 1**: **NO ES6 CLASSES**. Use ES5 `function` constructors and `prototype` methods.
|
||||||
|
- **Rule 2**: **NO Complex Template Strings in Attributes**. Do not use `src="${var}"` if the variable contains a URL. Use `document.createElement` and set properties (e.g., `element.src = value`) programmatically.
|
||||||
|
- **Rule 3**: **Unified Script Tags**. Consolidate scripts into a single block where possible to avoid parser merge errors.
|
||||||
|
- **Rule 4**: **Var over Let/Const**. Top-level variables should use `var` or explicit `window` assignment to ensure they are accessible to inline HTML handlers (e.g., `onclick="handler()"`).
|
||||||
|
|||||||
@ -86,7 +86,8 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
min-height: 100px; /* Drop target area */
|
min-height: 100px;
|
||||||
|
/* Drop target area */
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-item {
|
.media-item {
|
||||||
@ -127,7 +128,8 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #f8fafc; /* Placeholder bg */
|
background: #f8fafc;
|
||||||
|
/* Placeholder bg */
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@ -135,28 +137,52 @@
|
|||||||
/* Overlays & Badges */
|
/* Overlays & Badges */
|
||||||
.media-overlay {
|
.media-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: auto;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 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;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding: 40px 10px 10px 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-overlay .icon-btn {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-item:hover .media-overlay {
|
.media-item:hover .media-overlay {
|
||||||
opacity: 1;
|
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 {
|
.badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
font-size: 10px; /* Text badge */
|
font-size: 10px;
|
||||||
|
/* Text badge */
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -186,8 +212,13 @@
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete { color: var(--danger); }
|
.btn-delete {
|
||||||
.btn-view { color: var(--primary); }
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
@ -243,8 +274,11 @@
|
|||||||
/* Modal */
|
/* Modal */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0; left: 0; right: 0; bottom: 0;
|
top: 0;
|
||||||
background: rgba(0,0,0,0.8);
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -288,44 +322,73 @@
|
|||||||
animation: spin 1s linear infinite;
|
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 */
|
/* Session UI */
|
||||||
#photos-session-ui {
|
#photos-session-ui {
|
||||||
animation: slideDown 0.3s ease-out;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="main-ui" style="display:none">
|
<div id="main-ui" style="display:none">
|
||||||
<!-- Header Card -->
|
<!-- Header Card -->
|
||||||
|
<!-- Product Info Card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="header">
|
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
|
||||||
<h2>Media Manager</h2>
|
<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>
|
<span id="current-sku" class="sku-badge">...</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Optional: Could put metadata here or small status -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
|
<!-- Upload Options Card -->
|
||||||
<div style="font-size: 32px; margin-bottom: 8px;">☁️</div>
|
<div class="card">
|
||||||
<div style="font-size: 14px; font-weight: 500;">Drop files or click to upload</div>
|
<div class="header" style="margin-bottom: 12px;">
|
||||||
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">
|
<h3 style="margin:0; font-size:14px; color:var(--text);">Add Photos/Videos from...</h3>
|
||||||
Direct to Drive • JPG, PNG, MP4
|
</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>
|
</div>
|
||||||
<input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)">
|
<input type="file" id="file-input" multiple style="display:none" onchange="controller.handleFiles(this.files)">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
|
||||||
<button onclick="controller.openPicker()" class="btn btn-secondary" style="flex: 1; font-size: 12px;">
|
|
||||||
📂 Drive Picker
|
|
||||||
</button>
|
|
||||||
<button onclick="controller.startPhotoSession()" class="btn btn-secondary" style="flex: 1; font-size: 12px;">
|
|
||||||
📸 Google Photos
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photos Session UI -->
|
<!-- Photos Session UI -->
|
||||||
<div id="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;">
|
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
|
||||||
@ -340,7 +403,6 @@
|
|||||||
</a>
|
</a>
|
||||||
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
|
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card" style="padding-bottom: 0;">
|
<div class="card" style="padding-bottom: 0;">
|
||||||
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
|
<div class="header" style="margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;">
|
||||||
@ -366,24 +428,35 @@
|
|||||||
<div id="media-grid" class="media-grid">
|
<div id="media-grid" class="media-grid">
|
||||||
<!-- Rendered Items -->
|
<!-- Rendered Items -->
|
||||||
</div>
|
</div>
|
||||||
<!-- Action Footer -->
|
<!-- Action Footer -->
|
||||||
<div class="action-bar" style="display:flex; gap:8px;">
|
<div class="action-bar" style="display:flex; gap:8px;">
|
||||||
<button id="details-btn" onclick="ui.showDetails()" class="btn btn-secondary" style="flex:1;">
|
<button id="details-btn" onclick="ui.showDetails()" class="btn btn-secondary" style="flex:1;">
|
||||||
Show Plan
|
Show Plan
|
||||||
</button>
|
</button>
|
||||||
<button id="save-btn" onclick="controller.saveChanges()" class="btn" style="flex:2;" disabled>
|
<button id="save-btn" onclick="controller.saveChanges()" class="btn" style="flex:2;" disabled>
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading Screen -->
|
<!-- Loading Screen -->
|
||||||
<div id="loading-ui" style="text-align:center; padding-top: 100px;">
|
<div id="loading-ui" style="text-align:center; padding-top: 100px;">
|
||||||
<div class="spinner" style="width: 32px; height: 32px; border-width: 3px;"></div>
|
<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 style="margin-top:16px; color: var(--text-secondary); font-weight: 500;">Connecting...</div>
|
||||||
</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 -->
|
<!-- Preview Modal -->
|
||||||
<div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)">
|
<div id="preview-modal" class="modal-overlay" onclick="ui.closeModal(event)">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@ -411,132 +484,137 @@
|
|||||||
</div>
|
</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>
|
<script>
|
||||||
/**
|
/**
|
||||||
* State Management
|
* State Management & Error Handling
|
||||||
*/
|
*/
|
||||||
class MediaState {
|
window.onerror = function (msg, url, line) {
|
||||||
constructor() {
|
alert("Script Error: " + msg + "\nLine: " + line);
|
||||||
this.sku = null;
|
};
|
||||||
this.items = []; // Current UI State
|
|
||||||
this.initialState = []; // For diffing "isDirty"
|
|
||||||
}
|
|
||||||
|
|
||||||
setSku(sku) {
|
// --- ES5 Refactor: MediaState ---
|
||||||
this.sku = sku;
|
function MediaState() {
|
||||||
|
this.sku = null;
|
||||||
|
this.token = null;
|
||||||
this.items = [];
|
this.items = [];
|
||||||
this.initialState = [];
|
this.initialState = [];
|
||||||
ui.updateSku(sku);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setItems(items) {
|
MediaState.prototype.setSku = function (info) {
|
||||||
// items: { id, filename, thumbnail, status, source }
|
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.items = items || [];
|
||||||
this.initialState = JSON.parse(JSON.stringify(this.items)); // Deep copy
|
this.initialState = JSON.parse(JSON.stringify(this.items));
|
||||||
ui.render(this.items);
|
ui.render(this.items);
|
||||||
this.checkDirty();
|
this.checkDirty();
|
||||||
}
|
};
|
||||||
|
|
||||||
addItem(item) {
|
MediaState.prototype.addItem = function (item) {
|
||||||
this.items.push(item);
|
this.items.push(item);
|
||||||
ui.render(this.items);
|
ui.render(this.items);
|
||||||
this.checkDirty();
|
this.checkDirty();
|
||||||
}
|
};
|
||||||
|
|
||||||
deleteItem(index) {
|
MediaState.prototype.deleteItem = function (index) {
|
||||||
const item = this.items[index];
|
var item = this.items[index];
|
||||||
if (item.source === 'new') {
|
if (item.source === 'new') {
|
||||||
// Remove entirely if it's a new upload not yet synced
|
|
||||||
this.items.splice(index, 1);
|
this.items.splice(index, 1);
|
||||||
} else {
|
} else {
|
||||||
// Toggle soft delete for existing items
|
|
||||||
item._deleted = !item._deleted;
|
item._deleted = !item._deleted;
|
||||||
}
|
}
|
||||||
ui.render(this.items);
|
ui.render(this.items);
|
||||||
this.checkDirty();
|
this.checkDirty();
|
||||||
}
|
};
|
||||||
|
|
||||||
reorderItems(newIndices) {
|
MediaState.prototype.reorderItems = function (newIndices) {
|
||||||
// Handled by Sortable onEnd
|
// Handled by Sortable
|
||||||
}
|
};
|
||||||
|
|
||||||
// Check if current state differs from initial
|
MediaState.prototype.checkDirty = function () {
|
||||||
checkDirty() {
|
var plan = this.calculateDiff();
|
||||||
const plan = this.calculateDiff();
|
var isDirty = plan.hasChanges;
|
||||||
const isDirty = plan.hasChanges;
|
|
||||||
ui.toggleSave(isDirty);
|
ui.toggleSave(isDirty);
|
||||||
return plan;
|
return plan;
|
||||||
}
|
};
|
||||||
|
|
||||||
calculateDiff() {
|
MediaState.prototype.calculateDiff = function () {
|
||||||
const currentIds = new Set(this.items.map(i => i.id));
|
var currentIds = new Set(this.items.map(function (i) { return i.id; }));
|
||||||
const initialIds = new Set(this.initialState.map(i => 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(function (i) {
|
||||||
this.items.forEach(i => {
|
if (i._deleted) actions.push({ type: 'delete', name: i.filename || 'Item' });
|
||||||
if (i._deleted) {
|
|
||||||
actions.push({ type: 'delete', name: i.filename || 'Item' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Additions
|
this.items.forEach(function (i) {
|
||||||
this.items.forEach(i => {
|
if (i._deleted) return;
|
||||||
if (i._deleted) return; // Skip deleted items
|
|
||||||
|
|
||||||
if (!initialIds.has(i.id)) {
|
if (!initialIds.has(i.id)) {
|
||||||
actions.push({ type: 'upload', name: i.filename || 'New Item' });
|
actions.push({ type: 'upload', name: i.filename || 'New Item' });
|
||||||
} else if (i.status === 'drive_only') {
|
} else if (i.status === 'drive_only') {
|
||||||
// Existing drive items to be synced
|
|
||||||
actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
|
actions.push({ type: 'sync_upload', name: i.filename || 'Item' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Reorders
|
// 3. Reorders
|
||||||
const activeItems = this.items.filter(i => !i._deleted);
|
var activeItems = this.items.filter(function (i) { return !i._deleted; });
|
||||||
|
|
||||||
// Check order of common items
|
|
||||||
// Filter initial state to only items that are still active
|
// Filter initial state to only items that are still active
|
||||||
const initialCommon = this.initialState.filter(i => activeItems.find(c => c.id === i.id));
|
var initialCommon = this.initialState.filter(function (i) {
|
||||||
const currentCommon = activeItems.filter(i => initialIds.has(i.id));
|
return activeItems.some(function (c) { return c.id === i.id; });
|
||||||
|
});
|
||||||
|
var currentCommon = activeItems.filter(function (i) {
|
||||||
|
return initialIds.has(i.id);
|
||||||
|
});
|
||||||
|
|
||||||
let orderChanged = false;
|
var orderChanged = false;
|
||||||
if (initialCommon.length !== currentCommon.length) {
|
if (initialCommon.length === currentCommon.length) {
|
||||||
// Should match if we filtered correctly
|
for (var k = 0; k < initialCommon.length; k++) {
|
||||||
} else {
|
|
||||||
for (let k = 0; k < initialCommon.length; k++) {
|
|
||||||
if (initialCommon[k].id !== currentCommon[k].id) {
|
if (initialCommon[k].id !== currentCommon[k].id) {
|
||||||
orderChanged = true;
|
orderChanged = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// If lengths differ despite logic, assume change or weird state
|
||||||
}
|
}
|
||||||
|
|
||||||
if (orderChanged) {
|
if (orderChanged) {
|
||||||
actions.push({ type: 'reorder', name: 'Reorder Gallery' });
|
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 {
|
return {
|
||||||
hasChanges: uniqueActions.length > 0,
|
hasChanges: uniqueActions.length > 0,
|
||||||
actions: uniqueActions
|
actions: uniqueActions
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
hasNewItems() {
|
MediaState.prototype.hasNewItems = function () {
|
||||||
return this.items.some(i => !i._deleted && (i.status === 'drive_only' || i.source === 'new'));
|
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;
|
||||||
|
|
||||||
/**
|
// --- ES5 Refactor: UI ---
|
||||||
* UI Controller
|
function UI() {
|
||||||
*/
|
|
||||||
class UI {
|
|
||||||
constructor() {
|
|
||||||
this.grid = document.getElementById('media-grid');
|
this.grid = document.getElementById('media-grid');
|
||||||
this.saveBtn = document.getElementById('save-btn');
|
this.saveBtn = document.getElementById('save-btn');
|
||||||
this.toggleLogBtn = document.getElementById('toggle-log-btn');
|
this.toggleLogBtn = document.getElementById('toggle-log-btn');
|
||||||
@ -547,81 +625,99 @@
|
|||||||
this.shopifyUrl = null;
|
this.shopifyUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDriveLink(url) { this.driveUrl = url; this.renderLinks(); }
|
UI.prototype.setDriveLink = function (url) { this.driveUrl = url; this.renderLinks(); };
|
||||||
setShopifyLink(url) { this.shopifyUrl = url; this.renderLinks(); }
|
UI.prototype.setShopifyLink = function (url) { this.shopifyUrl = url; this.renderLinks(); };
|
||||||
|
|
||||||
renderLinks() {
|
UI.prototype.renderLinks = function () {
|
||||||
this.linksContainer.innerHTML = '';
|
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.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.shopifyUrl) this.linksContainer.innerHTML += '<a href="' + this.shopifyUrl + '" target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>';
|
||||||
}
|
};
|
||||||
|
|
||||||
toggleLog(forceState) {
|
UI.prototype.toggleLog = function (forceState) {
|
||||||
const isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none';
|
var isVisible = typeof forceState === 'boolean' ? !forceState : this.logContainer.style.display !== 'none';
|
||||||
this.logContainer.style.display = isVisible ? 'none' : 'block';
|
this.logContainer.style.display = isVisible ? 'none' : 'block';
|
||||||
this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log";
|
this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log";
|
||||||
}
|
};
|
||||||
|
|
||||||
updateSku(sku) {
|
UI.prototype.updateSku = function (sku, title) {
|
||||||
document.getElementById('current-sku').innerText = sku;
|
document.getElementById('current-sku').innerText = sku || '...';
|
||||||
|
document.getElementById('current-title').innerText = title || '';
|
||||||
document.getElementById('loading-ui').style.display = 'none';
|
document.getElementById('loading-ui').style.display = 'none';
|
||||||
document.getElementById('main-ui').style.display = 'block';
|
document.getElementById('main-ui').style.display = 'block';
|
||||||
}
|
};
|
||||||
|
|
||||||
toggleSave(enable) {
|
UI.prototype.toggleSave = function (enable) {
|
||||||
this.saveBtn.disabled = !enable;
|
this.saveBtn.disabled = !enable;
|
||||||
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
|
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
|
||||||
}
|
};
|
||||||
|
|
||||||
render(items) {
|
UI.prototype.render = function (items) {
|
||||||
this.grid.innerHTML = '';
|
this.grid.innerHTML = '';
|
||||||
const activeCount = items.filter(i => !i._deleted).length;
|
var _this = this; // Capture 'this' for callbacks
|
||||||
document.getElementById('item-count').innerText = `(${activeCount})`;
|
var activeCount = items.filter(function (i) { return !i._deleted; }).length;
|
||||||
|
document.getElementById('item-count').innerText = '(' + activeCount + ')';
|
||||||
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
|
this.grid.innerHTML = '<div class="empty-state">No media found. Upload something!</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach(function (item, index) {
|
||||||
const el = this.createCard(item, index);
|
var el = _this.createCard(item, index);
|
||||||
this.grid.appendChild(el);
|
_this.grid.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-init Sortable
|
|
||||||
if (this.sortable) this.sortable.destroy();
|
if (this.sortable) this.sortable.destroy();
|
||||||
this.sortable = new Sortable(this.grid, {
|
this.sortable = new Sortable(this.grid, {
|
||||||
animation: 150,
|
animation: 150,
|
||||||
filter: '.deleted-item',
|
filter: '.deleted-item',
|
||||||
ghostClass: 'sortable-ghost',
|
ghostClass: 'sortable-ghost',
|
||||||
dragClass: 'sortable-drag',
|
dragClass: 'sortable-drag',
|
||||||
onEnd: () => {
|
onEnd: function () {
|
||||||
// Update State Order
|
var newOrderIds = Array.from(_this.grid.children).map(function (el) { return el.dataset.id; });
|
||||||
const newOrderIds = Array.from(this.grid.children).map(el => el.dataset.id);
|
var newItems = newOrderIds.map(function (id) {
|
||||||
// Reorder state.items based on newOrderIds
|
return state.items.find(function (i) { return i.id === id; });
|
||||||
const newItems = newOrderIds.map(id => state.items.find(i => i.id === id)).filter(Boolean);
|
}).filter(Boolean);
|
||||||
state.items = newItems;
|
state.items = newItems;
|
||||||
state.checkDirty();
|
state.checkDirty();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
logStatus(step, message, type = 'info') {
|
UI.prototype.setLoadingState = function (isLoading) {
|
||||||
const container = this.logContainer;
|
if (isLoading) {
|
||||||
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
|
this.grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">' +
|
||||||
const el = document.createElement('div');
|
'<div class="spinner" style="margin-bottom: 12px;"></div>' +
|
||||||
el.innerHTML = `<span style="margin-right:8px;">${icon}</span> ${message}`;
|
'<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)';
|
if (type === 'error') el.style.color = 'var(--error)';
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
}
|
};
|
||||||
|
|
||||||
createCard(item, index) {
|
UI.prototype.createCard = function (item, index) {
|
||||||
const div = document.createElement('div');
|
var div = document.createElement('div');
|
||||||
div.className = `media-item ${item._deleted ? 'deleted-item' : ''}`;
|
div.className = 'media-item ' + (item._deleted ? 'deleted-item' : '');
|
||||||
div.dataset.id = item.id;
|
div.dataset.id = item.id;
|
||||||
|
|
||||||
// Badge logic
|
div.onmouseenter = function () {
|
||||||
let badge = '';
|
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._deleted) {
|
||||||
if (item.status === 'synced') badge = '<span class="badge" title="Synced" style="background:#dcfce7; color:#166534;">Synced</span>';
|
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 === 'drive_only') badge = '<span class="badge" title="Drive Only" style="background:#dbeafe; color:#1e40af;">Drive</span>';
|
||||||
@ -630,148 +726,159 @@
|
|||||||
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
|
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thumbnail
|
var isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.match(/\.(mp4|mov|webm)$/i));
|
||||||
const isVideo = (item.mimeType && item.mimeType.startsWith('video/')) || (item.filename && item.filename.toLowerCase().endsWith('.mp4'));
|
if (isVideo) console.log("[MediaManager] Video Detected: " + item.filename);
|
||||||
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">`;
|
|
||||||
|
|
||||||
const actionBtn = item._deleted
|
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">🎞️</div>' : '';
|
||||||
? `<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 = `
|
// content URL logic (Only relevant for Shopify where we have a direct public link)
|
||||||
${badge}
|
var contentUrl = item.contentUrl || "";
|
||||||
${mediaHtml}
|
|
||||||
<div class="media-overlay">
|
var actionBtn = item._deleted
|
||||||
<button class="icon-btn btn-view" onclick="ui.openPreview('${item.id}')" title="View">👁️</button>
|
? '<button class="icon-btn" onclick="state.deleteItem(' + index + ')" title="Restore">↩️</button>'
|
||||||
${actionBtn}
|
: '<button class="icon-btn btn-delete" onclick="state.deleteItem(' + index + ')" title="Delete">🗑️</button>';
|
||||||
</div>
|
|
||||||
`;
|
div.innerHTML =
|
||||||
return div;
|
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
|
||||||
|
// RULE: Only create <video> for Shopify-hosted videos (public).
|
||||||
|
// Drive videos use static thumbnail + Iframe Preview.
|
||||||
|
var mediaEl;
|
||||||
|
if (isVideo && item.source === 'shopify_only' && 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.className = 'media-content';
|
||||||
|
|
||||||
openPreview(id) {
|
var overlay = div.querySelector('.media-overlay');
|
||||||
const item = state.items.find(i => i.id === id);
|
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;
|
if (!item) return;
|
||||||
|
|
||||||
const modal = document.getElementById('preview-modal');
|
var modal = document.getElementById('preview-modal');
|
||||||
const img = document.getElementById('preview-image');
|
var img = document.getElementById('preview-image');
|
||||||
const vid = document.getElementById('preview-video');
|
var vid = document.getElementById('preview-video');
|
||||||
const iframe = document.getElementById('preview-iframe');
|
var iframe = document.getElementById('preview-iframe');
|
||||||
|
|
||||||
img.style.display = 'none';
|
img.style.display = 'none';
|
||||||
vid.style.display = 'none';
|
vid.style.display = 'none';
|
||||||
iframe.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) {
|
if (isVideo) {
|
||||||
// Use Drive Preview Embed URL
|
// Drive Video -> Iframe
|
||||||
// Note: This assumes item.id corresponds to Drive File ID for drive items.
|
if (item.source !== 'shopify_only') {
|
||||||
// (Which is true for 'drive_only' and 'synced' items in MediaService)
|
iframe.src = "https://drive.google.com/file/d/" + item.id + "/preview";
|
||||||
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
|
|
||||||
vid.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
console.log("[MediaManager] Opening Drive Embed: " + item.filename + ", URL: " + previewUrl);
|
|
||||||
iframe.src = previewUrl;
|
|
||||||
iframe.style.display = 'block';
|
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 {
|
} else {
|
||||||
// Image
|
// Fallback if URL missing
|
||||||
img.src = item.thumbnail; // Thumbnail is base64 for Drive, URL for Shopify
|
console.warn("Missing contentUrl for Shopify video");
|
||||||
// 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.
|
} else {
|
||||||
|
img.src = item.thumbnail;
|
||||||
img.style.display = 'block';
|
img.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
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;
|
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-modal').style.display = 'none';
|
||||||
document.getElementById('preview-video').pause();
|
document.getElementById('preview-video').pause();
|
||||||
document.getElementById('preview-iframe').src = 'about:blank'; // Stop playback
|
document.getElementById('preview-iframe').src = 'about:blank';
|
||||||
}
|
};
|
||||||
|
|
||||||
// --- Details Modal ---
|
UI.prototype.showDetails = function () {
|
||||||
showDetails() {
|
var plan = state.calculateDiff();
|
||||||
const plan = state.calculateDiff();
|
var container = document.getElementById('details-content');
|
||||||
const container = document.getElementById('details-content');
|
|
||||||
|
|
||||||
if (plan.actions.length === 0) {
|
if (plan.actions.length === 0) {
|
||||||
container.innerHTML = '<div style="text-align:center; padding:20px;">No pending changes.</div>';
|
container.innerHTML = '<div style="text-align:center; padding:20px;">No pending changes.</div>';
|
||||||
} else {
|
} else {
|
||||||
const html = plan.actions.map((a, i) => {
|
var html = plan.actions.map(function (a, i) {
|
||||||
let icon = '•';
|
var icon = '•';
|
||||||
if (a.type === 'delete') icon = '🗑️';
|
if (a.type === 'delete') icon = '🗑️';
|
||||||
if (a.type === 'upload') icon = '📤';
|
if (a.type === 'upload') icon = '📤';
|
||||||
if (a.type === 'sync_upload') icon = '☁️';
|
if (a.type === 'sync_upload') icon = '☁️';
|
||||||
if (a.type === 'reorder') icon = '🔢';
|
if (a.type === 'reorder') icon = '🔢';
|
||||||
|
|
||||||
let label = "";
|
var label = "";
|
||||||
if (a.type === 'delete') label = `Delete <b>${a.name}</b>`;
|
if (a.type === 'delete') label = 'Delete <b>' + a.name + '</b>';
|
||||||
if (a.type === 'upload') label = `Upload New <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 === 'sync_upload') label = 'Sync Drive File <b>' + a.name + '</b>';
|
||||||
if (a.type === 'reorder') label = `Update Order`;
|
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('');
|
}).join('');
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('details-modal').style.display = 'flex';
|
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;
|
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';
|
document.getElementById('details-modal').style.display = 'none';
|
||||||
}
|
};
|
||||||
|
|
||||||
// Photos Session Methods
|
UI.prototype.showPhotoSession = function (url) {
|
||||||
showPhotoSession(url) {
|
var uiEl = document.getElementById('photos-session-ui');
|
||||||
const ui = document.getElementById('photos-session-ui');
|
var link = document.getElementById('photos-session-link');
|
||||||
const link = document.getElementById('photos-session-link');
|
var status = document.getElementById('photos-session-status');
|
||||||
const status = document.getElementById('photos-session-status');
|
|
||||||
|
|
||||||
ui.style.display = 'block';
|
uiEl.style.display = 'block';
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.style.display = 'block';
|
link.style.display = 'block';
|
||||||
status.innerText = "Waiting for selection...";
|
status.innerText = "Waiting for selection...";
|
||||||
}
|
};
|
||||||
|
|
||||||
closePhotoSession() {
|
UI.prototype.closePhotoSession = function () {
|
||||||
document.getElementById('photos-session-ui').style.display = 'none';
|
document.getElementById('photos-session-ui').style.display = 'none';
|
||||||
}
|
};
|
||||||
|
|
||||||
updatePhotoStatus(msg) {
|
UI.prototype.updatePhotoStatus = function (msg) {
|
||||||
document.getElementById('photos-session-status').innerText = msg;
|
document.getElementById('photos-session-status').innerText = msg;
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
const ui = new UI();
|
var ui = new UI();
|
||||||
|
window.ui = ui;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data Controller
|
* Data Controller
|
||||||
*/
|
*/
|
||||||
const controller = {
|
var controller = {
|
||||||
init() {
|
init() {
|
||||||
// Start polling for SKU selection
|
// Start polling for SKU selection
|
||||||
setInterval(() => this.checkSku(), 2000);
|
setInterval(() => this.checkSku(), 2000);
|
||||||
@ -780,27 +887,56 @@
|
|||||||
|
|
||||||
checkSku() {
|
checkSku() {
|
||||||
google.script.run
|
google.script.run
|
||||||
.withSuccessHandler(sku => {
|
.withSuccessHandler(info => {
|
||||||
|
// Info is now { sku, title } or null
|
||||||
|
const sku = info ? info.sku : null;
|
||||||
|
|
||||||
if (sku && sku !== state.sku) {
|
if (sku && sku !== state.sku) {
|
||||||
state.setSku(sku);
|
state.setSku(info); // Pass whole object
|
||||||
|
this.loadMedia();
|
||||||
|
} else if (!sku && !state.sku) {
|
||||||
|
// If we don't have a SKU and haven't shown error yet
|
||||||
|
if (document.getElementById('error-ui').style.display !== 'flex') {
|
||||||
this.loadMedia();
|
this.loadMedia();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.getSelectedSku();
|
.getSelectedProductInfo();
|
||||||
},
|
},
|
||||||
|
|
||||||
loadMedia(preserveLogs = false) {
|
loadMedia(preserveLogs = false) {
|
||||||
const sku = document.getElementById('current-sku').innerText;
|
// Resolve SKU/Title - prefer state, fallback to DOM
|
||||||
// Ensure Loading UI is visible and Main UI is hidden until ready
|
let sku = state.sku;
|
||||||
document.getElementById('loading-ui').style.display = 'block';
|
let title = state.title;
|
||||||
document.getElementById('main-ui').style.display = 'none';
|
|
||||||
|
|
||||||
// Reset State (this calls ui.updateSku which might show main-ui, so we re-toggle below if needed)
|
if (!sku) {
|
||||||
state.setSku(sku);
|
const domSku = document.getElementById('current-sku').innerText;
|
||||||
|
if (domSku && domSku !== '...') sku = domSku;
|
||||||
|
}
|
||||||
|
|
||||||
// Enforce Loading State Again (in case setSku reset it)
|
// CHECK FOR MISSING SKU
|
||||||
document.getElementById('loading-ui').style.display = 'block';
|
if (!sku || sku === '...') {
|
||||||
|
console.warn("No SKU found. Showing error.");
|
||||||
|
document.getElementById('loading-ui').style.display = 'none';
|
||||||
document.getElementById('main-ui').style.display = 'none';
|
document.getElementById('main-ui').style.display = 'none';
|
||||||
|
document.getElementById('error-ui').style.display = 'flex';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
const domTitle = document.getElementById('current-title').innerText;
|
||||||
|
if (domTitle && domTitle !== 'Loading...') title = domTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (!preserveLogs) {
|
||||||
document.getElementById('status-log-container').innerHTML = '';
|
document.getElementById('status-log-container').innerHTML = '';
|
||||||
@ -822,6 +958,9 @@
|
|||||||
ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error');
|
ui.logStatus('drive', `Drive Check Failed: ${diagnostics.drive.error}`, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture Token
|
||||||
|
if (diagnostics.token) state.token = diagnostics.token;
|
||||||
|
|
||||||
// Shopify Status
|
// Shopify Status
|
||||||
if (diagnostics.shopify.status === 'ok') {
|
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.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');
|
||||||
@ -980,7 +1119,7 @@
|
|||||||
|
|
||||||
// --- Google Picker API ---
|
// --- Google Picker API ---
|
||||||
let pickerApiLoaded = false;
|
let pickerApiLoaded = false;
|
||||||
function onApiLoad() { gapi.load('picker', () => { pickerApiLoaded = true; }); }
|
window.onApiLoad = function () { gapi.load('picker', () => { pickerApiLoaded = true; }); };
|
||||||
|
|
||||||
function createPicker(config) {
|
function createPicker(config) {
|
||||||
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
|
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
|
||||||
@ -1006,19 +1145,59 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
|
try {
|
||||||
|
if (!window.state || !window.ui || !window.controller) {
|
||||||
|
throw new Error("Core components failed to initialize. Check console for SyntaxError.");
|
||||||
|
}
|
||||||
controller.init();
|
controller.init();
|
||||||
|
window.controller = controller; // Re-assert global access
|
||||||
|
} catch (e) {
|
||||||
|
alert("Init Failed: " + e.message);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
// Drag & Drop Handlers for upload zone (Visual only)
|
// Drag & Drop Handlers (Global)
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropOverlay = document.getElementById('drop-overlay');
|
||||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
let dragCounter = 0;
|
||||||
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); });
|
|
||||||
dropZone.addEventListener('drop', (e) => {
|
// 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();
|
e.preventDefault();
|
||||||
dropZone.classList.remove('dragover');
|
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);
|
controller.handleFiles(e.dataTransfer.files);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
|
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
|
|||||||
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
|
||||||
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
|
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
|
||||||
import { installSalesSyncTrigger } from "./triggers"
|
import { installSalesSyncTrigger } from "./triggers"
|
||||||
import { showMediaManager, getSelectedSku, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
|
import { showMediaManager, getSelectedProductInfo, getMediaForSku, saveFileToDrive, saveMediaChanges, getMediaDiagnostics, getPickerConfig, importFromPicker, debugScopes, createPhotoSession, checkPhotoSession, debugFolderAccess } from "./mediaHandlers"
|
||||||
import { runSystemDiagnostics } from "./verificationSuite"
|
import { runSystemDiagnostics } from "./verificationSuite"
|
||||||
|
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
@ -52,7 +52,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
|
|||||||
;(global as any).reconcileSalesHandler = reconcileSalesHandler
|
;(global as any).reconcileSalesHandler = reconcileSalesHandler
|
||||||
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
|
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
|
||||||
;(global as any).showMediaManager = showMediaManager
|
;(global as any).showMediaManager = showMediaManager
|
||||||
;(global as any).getSelectedSku = getSelectedSku
|
;(global as any).getSelectedProductInfo = getSelectedProductInfo
|
||||||
;(global as any).getMediaForSku = getMediaForSku
|
;(global as any).getMediaForSku = getMediaForSku
|
||||||
;(global as any).saveFileToDrive = saveFileToDrive
|
;(global as any).saveFileToDrive = saveFileToDrive
|
||||||
;(global as any).saveMediaChanges = saveMediaChanges
|
;(global as any).saveMediaChanges = saveMediaChanges
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedSku, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
|
import { importFromPicker, getMediaForSku, createPhotoSession, checkPhotoSession, debugFolderAccess, showMediaManager, getSelectedProductInfo, getPickerConfig, saveFileToDrive, debugScopes, saveMediaChanges } from "./mediaHandlers"
|
||||||
import { Config } from "./config"
|
import { Config } from "./config"
|
||||||
import { GASDriveService } from "./services/GASDriveService"
|
import { GASDriveService } from "./services/GASDriveService"
|
||||||
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
|
||||||
@ -47,7 +47,11 @@ jest.mock("./services/GASSpreadsheetService", () => {
|
|||||||
return {
|
return {
|
||||||
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
GASSpreadsheetService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
getCellValueByColumnName: jest.fn().mockReturnValue("TEST-SKU")
|
getCellValueByColumnName: jest.fn().mockImplementation((sheet, row, col) => {
|
||||||
|
if (col === "sku") return "TEST-SKU"
|
||||||
|
if (col === "title") return "Test Product Title"
|
||||||
|
return null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -336,9 +340,9 @@ describe("mediaHandlers", () => {
|
|||||||
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
|
expect(mockUi.showModalDialog).toHaveBeenCalledWith(mockHtml, "Media Manager")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getSelectedSku should return sku from sheet", () => {
|
test("getSelectedProductInfo should return sku and title from sheet", () => {
|
||||||
const sku = getSelectedSku()
|
const info = getSelectedProductInfo()
|
||||||
expect(sku).toBe("TEST-SKU")
|
expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getPickerConfig should return config", () => {
|
test("getPickerConfig should return config", () => {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function showMediaManager() {
|
|||||||
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
|
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectedSku(): string | null {
|
export function getSelectedProductInfo(): { sku: string, title: string } | null {
|
||||||
const ss = new GASSpreadsheetService()
|
const ss = new GASSpreadsheetService()
|
||||||
const sheet = SpreadsheetApp.getActiveSheet()
|
const sheet = SpreadsheetApp.getActiveSheet()
|
||||||
if (sheet.getName() !== "product_inventory") return null
|
if (sheet.getName() !== "product_inventory") return null
|
||||||
@ -24,7 +24,9 @@ export function getSelectedSku(): string | null {
|
|||||||
if (row <= 1) return null // Header
|
if (row <= 1) return null // Header
|
||||||
|
|
||||||
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
|
const sku = ss.getCellValueByColumnName("product_inventory", row, "sku")
|
||||||
return sku ? String(sku) : null
|
const title = ss.getCellValueByColumnName("product_inventory", row, "title")
|
||||||
|
|
||||||
|
return sku ? { sku: String(sku), title: String(title || "") } : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPickerConfig() {
|
export function getPickerConfig() {
|
||||||
@ -105,7 +107,13 @@ export function getMediaDiagnostics(sku: string) {
|
|||||||
|
|
||||||
const shopifyId = product.shopify_id || ""
|
const shopifyId = product.shopify_id || ""
|
||||||
|
|
||||||
return mediaService.getDiagnostics(sku, shopifyId)
|
const diagnostics = mediaService.getDiagnostics(sku, shopifyId)
|
||||||
|
|
||||||
|
// Inject OAuth token for frontend video streaming (Drive API alt=media)
|
||||||
|
return {
|
||||||
|
...diagnostics,
|
||||||
|
token: ScriptApp.getOAuthToken()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
export function saveFileToDrive(sku: string, filename: string, mimeType: string, base64Data: string) {
|
||||||
|
|||||||
@ -139,6 +139,20 @@ export class MediaService {
|
|||||||
// Find Shopify Orphans
|
// Find Shopify Orphans
|
||||||
shopifyMedia.forEach(m => {
|
shopifyMedia.forEach(m => {
|
||||||
if (!matchedShopifyIds.has(m.id)) {
|
if (!matchedShopifyIds.has(m.id)) {
|
||||||
|
let mimeType = 'image/jpeg'; // Default
|
||||||
|
let contentUrl = "";
|
||||||
|
|
||||||
|
if (m.mediaContentType === 'VIDEO' && m.sources) {
|
||||||
|
// Find MP4
|
||||||
|
const mp4 = m.sources.find((s: any) => s.mimeType === 'video/mp4')
|
||||||
|
if (mp4) {
|
||||||
|
mimeType = mp4.mimeType
|
||||||
|
contentUrl = mp4.url
|
||||||
|
}
|
||||||
|
} else if (m.mediaContentType === 'IMAGE' && m.image) {
|
||||||
|
contentUrl = m.image.url
|
||||||
|
}
|
||||||
|
|
||||||
unifiedState.push({
|
unifiedState.push({
|
||||||
id: m.id, // Use Shopify ID keys for orphans
|
id: m.id, // Use Shopify ID keys for orphans
|
||||||
driveId: null,
|
driveId: null,
|
||||||
@ -148,7 +162,9 @@ export class MediaService {
|
|||||||
source: 'shopify_only',
|
source: 'shopify_only',
|
||||||
thumbnail: m.preview?.image?.originalSrc || "",
|
thumbnail: m.preview?.image?.originalSrc || "",
|
||||||
status: 'active',
|
status: 'active',
|
||||||
galleryOrder: 10000 // End of list
|
galleryOrder: 10000, // End of list
|
||||||
|
mimeType: mimeType,
|
||||||
|
contentUrl: contentUrl
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -78,6 +78,17 @@ export class ShopifyMediaService implements IShopifyMediaService {
|
|||||||
originalSrc
|
originalSrc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
... on Video {
|
||||||
|
sources {
|
||||||
|
url
|
||||||
|
mimeType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
... on MediaImage {
|
||||||
|
image {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user