Jelajahi Sumber

Add infinite scroll to Photo app

- Updated Photo app homepage
- Add infinite scroll to save bandwidth on mobile
Toby Chui 2 hari lalu
induk
melakukan
79134905b5
2 mengubah file dengan 352 tambahan dan 131 penghapusan
  1. 264 86
      src/web/Photo/index.html
  2. 88 45
      src/web/Photo/photo.js

+ 264 - 86
src/web/Photo/index.html

@@ -27,14 +27,181 @@
             flex: 0 0 auto;
             width: 100%;
             height: auto;
+            margin-left: 0.4em;
+            margin-top: 0.4em;
         }
         
         #path-selector {
-            flex: 0 0 auto;
-            padding: 1em;
-            background: #2c2c2c;
-            border-bottom: 1px solid #444;
-            color: #ffffff;
+            display: none;
+        }
+
+        #content-area {
+            flex: 1;
+            display: flex;
+            flex-direction: row;
+            min-height: 0;
+            overflow: hidden;
+        }
+
+        #sidebar {
+            width: 210px;
+            min-width: 210px;
+            background: #1e1e1e;
+            border-right: 1px solid #333;
+            overflow-y: auto;
+            overflow-x: hidden;
+            display: flex;
+            flex-direction: column;
+            flex-shrink: 0;
+            padding-top: 3em;
+        }
+
+        /* Hide the top menu bar on desktop — only needed for mobile toggle */
+        @media (min-width: 769px) {
+            #menu { 
+                display: none; 
+            }
+            #sidebar {
+                padding-top: 0.4em;
+            }
+        }
+
+        .sidebar-section {
+            padding: 0.75em 0.5em 0.4em 0.5em;
+        }
+
+        .sidebar-section-title {
+            font-size: 0.68em;
+            text-transform: uppercase;
+            letter-spacing: 0.12em;
+            color: #555;
+            padding: 0 0.4em;
+            margin-bottom: 0.4em;
+        }
+
+        .sidebar-item {
+            display: flex;
+            align-items: center;
+            padding: 0.38em 0.6em;
+            border-radius: 5px;
+            cursor: pointer;
+            color: #aaa;
+            font-size: 0.87em;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            text-decoration: none;
+            margin: 1px 0.3em;
+            user-select: none;
+        }
+
+        .sidebar-item:hover {
+            background: #2a2a2a;
+            color: #fff;
+        }
+
+        .sidebar-item.active {
+            color: #f76c5d;
+            background: rgba(247, 108, 93, 0.12);
+        }
+
+        .sidebar-item.disk-root {
+            color: #888;
+            cursor: default;
+        }
+
+        .sidebar-item i {
+            flex-shrink: 0;
+            margin-right: 0.6em;
+            margin-top: -8px;
+            width: 1em;
+            text-align: center;
+        }
+
+        .sidebar-banner {
+            margin-top: auto;
+            padding: 1em 0.8em;
+            border-top: 1px solid #2a2a2a;
+            flex-shrink: 0;
+        }
+
+        .sidebar-banner img {
+            max-width: 100%;
+            max-height: 42px;
+            opacity: 1;
+        }
+
+        .sidebar-divider {
+            height: 1px;
+            background: #2a2a2a;
+            margin: 0.3em 0.8em;
+        }
+
+        .sidebar-select {
+            width: 100%;
+            background: #252525;
+            color: #bbb;
+            border: 1px solid #3a3a3a;
+            border-radius: 5px;
+            padding: 0.45em 2em 0.45em 0.7em;
+            font-size: 0.87em;
+            cursor: pointer;
+            outline: none;
+            appearance: none;
+            -webkit-appearance: none;
+            background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23777' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
+            background-repeat: no-repeat;
+            background-position: right 0.7em center;
+            background-size: 0.65em;
+            box-sizing: border-box;
+            transition: border-color 0.15s, color 0.15s;
+        }
+
+        .sidebar-select:hover,
+        .sidebar-select:focus {
+            border-color: #f76c5d;
+            color: #fff;
+            outline: none;
+        }
+
+        .sidebar-select option {
+            background: #252525;
+            color: #bbb;
+        }
+
+        #sidebar-overlay {
+            display: none;
+        }
+
+        @media (max-width: 768px) {
+            /* Keep the top menu bar above the sidebar so the toggle button is always clickable */
+            #menu {
+                position: relative;
+                z-index: 201;
+            }
+            #sidebar {
+                position: fixed;
+                top: 0;
+                left: 0;
+                height: 100%;
+                z-index: 200;
+                transform: translateX(-100%);
+                transition: transform 0.25s ease;
+                box-shadow: 3px 0 12px rgba(0,0,0,0.6);
+            }
+            #sidebar.open {
+                transform: translateX(0);
+            }
+            #sidebar-toggle-btn {
+                display: flex !important;
+            }
+            #sidebar-overlay {
+                position: fixed;
+                top: 0; left: 0;
+                width: 100%; height: 100%;
+                background: rgba(0,0,0,0.5);
+                z-index: 199;
+            }
         }
         
         #display {
@@ -42,6 +209,7 @@
             margin: 0;
             overflow: hidden;
             background: #1a1a1a;
+            min-width: 0;
         }
         
         body {
@@ -85,12 +253,6 @@
             backdrop-filter: blur(2px);
         }
 
-        .subfolders-container {
-            display: flex;
-            flex-wrap: wrap;
-            gap: 0.5em;
-        }
-
         #noimg {
             text-align: center;
             margin: 2em auto;
@@ -445,95 +607,111 @@
 <body>
     <div id="main" x-data='photoListObject()' x-init="init()">
         <div class="ui inverted secondary small menu" id="menu">
-            <div class="item">
-                <img class="ui fluid image" src="img/banner.png" style="max-height: 40px;">
-            </div>
-            <div class="ui inverted dropdown item">
-                Disk <i class="dropdown icon"></i>
-                <div class="menu">
+            <!-- Mobile sidebar toggle (hidden on desktop via CSS) -->
+            <a class="icon item" id="sidebar-toggle-btn" x-on:click="sidebarOpen = !sidebarOpen;" style="display:none;">
+                <i class="ui bars large icon"></i>
+            </a>
+        </div>
+
+        <!-- Mobile sidebar overlay -->
+        <div id="sidebar-overlay" x-show="sidebarOpen" x-on:click="sidebarOpen = false;"></div>
+
+        <div id="content-area">
+            <!-- Sidebar folder tree -->
+            <div id="sidebar" :class="{ 'open': sidebarOpen }">
+
+                <div class="sidebar-section">
+                    <div class="sidebar-section-title">Sort By</div>
+                    <div style="padding: 0 0.4em;">
+                        <select class="sidebar-select" x-model="sortOrder" x-on:change="getFolderInfo();">
+                            <option value="smart">Natural Order</option>
+                            <option value="mostRecent">Newest First</option>
+                            <option value="leastRecent">Oldest First</option>
+                            <option value="default">Filename A→Z</option>
+                            <option value="reverse">Filename Z→A</option>
+                            <option value="smallToLarge">Size Small→Large</option>
+                            <option value="largeToSmall">Size Large→Small</option>
+                            <option value="fileTypeAsce">Extension A→Z</option>
+                            <option value="fileTypeDesc">Extension Z→A</option>
+                        </select>
+                    </div>
+                </div>
+
+                <div class="sidebar-divider"></div>
+
+                <div class="sidebar-section">
+                    <div class="sidebar-section-title">Library</div>
                     <template x-for="vroot in vroots">
-                        <a class="item" :rootpath="vroot[1]" x-on:click="updateRenderingPath(vroot[2]);"><i class="ui disk icon"></i> <span x-text="vroot[0] + ' (' + vroot[1] + ')'"></span></a>
+                        <a class="sidebar-item" x-on:click="updateRenderingPath(vroot[2]);">
+                            <i class="ui hdd outline icon"></i>
+                            <span x-text="vroot[0]"></span>
+                        </a>
                     </template>
-                    
-                </div>
-            </div>
-            <div class="ui inverted dropdown item">
-                Sort By <i class="dropdown icon"></i>
-                <div class="menu">
-                    <a class="item" x-on:click="changeSort('default');" :class="{ 'active': sortOrder === 'default' }"><i class="ui sort alphabet up icon"></i> Filename Ascending</a>
-                    <a class="item" x-on:click="changeSort('reverse');" :class="{ 'active': sortOrder === 'reverse' }"><i class="ui sort alphabet down icon"></i> Filename Descending</a>
-                    <a class="item" x-on:click="changeSort('smallToLarge');" :class="{ 'active': sortOrder === 'smallToLarge' }"><i class="ui sort amount up icon"></i> Size Small to Large</a>
-                    <a class="item" x-on:click="changeSort('largeToSmall');" :class="{ 'active': sortOrder === 'largeToSmall' }"><i class="ui sort amount down icon"></i> Size Large to Small</a>
-                    <a class="item" x-on:click="changeSort('mostRecent');" :class="{ 'active': sortOrder === 'mostRecent' }"><i class="ui calendar icon"></i> Newest First</a>
-                    <a class="item" x-on:click="changeSort('leastRecent');" :class="{ 'active': sortOrder === 'leastRecent' }"><i class="ui calendar outline icon"></i> Oldest First</a>
-                    <a class="item" x-on:click="changeSort('fileTypeAsce');" :class="{ 'active': sortOrder === 'fileTypeAsce' }"><i class="ui file alternate icon"></i> Extension Ascending</a>
-                    <a class="item" x-on:click="changeSort('fileTypeDesc');" :class="{ 'active': sortOrder === 'fileTypeDesc' }"><i class="ui file alternate outline icon"></i> Extension Descending</a>
-                    <a class="item" x-on:click="changeSort('smart');" :class="{ 'active': sortOrder === 'smart' }"><i class="ui magic icon"></i> Natural Order</a>
                 </div>
-            </div>
-            
-            <!-- Right floated menu -->
-            <a class="right floated icon item" x-on:click="toggleViewMode();">
-                <i x-show="viewMode === 'grid'" class="ui bars large icon"></i>
-                <i x-show="viewMode === 'list'" class="ui th large icon"></i>
-            </a>
-
-        </div>
 
-        <div id="path-selector">
-            <div class="ui inverted breadcrumb">
-                <span x-text="currentPath"></span>
-            </div>
-            <div class="ui divider"></div>
-            <div >
-                <a id="parentFolderButton" class="ui basic inverted button" x-on:click="parentFolder();" style="display:none; margin-top: 0.6em;">
-                    <i class="reply icon"></i> Parent Folder
-                </a>
-                <div id="subfolders-buttons" class="subfolders-container" style="margin-top: 0.6em;">
-                    <div class="item" id="nosubfolder" style="display:none;"><!-- <i class="ui green circle check icon"></i> No Sub Folders --></div>
-                    <template x-for="folder in folders">
-                        <a class="ui basic small inverted button" x-on:click="updateRenderingPath(folder);"><i class="ui folder open icon"></i> <span x-text="extractFolderName(folder);"></span></a>
+                <div class="sidebar-divider"></div>
+
+                <div class="sidebar-section">
+                    <div class="sidebar-section-title">Current Path</div>
+                    <template x-for="(seg, idx) in getPathSegments()">
+                        <a class="sidebar-item"
+                           :class="{ 'active': seg.path === currentPath, 'disk-root': seg.isDiskRoot }"
+                           :style="{ paddingLeft: (0.6 + seg.depth * 0.75) + 'em' }"
+                           x-on:click="!seg.isDiskRoot && seg.path !== currentPath && updateRenderingPath(seg.path);">
+                            <i :class="seg.isDiskRoot ? 'ui hdd outline icon' : 'ui folder icon'"></i>
+                            <span x-text="seg.name"></span>
+                        </a>
                     </template>
                 </div>
-            </div>
-        </div>
 
-        <div id="display">
-            <div id="noimg" class="ui basic inverted segment" style="display:none;">
-                <h4 class="ui header">
-                    <div class="content">
-                        Empty Folder
-                        <div class="sub header">There are no photo stored in <span x-text="currentPath + '/'"></span></div>
-                    </div>
-                </h4>
-            </div>
-            <div id="viewboxContainer">
-                <div x-show="viewMode === 'grid'" id="viewbox" class="ui six cards viewbox">
-                    <template x-for="image in images">
-                        <div class="imagecard" style="cursor: pointer;" x-on:click="showImage($el); ShowModal();" :style="{width: renderSize + 'px', height: renderSize + 'px'}" :filedata="encodeURIComponent(JSON.stringify({'filename':image.filepath.split('/').pop(),'filepath':image.filepath,'filesize':image.filesize}))">
-                            <a class="image" x-init="updateImageSizes();">
-                                <img :src="'../system/file_system/loadThumbnail?bytes=true&vpath=' + image.filepath">
+                <div x-show="folders.length > 0">
+                    <div class="sidebar-divider"></div>
+                    <div class="sidebar-section">
+                        <div class="sidebar-section-title">Subfolders</div>
+                        <template x-for="folder in folders">
+                            <a class="sidebar-item" x-on:click="updateRenderingPath(folder);">
+                                <i class="ui folder open icon"></i>
+                                <span x-text="extractFolderName(folder)"></span>
                             </a>
+                        </template>
+                    </div>
+                </div>
+
+                <!-- Banner pinned to bottom of sidebar -->
+                <div class="sidebar-banner">
+                    <img src="img/banner.png" alt="Photo">
+                </div>
+
+            </div><!-- /#sidebar -->
+
+            <div id="display">
+                <div id="noimg" class="ui basic inverted segment" style="display:none;">
+                    <h4 class="ui header">
+                        <div class="content">
+                            Empty Folder
+                            <div class="sub header">There are no photo stored in <span x-text="currentPath + '/'"></span></div>
                         </div>
-                    </template>
+                    </h4>
                 </div>
-                <div x-show="viewMode === 'list'" class="ui relaxed divided inverted list">
-                    <template x-for="image in images">
-                        <div class="item" style="cursor: pointer; padding-left: 10px; " x-on:click="showImage($el); ShowModal();" :filedata="encodeURIComponent(JSON.stringify({'filename':image.filepath.split('/').pop(),'filepath':image.filepath,'filesize':image.filesize}))">
-                            <img class="ui small image" :src="'../system/file_system/loadThumbnail?bytes=true&vpath=' + image.filepath"
-                                 style="width: 60px; height: 60px;">
-                            <div class="content">
-                                <div class="header" x-text="image.filepath.split('/').pop()"></div>
-                                <div class="description" x-text="image.filepath"></div>
+                <div id="viewboxContainer">
+                    <div id="viewbox" class="ui six cards viewbox">
+                        <template x-for="image in images">
+                            <div class="imagecard" style="cursor: pointer;" x-on:click="showImage($el); ShowModal();" :style="{width: renderSize + 'px', height: renderSize + 'px'}" :filedata="encodeURIComponent(JSON.stringify({'filename':image.filepath.split('/').pop(),'filepath':image.filepath,'filesize':image.filesize}))">
+                                <a class="image" x-init="updateImageSizes();">
+                                    <img :src="'../system/file_system/loadThumbnail?bytes=true&vpath=' + image.filepath">
+                                </a>
                             </div>
-                        </div>
-                    </template>
+                        </template>
+                    </div>
+                    <!-- Infinite scroll sentinel -->
+                    <div id="load-more-indicator" style="text-align:center; padding: 1em; color: #aaa;" x-show="hasMoreImages">
+                        <i class="loading spinner icon"></i> Loading more photos...
+                    </div>
                 </div>
             </div>
-        </div>
 
-        
-    </div>
+        </div><!-- /#content-area -->
+    </div><!-- /#main -->
     <!-- Photo Viewer -->
     <div id="photo-viewer" class="photo-viewer">
         

+ 88 - 45
src/web/Photo/photo.js

@@ -6,6 +6,9 @@
 
 */
 
+// Number of photos to load per page (infinite scroll batch size)
+const PAGE_SIZE = 40; //large enough to fill the whole page on load, but small enough to keep initial load fast and responsive
+
 let photoList = [];
 let prePhoto = "";
 let nextPhoto = "";
@@ -27,30 +30,28 @@ function getViewableImageUrl(filepath, callback) {
     callback(imageUrl, true, false, isRawImage(filepath) ? 'backend_raw' : 'direct');
 }
 
-function scrollbarVisable(){
-    return $("body")[0].scrollHeight > $("body").height();
-}
-
 function getImageWidth(){
-    let boxCount = 4;
-    if (window.innerWidth < 500) {
+    // Use the actual viewbox container width so the sidebar and scrollbar are
+    // already subtracted — this prevents gaps when the window is resized.
+    const container = document.getElementById('viewboxContainer');
+    const containerWidth = container ? container.clientWidth : (window.innerWidth - 210);
+
+    let boxCount;
+    if (containerWidth < 400) {
+        boxCount = 2;
+    } else if (containerWidth < 600) {
         boxCount = 3;
-    } else if (window.innerWidth < 800) {
+    } else if (containerWidth < 900) {
         boxCount = 4;
-    } else if (window.innerWidth < 1200) {
+    } else if (containerWidth < 1100) {
         boxCount = 5;
-    }else if (window.innerWidth < 1600){
+    } else if (containerWidth < 1400) {
         boxCount = 6;
     } else {
         boxCount = 8;
     }
 
-    let offsets = 2;
-    if (scrollbarVisable()){
-        offsets = offsets * 1.2;
-    }
-
-    return window.innerWidth / boxCount - offsets;
+    return Math.floor(containerWidth / boxCount);
 }
 
 function updateImageSizes(){
@@ -97,12 +98,15 @@ function photoListObject() {
         currentPath: "user:/Photo",
         renderSize: 200,
         vroots: [],
-        images: [],
+        allImages: [],       // full list from server
+        images: [],           // currently displayed slice
         folders: [],
-        viewMode: 'grid', // 'grid' or 'list'
         sortOrder: 'smart',
         restored: false,
-        
+        hasMoreImages: false,
+        isLoadingMore: false, // guard: blocks new batch until DOM has updated
+        sidebarOpen: !isMobile,  // start hidden on mobile, visible on desktop
+
         // init
         init() {
             this.getFolderInfo();
@@ -110,27 +114,50 @@ function photoListObject() {
             this.renderSize = getImageWidth();
             updateImageSizes();
             this.restored = false;
+            this.$nextTick(() => { this.setupInfiniteScroll(); });
+
+            const MOBILE_BP = 768;
+            let _prevMobile = window.innerWidth <= MOBILE_BP;
+            let _resizeTimer;
+            window.addEventListener('resize', () => {
+                clearTimeout(_resizeTimer);
+                _resizeTimer = setTimeout(() => {
+                    // Recalculate tile sizes
+                    this.renderSize = getImageWidth();
+                    updateImageSizes();
+
+                    // Auto-manage sidebar visibility on breakpoint crossing
+                    const nowMobile = window.innerWidth <= MOBILE_BP;
+                    if (nowMobile && !_prevMobile) {
+                        // Desktop → mobile: hide sidebar so it doesn't overlay content
+                        this.sidebarOpen = false;
+                    } else if (!nowMobile && _prevMobile) {
+                        // Mobile → desktop: sidebar is back in normal flow, keep state clean
+                        this.sidebarOpen = false;
+                    }
+                    _prevMobile = nowMobile;
+                }, 80);
+            });
         },
         
         updateRenderingPath(newPath, callback = null){
             this.currentPath = JSON.parse(JSON.stringify(newPath));
             this.pathWildcard = newPath + '/*';
-            if (this.pathWildcard.split("/").length == 3){
-                //Root path already
-                $("#parentFolderButton").hide();
-            }else{
-                $("#parentFolderButton").show();
-            }
             this.restored = false;
+            if (isMobile) this.sidebarOpen = false;
             this.getFolderInfo(callback);
         },
 
-        parentFolder(){
-            var parentPath = JSON.parse(JSON.stringify(this.currentPath));
-            parentPath = parentPath.split("/");
-            parentPath.pop();
-            this.currentPath = parentPath.join("/");
-            this.updateRenderingPath( this.currentPath);
+        // Returns path segments for the sidebar breadcrumb tree
+        getPathSegments() {
+            const parts = this.currentPath.split('/');
+            let segments = [];
+            let accumulated = '';
+            for (let i = 0; i < parts.length; i++) {
+                accumulated = i === 0 ? parts[0] : accumulated + '/' + parts[i];
+                segments.push({ name: parts[i], path: accumulated, depth: i, isDiskRoot: i === 0 });
+            }
+            return segments;
         },
 
         getFolderInfo(callback = null) {
@@ -148,19 +175,17 @@ function photoListObject() {
                 resp.json().then(data => {
                     console.log(data);
                     this.folders = data[0];
-                    this.images = data[1];
+                    this.allImages = data[1];
+                    this.images = this.allImages.slice(0, PAGE_SIZE);
+                    this.hasMoreImages = this.allImages.length > PAGE_SIZE;
+                    this.isLoadingMore = false;
 
-                    if (this.images.length == 0){
+                    if (this.allImages.length == 0){
                         $("#noimg").show();
                     }else{
                         $("#noimg").hide();
                     }
 
-                    if (this.folders.length == 0){
-                        $("#nosubfolder").show();
-                    }else{
-                        $("#nosubfolder").hide();
-                    }
                     console.log(this.folders);
 
                     if (!this.restored) { restoreFromHash(); this.restored = true; }
@@ -188,17 +213,35 @@ function photoListObject() {
             })
         },
 
-        toggleViewMode() {
-            this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
-            if (this.viewMode === 'grid') {
-                this.renderSize = getImageWidth();
-                updateImageSizes();
-            }
-        },
-
         changeSort(newSort) {
             this.sortOrder = newSort;
             this.getFolderInfo();
+        },
+
+        // Load the next PAGE_SIZE images into the displayed list
+        loadMoreImages() {
+            if (this.isLoadingMore) return;
+            const current = this.images.length;
+            if (current >= this.allImages.length) return;
+            this.isLoadingMore = true;
+            const next = this.allImages.slice(current, current + PAGE_SIZE);
+            this.images = this.images.concat(next);
+            this.hasMoreImages = this.images.length < this.allImages.length;
+            // Release the guard only after Alpine has re-rendered and scrollHeight has grown
+            this.$nextTick(() => { this.isLoadingMore = false; });
+        },
+
+        // Attach a scroll listener to the viewbox container for infinite scroll
+        setupInfiniteScroll() {
+            const container = document.getElementById('viewboxContainer');
+            if (!container) return;
+            container.addEventListener('scroll', () => {
+                const { scrollTop, scrollHeight, clientHeight } = container;
+                // Trigger when within 300px of the bottom
+                if (scrollTop + clientHeight >= scrollHeight - 300) {
+                    this.loadMoreImages();
+                }
+            });
         }
     }
 }