@@ -30,6 +30,8 @@ const photosView = {
3030 _activeDecodes : 0 ,
3131 /** @type {Array } Pending video decode queue */
3232 _decodeQueue : [ ] ,
33+ /** @type {number } Items already rendered in the DOM */
34+ _renderedCount : 0 ,
3335
3436 PAGE_SIZE : 200 ,
3537
@@ -66,7 +68,8 @@ const photosView = {
6668 this . nextCursor = null ;
6769 this . exhausted = false ;
6870 this . selected . clear ( ) ;
69- this . _render ( ) ;
71+ this . _renderedCount = 0 ;
72+ this . _container . innerHTML = '' ;
7073 this . _loadPage ( ) ;
7174 } ,
7275
@@ -84,14 +87,16 @@ const photosView = {
8487 if ( this . groupMode === mode ) return ;
8588 this . groupMode = mode ;
8689 localStorage . setItem ( 'oxicloud-photos-group' , mode ) ;
87- this . _render ( ) ;
90+ this . _renderedCount = 0 ;
91+ this . _renderFull ( ) ;
8892 } ,
8993
9094 /** Fetch a page of photos from the API */
9195 async _loadPage ( ) {
9296 if ( this . loading || this . exhausted ) return ;
9397 this . loading = true ;
9498 this . _showLoading ( true ) ;
99+ const prevCount = this . items . length ;
95100
96101 try {
97102 let url = `/api/photos?limit=${ this . PAGE_SIZE } ` ;
@@ -125,74 +130,129 @@ const photosView = {
125130 } finally {
126131 this . loading = false ;
127132 this . _showLoading ( false ) ;
128- this . _render ( ) ;
133+ if ( prevCount === 0 ) {
134+ this . _renderFull ( ) ;
135+ } else {
136+ this . _appendBatch ( prevCount ) ;
137+ }
129138 }
130139 } ,
131140
132- /** Render the full timeline from this.items */
133- _render ( ) {
141+ // ── Rendering ───────────────────────────────────────────────────
142+ // Two render paths:
143+ // _renderFull() — full DOM rebuild (first load, group-mode change, delete)
144+ // _appendBatch(n) — append-only for infinite-scroll pages (O(batch))
145+
146+ /** Full DOM rebuild — first load, group-mode switch, or after deletions. */
147+ _renderFull ( ) {
134148 if ( ! this . _container ) return ;
135149 this . _destroyObserver ( ) ;
136150
137- // Set group mode class on container
138151 this . _container . classList . remove ( 'photos-group-daily' , 'photos-group-monthly' , 'photos-group-yearly' ) ;
139152 this . _container . classList . add ( `photos-group-${ this . groupMode } ` ) ;
140153
141154 if ( this . items . length === 0 && this . exhausted ) {
142155 this . _renderEmpty ( ) ;
143156 return ;
144157 }
145-
146158 if ( this . items . length === 0 ) return ;
147159
148- // Group by selected mode
149160 const groups = this . _groupItems ( this . items ) ;
150161 let html = this . _renderToolbar ( ) ;
151162
152163 for ( const [ label , files ] of groups ) {
153- html += `<div class="photos-day-header">${ this . _escHtml ( label ) } <span class="photos-day-count">${ files . length } </span></div>` ;
164+ html += `<div class="photos-day-header" data-group=" ${ this . _escAttr ( label ) } " >${ this . _escHtml ( label ) } <span class="photos-day-count">${ files . length } </span></div>` ;
154165 html += '<div class="photos-grid">' ;
155- for ( const file of files ) {
156- const isVideo = file . mime_type && file . mime_type . startsWith ( 'video/' ) ;
157- const selected = this . selected . has ( file . id ) ? ' selected' : '' ;
158- // For videos with a cached local thumb, use it directly;
159- // avoids the 204 → error → re-decode cycle on re-renders.
160- const cachedThumb = isVideo && this . _videoThumbCache . has ( file . id )
161- ? this . _videoThumbCache . get ( file . id )
162- : null ;
163- const thumbUrl = cachedThumb || `/api/files/${ file . id } /thumbnail/preview` ;
164- html += `<div class="photo-tile${ selected } " data-id="${ this . _escAttr ( file . id ) } " data-mime="${ this . _escAttr ( file . mime_type ) } ">` ;
165- html += `<div class="photo-check"><i class="fas fa-check"></i></div>` ;
166- html += `<img src="${ thumbUrl } " loading="lazy" alt="${ this . _escAttr ( file . name ) } ">` ;
167- if ( isVideo ) {
168- html += `<div class="video-badge"><i class="fas fa-play"></i></div>` ;
169- }
170- html += `</div>` ;
171- }
166+ for ( const file of files ) html += this . _renderTile ( file ) ;
172167 html += '</div>' ;
173168 }
174169
175- // Sentinel for infinite scroll
176170 html += '<div class="photos-sentinel"></div>' ;
177-
178171 this . _container . innerHTML = html ;
179-
180- // Attach click handlers via delegation
181172 this . _container . onclick = ( e ) => this . _handleClick ( e ) ;
173+ this . _renderedCount = this . items . length ;
174+ this . _observeSentinel ( ) ;
175+ this . _setupVideoThumbnails ( ) ;
176+ } ,
182177
183- // Observe sentinel for infinite scroll
178+ /** Append-only render for infinite scroll — inserts only the items
179+ * from this.items[startIndex..] without destroying existing DOM.
180+ * Complexity: O(batch) instead of O(total_items). */
181+ _appendBatch ( startIndex ) {
182+ if ( ! this . _container ) return ;
183+ this . _destroyObserver ( ) ;
184+
185+ const newItems = this . items . slice ( startIndex ) ;
186+ if ( newItems . length === 0 ) {
187+ this . _observeSentinel ( ) ;
188+ return ;
189+ }
190+
191+ const newGroups = this . _groupItems ( newItems ) ;
184192 const sentinel = this . _container . querySelector ( '.photos-sentinel' ) ;
193+ if ( ! sentinel ) {
194+ // Fallback: sentinel missing — full rebuild
195+ this . _renderedCount = 0 ;
196+ this . _renderFull ( ) ;
197+ return ;
198+ }
199+
200+ for ( const [ label , files ] of newGroups ) {
201+ let tilesHtml = '' ;
202+ for ( const file of files ) tilesHtml += this . _renderTile ( file ) ;
203+
204+ // Does this date-group already exist in the DOM?
205+ const existingHeader = this . _container . querySelector (
206+ `.photos-day-header[data-group="${ CSS . escape ( label ) } "]`
207+ ) ;
208+
209+ if ( existingHeader ) {
210+ // Append tiles to existing grid and update count badge
211+ const grid = existingHeader . nextElementSibling ;
212+ if ( grid && grid . classList . contains ( 'photos-grid' ) ) {
213+ grid . insertAdjacentHTML ( 'beforeend' , tilesHtml ) ;
214+ const countSpan = existingHeader . querySelector ( '.photos-day-count' ) ;
215+ if ( countSpan ) countSpan . textContent = grid . children . length ;
216+ }
217+ } else {
218+ // New group — insert header + grid before sentinel
219+ const sectionHtml =
220+ `<div class="photos-day-header" data-group="${ this . _escAttr ( label ) } ">${ this . _escHtml ( label ) } <span class="photos-day-count">${ files . length } </span></div>` +
221+ `<div class="photos-grid">${ tilesHtml } </div>` ;
222+ sentinel . insertAdjacentHTML ( 'beforebegin' , sectionHtml ) ;
223+ }
224+ }
225+
226+ this . _renderedCount = this . items . length ;
227+ this . _observeSentinel ( ) ;
228+ this . _setupVideoThumbnails ( startIndex ) ;
229+ } ,
230+
231+ /** Generate HTML for a single photo/video tile */
232+ _renderTile ( file ) {
233+ const isVideo = file . mime_type && file . mime_type . startsWith ( 'video/' ) ;
234+ const selected = this . selected . has ( file . id ) ? ' selected' : '' ;
235+ const cachedThumb = isVideo && this . _videoThumbCache . has ( file . id )
236+ ? this . _videoThumbCache . get ( file . id ) : null ;
237+ const thumbUrl = cachedThumb || `/api/files/${ file . id } /thumbnail/preview` ;
238+ let h = `<div class="photo-tile${ selected } " data-id="${ this . _escAttr ( file . id ) } " data-mime="${ this . _escAttr ( file . mime_type ) } ">` ;
239+ h += `<div class="photo-check"><i class="fas fa-check"></i></div>` ;
240+ h += `<img src="${ thumbUrl } " loading="lazy" alt="${ this . _escAttr ( file . name ) } ">` ;
241+ if ( isVideo ) h += `<div class="video-badge"><i class="fas fa-play"></i></div>` ;
242+ h += `</div>` ;
243+ return h ;
244+ } ,
245+
246+ /** (Re-)observe the sentinel element for infinite scroll */
247+ _observeSentinel ( ) {
248+ this . _destroyObserver ( ) ;
249+ const sentinel = this . _container ?. querySelector ( '.photos-sentinel' ) ;
185250 if ( sentinel && ! this . exhausted ) {
186251 this . _observer = new IntersectionObserver ( ( entries ) => {
187- if ( entries [ 0 ] . isIntersecting ) {
188- this . _loadPage ( ) ;
189- }
252+ if ( entries [ 0 ] . isIntersecting ) this . _loadPage ( ) ;
190253 } , { rootMargin : '400px' } ) ;
191254 this . _observer . observe ( sentinel ) ;
192255 }
193-
194- // Set up client-side video thumbnail generation
195- this . _setupVideoThumbnails ( ) ;
196256 } ,
197257
198258 // ── Client-side video thumbnail generation ──────────────────────
@@ -202,18 +262,22 @@ const photosView = {
202262
203263 /** Attach error handlers to video tile images; on failure, extract a
204264 * frame from the video using the browser's built-in codec. */
205- _setupVideoThumbnails ( ) {
265+ /** @param {number } [startIndex=0] When > 0, only process video tiles
266+ * for items[startIndex..] — avoids re-scanning the entire DOM. */
267+ _setupVideoThumbnails ( startIndex = 0 ) {
206268 const tiles = this . _container . querySelectorAll ( '.photo-tile[data-mime^="video/"]' ) ;
269+ const newIds = startIndex > 0
270+ ? new Set ( this . items . slice ( startIndex ) . map ( f => f . id ) )
271+ : null ;
272+
207273 for ( const tile of tiles ) {
208- const img = tile . querySelector ( 'img' ) ;
209- if ( ! img ) continue ;
210274 const fileId = tile . dataset . id ;
211-
212- // Skip if already cached locally (URL was set during render)
275+ if ( newIds && ! newIds . has ( fileId ) ) continue ;
213276 if ( this . _videoThumbCache . has ( fileId ) ) continue ;
214277
215- // The server returns 204 for videos without a cached thumbnail,
216- // which causes the <img> to fire 'error'.
278+ const img = tile . querySelector ( 'img' ) ;
279+ if ( ! img ) continue ;
280+
217281 img . addEventListener ( 'error' , ( ) => {
218282 this . _enqueueVideoThumbnail ( tile , img ) ;
219283 } , { once : true } ) ;
@@ -241,7 +305,7 @@ const photosView = {
241305 } ,
242306
243307 /** Extract a single frame from a video and display it as the tile
244- * thumbnail, then upload the WebP to the server for caching. */
308+ * thumbnail, then upload the JPEG to the server for caching. */
245309 _generateVideoThumbnail ( tile , img ) {
246310 const fileId = tile . dataset . id ;
247311 const video = document . createElement ( 'video' ) ;
@@ -299,7 +363,7 @@ const photosView = {
299363 if ( resp . ok ) {
300364 // Switch from blob URL to server URL so the blob
301365 // can be garbage-collected and future loads use
302- // the permanently cached WebP from the server.
366+ // the permanently cached JPEG from the server.
303367 const serverUrl = `/api/files/${ fileId } /thumbnail/preview?v=1` ;
304368 this . _videoThumbCache . set ( fileId , serverUrl ) ;
305369 }
@@ -457,7 +521,8 @@ const photosView = {
457521 this . items = this . items . filter ( f => ! this . selected . has ( f . id ) ) ;
458522 this . selected . clear ( ) ;
459523 this . _hideSelectionBar ( ) ;
460- this . _render ( ) ;
524+ this . _renderedCount = 0 ;
525+ this . _renderFull ( ) ;
461526 } ;
462527
463528 bar . querySelector ( '#photos-sel-download' ) . onclick = async ( ) => {
0 commit comments