Implement paged apps launcher UI

This commit is contained in:
jelveh
2026-07-08 17:50:34 -07:00
parent db89fbfc52
commit d117dc231e
2 changed files with 444 additions and 140 deletions
+277 -85
View File
@@ -32,47 +32,77 @@ const APP_NAMES_NO_UNINSTALL = new Set([
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
function buildAppsGrid (apps) {
if ( !apps || apps.length === 0 ) {
let h = '<div class="myapps-empty">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">';
h += '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>';
h += '<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>';
h += '</svg>';
h += '<p>No apps installed yet</p>';
h += '</div>';
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 = '<div class="myapps-grid myapps-grid-loading">';
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 = `<div class="myapps-tile" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(title)}" data-app-uid="${html_encode(app.uid || '')}" data-target-link="${html_encode(targetLink)}" title="${html_encode(title)}">`;
h += '<div class="myapps-tile-icon">';
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
h += '</div>';
h += `<span class="myapps-tile-label">${html_encode(title)}</span>`;
h += '</div>';
return h;
}
function buildNoAppsHtml () {
let h = '<div class="myapps-empty">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">';
h += '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/>';
h += '<rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>';
h += '</svg>';
h += '<p>No apps installed yet</p>';
h += '</div>';
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 = `<div class="myapps-pager myapps-pager-loading" style="--myapps-cols: ${layout.cols}">`;
h += '<div class="myapps-pager-scroller">';
for ( let p = 0; p < pageCount; p++ ) {
h += '<div class="myapps-page">';
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 += `<div class="myapps-tile" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(title)}" data-app-uid="${html_encode(app.uid || '')}" data-target-link="${html_encode(targetLink)}" title="${html_encode(title)}">`;
h += '<div class="myapps-tile-icon">';
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
h += '</div>';
h += `<span class="myapps-tile-label">${html_encode(title)}</span>`;
h += '</div>';
}
h += '</div>';
h += '<button class="myapps-pager-arrow myapps-pager-arrow-prev" aria-label="Previous page">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>';
h += '</button>';
h += '<button class="myapps-pager-arrow myapps-pager-arrow-next" aria-label="Next page">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
h += '</button>';
h += `<div class="myapps-pager-dots${pageCount < 2 ? ' myapps-pager-dots-hidden' : ''}" aria-label="App pages">`;
for ( let p = 0; p < pageCount; p++ ) {
h += `<button class="myapps-pager-dot" data-page="${p}" aria-label="Page ${p + 1} of ${pageCount}"></button>`;
}
h += '</div>';
h += '</div>';
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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/></svg>',
_apps: null,
_layout: null,
_page: 0,
_pageCount: 0,
html () {
let h = '<div class="dashboard-tab-content myapps-tab">';
@@ -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)
: '<div class="myapps-empty"><p>No apps match your search</p></div>',
);
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
? '<div class="myapps-empty"><p>No apps match your search</p></div>'
: 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('<div class="myapps-empty"><p>Failed to load apps</p></div>');
+167 -55
View File
@@ -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 */