@ -86,7 +86,8 @@
grid-template-columns : repeat ( auto - fill , minmax ( 140 px , 1 fr ) ) ;
gap : 12 px ;
margin-top : 16 px ;
min-height : 100 px ; /* Drop target area */
min-height : 100 px ;
/* Drop target area */
}
. media-item {
@ -127,7 +128,8 @@
width : 100 % ;
height : 100 % ;
object-fit : contain ;
background : #f8fafc ; /* Placeholder bg */
background : #f8fafc ;
/* Placeholder bg */
padding : 4 px ;
box-sizing : border-box ;
}
@ -135,28 +137,52 @@
/* Overlays & Badges */
. media-overlay {
position : absolute ;
top : 0 ;
top : auto ;
left : 0 ;
right : 0 ;
bottom : 0 ;
background : rgba ( 0 , 0 , 0 , 0.4 ) ;
background : linear-gradient ( to top , rgba( 0 , 0 , 0 , 0.7 ) 0 % , transparent 100 % ) ;
opacity : 0 ;
transition : opacity 0.2 s ;
display : flex ;
align-items : center ;
align-items : flex-end ;
justify-content : center ;
gap : 8 px ;
padding : 40 px 10 px 10 px 10 px ;
pointer-events : none ;
}
. media-overlay . icon-btn {
pointer-events : auto ;
}
. media-item : hover . media-overlay {
opacity : 1 ;
}
. type-badge {
position : absolute ;
top : 6 px ;
left : 6 px ;
z-index : 10 ;
background : rgba ( 0 , 0 , 0 , 0.5 ) ;
color : white ;
border-radius : 4 px ;
padding : 4 px ;
font-size : 14 px ;
line-height : 1 ;
display : flex ;
align-items : center ;
justify-content : center ;
box-shadow : 0 1 px 2 px rgb ( 0 0 0 / 0.1 ) ;
}
. badge {
position : absolute ;
top : 6 px ;
right : 6 px ;
font-size : 10 px ; /* Text badge */
font-size : 10 px ;
/* Text badge */
z-index : 10 ;
padding : 2 px 6 px ;
border-radius : 4 px ;
@ -186,8 +212,13 @@
transform : scale ( 1.1 ) ;
}
. btn-delete { color : var ( - - danger ) ; }
. btn-view { color : var ( - - primary ) ; }
. btn-delete {
color : var ( - - danger ) ;
}
. btn-view {
color : var ( - - primary ) ;
}
/* Buttons */
. btn {
@ -243,7 +274,10 @@
/* Modal */
. modal-overlay {
position : fixed ;
top : 0 ; left : 0 ; right : 0 ; bottom : 0 ;
top : 0 ;
left : 0 ;
right : 0 ;
bottom : 0 ;
background : rgba ( 0 , 0 , 0 , 0.8 ) ;
z-index : 100 ;
display : none ;
@ -288,44 +322,73 @@
animation : spin 1 s linear infinite ;
}
@ keyframes spin { 0 % { transform : rotate ( 0 deg ) ; } 100 % { transform : rotate ( 360 deg ) ; } }
@ keyframes spin {
0 % {
transform : rotate ( 0 deg ) ;
}
100 % {
transform : rotate ( 360 deg ) ;
}
}
/* Session UI */
# photos-session-ui {
animation : slideDown 0.3 s ease-out ;
}
@ keyframes slideDown { from { opacity : 0 ; transform : translateY ( -10 px ) ; } to { opacity : 1 ; transform : translateY ( 0 ) ; } }
@ keyframes slideDown {
from {
opacity : 0 ;
transform : translateY ( -10 px ) ;
}
to {
opacity : 1 ;
transform : translateY ( 0 ) ;
}
}
< / style >
< / head >
< body >
< div id = "main-ui" style = "display:none" >
<!-- Header Card -->
<!-- Product Info Card -->
< div class = "card" >
< div class = "header " >
< h2 > Media Manager< / h2 >
< div style = "display:flex; justify-content:space-between; align-items:flex-start; " >
< div >
< div id = "current-title" style = "font-weight:600; font-size:16px; margin-bottom:4px; color:var(--text);" >
Loading...
< / div >
< span id = "current-sku" class = "sku-badge" > ...< / span >
< / div >
<!-- Optional: Could put metadata here or small status -->
< / div >
< / div >
< div class = "upload-zone" id = "drop-zone" onclick = "document.getElementById('file-input').click()" >
< div style = "font-size: 32px; margin-bottom: 8px;" > ☁️< / div >
< div style = "font-size: 14px; font-weight: 500;" > Drop files or click to upload< / div >
< div style = "font-size: 12 px; color: var(--text-secondary); margin-top: 4px;" >
Direct to Drive • JPG, PNG, MP4
<!-- Upload Options Card -- >
< div class = "card" >
< div class = "header" style = "margin-bottom: 12px;" >
< h3 style = "margin:0; font-size:14 px; color:var(--text);" > Add Photos/Videos from...< / h3 >
< / div >
< div style = "display: flex; gap: 8px; width: 100%;" >
< button onclick = "controller.openPicker()" class = "btn btn-secondary"
style = "flex: 1; font-size: 13px; white-space: nowrap;" >
Google Drive
< / button >
< button onclick = "controller.startPhotoSession()" class = "btn btn-secondary"
style = "flex: 1; font-size: 13px; white-space: nowrap;" >
Google Photos
< / button >
< button onclick = "document.getElementById('file-input').click()" class = "btn btn-secondary"
style = "flex: 1; font-size: 13px;" >
Your Computer
< / button >
< / div >
< input type = "file" id = "file-input" multiple style = "display:none" onchange = "controller.handleFiles(this.files)" >
< / div >
< div style = "display: flex; gap: 8px; margin-top: 12px;" >
< button onclick = "controller.openPicker()" class = "btn btn-secondary" style = "flex: 1; font-size: 12px;" >
📂 Drive Picker
< / button >
< button onclick = "controller.startPhotoSession()" class = "btn btn-secondary" style = "flex: 1; font-size: 12px;" >
📸 Google Photos
< / button >
< / div >
<!-- Photos Session UI -->
< div id = "photos-session-ui"
style = "display:none; margin-top:12px; padding:12px; background:#f0f9ff; border-radius:8px; border:1px solid #bae6fd;" >
@ -340,7 +403,6 @@
< / a >
< div id = "photos-session-status" style = "font-size:11px; color:#64748b; text-align:center;" > Initializing...< / div >
< / div >
< / div >
< div class = "card" style = "padding-bottom: 0;" >
< div class = "header" style = "margin-bottom:8px; display:flex; justify-content:space-between; align-items:center;" >
@ -384,6 +446,17 @@
< div style = "margin-top:16px; color: var(--text-secondary); font-weight: 500;" > Connecting...< / div >
< / div >
<!-- Error UI -->
< div id = "error-ui"
style = "display:none; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding-top: 80px;" >
< div style = "font-size: 48px; margin-bottom: 20px;" > ⚠️< / div >
< h3 style = "margin: 0 0 8px 0; color: var(--text);" > No SKU Found< / h3 >
< p style = "color: var(--text-secondary); max-width: 300px; margin-bottom: 24px; line-height: 1.5;" >
This row does not appear to have a valid SKU. Please ensure the product is set up correctly before managing media.
< / p >
< button onclick = "google.script.host.close()" class = "btn" style = "width: auto;" > Close< / button >
< / div >
<!-- Preview Modal -->
< div id = "preview-modal" class = "modal-overlay" onclick = "ui.closeModal(event)" >
< div class = "modal-content" >
@ -411,132 +484,137 @@
< / div >
< / div >
< div id = "drop-overlay"
style = "position: fixed; top:0; left:0; right:0; bottom:0; background: rgba(37, 99, 235, 0.9); z-index: 200; display: none; flex-direction: column; align-items: center; justify-content: center; color: white;" >
< div style = "font-size: 48px; margin-bottom: 16px;" > ☁️< / div >
< div style = "font-size: 24px; font-weight: 600;" > Drop files to Upload< / div >
< / div >
< script >
/**
* State Management
* State Management & Error Handling
*/
class MediaState {
constructor ( ) {
this . sku = null ;
this . items = [ ] ; // Current UI State
this . initialState = [ ] ; // For diffing "isDirty"
}
window . onerror = function ( msg , url , line ) {
alert ( "Script Error: " + msg + "\nLine: " + line ) ;
} ;
setSku ( sku ) {
this . sku = sku ;
// --- ES5 Refactor: MediaState ---
function MediaState ( ) {
this . sku = null ;
this . token = null ;
this . items = [ ] ;
this . initialState = [ ] ;
ui . updateSku ( sku ) ;
}
setItems ( items ) {
// items: { id, filename, thumbnail, status, source }
MediaState . prototype . setSku = function ( info ) {
this . sku = info ? info . sku : null ;
this . title = info ? info . title : "" ;
this . items = [ ] ;
this . initialState = [ ] ;
ui . updateSku ( this . sku , this . title ) ;
} ;
MediaState . prototype . setItems = function ( items ) {
this . items = items || [ ] ;
this . initialState = JSON . parse ( JSON . stringify ( this . items ) ) ; // Deep copy
this . initialState = JSON . parse ( JSON . stringify ( this . items ) ) ;
ui . render ( this . items ) ;
this . checkDirty ( ) ;
}
} ;
addItem ( item ) {
MediaState . prototype . addItem = function ( item ) {
this . items . push ( item ) ;
ui . render ( this . items ) ;
this . checkDirty ( ) ;
}
} ;
deleteItem ( index ) {
const item = this . items [ index ] ;
MediaState . prototype . deleteItem = function ( index ) {
var item = this . items [ index ] ;
if ( item . source === 'new' ) {
// Remove entirely if it's a new upload not yet synced
this . items . splice ( index , 1 ) ;
} else {
// Toggle soft delete for existing items
item . _deleted = ! item . _deleted ;
}
ui . render ( this . items ) ;
this . checkDirty ( ) ;
}
} ;
reorderItems ( newIndices ) {
// Handled by Sortable onEnd
}
MediaState . prototype . reorderItems = function ( newIndices ) {
// Handled by Sortable
} ;
// Check if current state differs from initial
checkDirty ( ) {
const plan = this . calculateDiff ( ) ;
const isDirty = plan . hasChanges ;
MediaState . prototype . checkDirty = function ( ) {
var plan = this . calculateDiff ( ) ;
var isDirty = plan . hasChanges ;
ui . toggleSave ( isDirty ) ;
return plan ;
}
} ;
calculateDiff ( ) {
const currentIds = new Set ( this . items . map ( i => i . id ) ) ;
const initialIds = new Set ( this . initialState . map ( i => i . id ) ) ;
MediaState . prototype . calculateDiff = function ( ) {
var currentIds = new Set ( this . items . map ( function ( i ) { return i . id ; } )) ;
var initialIds = new Set ( this . initialState . map ( function ( i ) { return i . id ; } )) ;
const actions = [ ] ;
var actions = [ ] ;
// 1. Deletions (Marked _deleted)
this . items . forEach ( i => {
if ( i . _deleted ) {
actions . push ( { type : 'delete' , name : i . filename || 'Item' } ) ;
}
this . items . forEach ( function ( i ) {
if ( i . _deleted ) actions . push ( { type : 'delete' , name : i . filename || 'Item' } ) ;
} ) ;
// 2. Additions
this . items . forEach ( i => {
if ( i . _deleted ) return ; // Skip deleted items
this . items . forEach ( function ( i ) {
if ( i . _deleted ) return ;
if ( ! initialIds . has ( i . id ) ) {
actions . push ( { type : 'upload' , name : i . filename || 'New Item' } ) ;
} else if ( i . status === 'drive_only' ) {
// Existing drive items to be synced
actions . push ( { type : 'sync_upload' , name : i . filename || 'Item' } ) ;
}
} ) ;
// 3. Reorders
const activeItems = this . items . filter ( i => ! i . _deleted ) ;
// Check order of common items
var activeItems = this . items . filter ( function ( i ) { return ! i . _deleted ; } );
// Filter initial state to only items that are still active
const initialCommon = this . initialState . filter ( i => activeItems . find ( c => c . id === i . id ) ) ;
const currentCommon = activeItems . filter ( i => initialIds . has ( i . id ) ) ;
var initialCommon = this . initialState . filter ( function ( i ) {
return activeItems . some ( function ( c ) { return c . id === i . id ; } ) ;
} ) ;
var currentCommon = activeItems . filter ( function ( i ) {
return initialIds . has ( i . id ) ;
} ) ;
let orderChanged = false ;
if ( initialCommon . length ! == currentCommon . length ) {
// Should match if we filtered correctly
} else {
for ( let k = 0 ; k < initialCommon . length ; k ++ ) {
var orderChanged = false ;
if ( initialCommon . length = == currentCommon . length ) {
for ( var k = 0 ; k < initialCommon . length ; k ++ ) {
if ( initialCommon [ k ] . id !== currentCommon [ k ] . id ) {
orderChanged = true ;
break ;
}
}
} else {
// If lengths differ despite logic, assume change or weird state
}
if ( orderChanged ) {
actions . push ( { type : 'reorder' , name : 'Reorder Gallery' } ) ;
}
const uniqueActions = actions . filter ( ( v , i , a ) => a . findIndex ( t => ( t . type === v . type && t . name === v . name ) ) === i ) ;
var uniqueActions = actions . filter ( function ( v , i , a ) {
return a . findIndex ( function ( t ) { return t . type === v . type && t . name === v . name ; } ) === i ;
} ) ;
return {
hasChanges : uniqueActions . length > 0 ,
actions : uniqueActions
} ;
}
} ;
hasNewItems ( ) {
return this . items . some ( i => ! i . _deleted && ( i . status === 'drive_only' || i . source === 'new' ) ) ;
}
}
MediaState . prototype . hasNewItems = function ( ) {
return this . items . some ( function ( i ) {
return ! i . _deleted && ( i . status === 'drive_only' || i . source === 'new' ) ;
} ) ;
} ;
const state = new MediaState ( ) ;
var state = new MediaState ( ) ;
window . state = state ;
/**
* UI Controller
*/
class UI {
constructor ( ) {
// --- ES5 Refactor: UI ---
function UI ( ) {
this . grid = document . getElementById ( 'media-grid' ) ;
this . saveBtn = document . getElementById ( 'save-btn' ) ;
this . toggleLogBtn = document . getElementById ( 'toggle-log-btn' ) ;
@ -547,81 +625,99 @@
this . shopifyUrl = null ;
}
setDriveLink ( url ) { this . driveUrl = url ; this . renderLinks ( ) ; }
setShopifyLink ( url ) { this . shopifyUrl = url ; this . renderLinks ( ) ; }
UI . prototype . setDriveLink = function ( url ) { this . driveUrl = url ; this . renderLinks ( ) ; } ;
UI . prototype . setShopifyLink = function ( url ) { this . shopifyUrl = url ; this . renderLinks ( ) ; } ;
renderLinks ( ) {
UI . prototype . renderLinks = function ( ) {
this . linksContainer . innerHTML = '' ;
if ( this . driveUrl ) this . linksContainer . innerHTML += ` <a href="${ this . driveUrl } " target="_blank" style="color:var(--primary); text-decoration:none;">Drive ↗</a>` ;
if ( this . shopifyUrl ) this . linksContainer . innerHTML += ` <a href="${ this . shopifyUrl } " target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>` ;
}
if ( this . driveUrl ) this . linksContainer . innerHTML += ' <a href="' + this . driveUrl + ' " target="_blank" style="color:var(--primary); text-decoration:none;">Drive ↗</a>' ;
if ( this . shopifyUrl ) this . linksContainer . innerHTML += ' <a href="' + this . shopifyUrl + ' " target="_blank" style="color:var(--primary); text-decoration:none; margin-left:8px;">Shopify ↗</a>' ;
} ;
toggleLog ( forceState ) {
const isVisible = typeof forceState === 'boolean' ? ! forceState : this . logContainer . style . display !== 'none' ;
UI . prototype . toggleLog = function ( forceState ) {
var isVisible = typeof forceState === 'boolean' ? ! forceState : this . logContainer . style . display !== 'none' ;
this . logContainer . style . display = isVisible ? 'none' : 'block' ;
this . toggleLogBtn . innerText = isVisible ? "View Log" : "Hide Log" ;
}
} ;
updateSku ( sku ) {
document . getElementById ( 'current-sku' ) . innerText = sku ;
UI . prototype . updateSku = function ( sku , title ) {
document . getElementById ( 'current-sku' ) . innerText = sku || '...' ;
document . getElementById ( 'current-title' ) . innerText = title || '' ;
document . getElementById ( 'loading-ui' ) . style . display = 'none' ;
document . getElementById ( 'main-ui' ) . style . display = 'block' ;
}
} ;
toggleSave ( enable ) {
UI . prototype . toggleSave = function ( enable ) {
this . saveBtn . disabled = ! enable ;
this . saveBtn . innerText = enable ? "Save Changes" : "No Changes" ;
}
} ;
render ( items ) {
UI . prototype . render = function ( items ) {
this . grid . innerHTML = '' ;
const activeCount = items . filter ( i => ! i . _deleted ) . length ;
document . getElementById ( 'item-count' ) . innerText = ` ( ${ activeCount } )` ;
var _this = this ; // Capture 'this' for callbacks
var activeCount = items . filter ( function ( i ) { return ! i . _deleted ; } ). length ;
document . getElementById ( 'item-count' ) . innerText = '(' + activeCount + ')' ;
if ( items . length === 0 ) {
this . grid . innerHTML = '<div class="empty-state">No media found. Upload something!</div>' ;
return ;
}
items . forEach ( ( item , index ) => {
const el = this. createCard ( item , index ) ;
this . grid . appendChild ( el ) ;
items . forEach ( function ( item , index ) {
var el = _ this. createCard ( item , index ) ;
_ this. grid . appendChild ( el ) ;
} ) ;
// Re-init Sortable
if ( this . sortable ) this . sortable . destroy ( ) ;
this . sortable = new Sortable ( this . grid , {
animation : 150 ,
filter : '.deleted-item' ,
ghostClass : 'sortable-ghost' ,
dragClass : 'sortable-drag' ,
onEnd : ( ) => {
// Update State Order
const newOrderIds = Array . from ( this . grid . children ) . map ( el => el . dataset . id ) ;
// Reorder state.items based on newOrderIds
const newItems = newOrderIds . map ( id => state . items . find ( i => i . id === id ) ) . filter ( Boolean ) ;
onEnd : function ( ) {
var newOrderIds = Array . from ( _this . grid . children ) . map ( function ( el ) { return el . dataset . id ; } ) ;
var newItems = newOrderIds . map ( function ( id ) {
return state . items . find ( function ( i ) { return i . id === id ; } ) ;
} ) . filter ( Boolean ) ;
state . items = newItems ;
state . checkDirty ( ) ;
}
} ) ;
}
} ;
logStatus ( step , message , type = 'info' ) {
const container = this . logContainer ;
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳' ;
const el = document . createElement ( ' div' ) ;
el . innerHTML = ` <span style="margin-right:8px;"> ${ icon } </span> ${ message } ` ;
UI . prototype . setLoadingState = function ( isLoading ) {
if ( isLoading ) {
this . grid . innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: var(--text-secondary);">' +
'<div class="spinner" style="margin-bottom: 12px;"></ div> ' +
'<div>Connecting to systems...</div></div>' ;
}
} ;
UI . prototype . logStatus = function ( step , message , type ) {
if ( ! type ) type = 'info' ;
var container = this . logContainer ;
var icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳' ;
var el = document . createElement ( 'div' ) ;
el . innerHTML = '<span style="margin-right:8px;">' + icon + '</span> ' + message ;
if ( type === 'error' ) el . style . color = 'var(--error)' ;
container . appendChild ( el ) ;
}
} ;
createCard ( item , index ) {
const div = document . createElement ( 'div' ) ;
div . className = ` media-item ${ item . _deleted ? 'deleted-item' : '' } ` ;
UI . prototype . createCard = function ( item , index ) {
var div = document . createElement ( 'div' ) ;
div . className = ' media-item ' + ( item . _deleted ? 'deleted-item' : '' ) ;
div . dataset . id = item . id ;
// Badge logic
let badge = '' ;
div . onmouseenter = function ( ) {
var v = div . querySelector ( 'video' ) ;
if ( v ) v . play ( ) ;
} ;
div . onmouseleave = function ( ) {
var v = div . querySelector ( 'video' ) ;
if ( v ) v . pause ( ) ;
} ;
var badge = '' ;
if ( ! item . _deleted ) {
if ( item . status === 'synced' ) badge = '<span class="badge" title="Synced" style="background:#dcfce7; color:#166534;">Synced</span>' ;
else if ( item . status === 'drive_only' ) badge = '<span class="badge" title="Drive Only" style="background:#dbeafe; color:#1e40af;">Drive</span>' ;
@ -630,148 +726,159 @@
badge = '<span class="badge" style="background:#fee2e2; color:#991b1b;">Deleted</span>' ;
}
// Thumbnail
const isVideo = ( item . mimeType && item . mimeType . startsWith ( 'video/' ) ) || ( item . filename && item . filename . toLowerCase ( ) . endsWith ( '.mp4' ) );
if ( isVideo ) console . log ( "[MediaManager] Video Detected: " + item . filename + ", ContentUrl: " + item . contentUrl ) ;
const mediaHtml = isVideo
? ` <video src=" ${ item . contentUrl || '' } " poster=" ${ item . thumbnail } " muted loop onmouseover="this.play()" onmouseout="this.pause()" class="media-content"></video> `
: ` <img src=" ${ item . thumbnail } " class="media-content" loading="lazy"> ` ;
var isVideo = ( item . mimeType && item . mimeType . startsWith ( 'video/' ) ) || ( item . filename && item . filename . match ( /\.(mp4|mov|webm)$/i ) ) ;
if ( isVideo ) console . log ( "[MediaManager] Video Detected: " + item . filename ) ;
const actionBtn = item . _deleted
? ` <button class="icon-btn" onclick="state.deleteItem( ${ index } )" title="Restore">↩️</button> `
: ` <button class="icon-btn btn-delete" onclick="state.deleteItem( ${ index } )" title="Delete">🗑️</button> ` ;
var videoBadgeIcon = isVideo ? '<div class="type-badge" title="Video">🎞️</div>' : '' ;
div . innerHTML = `
${ badge }
${ mediaHtml }
<div class="media-overlay">
<button class="icon-btn btn-view " onclick="ui.openPreview(' ${ item . id } ')" title="View ">👁 ️ </button>
${ actionBtn }
</div>
` ;
return div ;
// content URL logic (Only relevant for Shopify where we have a direct public link)
var contentUrl = item . contentUrl || "" ;
var actionBtn = item . _deleted
? ' <button class="icon-btn" onclick="state.deleteItem(' + index + ')" title="Restore ">↩ ️ </button>'
: '<button class="icon-btn btn-delete" onclick="state.deleteItem(' + index + ')" title="Delete">🗑️</button>' ;
div . innerHTML =
badge +
videoBadgeIcon +
'<div class="media-overlay">' +
'<button class="icon-btn btn-view" onclick="ui.openPreview(\'' + item . id + '\')" title="View">👁️</button>' +
actionBtn +
'</div>' ;
// Create Media Element
// RULE: Only create <video> for Shopify-hosted videos (public).
// Drive videos use static thumbnail + Iframe Preview.
var mediaEl ;
if ( isVideo && item . source === 'shopify_only' && contentUrl ) {
mediaEl = document . createElement ( 'video' ) ;
mediaEl . src = contentUrl ;
mediaEl . poster = item . thumbnail || "" ;
mediaEl . muted = true ;
mediaEl . loop = true ;
mediaEl . style . objectFit = 'cover' ;
} else {
// Static Image for Drive videos or regular images
mediaEl = document . createElement ( 'img' ) ;
mediaEl . src = item . thumbnail || "" ;
mediaEl . loading = "lazy" ;
}
mediaEl . className = 'media-content' ;
openPreview ( id ) {
const item = state . items . find ( i => i . id === id ) ;
var overlay = div . querySelector ( '.media-overlay' ) ;
div . insertBefore ( mediaEl , overlay ) ;
return div ;
} ;
UI . prototype . openPreview = function ( id ) {
var item = state . items . find ( function ( i ) { return i . id === id ; } ) ;
if ( ! item ) return ;
const modal = document . getElementById ( 'preview-modal' ) ;
const img = document . getElementById ( 'preview-image' ) ;
const vid = document . getElementById ( 'preview-video' ) ;
const iframe = document . getElementById ( 'preview-iframe' ) ;
var modal = document . getElementById ( 'preview-modal' ) ;
var img = document . getElementById ( 'preview-image' ) ;
var vid = document . getElementById ( 'preview-video' ) ;
var iframe = document . getElementById ( 'preview-iframe' ) ;
img . style . display = 'none' ;
vid . style . display = 'none' ;
iframe . style . display = 'none' ;
iframe . src = 'about:blank' ; // Reset
iframe . src = 'about:blank' ;
const isVideo = ( item . mimeType && item . mimeType . startsWith ( 'video/' ) ) || ( item . filename && item . filename . toLowerCase ( ) . endsWith ( '.mp4' ) ) ;
var isVideo = ( item . mimeType && item . mimeType . startsWith ( 'video/' ) ) || ( item . filename && item . filename . match ( /\.(mp4|mov|webm)$/i ) ) ;
if ( isVideo ) {
// Use Drive Preview Embed URL
// Note: This assumes item.id corresponds to Drive File ID for drive items.
// (Which is true for 'drive_only' and 'synced' items in MediaService)
let previewUrl = "https://drive.google.com/file/d/" + item . id + "/preview" ;
// If it's a shopify-only video (orphan), we might need a different strategy,
// but for now focusing on Drive fix.
if ( item . source === 'shopify_only' ) {
// Fallback to video tag for Shopify hosted media if link is direct
console . log ( "[MediaManager] Shopify Video: " + item . filename ) ;
vid . src = item . contentUrl || item . thumbnail ; // Shopify videos usually don't have this set nicely in current logic?
// Actually MediaService for orphans puts originalSrc in thumbnail.
// But originalSrc for video is usually an image.
// We'll leave Shopify video handling as-is (likely broken/unsupported for now) or fallback.
// Effectively this branch executes the OLD logic for non-drive.
vid . src = item . thumbnail ; // Risk
vid . style . display = 'block' ;
} else {
console . log ( "[MediaManager] Opening Drive Embed: " + item . filename + ", URL: " + previewUrl ) ;
iframe . src = previewUrl ;
// Drive Video -> Iframe
if ( item . source !== 'shopify_only' ) {
iframe . src = "https://drive.google.com/file/d/" + item . id + "/preview" ;
iframe . style . display = 'block' ;
}
// Shopify Video -> Direct Player
else {
var previewUrlParam = item . contentUrl || "" ;
if ( previewUrlParam ) {
vid . src = previewUrlParam ;
vid . style . display = 'block' ;
vid . play ( ) . catch ( console . warn ) ;
} else {
// Image
img . src = item . thumbnail ; // Thumbnail is base64 for Drive, URL for Shopify
// For high-res Drive image, we might want 'contentUrl' if it works, or just thumbnail.
// Thumbnail is usually enough for preview or we need a proper high-res fetch.
// Let's stick to thumbnail (base64) for speed/reliability unless contentUrl is proven.
// Fallback if URL missing
console . warn ( "Missing contentUrl for Shopify video" ) ;
}
}
} else {
img . src = item . thumbnail ;
img . style . display = 'block' ;
}
modal . style . display = 'flex' ;
}
} ;
closeModal ( e ) {
UI . prototype . closeModal = function ( e ) {
if ( e && e . target !== document . getElementById ( 'preview-modal' ) && e . target !== document . querySelector ( '.modal-close' ) ) return ;
document . getElementById ( 'preview-modal' ) . style . display = 'none' ;
document . getElementById ( 'preview-video' ) . pause ( ) ;
document . getElementById ( 'preview-iframe' ) . src = 'about:blank' ; // Stop playback
}
document . getElementById ( 'preview-iframe' ) . src = 'about:blank' ;
} ;
// --- Details Modal ---
showDetails ( ) {
const plan = state . calculateDiff ( ) ;
const container = document . getElementById ( 'details-content' ) ;
UI . prototype . showDetails = function ( ) {
var plan = state . calculateDiff ( ) ;
var container = document . getElementById ( 'details-content' ) ;
if ( plan . actions . length === 0 ) {
container . innerHTML = '<div style="text-align:center; padding:20px;">No pending changes.</div>' ;
} else {
const html = plan . actions . map ( ( a , i ) => {
let icon = '•' ;
var html = plan . actions . map ( function ( a , i ) {
var icon = '•' ;
if ( a . type === 'delete' ) icon = '🗑️' ;
if ( a . type === 'upload' ) icon = '📤' ;
if ( a . type === 'sync_upload' ) icon = '☁️' ;
if ( a . type === 'reorder' ) icon = '🔢' ;
let label = "" ;
if ( a . type === 'delete' ) label = ` Delete <b>${ a . name } </b>` ;
if ( a . type === 'upload' ) label = ` Upload New <b>${ a . name } </b>` ;
if ( a . type === 'sync_upload' ) label = ` Sync Drive File <b>${ a . name } </b>` ;
if ( a . type === 'reorder' ) label = ` Update Order` ;
var label = "" ;
if ( a . type === 'delete' ) label = ' Delete <b>' + a . name + ' </b>' ;
if ( a . type === 'upload' ) label = ' Upload New <b>' + a . name + ' </b>' ;
if ( a . type === 'sync_upload' ) label = ' Sync Drive File <b>' + a . name + ' </b>' ;
if ( a . type === 'reorder' ) label = ' Update Order' ;
return ` <div style="margin-bottom:8px;">${ i + 1 } . ${ icon } ${ label } </div>` ;
return ' <div style="margin-bottom:8px;">' + ( i + 1 ) + '. ' + icon + ' ' + label + ' </div>' ;
} ) . join ( '' ) ;
container . innerHTML = html ;
}
document . getElementById ( 'details-modal' ) . style . display = 'flex' ;
}
} ;
closeDetails ( e ) {
UI . prototype . closeDetails = function ( e ) {
if ( e && e . target !== document . getElementById ( 'details-modal' ) && ! e . target . matches ( '.modal-close, .btn-secondary, .close-btn' ) ) return ;
document . getElementById ( 'details-modal' ) . style . display = 'none' ;
}
} ;
// Photos Session Methods
showP hotoS ession( url ) {
const ui = document . getElementById ( 'photos-session-ui ' ) ;
const link = document . getElementById ( 'photos-session-link ' ) ;
const status = document . getElementById ( 'photos-session-status' ) ;
UI . prototype . show PhotoSession = function ( url ) {
var uiEl = document . getElementById ( 'p hotos-s ession-ui' ) ;
var link = document . getElementById ( 'photos-session-link ' ) ;
var status = document . getElementById ( 'photos-session-status ' ) ;
ui . style . display = 'block' ;
uiEl . style . display = 'block' ;
link . href = url ;
link . style . display = 'block' ;
status . innerText = "Waiting for selection..." ;
}
} ;
closePhotoSession ( ) {
UI . prototype . closePhotoSession = function ( ) {
document . getElementById ( 'photos-session-ui' ) . style . display = 'none' ;
}
} ;
updatePhotoStatus ( msg ) {
UI . prototype . updatePhotoStatus = function ( msg ) {
document . getElementById ( 'photos-session-status' ) . innerText = msg ;
}
}
} ;
const ui = new UI ( ) ;
var ui = new UI ( ) ;
window . ui = ui ;
/**
* Data Controller
*/
const controller = {
var controller = {
init ( ) {
// Start polling for SKU selection
setInterval ( ( ) => this . checkSku ( ) , 2000 ) ;
@ -780,27 +887,56 @@
checkSku ( ) {
google . script . run
. withSuccessHandler ( sku => {
. withSuccessHandler ( info => {
// Info is now { sku, title } or null
const sku = info ? info . sku : null ;
if ( sku && sku !== state . sku ) {
state . setSku ( sku ) ;
state . setSku ( info ) ; // Pass whole object
this . loadMedia ( ) ;
} else if ( ! sku && ! state . sku ) {
// If we don't have a SKU and haven't shown error yet
if ( document . getElementById ( 'error-ui' ) . style . display !== 'flex' ) {
this . loadMedia ( ) ;
}
}
} )
. getSelectedSku ( ) ;
. getSelectedProductInfo ( ) ;
} ,
loadMedia ( preserveLogs = false ) {
const sku = document . getElementById ( 'current-sku' ) . innerText ;
// Ensure Loading UI is visible and Main UI is hidden until ready
document . getElementById ( 'loading-ui' ) . styl e . display = 'block' ;
document . getElementById ( 'main-ui' ) . style . display = 'none' ;
// Resolve SKU/Title - prefer state, fallback to DOM
let sku = state . sku ;
let title = stat e . title ;
// Reset State (this calls ui.updateSku which might show main-ui, so we re-toggle below if needed)
state . setSku ( sku) ;
if ( ! sku ) {
const domSku = document . getElementById ( 'current- sku' ) . innerText ;
if ( domSku && domSku !== '...' ) sku = domSku ;
}
// Enforce Loading State Again (in case setSku reset it)
document . getElementById ( 'loading-ui' ) . style . display = 'block' ;
// CHECK FOR MISSING SKU
if ( ! sku || sku === '...' ) {
console . warn ( "No SKU found. Showing error." ) ;
document . getElementById ( 'loading-ui' ) . style . display = 'none' ;
document . getElementById ( 'main-ui' ) . style . display = 'none' ;
document . getElementById ( 'error-ui' ) . style . display = 'flex' ;
return ;
}
if ( ! title ) {
const domTitle = document . getElementById ( 'current-title' ) . innerText ;
if ( domTitle && domTitle !== 'Loading...' ) title = domTitle ;
}
// Show Main UI immediately so logs are visible
document . getElementById ( 'loading-ui' ) . style . display = 'none' ;
document . getElementById ( 'main-ui' ) . style . display = 'block' ;
// Set Inline Loading State
ui . setLoadingState ( true ) ;
// Reset State (this calls ui.updateSku)
state . setSku ( { sku , title } ) ;
if ( ! preserveLogs ) {
document . getElementById ( 'status-log-container' ) . innerHTML = '' ;
@ -822,6 +958,9 @@
ui . logStatus ( 'drive' , ` Drive Check Failed: ${ diagnostics . drive . error } ` , 'error' ) ;
}
// Capture Token
if ( diagnostics . token ) state . token = diagnostics . token ;
// Shopify Status
if ( diagnostics . shopify . status === 'ok' ) {
ui . logStatus ( 'shopify' , ` Shopify Product: ok ( ${ diagnostics . shopify . mediaCount } media) (ID: ${ diagnostics . shopify . id } ) <a href=" ${ diagnostics . shopify . adminUrl } " target="_blank" style="margin-left:8px;">Open Admin ↗</a> ` , 'success' ) ;
@ -980,7 +1119,7 @@
// --- Google Picker API ---
let pickerApiLoaded = false ;
function onApiLoad ( ) { gapi . load ( 'picker' , ( ) => { pickerApiLoaded = true ; } ) ; }
window . onApiLoad = function ( ) { gapi . load ( 'picker' , ( ) => { pickerApiLoaded = true ; } ) ; } ;
function createPicker ( config ) {
const view = new google . picker . DocsView ( google . picker . ViewId . DOCS )
@ -1006,19 +1145,59 @@
}
// Init
try {
if ( ! window . state || ! window . ui || ! window . controller ) {
throw new Error ( "Core components failed to initialize. Check console for SyntaxError." ) ;
}
controller . init ( ) ;
window . controller = controller ; // Re-assert global access
} catch ( e ) {
alert ( "Init Failed: " + e . message ) ;
console . error ( e ) ;
}
// Drag & Drop Handlers for upload zone (Visual only )
const dropZone = document . getElementById ( 'drop-zone ' ) ;
dropZone . addEventListener ( 'dragover' , ( e ) => { e . preventDefault ( ) ; dropZone . classList . add ( 'dragover' ) ; } ) ;
dropZone . addEventListener ( 'dragleave' , ( e ) => { e . preventDefault ( ) ; dropZone . classList . remove ( 'dragover' ) ; } ) ;
dropZone . addEventListener ( 'drop' , ( e ) => {
// Drag & Drop Handlers (Global )
const dropOverlay = document . getElementById ( 'drop-overlay ' ) ;
let dragCounter = 0 ;
// Check if the drag involves files
function isFileDrag ( e ) {
return e . dataTransfer . types && Array . from ( e . dataTransfer . types ) . includes ( 'Files' ) ;
}
document . addEventListener ( 'dragenter' , ( e ) => {
if ( ! isFileDrag ( e ) ) return ;
e . preventDefault ( ) ;
dropZone . classList . remove ( 'dragover' ) ;
dragCounter ++ ;
dropOverlay . style . display = 'flex' ;
} ) ;
document . addEventListener ( 'dragleave' , ( e ) => {
if ( ! isFileDrag ( e ) ) return ;
e . preventDefault ( ) ;
dragCounter -- ;
if ( dragCounter === 0 ) {
dropOverlay . style . display = 'none' ;
}
} ) ;
document . addEventListener ( 'dragover' , ( e ) => {
if ( ! isFileDrag ( e ) ) return ;
e . preventDefault ( ) ;
} ) ;
document . addEventListener ( 'drop' , ( e ) => {
if ( ! isFileDrag ( e ) ) return ;
e . preventDefault ( ) ;
dragCounter = 0 ;
dropOverlay . style . display = 'none' ;
if ( e . dataTransfer && e . dataTransfer . files . length > 0 ) {
controller . handleFiles ( e . dataTransfer . files ) ;
}
} ) ;
< / script >
< script async defer src = "https://apis.google.com/js/api.js" onload = "onApiLoad()" > < / script >
< / body >
< / html >