Files
product_inventory/src/MediaSidebar.html
Ben Miller 50ddfc9e15 Feature: Robust Google Photos Integration & Media Hardening
- Implemented Google Photos Picker with Session API.
- Fixed 403 Forbidden errors by adding OAuth headers to download requests.
- Implemented MediaHandler resilience:
  - 3-Step Import (Save to Root -> Verify Folder -> Move).
  - Advanced Drive API Fallback (v3/v2) for file creation.
  - Blob Sanitization (Utilities.newBlob) to fix server errors.
- Enabled Advanced Drive Service in ppsscript.json.
- Updated Documentation (MEMORY.md, ARCHITECTURE.md) with findings.
2025-12-26 01:51:04 -07:00

442 lines
16 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--primary: #2563eb;
--surface: #ffffff;
--background: #f8fafc;
--text: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
--danger: #ef4444;
}
body {
font-family: 'Inter', sans-serif;
margin: 0;
padding: 16px;
background-color: var(--background);
color: var(--text);
}
.card {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.sku-badge {
background: #dbeafe;
color: #1e40af;
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.upload-zone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: transparent;
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--primary);
background: rgba(37, 99, 235, 0.05);
}
.media-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
}
.media-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.media-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.btn {
background-color: var(--primary);
color: white;
border: none;
padding: 10px 16px;
border-radius: 8px;
width: 100%;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background-color: #1d4ed8;
}
.btn-secondary {
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-left-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.empty-state {
text-align: center;
color: var(--text-secondary);
font-size: 13px;
padding: 20px 0;
}
</style>
</head>
<body>
<div id="main-ui" style="display:none">
<div class="card">
<div class="header">
<h2>Media Manager</h2>
<span id="current-sku" class="sku-badge">...</span>
</div>
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div style="font-size: 24px; margin-bottom: 8px;">☁️</div>
<div style="font-size: 13px; color: var(--text-secondary);">
Drop files or click to upload<br>
<span style="font-size: 11px; opacity: 0.7">(Goes to Drive first)</span>
</div>
<input type="file" id="file-input" multiple style="display:none" onchange="handleFiles(this.files)">
</div>
<button onclick="openPicker()" class="btn btn-secondary" style="margin-top: 8px; font-size: 13px;">
Import from Google Drive
</button>
<button onclick="startPhotoSession()" class="btn btn-secondary" style="margin-top: 4px; font-size: 13px;">
Import from Google Photos
</button>
<div id="photos-session-ui"
style="display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;">
<div style="font-weight:500; font-size:13px; margin-bottom:4px;">Pick Photos</div>
<a id="photos-session-link" href="#" target="_blank"
style="font-size:13px; color:#0284c7; text-decoration:none; display:block; margin-bottom:8px;">
Active Session (Click to Open) ↗
</a>
<div id="photos-session-status" style="font-size:11px; color:#64748b;">Waiting for selection...</div>
</div>
</div>
<div class="card">
<div class="header">
<h2>Current Media</h2>
<button onclick="loadMedia()" style="background:none; border:none; cursor:pointer; font-size:16px;"></button>
</div>
<div id="media-grid" class="media-grid">
<!-- Items injected here -->
</div>
</div>
<button onclick="triggerSync()" class="btn">Sync to Shopify</button>
</div>
<div id="loading-ui" style="text-align:center; padding-top: 50px;">
<div class="spinner"></div>
<div style="margin-top:12px; color: var(--text-secondary); font-size: 13px;">Scanning Sheet...</div>
</div>
<script type="text/javascript">
let currentSku = "";
let pollInterval;
// Picker Globals
let pickerApiLoaded = false;
let pickerConfig = null;
function onApiLoad() {
gapi.load('picker', () => {
pickerApiLoaded = true;
});
}
function init() {
pollInterval = setInterval(checkSelection, 2000);
checkSelection();
}
function checkSelection() {
google.script.run
.withSuccessHandler(onSelectionCheck)
.withFailureHandler(console.error)
.getSelectedSku();
}
function onSelectionCheck(sku) {
if (sku && sku !== currentSku) {
currentSku = sku;
updateUI(sku);
loadMedia(sku);
} else if (!sku) {
// Show "Select a SKU" state?
// For now, keep showing last or show loading
}
}
function updateUI(sku) {
document.getElementById('loading-ui').style.display = 'none';
document.getElementById('main-ui').style.display = 'block';
document.getElementById('current-sku').innerText = sku;
}
function loadMedia(sku) {
if (!sku) sku = currentSku;
const grid = document.getElementById('media-grid');
grid.innerHTML = '<div style="grid-column: span 2; text-align:center; padding: 20px;"><div class="spinner"></div></div>';
google.script.run
.withSuccessHandler(renderMedia)
.getMediaForSku(sku);
}
function renderMedia(files) {
const grid = document.getElementById('media-grid');
grid.innerHTML = '';
if (!files || files.length === 0) {
grid.innerHTML = '<div class="empty-state" style="grid-column: span 2">No media in Drive folder</div>';
return;
}
files.forEach(f => {
const div = document.createElement('div');
div.className = 'media-item';
div.innerHTML = `<img src="${f.thumbnailLink}" title="${f.name}">`;
grid.appendChild(div);
});
}
function handleFiles(files) {
const file = files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
const data = e.target.result.split(',')[1];
google.script.run
.withSuccessHandler(() => loadMedia(currentSku))
.saveFileToDrive(currentSku, file.name, file.type, data);
};
reader.readAsDataURL(file);
}
function triggerSync() {
if (!currentSku) return;
google.script.run
.withSuccessHandler(() => alert('Sync Complete'))
.withFailureHandler(e => alert('Failed: ' + e.message))
.syncMediaForSku(currentSku);
}
// --- Picker Logic ---
function openPicker() {
if (!pickerApiLoaded) {
alert("Google Picker API not loaded yet. Please wait.");
return;
}
if (pickerConfig) {
createPicker(pickerConfig);
} else {
google.script.run
.withSuccessHandler((config) => {
pickerConfig = config;
createPicker(config);
})
.withFailureHandler(e => alert('Failed to load picker config: ' + e.message))
.getPickerConfig();
}
}
function createPicker(config) {
if (!config.apiKey) {
alert("Google Picker API Key missing. Please check config.");
return;
}
const view = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setMimeTypes("image/png,image/jpeg,image/jpg,video/mp4")
.setIncludeFolders(true)
.setSelectFolderEnabled(false);
const picker = new google.picker.PickerBuilder()
.addView(view)
.setOAuthToken(config.token)
.setDeveloperKey(config.apiKey)
.setOrigin(google.script.host.origin)
.setCallback(pickerCallback)
.build();
picker.setVisible(true);
}
function pickerCallback(data) {
if (data.action == google.picker.Action.PICKED) {
const doc = data.docs[0];
const fileId = doc.id;
const mimeType = doc.mimeType;
const name = doc.name;
const url = doc.url; // Often the link to the file in Drive/Photos
// For Photos, we might need the direct image URL, which is often in thumbnails or requires specific handling
// doc.thumbnails contains 's75-c' style URLs. We can strip the size to get full size?
// Actually, for Photos API items, 'url' might be the user-facing URL.
// Let's pass the 'thumbnails' closest to original if possible, or just pass the whole doc object to backend?
// Simpler: pass specific fields.
const imageUrl = (doc.thumbnails && doc.thumbnails.length > 0) ? doc.thumbnails[doc.thumbnails.length - 1].url : null;
google.script.run
.withSuccessHandler(() => loadMedia(currentSku))
.importFromPicker(currentSku, fileId, mimeType, name, imageUrl);
}
}
// --- Photos Session Logic (New API) ---
let pollingTimer = null;
function startPhotoSession() {
// Reset UI
document.getElementById('photos-session-ui').style.display = 'block';
document.getElementById('photos-session-status').innerText = "Creating session...";
document.getElementById('photos-session-link').style.display = 'none';
google.script.run
.withSuccessHandler(onSessionCreated)
.withFailureHandler(e => {
alert('Failed to start session: ' + e.message);
document.getElementById('photos-session-ui').style.display = 'none';
})
.createPhotoSession();
}
function onSessionCreated(session) {
if (!session || !session.pickerUri) {
alert("Failed to get picker URI");
return;
}
const link = document.getElementById('photos-session-link');
link.href = session.pickerUri;
link.style.display = 'block';
link.innerText = "Click here to pick photos ↗";
document.getElementById('photos-session-status').innerText = "Waiting for you to pick photos...";
// Open automatically? Browsers block it. User must click.
// Start polling
if (pollingTimer) clearInterval(pollingTimer);
pollingTimer = setInterval(() => pollSession(session.id), 2000); // Poll every 2s
}
function pollSession(sessionId) {
google.script.run
.withSuccessHandler(result => {
console.log("Poll result:", result);
if (result.status === 'complete') {
clearInterval(pollingTimer);
document.getElementById('photos-session-status').innerText = "Importing photos...";
processPickedPhotos(result.mediaItems);
} else if (result.status === 'error') {
document.getElementById('photos-session-status').innerText = "Error: " + result.message;
}
})
.checkPhotoSession(sessionId);
}
function processPickedPhotos(items) {
// Reuse importFromPicker logic logic?
// We can call importFromPicker for each item.
let processed = 0;
items.forEach(item => {
// console.log("Processing item:", item);
// The new Picker API returns baseUrl nested inside mediaFile
const imageUrl = (item.mediaFile && item.mediaFile.baseUrl) ? item.mediaFile.baseUrl : item.baseUrl;
google.script.run
.withSuccessHandler(() => {
processed++;
if (processed === items.length) {
document.getElementById('photos-session-status').innerText = "Done!";
loadMedia(currentSku);
setTimeout(() => {
document.getElementById('photos-session-ui').style.display = 'none';
}, 3000);
}
})
.importFromPicker(currentSku, null, item.mimeType, item.filename, imageUrl);
});
}
// Start
init();
</script>
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
</body>
</html>