@@ -36,7 +36,12 @@ function enhanceGallery(){
const controls = document . createElement ( 'div' ) ; controls . className = 'form-grid gallery-filters' ; controls . innerHTML = ` <div class="field"><label for="gallery-search"> ${ t ( 'searchImages' ) } </label><input id="gallery-search" type="search"></div><div class="field"><label for="gallery-sort"> ${ t ( 'sortOrder' ) } </label><select id="gallery-sort"><option value="newest"> ${ t ( 'sortNewest' ) } </option><option value="oldest"> ${ t ( 'sortOldest' ) } </option><option value="name"> ${ t ( 'sortName' ) } </option></select></div> ` ; grid . before ( controls ) ;
if ( state . tab === 'local' ) { controls . classList . add ( 'gallery-filters-local' ) ; const bar = document . createElement ( 'div' ) ; bar . className = 'actions' ; bar . innerHTML = ` <select id="asset-origin" aria-label=" ${ t ( 'originFilter' ) } "> ${ [ 'all' , 'manual' , 'task' , 'external' , 'template' , 'unknown' ] . map ( x => ` <option value=" ${ x } "> ${ t ( 'origin_' + x ) } </option> ` ) . join ( '' ) } </select><select id="asset-job" aria-label=" ${ t ( 'taskOrigin' ) } "><option value=""> ${ t ( 'allTasks' ) } </option></select><button id="asset-prev"> ${ t ( 'previousPage' ) } </button><button id="asset-next"> ${ t ( 'nextPage' ) } </button><button id="asset-all"> ${ t ( 'selectAll' ) } </button><button id="asset-invert"> ${ t ( 'invertSelection' ) } </button><button id="asset-batch-star"> ${ t ( 'star' ) } </button><button id="asset-batch-unstar"> ${ t ( 'unstar' ) } </button><button id="asset-batch-exclude"> ${ t ( 'excludePhoto' ) } </button><button id="asset-batch-include"> ${ t ( 'includePhoto' ) } </button><button id="asset-batch-delete" class="danger"> ${ t ( 'deleteSelected' ) } </button> ` ; grid . before ( bar ) ; for ( const [ id , key ] of [ [ 'asset-origin' , 'originFilter' ] , [ 'asset-job' , 'taskOrigin' ] ] ) { const field = document . createElement ( 'div' ) ; field . className = 'field' ; const label = document . createElement ( 'label' ) ; label . htmlFor = id ; label . textContent = t ( key ) ; field . append ( label , bar . querySelector ( '#' + id ) ) ; controls . append ( field ) ; } const help = document . createElement ( 'p' ) ; help . className = 'hint' ; help . id = 'gallery-selection-help' ; help . textContent = t ( 'gallerySelectionHelp' ) + ' ' + t ( 'starHelp' ) + ' ' + t ( 'excludePhotoHelp' ) ; grid . before ( help ) ; bar . setAttribute ( 'aria-describedby' , help . id ) ; $ ( '#asset-origin' ) . value = state . assetOrigin || 'all' ; if ( state . galleryJobsEpoch !== state . epoch ) { state . galleryJobsEpoch = state . epoch ; state . galleryJobsRequest = api ( '/api/v1/jobs' ) ; } void state . galleryJobsRequest . then ( jobs => { if ( ! bar . isConnected ) return ; const picker = controls . querySelector ( '#asset-job' ) ; for ( const job of jobs ) { const option = document . createElement ( 'option' ) ; option . value = job . id ; option . textContent = job . name ; picker . append ( option ) ; } picker . value = state . assetJob || '' ; } ) . catch ( error => { if ( bar . isConnected ) toast ( error . message ) ; } ) ; $ ( '#asset-origin' ) . onchange = ( ) => { state . assetOffset = 0 ; state . assetOrigin = $ ( '#asset-origin' ) . value ; return navigate ( ) ; } ; $ ( '#asset-job' ) . onchange = ( ) => { state . assetOffset = 0 ; state . assetJob = $ ( '#asset-job' ) . value ; state . assetOrigin = state . assetJob ? 'task' : state . assetOrigin ; return navigate ( ) ; } ; $ ( '#asset-prev' ) . disabled = ! ( state . assetOffset || 0 ) ; $ ( '#asset-next' ) . disabled = state . gallery . length < 200 ; bind ( 'asset-prev' , ( ) => { state . assetOffset = Math . max ( 0 , ( state . assetOffset || 0 ) - 200 ) ; return navigate ( ) ; } ) ; bind ( 'asset-next' , ( ) => { state . assetOffset = ( state . assetOffset || 0 ) + 200 ; return navigate ( ) ; } ) ; const visibleSelection = ( ) => [ ... grid . querySelectorAll ( '.asset-select' ) ] . filter ( x => ! x . closest ( '.image-card' ) . hidden ) ; $ ( '#asset-all' ) . onclick = ( ) => { const boxes = visibleSelection ( ) ; const checked = boxes . some ( x => ! x . checked ) ; boxes . forEach ( x => x . checked = checked ) ; } ; $ ( '#asset-invert' ) . onclick = ( ) => visibleSelection ( ) . forEach ( x => x . checked = ! x . checked ) ; for ( const action of [ 'star' , 'unstar' , 'exclude' , 'include' , 'delete' ] ) { bind ( 'asset-batch-' + action , async ( ) => { const ids = [ ... document . querySelectorAll ( '.asset-select:checked' ) ] . map ( x => x . dataset . asset ) ; if ( ! ids . length ) { toast ( t ( 'selectImagesFirst' ) ) ; return ; } if ( action === 'delete' && ! confirm ( t ( 'deleteSelectedConfirm' ) ) ) return ; const results = [ ] ; for ( let i = 0 ; i < ids . length ; i += 100 ) { const r = await api ( '/api/v1/assets/batch' , { method : 'POST' , body : { asset _ids : ids . slice ( i , i + 100 ) , action } } ) ; results . push ( ... r . results ) ; } const failed = results . filter ( x => x . status === 'failed' ) ; if ( failed . length ) toast ( t ( 'batchFailed' ) + ' ' + failed . length ) ; await navigate ( ) ; } ) ; } }
const cards = [ ... grid . querySelectorAll ( '.image-card' ) ] ; $ ( '#gallery-search' ) . oninput = ( ) => { state . galleryQuery = $ ( '#gallery-search' ) . value ; const query = state . galleryQuery . toLowerCase ( ) ; cards . forEach ( card => card . hidden = ! card . textContent . toLowerCase ( ) . includes ( query ) ) ; } ; $ ( '#gallery-sort' ) . onchange = ( ) => { const order = $ ( '#gallery-sort' ) . value ; state . galleryOrder = order ; const indexes = cards . map ( ( card , index ) => ( { card , item : state . gallery [ index ] } ) ) ; indexes . sort ( ( a , b ) => order === 'name' ? a . item . name . localeCompare ( b . item . name ) : order === 'oldest' ? String ( a . item . created _at ) . localeCompare ( String ( b . item . created _at ) ) : String ( b . item . created _at ) . localeCompare ( String ( a . item . created _at ) ) ) ; indexes . forEach ( x => grid . append ( x . card ) ) ; } ;
cards . forEach ( ( card , i ) => { const item = state . gallery [ i ] ; if ( state . tab === 'local' ) { const star = document . createElement ( 'button' ) ; star . textContent = t ( item . starred ? 'unstar' : 'star' ) ; star . setAttribute ( 'aria-pressed' , String ( item . starred ) ) ; card . querySelector ( '.actions' ) . append ( star ) ; const exclude = document . createElement ( 'button' ) ; exclude . textContent = t ( item . excluded ? 'includePhoto' : 'excludePhoto' ) ; exclude . setAttribute ( 'aria-pressed' , String ( Boolean ( item . excluded ) ) ) ; card . querySelector ( '.actions' ) . append ( exclude ) ; exclude . onclick = busy ( async ( ) => { await api ( '/api/v1/assets/' + item . asset _id + '/exclusion' , { method : 'PUT' , body : { excluded : ! item . excluded } } ) ; await navigate ( ) ; } ) ; if ( item . excluded ) { const badge = document . createElement ( 'small' ) ; badge . className = 'excluded-photo' ; badge . textContent = t ( 'photoExcluded' ) ; card . querySelector ( 'h3' ) . after ( badge ) ; } star . onclick = busy ( async ( ) => { await api ( '/api/v1/assets/' + item . asset _id + '/star' , { method : 'PUT' , body : { starred : ! item . starred } } ) ; await navigate ( ) ; } ) ; const label = document . createElement ( 'small' ) ; label . textContent = item . origin === 'task' ? t ( 'taskOrigin' ) + ' · ' + ( item . job _name || item . job _id ) : t ( 'origin_' + item . origin ) ; card . querySelector ( 'h3' ) . after ( label ) ; const select = document . createElement ( 'input' ) ; select . type = 'checkbox' ; select . className = 'asset-select' ; select . dataset . asset = item . asset _id ; select . setAttribute ( 'aria-label' , t ( 'selectImage' ) ) ; card . prepend ( select ) ; } if ( item . width && item . height ) { const dimensions = document . createElement ( 'small' ) ; dimensions . textContent = item . width + ' × ' + item . height ; card . querySelector ( 'h3' ) . after ( dimensions ) ; } const download = document . createElement ( 'button' ) ; download . textContent = t ( state . tab === 'local' ? 'downloadShort' : 'download' ) ; card . querySelector ( '.actions' ) . append ( download ) ; download . onclick = busy ( ( ) => downloadImage ( item . url , item . name ) ) ; if ( state . tab === 'local' ) card . querySelector ( '.actions' ) . append ( card . querySelector ( '[data-delete]' ) ) ; } ) ;
cards . forEach ( ( card , i ) => { const item = state . gallery [ i ] ; if ( state . tab === 'local' ) { const star = document . createElement ( 'button' ) ; star . textContent = t ( item . starred ? 'unstar' : 'star' ) ; star . setAttribute ( 'aria-pressed' , String ( item . starred ) ) ; star . disabled = Boolean ( item . excluded ) ; star . title = item . excluded ? t ( 'clearExclusionFirst' ) : '' ; card . querySelector ( '.actions' ) . append ( star ) ; const exclude = document . createElement ( 'button' ) ; exclude . textContent = t ( item . excluded ? 'includePhoto' : 'excludePhoto' ) ; exclude . setAttribute ( 'aria-pressed' , String ( Boolean ( item . excluded ) ) ) ; exclude . disabled = Boolean ( item . starred ) ; exclude . title = item . starred ? t ( 'clearStarFirst' ) : '' ; card . querySelector ( '.actions' ) . append ( exclude ) ; exclude . onclick = busy ( async ( ) => { await api ( '/api/v1/assets/' + item . asset _id + '/exclusion' , { method : 'PUT' , body : { excluded : ! item . excluded } } ) ; await navigate ( ) ; } ) ; if ( item . starred ) { const kept = document . createElement ( 'small' ) ; kept . className = 'photo-mark' ; kept . textContent = t ( 'photoStarred' ) ; card . querySelector ( 'h3' ) . after ( kept ) ; } if ( item . excluded ) { const badge = document . createElement ( 'small' ) ; badge . className = 'excluded-photo' ; badge . textContent = t ( 'photoExcluded' ) ; card . querySelector ( 'h3' ) . after ( badge ) ; } star . onclick = busy ( async ( ) => { await api ( '/api/v1/assets/' + item . asset _id + '/star' , { method : 'PUT' , body : { starred : ! item . starred } } ) ; await navigate ( ) ; } ) ; const label = document . createElement ( 'small' ) ; label . textContent = item . origin === 'task' ? t ( 'taskOrigin' ) + ' · ' + ( item . job _name || item . job _id ) : t ( 'origin_' + item . origin ) ; card . querySelector ( 'h3' ) . after ( label ) ; const select = document . createElement ( 'input' ) ; select . type = 'checkbox' ; select . className = 'asset-select' ; select . dataset . asset = item . asset _id ; select . setAttribute ( 'aria-label' , t ( 'selectImage' ) ) ; card . prepend ( select ) ; } if ( item . width && item . height ) { const dimensions = document . createElement ( 'small' ) ; dimensions . textContent = item . width + ' × ' + item . height ; card . querySelector ( 'h3' ) . after ( dimensions ) ; } const download = document . createElement ( 'button' ) ; download . textContent = t ( state . tab === 'local' ? 'downloadShort' : 'download' ) ; card . querySelector ( '.actions' ) . append ( download ) ; download . onclick = busy ( ( ) => downloadImage ( item . url , item . name ) ) ; if ( state . tab === 'local' ) card . querySelector ( '.actions' ) . append ( card . querySelector ( '[data-delete]' ) ) ; } ) ;
if ( state . tab === 'local' ) {
const updateMarks = ( ) => { const selected = [ ... grid . querySelectorAll ( '.asset-select:checked' ) ] . map ( box => state . gallery . find ( item => item . asset _id === box . dataset . asset ) ) ; const starred = selected . some ( item => item ? . starred ) , excluded = selected . some ( item => item ? . excluded ) ; $ ( '#asset-batch-star' ) . disabled = ! selected . length || excluded ; $ ( '#asset-batch-star' ) . title = excluded ? t ( 'clearExclusionFirst' ) : '' ; $ ( '#asset-batch-exclude' ) . disabled = ! selected . length || starred ; $ ( '#asset-batch-exclude' ) . title = starred ? t ( 'clearStarFirst' ) : '' ; } ;
grid . querySelectorAll ( '.asset-select' ) . forEach ( box => box . onchange = updateMarks ) ;
for ( const id of [ 'asset-all' , 'asset-invert' ] ) { const previous = $ ( '#' + id ) . onclick ; $ ( '#' + id ) . onclick = event => { previous ( event ) ; updateMarks ( ) ; } ; } updateMarks ( ) ;
}
$ ( '#gallery-search' ) . value = state . galleryQuery || '' ; $ ( '#gallery-sort' ) . value = state . galleryOrder || 'newest' ; $ ( '#gallery-search' ) . oninput ( ) ; $ ( '#gallery-sort' ) . onchange ( ) ;
}
async function weatherSettings ( ) {
@@ -157,7 +162,7 @@ async function photoSourceForm(source=null){
}
async function browseSource ( id , offset = 0 , refresh = false ) {
const result = await api ( '/api/v1/photo-sources/' + id + '/images?device_id=' + encodeURIComponent ( state . device ) + '&offset=' + offset + '&limit=50&refresh=' + refresh ) ; const box = $ ( '#source-browser' ) ; if ( ! box ) return ;
box . innerHTML = ` <h4> ${ t ( 'sourceImages' ) } · ${ result . total } ${ result . total >= result . catalog _limit ? ' (limit)' : '' } </h4><p class="hint"> ${ t ( 'sourceBrowseHint' ) } ${ t ( 'excludePhotoHelp' ) } </p><div class="source-items"> ${ result . items . map ( ( x , i ) => ` <article class="source-photo"><button data-source-item=" ${ i } "> ${ esc ( x . name ) } ${ x . width ? ` <small> ${ x . width } × ${ x . height } </small> ` : '' } ${ x . caption ? ` <small> ${ esc ( [ x . caption . date , x . caption . people , x . caption . title , x . caption . place , x . caption . filename ] . filter ( Boolean ) . join ( ' · ' ) ) } </small> ` : '' } </button><div class="actions"><button data-source-star=" ${ i } " aria-pressed=" ${ Boolean ( x . starred ) } "> ${ t ( x . starred ? 'unstar' : 'star' ) } </button><button data-source-exclude=" ${ i } " aria-pressed=" ${ Boolean ( x . excluded ) } "> ${ t ( x . excluded ? 'includePhoto' : 'excludePhoto' ) } </button></div> ${ x . excluded ? ` <small class="excluded-photo"> ${ t ( 'photoExcluded' ) } </small> ` : '' } </article> ` ) . join ( '' ) || t ( 'noSourcePhotos' ) } </div><div class="actions"><button id="source-prev" ${ offset ? '' : 'disabled' } > ${ t ( 'previousPage' ) } </button><button id="source-next" ${ offset + result . items . length < result . total ? '' : 'disabled' } > ${ t ( 'nextPage' ) } </button> ${ buttons ( 'source-refresh' , 'refresh' ) } </div> ` ;
box . innerHTML = ` <h4> ${ t ( 'sourceImages' ) } · ${ result . total } ${ result . total >= result . catalog _limit ? ' (limit)' : '' } </h4><p class="hint"> ${ t ( 'sourceBrowseHint' ) } ${ t ( 'excludePhotoHelp' ) } </p><div class="source-items"> ${ result . items . map ( ( x , i ) => ` <article class="source-photo"><button data-source-item=" ${ i } "> ${ esc ( x . name ) } ${ x . width ? ` <small> ${ x . width } × ${ x . height } </small> ` : '' } ${ x . caption ? ` <small> ${ esc ( [ x . caption . date , x . caption . people , x . caption . title , x . caption . place , x . caption . filename ] . filter ( Boolean ) . join ( ' · ' ) ) } </small> ` : '' } </button><div class="actions"><button data-source-star=" ${ i } " aria-pressed=" ${ Boolean ( x . starred ) } " ${ x . excluded ? 'disabled' : '' } title=" ${ x . excluded ? t ( 'clearExclusionFirst' ) : '' } " >${ t ( x . starred ? 'unstar' : 'star' ) } </button><button data-source-exclude=" ${ i } " aria-pressed=" ${ Boolean ( x . excluded ) } " ${ x . starred ? 'disabled' : '' } title=" ${ x . starred ? t ( 'clearStarFirst' ) : '' } "> ${ t ( x . excluded ? 'includePhoto' : 'excludePhoto' ) } </button></div> ${ x . starred ? ` <small class="photo-mark"> ${ t ( 'photoStarred' ) } </small> ` : '' } ${ x . excluded ? ` <small class="excluded-photo"> ${ t ( 'photoExcluded' ) } </small> ` : '' } </article> ` ) . join ( '' ) || t ( 'noSourcePhotos' ) } </div><div class="actions"><button id="source-prev" ${ offset ? '' : 'disabled' } > ${ t ( 'previousPage' ) } </button><button id="source-next" ${ offset + result . items . length < result . total ? '' : 'disabled' } > ${ t ( 'nextPage' ) } </button> ${ buttons ( 'source-refresh' , 'refresh' ) } </div> ` ;
for ( const key of [ 'star' , 'exclude' ] ) box . querySelectorAll ( '[data-source-' + key + ']' ) . forEach ( button => button . onclick = busy ( async ( ) => { const item = result . items [ Number ( button . dataset [ key === 'star' ? 'sourceStar' : 'sourceExclude' ] ) ] , field = key === 'star' ? 'starred' : 'excluded' ; await api ( '/api/v1/photo-sources/' + id + '/preferences?device_id=' + encodeURIComponent ( state . device ) , { method : 'PUT' , body : { item _id : item . id , [ field ] : ! item [ field ] } } ) ; await browseSource ( id , offset ) ; } ) ) ;
box . querySelectorAll ( '[data-source-item]' ) . forEach ( b => b . onclick = busy ( ( ) => previewSource ( id , result . items [ Number ( b . dataset . sourceItem ) ] . id ) ) ) ; bind ( 'source-prev' , ( ) => browseSource ( id , Math . max ( 0 , offset - 50 ) ) ) ; bind ( 'source-next' , ( ) => browseSource ( id , offset + 50 ) ) ; bind ( 'source-refresh' , ( ) => browseSource ( id , 0 , true ) ) ;
}