diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index b4e8831f6..0ce36f231 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -32,47 +32,77 @@ const APP_NAMES_NO_UNINSTALL = new Set([ * along with this program. If not, see . */ -function buildAppsGrid (apps) { - if ( !apps || apps.length === 0 ) { - let h = '
'; - h += ''; - h += ''; - h += ''; - h += ''; - h += '

No apps installed yet

'; - h += '
'; - return h; +function buildTileHtml (app) { + let title = (app.title || app.name || '').trim(); + let targetLink = ''; + + // External apps (not owned by a Puter user) can report an opaque app-… id + // as their title (uid === name === title); in that case show the hostname + // of index_url instead, and open the app's website (index_url) on click — + // matching the Home tab. + const appUid = app.uid || app.uuid; + if ( + app.external && + app.name === app.title && + app.name === appUid && + app.index_url + ) { + title = new URL(app.index_url).hostname; + targetLink = app.index_url; } - let h = '
'; - for ( const app of apps ) { - let title = (app.title || app.name || '').trim(); - let targetLink = ''; + const iconUrl = app.iconUrl || window.icons['app.svg']; - // External apps (not owned by a Puter user) can report an opaque app-… id - // as their title (uid === name === title); in that case show the hostname - // of index_url instead, and open the app's website (index_url) on click — - // matching the Home tab. - const appUid = app.uid || app.uuid; - if ( - app.external && - app.name === app.title && - app.name === appUid && - app.index_url - ) { - title = new URL(app.index_url).hostname; - targetLink = app.index_url; + let h = `
`; + h += '
'; + h += ``; + h += '
'; + h += `${html_encode(title)}`; + h += '
'; + return h; +} + +function buildNoAppsHtml () { + let h = '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += '

No apps installed yet

'; + h += '
'; + return h; +} + +// iOS-home-screen-style pager: fixed cols × rows pages in a horizontal +// scroll-snap scroller, with page dots below and hover arrows for mouse users. +function buildPagerHtml (apps, layout) { + const pageCount = Math.ceil(apps.length / layout.perPage); + + let h = `
`; + + h += '
'; + for ( let p = 0; p < pageCount; p++ ) { + h += '
'; + for ( const app of apps.slice(p * layout.perPage, (p + 1) * layout.perPage) ) { + h += buildTileHtml(app); } - - const iconUrl = app.iconUrl || window.icons['app.svg']; - - h += `
`; - h += '
'; - h += ``; - h += '
'; - h += `${html_encode(title)}`; h += '
'; } + h += '
'; + + h += ''; + h += ''; + + h += `
`; + for ( let p = 0; p < pageCount; p++ ) { + h += ``; + } + h += '
'; + h += '
'; return h; } @@ -113,15 +143,8 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { try { await puter.perms.revokeApp(appUid, '*'); - // Remove from internal state self._apps = self._apps.filter(a => a.name !== appName); - // Remove tile from DOM - $el_window.find(`.myapps-tile[data-app-name="${appName}"]`).remove(); - // Show empty state if no apps left - if ( self._apps.length === 0 ) { - const $container = $el_window.find('.myapps-container'); - $container.html(buildAppsGrid(null)); - } + self.renderApps($el_window, { preservePage: true }); } catch ( err ) { console.error('Failed to uninstall app:', err); } @@ -131,12 +154,14 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { } function revealWhenLoaded ($container) { - const $grid = $container.find('.myapps-grid-loading'); - if ( $grid.length === 0 ) return; + const $pager = $container.find('.myapps-pager-loading'); + if ( $pager.length === 0 ) return; - const imgs = $grid.find('img').toArray(); + // Only the first page's icons gate the fade-in; the other pages are + // offscreen and their icons can finish loading behind it. + const imgs = $pager.find('.myapps-page').first().find('img').toArray(); if ( imgs.length === 0 ) { - $grid.removeClass('myapps-grid-loading'); + $pager.removeClass('myapps-pager-loading'); return; } @@ -146,7 +171,7 @@ function revealWhenLoaded ($container) { function onDone () { loaded++; if ( loaded >= total ) { - $grid.removeClass('myapps-grid-loading'); + $pager.removeClass('myapps-pager-loading'); } } @@ -166,6 +191,9 @@ const TabApps = { icon: '', _apps: null, + _layout: null, + _page: 0, + _pageCount: 0, html () { let h = '
'; @@ -189,46 +217,16 @@ const TabApps = { const self = this; - // Toggle search/clear icons and filter - function updateSearch ($input) { - const query = $input.val().toLowerCase().trim(); - const hasText = query.length > 0; - $el_window.find('.myapps-icon-search').toggle(!hasText); - $el_window.find('.myapps-icon-clear').toggle(hasText); - - if ( ! self._apps ) return; - - const $container = $el_window.find('.myapps-container'); - - if ( ! query ) { - $container.html(buildAppsGrid(self._apps)); - revealWhenLoaded($container); - return; - } - - const filtered = self._apps.filter(app => { - const title = (app.title || '').toLowerCase(); - const name = (app.name || '').toLowerCase(); - return title.includes(query) || name.includes(query); - }); - - $container.html( - filtered.length > 0 - ? buildAppsGrid(filtered) - : '

No apps match your search

', - ); - revealWhenLoaded($container); - } - $el_window.on('input', '.myapps-search', function () { - updateSearch($(this)); + self.updateSearchIcons($el_window); + self.renderApps($el_window); }); // Clear search on cross click $el_window.on('click', '.myapps-icon-clear', function () { - const $input = $el_window.find('.myapps-search'); - $input.val('').focus(); - updateSearch($input); + $el_window.find('.myapps-search').val('').focus(); + self.updateSearchIcons($el_window); + self.renderApps($el_window); }); // Handle app tile clicks. External apps carry a target link (their @@ -282,6 +280,201 @@ const TabApps = { items, }); }); + + // -- Pager navigation -- + + $el_window.on('click', '.myapps-pager-dot', function () { + self.goToPage($el_window, parseInt($(this).attr('data-page'), 10), true); + }); + + $el_window.on('click', '.myapps-pager-arrow-prev', function () { + self.goToPage($el_window, self._page - 1, true); + }); + + $el_window.on('click', '.myapps-pager-arrow-next', function () { + self.goToPage($el_window, self._page + 1, true); + }); + + // Mouse wheel / two-finger vertical swipe flips one page per gesture. + // Horizontal trackpad panning is left to the native scroller, whose + // snap points already handle it. + $el_window.on('wheel', '.myapps-pager-scroller', function (e) { + const oe = e.originalEvent; + if ( Math.abs(oe.deltaX) > Math.abs(oe.deltaY) ) return; + if ( Math.abs(oe.deltaY) < 4 ) return; + e.preventDefault(); + // Inertial scrolling keeps emitting events after the flip; treat + // everything within 150ms of the last event as the same gesture. + clearTimeout(self._wheelTimer); + self._wheelTimer = setTimeout(() => { + self._wheelActive = false; + }, 150); + if ( self._wheelActive ) return; + self._wheelActive = true; + self.goToPage($el_window, self._page + (oe.deltaY > 0 ? 1 : -1), true); + }); + + // Left/right arrow keys flip pages, including while the (empty) search + // box holds focus — it's auto-focused on desktop, Launchpad-style. + $(document).off('keydown.myapps-pager').on('keydown.myapps-pager', function (e) { + if ( e.key !== 'ArrowLeft' && e.key !== 'ArrowRight' ) return; + if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return; + if ( ! $el_window.is(':visible') ) return; + if ( $el_window.find('.myapps-modal-overlay').length ) return; + if ( $('.window').not($el_window[0]).filter(':visible').length ) return; + const ae = document.activeElement; + if ( ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT' || ae.isContentEditable) ) { + if ( ! ($(ae).hasClass('myapps-search') && ae.value === '') ) return; + } + e.preventDefault(); + self.goToPage($el_window, self._page + (e.key === 'ArrowRight' ? 1 : -1), true); + }); + + // Re-paginate when the container resizes (window resize, sidebar + // collapse, tab becoming visible, on-screen keyboard, …). + if ( self._resizeObserver ) self._resizeObserver.disconnect(); + self._resizeObserver = new ResizeObserver(() => { + clearTimeout(self._resizeTimer); + self._resizeTimer = setTimeout(() => { + if ( ! self._apps ) return; + const layout = self.computeLayout($el_window.find('.myapps-container')); + if ( ! layout ) return; + if ( self._layout && layout.cols === self._layout.cols && layout.rows === self._layout.rows ) { + // Same grid, new page width — just re-align the scroller. + self.goToPage($el_window, self._page, false); + return; + } + self.renderApps($el_window, { preservePage: true }); + }, 100); + }); + self._resizeObserver.observe($el_window.find('.myapps-container')[0]); + }, + + updateSearchIcons ($el_window) { + const hasText = String($el_window.find('.myapps-search').val() || '').trim().length > 0; + $el_window.find('.myapps-icon-search').toggle(!hasText); + $el_window.find('.myapps-icon-clear').toggle(hasText); + }, + + computeLayout ($container) { + const el = $container[0]; + if ( ! el || el.clientWidth === 0 || el.clientHeight === 0 ) return null; + + const cs = getComputedStyle(el); + const readVar = (name, fallback) => { + const v = parseFloat(cs.getPropertyValue(name)); + return Number.isFinite(v) ? v : fallback; + }; + const tileW = readVar('--myapps-tile-w', 100); + const tileH = readVar('--myapps-tile-h', 78); + const gapX = readVar('--myapps-gap-x', 32); + const gapY = readVar('--myapps-gap-y', 32); + const dotsH = readVar('--myapps-dots-h', 28); + + const width = el.clientWidth; + const height = Math.max(tileH, el.clientHeight - dotsH); + const cols = Math.max(1, Math.floor((width + gapX) / (tileW + gapX))); + const rows = Math.max(1, Math.floor((height + gapY) / (tileH + gapY))); + return { cols, rows, perPage: cols * rows }; + }, + + // Central renderer: applies the current search query to _apps and rebuilds + // the pager (or an empty state). Everything that changes what's shown — + // load, search, uninstall, re-layout — funnels through here. + renderApps ($el_window, { preservePage = false } = {}) { + if ( ! this._apps ) return; + + const $container = $el_window.find('.myapps-container'); + const query = String($el_window.find('.myapps-search').val() || '').toLowerCase().trim(); + + let list = this._apps; + if ( query ) { + list = list.filter(app => { + const title = (app.title || '').toLowerCase(); + const name = (app.name || '').toLowerCase(); + return title.includes(query) || name.includes(query); + }); + } + + if ( list.length === 0 ) { + this._layout = null; + this._page = 0; + this._pageCount = 0; + $container.html(query + ? '

No apps match your search

' + : buildNoAppsHtml()); + return; + } + + const layout = this.computeLayout($container); + if ( ! layout ) { + // Not laid out yet (e.g. hidden while the window enters full-page + // mode); the ResizeObserver re-renders once there's a size. + return; + } + + const anchorIndex = (preservePage && this._layout) + ? this._page * this._layout.perPage + : 0; + + this._layout = layout; + this._pageCount = Math.ceil(list.length / layout.perPage); + this._page = Math.min(Math.floor(anchorIndex / layout.perPage), this._pageCount - 1); + + $container.html(buildPagerHtml(list, layout)); + revealWhenLoaded($container); + + const scroller = $container.find('.myapps-pager-scroller')[0]; + if ( this._page > 0 ) { + scroller.scrollLeft = this._page * scroller.clientWidth; + } + this.updatePagerUI($el_window); + + // Keep the active dot and arrows in sync with swipes/scrolls. + const self = this; + let ticking = false; + scroller.addEventListener('scroll', () => { + if ( ticking ) return; + ticking = true; + requestAnimationFrame(() => { + ticking = false; + const pageW = scroller.clientWidth || 1; + const idx = Math.round(Math.abs(scroller.scrollLeft) / pageW); + const page = Math.max(0, Math.min(self._pageCount - 1, idx)); + if ( page !== self._page ) { + self._page = page; + self.updatePagerUI($el_window); + } + }); + }, { passive: true }); + }, + + updatePagerUI ($el_window) { + const $container = $el_window.find('.myapps-container'); + const page = this._page; + $container.find('.myapps-pager-dot').each(function (i) { + this.classList.toggle('active', i === page); + if ( i === page ) { + this.setAttribute('aria-current', 'true'); + } else { + this.removeAttribute('aria-current'); + } + }); + $container.find('.myapps-pager-arrow-prev') + .toggleClass('myapps-pager-arrow-hidden', page <= 0); + $container.find('.myapps-pager-arrow-next') + .toggleClass('myapps-pager-arrow-hidden', page >= this._pageCount - 1); + }, + + goToPage ($el_window, index, smooth) { + const scroller = $el_window.find('.myapps-pager-scroller')[0]; + if ( ! scroller || this._pageCount === 0 ) return; + const page = Math.max(0, Math.min(this._pageCount - 1, index)); + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + scroller.scrollTo({ + left: page * scroller.clientWidth, + behavior: (smooth && !reduceMotion) ? 'smooth' : 'auto', + }); }, async loadApps ($el_window) { @@ -340,8 +533,7 @@ const TabApps = { } this._apps = merged; - $container.html(buildAppsGrid(merged)); - revealWhenLoaded($container); + this.renderApps($el_window); } catch (e) { console.error('Failed to load installed apps:', e); $container.html('

Failed to load apps

'); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 4bc154bc2..134efb9e1 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -630,40 +630,22 @@ body { /* My Apps tab */ -.myapps-tab { - max-width: 900px; - padding: 32px 40px; - width: 100%; +/* The Apps tab fills the content area and paginates horizontally + (iOS-home-screen style) instead of scrolling vertically. */ +.dashboard-section-apps { + height: 100%; +} + +.dashboard-tab-content.myapps-tab { + height: 100%; + display: flex; + flex-direction: column; } .myapps-search-wrap { - position: sticky; - top: 0; - z-index: 10; padding-top: 30px; padding-bottom: 30px; margin-bottom: 20px; - margin-left: -40px; - margin-right: -40px; - padding-left: 40px; - padding-right: 40px; - background: var(--dashboard-background); -} - -@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) { - .myapps-search-wrap { - background: rgba(255, 255, 255, 0); - backdrop-filter: blur(20px) saturate(1.5); - -webkit-backdrop-filter: blur(20px) saturate(1.5); - } -} - -@media (prefers-color-scheme: dark) { - @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) { - .myapps-search-wrap { - background: rgba(30, 30, 30, 0.55); - } - } } .myapps-search-inner { @@ -731,11 +713,20 @@ input.myapps-search::-webkit-search-decoration { } .myapps-container { - min-height: 200px; - padding-bottom: 50px; + /* Pager geometry; TabApps.js reads these to size pages. */ + --myapps-tile-w: 100px; + --myapps-tile-h: 78px; + --myapps-gap-x: 32px; + --myapps-gap-y: 32px; + --myapps-dots-h: 28px; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; } .myapps-empty { + flex: 1; display: flex; flex-direction: column; align-items: center; @@ -756,19 +747,146 @@ input.myapps-search::-webkit-search-decoration { margin: 0; } -.myapps-grid { - display: grid; - grid-template-columns: repeat(auto-fill, 100px); - gap: 32px 32px; - justify-content: center; +/* iOS-style pager: pages snap horizontally, dots below, hover arrows */ + +.myapps-pager { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; opacity: 1; transition: opacity 0.2s ease; } -.myapps-grid-loading { +.myapps-pager-loading { opacity: 0; } +.myapps-pager-scroller { + flex: 1; + min-height: 0; + display: flex; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + overscroll-behavior-x: contain; + touch-action: pan-x; + scrollbar-width: none; +} + +.myapps-pager-scroller::-webkit-scrollbar { + display: none; +} + +.myapps-page { + flex: 0 0 100%; + min-width: 0; + scroll-snap-align: start; + scroll-snap-stop: always; + display: grid; + grid-template-columns: repeat(var(--myapps-cols, 1), var(--myapps-tile-w)); + grid-auto-rows: var(--myapps-tile-h); + column-gap: var(--myapps-gap-x); + row-gap: var(--myapps-gap-y); + justify-content: center; + align-content: start; +} + +.myapps-pager-dots { + flex: none; + height: var(--myapps-dots-h); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.myapps-pager-dots-hidden { + visibility: hidden; +} + +.myapps-pager-dot { + -webkit-appearance: none; + appearance: none; + flex: none; + width: 17px; + height: 17px; + padding: 5px; + margin: 0; + border: none; + border-radius: 50%; + background-color: var(--dashboard-text-primary); + background-clip: content-box; + opacity: 0.22; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.myapps-pager-dot.active { + opacity: 0.85; +} + +@media (hover: hover) { + .myapps-pager-dot:not(.active):hover { + opacity: 0.5; + } +} + +.myapps-pager-dot:focus-visible { + outline: 2px solid var(--select-color); + outline-offset: -1px; +} + +.myapps-pager-arrow { + position: absolute; + top: calc(50% - var(--myapps-dots-h) / 2); + transform: translateY(-50%); + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--dashboard-border); + border-radius: 50%; + background: var(--dashboard-card-background); + color: var(--dashboard-text-secondary); + box-shadow: 0 2px 8px var(--dashboard-shadow-light); + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease, background 0.15s ease; + z-index: 2; +} + +.myapps-pager-arrow svg { + width: 16px; + height: 16px; +} + +.myapps-pager-arrow-prev { + left: 0; +} + +.myapps-pager-arrow-next { + right: 0; +} + +/* Arrows are a hover-only affordance for mouse users; touch swipes instead. */ +@media (hover: hover) and (pointer: fine) { + .myapps-pager:hover .myapps-pager-arrow:not(.myapps-pager-arrow-hidden), + .myapps-pager-arrow:focus-visible:not(.myapps-pager-arrow-hidden) { + opacity: 1; + pointer-events: auto; + } + + .myapps-pager-arrow:hover { + background: var(--dashboard-hover); + color: var(--dashboard-text-primary); + } +} + .myapps-tile { display: flex; flex-direction: column; @@ -1025,19 +1143,22 @@ input.myapps-search::-webkit-search-decoration { height: 40px; } + .dashboard-tab-content.myapps-tab { + padding-bottom: max(6px, env(safe-area-inset-bottom)); + } + .myapps-container { - padding: 20px 10px; + --myapps-tile-w: 72px; + --myapps-tile-h: 74px; + --myapps-gap-x: 12px; + --myapps-gap-y: 30px; } - .myapps-tab h2 { - font-size: 22px; - margin-bottom: 16px; - } - - .myapps-grid { - grid-template-columns: repeat(4, 72px); - column-gap: max(0px, calc((100% - 4 * 72px) / 3)); - row-gap: 30px; + /* Spread the fixed column count across the page width, iOS-style; + --myapps-gap-x stays the minimum gap the column count is computed for. */ + .myapps-page { + justify-content: space-between; + column-gap: 0; } .myapps-tile { @@ -1049,15 +1170,6 @@ input.myapps-search::-webkit-search-decoration { height: 52px; border-radius: 12px; } - - /* Too narrow for 4×72px in one row: wrap like before */ - @media (max-width: 400px) { - .myapps-grid { - grid-template-columns: repeat(auto-fill, 72px); - column-gap: 12px; - row-gap: 20px; - } - } } /* Full HD */