@@ -30,6 +30,15 @@ function applyTheme(){const dark=state.theme==='dark'||(state.theme==='system'&&
function revealCurrentNavigation ( ) { const active = $ ( '#nav .active' ) , sidebar = $ ( '.sidebar' ) ; if ( active && matchMedia ( '(max-width:720px)' ) . matches ) sidebar . scrollLeft = active . offsetLeft - ( sidebar . clientWidth - active . offsetWidth ) / 2 ; }
window . addEventListener ( 'resize' , revealCurrentNavigation ) ;
function shell ( ) { document . documentElement . lang = state . lang === 'zh' ? 'zh-CN' : 'en' ; $ ( '#lang' ) . textContent = state . lang === 'zh' ? 'EN' : '中文' ; $ ( '#theme' ) . title = t ( 'theme' ) ; $ ( '#nav' ) . innerHTML = Object . entries ( icons ) . map ( ( [ id , icon ] ) => ` <button data-page=" ${ id } " class=" ${ id === state . page ? 'active' : '' } " ${ id === state . page ? 'aria-current="page"' : '' } ><span class="nav-icon"> ${ icon } </span><span> ${ t ( id ) } </span></button> ` ) . join ( '' ) ; document . querySelectorAll ( '[data-page]' ) . forEach ( b => b . onclick = ( ) => navigate ( b . dataset . page ) ) ; $ ( '#page-title' ) . textContent = t ( state . page ) ; $ ( '#device' ) . innerHTML = ( state . devices . length ? state . devices : [ { id : state . device , name : state . device } ] ) . map ( d => ` <option value=" ${ esc ( d . id ) } " ${ d . id === state . device ? 'selected' : '' } > ${ esc ( d . name ) } </option> ` ) . join ( '' ) ; revealCurrentNavigation ( ) ; }
// A destination appears only when its connection and read/write adapter are available.
// Future cloud-drive destinations must implement their adapter before registration here.
const galleryDestinationAdapters = new Set ( [ 'kokonna' , 'local-assets' ] ) ;
function galleryDestinations ( ) {
const configured = state . settings ? . gallery _destinations ;
const destinations = Array . isArray ( configured ) ? configured : [ { id : 'cloud' , adapter : 'kokonna' , name : 'KoKonna' , storage : 'cloud' , connected : true , supports _upload : true } , { id : 'local' , adapter : 'local-assets' , name : state . settings ? . storage _name || 'NAS' , storage : 'local' , connected : true , supports _upload : true } ] ;
return destinations . filter ( d => d . connected && d . supports _upload && galleryDestinationAdapters . has ( d . adapter ) ) ;
}
function galleryDestinationName ( destination ) { return ` ${ destination . name } ${ t ( destination . storage === 'local' ? 'local' : 'cloudGallery' ) } ` ; }
function imageLoading ( img ) {
if ( img . parentElement ? . classList . contains ( 'image-loading' ) ) return img . parentElement ;
const box = document . createElement ( 'div' ) ; box . className = 'image-loading' ;
@@ -104,9 +113,10 @@ const views={
'photo-sources' : async ( ) => { $ ( '#main' ) . innerHTML = '' ; await photoSourcesPanel ( ) ; } ,
async dashboard ( epoch ) { const d = await api ( '/api/v1/devices/' + state . device ) ; if ( epoch !== state . epoch ) return ; const s = d . slideshow ; $ ( '#main' ) . innerHTML = notice ( ) + ` ${ d . power . battery _percent !== null && d . power . battery _percent < 20 ? ` <div class="notice warning"> ${ t ( 'lowBattery' ) } </div> ` : '' } <section class="card hero"><div class="hero-info"><span class="badge ${ d . status === 'offline' ? 'bad' : '' } ">● ${ t ( d . status ) . toUpperCase ( ) } </span><h2> ${ esc ( d . name ) } </h2><p> ${ esc ( d . model ) } · ${ esc ( d . model _code ) } · ${ d . display . width } × ${ d . display . height } </p><div class="actions"> ${ buttons ( 'go-upload' , 'upload' , true ) } ${ buttons ( 'restore' , 'restore' ) } ${ buttons ( 'refresh-device' , 'refresh' ) } </div><p class="hint" style="margin-top:28px"> ${ t ( 'refreshInterval' ) } </p></div><div class="frame-demo"></div></section><div class="stats"> ${ [ [ 'battery' , d . power . battery _percent === null ? '—' : d . power . battery _percent + '%' ] , [ 'current' , d . current _content . id ? ? '—' ] , [ 'sync' , t ( d . current _content . synced ? 'synced' : 'syncing' ) ] , [ 'firmware' , d . firmware ] ] . map ( ( [ l , v ] ) => ` <div class="card stat"><div class="label"> ${ t ( l ) } </div><div class="value"> ${ esc ( v ) } </div></div> ` ) . join ( '' ) } </div><div class="columns"><section class="card"><h3> ${ t ( 'device' ) } </h3><div class="detail-grid"> ${ detail ( 'heartbeat' , fmt ( d . last _seen _at ) ) } ${ detail ( 'rotation' , d . display . rotation + '°' ) } ${ detail ( 'timezone' , d . timezone ) } ${ detail ( 'coverId' , d . current _content . cover _id ? ? '—' ) } ${ detail ( 'storage' , d . storage _used _bytes === null ? '—' : ( d . storage _used _bytes / 1024 / 1024 ) . toFixed ( 1 ) + ' MiB' ) } ${ detail ( 'model' , t ( d . model _verified ? 'modelVerified' : 'modelConfigured' ) ) } </div></section><section class="card"><h3> ${ t ( 'cloudRotation' ) } </h3><div class="detail-grid"> ${ detail ( 'mode' , s . mode ? t ( s . mode ) : '—' ) } ${ detail ( 'interval' , s . interval _minutes == null ? '—' : s . interval _minutes + ' ' + t ( 'minutes' ) ) } </div><p class="hint"> ${ t ( 'rotationNotice' ) } </p><a href="#playlists" id="go-playlists"> ${ t ( 'playlists' ) } →</a></section></div> ` ; bind ( 'go-upload' , ( ) => navigate ( 'upload' ) ) ; bind ( 'refresh-device' , async ( ) => { await api ( '/api/v1/devices/' + state . device + '?refresh=true' ) ; await navigate ( ) ; } ) ; $ ( '#restore' ) . disabled = ! d . can _restore ; bind ( 'restore' , ( ) => accepted ( '/api/v1/devices/' + state . device + '/restore' , { } ) ) ; $ ( '#go-playlists' ) . onclick = e => { e . preventDefault ( ) ; navigate ( 'playlists' ) ; } ; } ,
async gallery ( epoch ) {
const destinations = galleryDestinations ( ) ; if ( ! destinations . some ( d => d . id === state . tab ) ) state . tab = destinations [ 0 ] ? . id || 'local' ;
const key = JSON . stringify ( [ state . device , state . tab , state . assetOffset || 0 , state . assetOrigin || 'all' , state . assetJob || '' ] ) ;
const cache = state . galleryCache || ( state . galleryCache = new Map ( ) ) ; const cached = cache . get ( key ) ;
const render = items => { if ( epoch !== state . epoch ) return ; state . gallery = items ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="section-heading"><div class="tabs"><button id="cloud-tab" class=" ${ state . tab === 'cloud' ? 'active' : '' } "> ${ t ( 'cloud' ) } </button><button id="local-tab " class="${ state . tab === 'local' ? 'active' : '' } "> ${ t ( 'local' ) } </button></div><div class="actions"> ${ buttons ( 'gallery-refresh' , 'refresh' ) } ${ state . tab === 'cloud' ? buttons ( 'show-name' , 'displayByName' ) : '' } ${ buttons ( 'gallery-upload' , 'upload' , true ) } </div></div> ${ state . tab === 'local' ? ` <div class="notice"> ${ t ( 'uploadNotice' ) } </div> ` : '' } ${ items . length ? ` <div class="gallery"> ${ items . map ( ( x , i ) => ` <article class="card image-card"><img class="thumb" alt=" ${ esc ( x . name ) } " data-src=" ${ esc ( x . url + ( state . tab === 'cloud' ? '?thumbnail=true' : '' ) ) } "><h3 title=" ${ esc ( x . name ) } "> ${ esc ( x . name ) } </h3><small> ${ state . tab === 'cloud' ? '#' + x . image _id : 'LOCAL' } · ${ esc ( fmt ( x . created _at ) ) } </small><div class="actions"><button class="primary" data-show=" ${ i } " ${ x . current ? 'disabled' : '' } > ${ t ( x . current ? 'current' : 'display' ) } </button><button data-original=" ${ i } "> ${ t ( 'original' ) } </button><button class="danger" data-delete=" ${ i } "> ${ t ( 'delete' ) } </button></div></article> ` ) . join ( '' ) } </div> ` : ` <div class="card empty"><strong> ${ t ( 'noImages' ) } </strong> ${ t ( 'emptyHint' ) } </div> ` } ` ; bind ( 'cloud-tab' , ( ) => { state . tab = 'cloud' ; return navigate ( ) ; } ) ; bind ( 'local-tab' , ( ) => { state . tab = 'local' ; return navigate ( ) ; } ) ; bind ( 'gallery-refresh' , ( ) => navigate ( ) ) ; bind ( 'gallery-upload' , ( ) => navigate ( 'upload' ) ) ; bind ( 'show-name' , ( ) => { showModal ( ` <h2> ${ t ( 'displayByName' ) } </h2><div class="field"><label for="image-name"> ${ t ( 'name' ) } </label><input id="image-name" maxlength="120"></div><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } ${ buttons ( 'show-name-confirm' , 'display' , true ) } </div> ` ) ; bind ( 'show-name-confirm' , async ( ) => { const name = $ ( '#image-name' ) . value ; $ ( '#modal' ) . close ( ) ; displayDialog ( { type : 'image' , image _name : name } ) ; } ) ; } ) ; enhanceGallery ( ) ; guardDeviceButtons ( ) ; hydrateImages ( ) ; document . querySelectorAll ( '[data-show]' ) . forEach ( b => b . onclick = busy ( ( ) => { const x = items [ b . dataset . show ] ; if ( x . current ) return ; return displayDialog ( state . tab === 'cloud' ? { type : 'image' , image _id : x . image _id } : { type : 'image' , asset _id : x . asset _id } ) ; } ) ) ; document . querySelectorAll ( '[data-original]' ) . forEach ( b => b . onclick = busy ( async ( ) => { const x = items [ b . dataset . original ] ; showModal ( ` <h2> ${ esc ( x . name ) } </h2><img id="original-image" class="preview" alt=" ${ esc ( x . name ) } "><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } </div> ` ) ; await blobImage ( $ ( '#original-image' ) , x . url ) ; } ) ) ; document . querySelectorAll ( '[data-delete]' ) . forEach ( b => b . onclick = ( ) => { const x = items [ b . dataset . delete ] ; confirmDialog ( x . current ? t ( 'deleteCurrent' ) : t ( 'deleteConfirm' ) , async ( ) => { if ( state . tab === 'local' ) { await api ( '/api/v1/assets/' + x . asset _id , { method : 'DELETE' } ) ; } else { const result = await api ( ` /api/v1/devices/ ${ state . device } /images/ ${ x . image _id } ?confirm_current= ${ x . current } ` , { method : 'DELETE' , headers : { 'Idempotency-Key' : 'web-' + clientUUID ( ) } } ) ; await waitOperation ( result . operation _id ) ; } await navigate ( ) ; } ) ; } ) ; } ;
const render = items => { if ( epoch !== state . epoch ) return ; state . gallery = items ; $ ( '#main' ) . innerHTML = notice ( ) + ` <div class="section-heading"><div class="tabs gallery-tabs"> ${ destinations . map ( d => ` <button id=" ${ esc ( d . id ) } -tab" data-gallery-destination=" ${ esc ( d . id ) } " class=" ${ state . tab === d . id ? 'active' : '' } "> ${ esc ( galleryDestinationName ( d ) ) } </button> ` ) . join ( '' ) } </div><div class="actions"> ${ buttons ( 'gallery-refresh' , 'refresh' ) } ${ state . tab === 'cloud' ? buttons ( 'show-name' , 'displayByName' ) : '' } ${ buttons ( 'gallery-upload' , 'upload' , true ) } </div></div> ${ state . tab === 'local' ? ` <div class="notice"> ${ t ( 'uploadNotice' ) } </div> ` : '' } ${ items . length ? ` <div class="gallery"> ${ items . map ( ( x , i ) => ` <article class="card image-card"><img class="thumb" alt=" ${ esc ( x . name ) } " data-src=" ${ esc ( x . url + ( state . tab === 'cloud' ? '?thumbnail=true' : '' ) ) } "><h3 title=" ${ esc ( x . name ) } "> ${ esc ( x . name ) } </h3><small> ${ state . tab === 'cloud' ? '#' + x . image _id : 'LOCAL' } · ${ esc ( fmt ( x . created _at ) ) } </small><div class="actions"><button class="primary" data-show=" ${ i } " ${ x . current ? 'disabled' : '' } > ${ t ( x . current ? 'current' : 'display' ) } </button><button data-original=" ${ i } "> ${ t ( 'original' ) } </button><button class="danger" data-delete=" ${ i } "> ${ t ( 'delete' ) } </button></div></article> ` ) . join ( '' ) } </div> ` : ` <div class="card empty"><strong> ${ t ( 'noImages' ) } </strong> ${ t ( 'emptyHint' ) } </div> ` } ` ; bind ( 'cloud-tab' , ( ) => { state . tab = 'cloud' ; return navigate ( ) ; } ) ; bind ( 'local-tab' , ( ) => { state . tab = 'local' ; return navigate ( ) ; } ) ; bind ( 'gallery-refresh' , ( ) => navigate ( ) ) ; bind ( 'gallery-upload' , ( ) => navigate ( 'upload' ) ) ; bind ( 'show-name' , ( ) => { showModal ( ` <h2> ${ t ( 'displayByName' ) } </h2><div class="field"><label for="image-name"> ${ t ( 'name' ) } </label><input id="image-name" maxlength="120"></div><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } ${ buttons ( 'show-name-confirm' , 'display' , true ) } </div> ` ) ; bind ( 'show-name-confirm' , async ( ) => { const name = $ ( '#image-name' ) . value ; $ ( '#modal' ) . close ( ) ; displayDialog ( { type : 'image' , image _name : name } ) ; } ) ; } ) ; enhanceGallery ( ) ; guardDeviceButtons ( ) ; hydrateImages ( ) ; document . querySelectorAll ( '[data-show]' ) . forEach ( b => b . onclick = busy ( ( ) => { const x = items [ b . dataset . show ] ; if ( x . current ) return ; return displayDialog ( state . tab === 'cloud' ? { type : 'image' , image _id : x . image _id } : { type : 'image' , asset _id : x . asset _id } ) ; } ) ) ; document . querySelectorAll ( '[data-original]' ) . forEach ( b => b . onclick = busy ( async ( ) => { const x = items [ b . dataset . original ] ; showModal ( ` <h2> ${ esc ( x . name ) } </h2><img id="original-image" class="preview" alt=" ${ esc ( x . name ) } "><div class="modal-actions"> ${ buttons ( 'modal-cancel' , 'cancel' ) } </div> ` ) ; await blobImage ( $ ( '#original-image' ) , x . url ) ; } ) ) ; document . querySelectorAll ( '[data-delete]' ) . forEach ( b => b . onclick = ( ) => { const x = items [ b . dataset . delete ] ; confirmDialog ( x . current ? t ( 'deleteCurrent' ) : t ( 'deleteConfirm' ) , async ( ) => { if ( state . tab === 'local' ) { await api ( '/api/v1/assets/' + x . asset _id , { method : 'DELETE' } ) ; } else { const result = await api ( ` /api/v1/devices/ ${ state . device } /images/ ${ x . image _id } ?confirm_current= ${ x . current } ` , { method : 'DELETE' , headers : { 'Idempotency-Key' : 'web-' + clientUUID ( ) } } ) ; await waitOperation ( result . operation _id ) ; } await navigate ( ) ; } ) ; } ) ; } ;
if ( cached ) render ( cached ) ;
const items = state . tab === 'cloud' ? await api ( '/api/v1/devices/' + state . device + '/images' ) : await api ( '/api/v1/assets?limit=200&offset=' + ( state . assetOffset || 0 ) + '&origin=' + encodeURIComponent ( state . assetOrigin || 'all' ) + ( state . assetJob ? '&job_id=' + encodeURIComponent ( state . assetJob ) : '' ) ) ;
if ( epoch !== state . epoch ) return ; cache . set ( key , items ) ;