Refactor Media Manager UI and Fix Infinite Loop

- **UI Refactor**:
  - Split Media Manager header into two distinct cards: 'Product Info' and 'Upload Options'.
  - 'Product Info' now displays the Product Title and SKU.
  - Renamed upload buttons to 'Google Drive', 'Google Photos', and 'Your Computer' for clarity.
  - Added global drag-and-drop support with overlay.
  - Replaced full-screen 'Connecting' overlay with an inline spinner for better UX and log visibility.

- **Backend**:
  - Renamed getSelectedSku to getSelectedProductInfo in mediaHandlers.ts to fetch and return both SKU and Title.
  - Updated global.ts exports and mediaHandlers.test.ts to support the new signature.

- **Fixes**:
  - Resolved an infinite loop issue in loadMedia caused by incorrect SKU state handling.
This commit is contained in:
Ben Miller
2025-12-28 16:34:02 -07:00
parent d9d884e1fc
commit c738ab3ef7
4 changed files with 102 additions and 49 deletions

View File

@ -302,30 +302,40 @@
<body>
<div id="main-ui" style="display:none">
<!-- Header Card -->
<!-- Product Info Card -->
<div class="card">
<div class="header">
<h2>Media Manager</h2>
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div>
<div id="current-title" style="font-weight:600; font-size:16px; margin-bottom:4px; color:var(--text);">Loading...
</div>
<span id="current-sku" class="sku-badge">...</span>
</div>
<!-- Optional: Could put metadata here or small status -->
</div>
</div>
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div style="font-size: 32px; margin-bottom: 8px;">☁️</div>
<div style="font-size: 14px; font-weight: 500;">Drop files or click to upload</div>
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">
Direct to Drive • JPG, PNG, MP4
<!-- 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>
<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 -->
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
@ -411,6 +421,12 @@
</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
@ -422,11 +438,12 @@
this.initialState = []; // For diffing "isDirty"
}
setSku(sku) {
this.sku = sku;
setSku(info) {
this.sku = info ? info.sku : null;
this.title = info ? info.title : "";
this.items = [];
this.initialState = [];
ui.updateSku(sku);
ui.updateSku(this.sku, this.title);
}
setItems(items) {
@ -562,8 +579,9 @@
this.toggleLogBtn.innerText = isVisible ? "View Log" : "Hide Log";
}
updateSku(sku) {
document.getElementById('current-sku').innerText = sku;
updateSku(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';
}
@ -791,23 +809,30 @@
checkSku() {
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) {
state.setSku(sku);
state.setSku(info); // Pass whole object
this.loadMedia();
}
})
.getSelectedSku();
.getSelectedProductInfo();
},
loadMedia(preserveLogs = false) {
const sku = document.getElementById('current-sku').innerText;
// Ensure Loading UI is visible and Main UI is hidden until ready
document.getElementById('loading-ui').style.display = 'block';
document.getElementById('main-ui').style.display = 'none';
// Resolve SKU/Title - prefer state, fallback to DOM
let sku = state.sku;
let title = state.title;
// Reset State (this calls ui.updateSku which might show main-ui, so we re-toggle below if needed)
state.setSku(sku);
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';
@ -816,6 +841,9 @@
// 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 = '';
}
@ -1022,14 +1050,33 @@
// Init
controller.init();
// Drag & Drop Handlers for upload zone (Visual only)
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); });
dropZone.addEventListener('drop', (e) => {
// Drag & Drop Handlers (Global)
const dropOverlay = document.getElementById('drop-overlay');
let dragCounter = 0;
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
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>

View File

@ -23,7 +23,7 @@ import { fillProductFromTemplate } from "./fillProductFromTemplate"
import { showSidebar, getQueueStatus, setQueueEnabled, deleteEdit, pushEdit } from "./sidebar"
import { checkRecentSales, reconcileSalesHandler } from "./salesSync"
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"
// prettier-ignore
@ -52,7 +52,7 @@ import { runSystemDiagnostics } from "./verificationSuite"
;(global as any).reconcileSalesHandler = reconcileSalesHandler
;(global as any).installSalesSyncTrigger = installSalesSyncTrigger
;(global as any).showMediaManager = showMediaManager
;(global as any).getSelectedSku = getSelectedSku
;(global as any).getSelectedProductInfo = getSelectedProductInfo
;(global as any).getMediaForSku = getMediaForSku
;(global as any).saveFileToDrive = saveFileToDrive
;(global as any).saveMediaChanges = saveMediaChanges

View File

@ -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 { GASDriveService } from "./services/GASDriveService"
import { GASSpreadsheetService } from "./services/GASSpreadsheetService"
@ -47,7 +47,11 @@ jest.mock("./services/GASSpreadsheetService", () => {
return {
GASSpreadsheetService: jest.fn().mockImplementation(() => {
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")
})
test("getSelectedSku should return sku from sheet", () => {
const sku = getSelectedSku()
expect(sku).toBe("TEST-SKU")
test("getSelectedProductInfo should return sku and title from sheet", () => {
const info = getSelectedProductInfo()
expect(info).toEqual({ sku: "TEST-SKU", title: "Test Product Title" })
})
test("getPickerConfig should return config", () => {

View File

@ -15,7 +15,7 @@ export function showMediaManager() {
SpreadsheetApp.getUi().showModalDialog(html, "Media Manager");
}
export function getSelectedSku(): string | null {
export function getSelectedProductInfo(): { sku: string, title: string } | null {
const ss = new GASSpreadsheetService()
const sheet = SpreadsheetApp.getActiveSheet()
if (sheet.getName() !== "product_inventory") return null
@ -24,7 +24,9 @@ export function getSelectedSku(): string | null {
if (row <= 1) return null // Header
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() {