- 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.
442 lines
16 KiB
HTML
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> |