Refine Photo Picker Session UI and Logic

- Unified transfer session UI layout with instructions at the top and footer controls.
- Implemented side-by-side 'Re-open Popup' and 'Cancel' buttons with proper state management (disabled/enabled).
- Added dynamic service context to instructions (e.g., 'Importing from Google Photos').
- Refactored UI class to handle new DOM structure and button logic.
- Updated controller to support new UI interactions and improved cancellation flow.
This commit is contained in:
Ben Miller
2025-12-31 08:18:51 -07:00
parent d34f9a1417
commit 55a89a0802

View File

@ -439,14 +439,44 @@
width: 6px; width: 6px;
} }
.log-content::-webkit-scrollbar-track {
background: transparent;
}
.log-content::-webkit-scrollbar-thumb { .log-content::-webkit-scrollbar-thumb {
background: #cbd5e1; background: #cbd5e1;
border-radius: 3px; border-radius: 3px;
} }
/* Transfer Session UI */
.transfer-session {
margin-top: 12px;
padding: 16px;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 8px;
}
.progress-track {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.3s ease;
}
.transfer-footer {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f1f5f9;
}
</style> </style>
</head> </head>
@ -530,21 +560,36 @@
</button> </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)">
<!-- Unified Transfer Session UI -->
<div id="transfer-session-ui" class="transfer-session" style="display:none;">
<!-- Instructions (Top) -->
<div id="transfer-desc" style="font-size:13px; color:var(--text); margin-bottom:12px; line-height:1.4;">
<!-- Dynamic Helper Text -->
</div> </div>
<!-- Photos Session UI --> <!-- Progress Section (Middle) -->
<div id="photos-session-ui" <div id="transfer-progress-container">
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;"> <div
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;"> style="display:flex; justify-content:space-between; font-size:11px; color:var(--text-secondary); margin-bottom: 4px;">
<span style="font-weight:600; font-size:12px; color:#0369a1;">Photo Picker Session</span> <span id="transfer-status-text">Processing...</span>
<button onclick="ui.closePhotoSession()" <span id="transfer-count"></span>
style="background:none; border:none; color:#0369a1; cursor:pointer; font-size:16px;">×</button> </div>
<div class="progress-track">
<div id="transfer-progress-bar" class="progress-fill"></div>
</div>
</div>
<!-- Footer Buttons (Bottom) -->
<div class="transfer-footer">
<button id="btn-transfer-reopen" class="btn btn-secondary" style="font-size:12px;" disabled>
Re-open Popup ↗
</button>
<button id="btn-transfer-cancel" onclick="controller.cancelTransfer()" class="btn btn-secondary"
style="font-size:12px;">
Cancel
</button>
</div>
</div> </div>
<a id="photos-session-link" href="#" target="_blank" class="btn"
style="background:#0ea5e9; text-decoration:none; margin-bottom:8px;">
Open Google Photos ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b; text-align:center;">Initializing...</div>
</div> </div>
</div> </div>
<!-- Permanent Log Card --> <!-- Permanent Log Card -->
@ -820,42 +865,81 @@
this.saveBtn.innerText = enable ? "Save Changes" : "No Changes"; this.saveBtn.innerText = enable ? "Save Changes" : "No Changes";
}; };
UI.prototype.showPhotoSession = function (url) { UI.prototype.showTransferSession = function (mode, serviceName) {
var uiEl = document.getElementById('photos-session-ui'); var el = document.getElementById('transfer-session-ui');
var link = document.getElementById('photos-session-link'); var desc = document.getElementById('transfer-desc');
var status = document.getElementById('photos-session-status'); var statusText = document.getElementById('transfer-status-text');
var bar = document.getElementById('transfer-progress-bar');
var btnReopen = document.getElementById('btn-transfer-reopen');
var btnCancel = document.getElementById('btn-transfer-cancel');
el.style.display = 'block';
bar.style.width = '0%';
statusText.innerText = 'Initializing...';
document.getElementById('transfer-count').innerText = '';
// Reset Buttons
btnCancel.disabled = false;
btnReopen.disabled = true; // Default
if (mode === 'waiting') {
desc.innerText = "Select items from " + (serviceName || "the service") + " in the popup window. Click 'Done' when finished.";
statusText.innerText = "Waiting for selection...";
} else {
desc.innerText = "Importing media from " + (serviceName || "source") + "...";
}
};
UI.prototype.updateTransferProgress = function (current, total, statusMsg) {
var bar = document.getElementById('transfer-progress-bar');
var statusText = document.getElementById('transfer-status-text');
var countText = document.getElementById('transfer-count');
// Disable Reopen once transfer starts
const btnReopen = document.getElementById('btn-transfer-reopen');
if (btnReopen) btnReopen.disabled = true;
if (total > 0) {
var pct = Math.round((current / total) * 100);
bar.style.width = pct + '%';
countText.innerText = current + ' / ' + total;
} else {
// Indeterminate
bar.style.width = '100%';
countText.innerText = '';
}
if (statusMsg) statusText.innerText = statusMsg;
// If done, disable cancel
if (current === total && total > 0) {
const btnCancel = document.getElementById('btn-transfer-cancel');
if (btnCancel) btnCancel.disabled = true;
}
};
UI.prototype.hideTransferSession = function () {
document.getElementById('transfer-session-ui').style.display = 'none';
};
UI.prototype.setupReopenButton = function (url) {
var btn = document.getElementById('btn-transfer-reopen');
if (!url) {
btn.disabled = true;
return;
}
uiEl.style.display = 'block';
link.href = url;
// We also open it automatically in a popup
const width = 1200; const width = 1200;
const height = 800; const height = 800;
const left = (screen.width - width) / 2; const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2; const top = (screen.height - height) / 2;
const params = `width=${width},height=${height},top=${top},left=${left}`;
// Attempt popup btn.onclick = function (e) {
const popup = window.open(url, 'googlePhotos', `width=${width},height=${height},top=${top},left=${left}`);
if (popup) {
link.innerText = "Re-open Popup ↗";
link.onclick = function (e) {
e.preventDefault(); e.preventDefault();
window.open(url, 'googlePhotos', `width=${width},height=${height},top=${top},left=${left}`); window.open(url, 'googlePhotos', params);
}
} else {
link.innerText = "Open Google Photos ↗";
link.onclick = null; // Default href behavior
}
status.innerText = "Waiting for selection in popup...";
}; };
btn.disabled = false;
UI.prototype.closePhotoSession = function () {
document.getElementById('photos-session-ui').style.display = 'none';
};
UI.prototype.updatePhotoStatus = function (msg) {
document.getElementById('photos-session-status').innerText = msg;
}; };
UI.prototype.render = function (items) { UI.prototype.render = function (items) {
@ -1268,11 +1352,6 @@
// Start Polling // Start Polling
this.startLogPolling(jobId); this.startLogPolling(jobId);
// Expand Log Card
if (!ui.logCard.classList.contains('expanded')) {
ui.toggleLogExpand();
}
// Filter out deleted items // Filter out deleted items
const activeItems = state.items.filter(i => !i._deleted); const activeItems = state.items.filter(i => !i._deleted);
@ -1336,21 +1415,42 @@
handleFiles(fileList) { handleFiles(fileList) {
if (!fileList || fileList.length === 0) return; if (!fileList || fileList.length === 0) return;
this.setPickerState(true); this.setPickerState(true);
Array.from(fileList).forEach(file => { this.initTransferState();
ui.showTransferSession('transferring', 'Your Computer');
const total = fileList.length;
let done = 0;
Array.from(fileList).forEach((file, i) => {
if (this.transferState.shouldCancel) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
if (this.transferState.shouldCancel) return;
const data = e.target.result.split(',')[1]; // Base64 const data = e.target.result.split(',')[1]; // Base64
ui.updateTransferProgress(done, total, `Uploading ${file.name}...`);
google.script.run google.script.run
.withSuccessHandler(() => { .withSuccessHandler(() => {
this.loadMedia(); if (this.transferState.shouldCancel) return;
this.setPickerState(false); done++;
ui.updateTransferProgress(done, total);
if (done === total) {
ui.updateTransferProgress(total, total, "Done!");
this.finishTransfer();
}
}) })
.withFailureHandler(err => { .withFailureHandler(err => {
console.error(err); console.error(err);
alert("Upload failed: " + err.message); // Allow continuing even if one fails
this.setPickerState(false); done++;
ui.updateTransferProgress(done, total, "Error on " + file.name);
if (done === total) this.finishTransfer();
}) })
.saveFileToDrive(state.sku, file.name, file.type, data); .saveFileToDrive(state.sku, file.name, file.type, data);
}; };
@ -1375,6 +1475,8 @@
openPicker() { openPicker() {
if (!pickerApiLoaded) return alert("API Loading..."); if (!pickerApiLoaded) return alert("API Loading...");
this.setPickerState(true); this.setPickerState(true);
// We don't show the full session UI for Drive picker config loading,
// just disable buttons. The picker is its own UI.
google.script.run google.script.run
.withSuccessHandler(c => createPicker(c)) .withSuccessHandler(c => createPicker(c))
.withFailureHandler(e => { .withFailureHandler(e => {
@ -1385,53 +1487,119 @@
}, },
importFromPicker(fileId, mime, name, url) { importFromPicker(fileId, mime, name, url) {
// Drive Picker Result
this.initTransferState();
ui.showTransferSession('transferring', 'Google Drive');
// Single Item, so 0/1 -> 1/1
ui.updateTransferProgress(0, 1, "Importing " + name + "...");
google.script.run google.script.run
.withSuccessHandler(() => { .withSuccessHandler(() => {
this.loadMedia(); if (this.transferState.shouldCancel) return;
this.setPickerState(false); ui.updateTransferProgress(1, 1, "Done!");
this.finishTransfer();
}) })
.withFailureHandler(e => { .withFailureHandler(e => {
alert("Import failed: " + e.message); alert("Import failed: " + e.message);
this.setPickerState(false); this.finishTransfer(); // Cleanup
}) })
.importFromPicker(state.sku, fileId, mime, name, url); .importFromPicker(state.sku, fileId, mime, name, url);
}, },
// --- Transfer State & Logic ---
transferState: {
isTransferring: false,
shouldCancel: false,
pollingId: null
},
initTransferState() {
this.transferState = { isTransferring: true, shouldCancel: false, pollingId: null };
},
cancelTransfer() {
this.transferState.shouldCancel = true;
this.transferState.isTransferring = false;
if (this.transferState.pollingId) {
clearTimeout(this.transferState.pollingId); // Note: using setTimeout in pollPhotoSession
this.transferState.pollingId = null;
}
ui.hideTransferSession();
this.setPickerState(false);
ui.logStatus('info', 'Transfer cancelled by user.', 'info');
},
finishTransfer() {
if (this.transferState.shouldCancel) return; // Already cleaned up
this.transferState.isTransferring = false;
this.loadMedia();
this.setPickerState(false);
setTimeout(() => ui.hideTransferSession(), 2000);
},
startPhotoSession() { startPhotoSession() {
this.setPickerState(true); this.setPickerState(true);
ui.updatePhotoStatus("Starting session..."); this.initTransferState();
ui.showTransferSession('waiting', 'Google Photos');
google.script.run google.script.run
.withSuccessHandler(session => { .withSuccessHandler(session => {
ui.showPhotoSession(session.pickerUri); if (this.transferState.shouldCancel) return; // Cancelled during init
// Setup popup logic
const width = 1200, height = 800;
const left = (screen.width - width) / 2;
const top = (screen.height - height) / 2;
const params = `width=${width},height=${height},top=${top},left=${left}`;
// Auto open
window.open(session.pickerUri, 'googlePhotos', params);
// Setup reopen link
ui.setupReopenButton(session.pickerUri);
this.pollPhotoSession(session.id); this.pollPhotoSession(session.id);
}) })
.withFailureHandler(e => { .withFailureHandler(e => {
ui.updatePhotoStatus("Failed: " + e.message); alert("Failed to start session: " + e.message);
this.setPickerState(false); this.setPickerState(false);
ui.hideTransferSession();
}) })
.createPhotoSession(); .createPhotoSession();
}, },
pollPhotoSession(sessionId) { pollPhotoSession(sessionId) {
let processing = false; if (this.transferState.shouldCancel) return;
const check = () => { const check = () => {
if (processing) return; if (this.transferState.shouldCancel) return;
google.script.run google.script.run
.withSuccessHandler(res => { .withSuccessHandler(res => {
if (this.transferState.shouldCancel) return;
if (res.status === 'complete') { if (res.status === 'complete') {
processing = true; // Transition to transferring
ui.updatePhotoStatus("Importing photos..."); ui.updateTransferProgress(0, 0, "Importing photos...");
this.processPhotoItems(res.mediaItems); this.processPhotoItems(res.mediaItems);
} else if (res.status === 'error') { } else if (res.status === 'error') {
ui.updatePhotoStatus("Error: " + res.message); alert("Photo Picker Error: " + res.message);
this.setPickerState(false); this.cancelTransfer();
} else { } else {
setTimeout(check, 2000); // Keep polling
this.transferState.pollingId = setTimeout(check, 2000);
} }
}) })
.withFailureHandler(e => { .withFailureHandler(e => {
ui.updatePhotoStatus("Error polling: " + e.message); console.error(e);
this.setPickerState(false); // Retry? Or fail. Let's fail after error.
alert("Polling Error: " + e.message);
this.cancelTransfer();
}) })
.checkPhotoSession(sessionId); .checkPhotoSession(sessionId);
}; };
@ -1440,41 +1608,37 @@
processPhotoItems(items) { processPhotoItems(items) {
let done = 0; let done = 0;
const total = items.length;
ui.updateTransferProgress(0, total, `Found ${total} items. Starting import...`);
// Process sequentially or parallel? Parallel is fine for GAS calls usually,
// but let's count them accurately.
items.forEach(item => { items.forEach(item => {
console.log("[MediaManager] Processing Item:", JSON.stringify(item)); if (this.transferState.shouldCancel) return;
// The API returns nested 'mediaFile' object for actual file details
const mediaFile = item.mediaFile || item; const mediaFile = item.mediaFile || item;
const url = mediaFile.baseUrl || item.baseUrl; const url = mediaFile.baseUrl || item.baseUrl;
const filename = mediaFile.filename || item.filename; const filename = mediaFile.filename || item.filename;
let mimeType = mediaFile.mimeType || item.mimeType; let mimeType = mediaFile.mimeType || item.mimeType;
console.log(`[MediaManager] Extracted: URL=${url ? 'Yes' : 'No'}, Mime=${mimeType}, Name=${filename}`);
// Force video mimeType if metadata indicates video (Critical for backend =dv param)
if (item.mediaMetadata && item.mediaMetadata.video) { if (item.mediaMetadata && item.mediaMetadata.video) {
console.log("[MediaManager] Metadata indicates VIDEO. Forcing video/mp4.");
mimeType = 'video/mp4'; mimeType = 'video/mp4';
} }
google.script.run google.script.run
.withSuccessHandler(() => { .withSuccessHandler(() => {
if (this.transferState.shouldCancel) return;
done++; done++;
if (done === items.length) { ui.updateTransferProgress(done, total);
ui.updatePhotoStatus("Done!"); if (done === total) this.finishTransfer();
controller.loadMedia();
this.setPickerState(false);
setTimeout(() => ui.closePhotoSession(), 2000);
}
}) })
.withFailureHandler(e => { .withFailureHandler(e => {
console.error("Import failed", e); console.error(e);
// If last one
done++; done++;
if (done === items.length) { ui.updateTransferProgress(done, total, "Error importing item");
this.setPickerState(false); if (done === total) this.finishTransfer();
}
}) })
.importFromPicker(state.sku, null, mimeType, filename, url); .importFromPicker(state.sku, null, mimeType, filename, url);
}); });