feat(media): implement integrated media manager with sidebar and picker

- Implement DriveService and ShopifyMediaService for backend operations
- Create MediaSidebar.html with premium UI and auto-polling
- Integrate Google Picker API for robust file selection
- Orchestrate sync logic via MediaService (Drive -> Staged Upload -> Shopify)
- Add secure config handling for API keys and tokens
- Update ppsscript.json with required OAuth scopes
- Update MEMORY.md and README.md with architecture details
This commit is contained in:
Ben Miller
2025-12-25 15:10:17 -07:00
parent 2417359595
commit 95094b1674
21 changed files with 973 additions and 16 deletions

341
src/MediaSidebar.html Normal file
View File

@ -0,0 +1,341 @@
<!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 / Photos
</button>
</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 photosView = new google.picker.PhotosView();
const picker = new google.picker.PickerBuilder()
.addView(view)
.addView(photosView)
.setOAuthToken(config.token)
.setDeveloperKey(config.apiKey)
.setCallback(pickerCallback)
.build();
picker.setVisible(true);
}
function pickerCallback(data) {
if (data.action == google.picker.Action.PICKED) {
const fileId = data.docs[0].id;
const mimeType = data.docs[0].mimeType;
google.script.run
.withSuccessHandler(() => loadMedia(currentSku))
.importFromPicker(currentSku, fileId, mimeType);
}
}
// Start
init();
</script>
<script async defer src="https://apis.google.com/js/api.js" onload="onApiLoad()"></script>
</body>
</html>