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:
341
src/MediaSidebar.html
Normal file
341
src/MediaSidebar.html
Normal 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>
|
||||
Reference in New Issue
Block a user