mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-26 17:01:31 +00:00
Implement paged apps launcher UI
This commit is contained in:
@@ -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
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user