mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
feat: dashboard app search continues into the App Center
A search that matches none of the user's own apps now puts the same query to the App Center catalogue and renders the hits under the empty state — debounced, filtered to apps the user doesn't already have, and launchable in place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d18ea1adb5
commit
2933772362
@@ -58,6 +58,14 @@ const APP_CENTER_APP_NAME = 'app-center';
|
||||
/** Puter's AI app builder, which generates an app from a description. */
|
||||
const BUILDER_APP_NAME = 'builder';
|
||||
|
||||
// -- App Center discovery under search --
|
||||
// When a search matches none of the user's own apps, the same query is put to
|
||||
// the App Center catalogue, so "I don't have it" continues into "here it is"
|
||||
// without leaving the tab. Shown only on the no-local-matches state — a query
|
||||
// that found the user's apps already answered them.
|
||||
const DISCOVER_PAGE_SIZE = 8;
|
||||
const DISCOVER_DEBOUNCE_MS = 250;
|
||||
|
||||
// -- Drag-to-reorder tuning --
|
||||
const DRAG_START_DISTANCE = 5; // px a pointer must travel before a drag begins
|
||||
const MODE_LONGPRESS_MS = 500; // hold on empty grid space before reorder mode engages
|
||||
@@ -827,6 +835,11 @@ const TabApps = {
|
||||
// their tiles (the mirror of _removedLocal).
|
||||
_arriving: null,
|
||||
_pendingInstalls: null,
|
||||
// Latest App Center answer for the search box ({ query, items }), plus
|
||||
// the debounce timer and a sequence guard against out-of-order responses.
|
||||
_discover: null,
|
||||
_discoverTimer: null,
|
||||
_discoverSeq: 0,
|
||||
|
||||
html () {
|
||||
let h = '<div class="dashboard-tab-content myapps-tab">';
|
||||
@@ -920,6 +933,26 @@ const TabApps = {
|
||||
$el_window.on('input', '.myapps-search', function () {
|
||||
self.updateSearchIcons($el_window);
|
||||
self.renderApps($el_window);
|
||||
self._queueDiscover($el_window);
|
||||
});
|
||||
|
||||
// Launch an App Center discovery straight from the search results.
|
||||
// Distinct from '.myapps-tile': discovery tiles take no part in
|
||||
// drag-reorder, folders, or the running-dot machinery.
|
||||
const launchDiscovery = function () {
|
||||
const name = this.dataset.appName;
|
||||
if ( ! name || TabApps._launchingApps.has(name) ) return;
|
||||
TabApps._launchingApps.add(name);
|
||||
launch_app({ name, maximized: true })
|
||||
.catch(err => console.error(`Failed to launch ${name}:`, err))
|
||||
.finally(() => TabApps._launchingApps.delete(name));
|
||||
};
|
||||
$el_window.on('click', '.myapps-discover-tile', launchDiscovery);
|
||||
$el_window.on('keydown', '.myapps-discover-tile', function (e) {
|
||||
if ( e.key === 'Enter' || e.key === ' ' ) {
|
||||
e.preventDefault();
|
||||
launchDiscovery.call(this);
|
||||
}
|
||||
});
|
||||
|
||||
// Clear search on cross click
|
||||
@@ -1352,7 +1385,7 @@ const TabApps = {
|
||||
this._page = 0;
|
||||
this._pageCount = 0;
|
||||
$container.html(query
|
||||
? '<div class="myapps-empty"><p>No apps match your search</p></div>'
|
||||
? `<div class="myapps-empty"><p>No apps match your search</p></div>${this._discoverHtml(query)}`
|
||||
: buildNoAppsHtml());
|
||||
return;
|
||||
}
|
||||
@@ -1421,6 +1454,89 @@ const TabApps = {
|
||||
}, { passive: true });
|
||||
},
|
||||
|
||||
// -- App Center discovery under search --
|
||||
|
||||
/**
|
||||
* The "From the App Center" strip for the no-local-matches state, from
|
||||
* the cached catalogue answer. Empty until the fetch for exactly this
|
||||
* query has landed — a stale strip would answer the wrong question.
|
||||
*/
|
||||
_discoverHtml (query) {
|
||||
const discover = this._discover;
|
||||
if ( ! discover || discover.query !== query || discover.items.length === 0 ) {
|
||||
return '';
|
||||
}
|
||||
let h = '<div class="myapps-discover">';
|
||||
h += '<div class="myapps-discover-title">From the App Center</div>';
|
||||
h += '<div class="myapps-discover-row">';
|
||||
for ( const item of discover.items ) {
|
||||
h += `<div class="myapps-discover-tile" role="button" tabindex="0" data-app-name="${html_encode(item.name)}">`;
|
||||
h += item.icon
|
||||
? `<img src="${html_encode(item.icon)}" alt="" draggable="false">`
|
||||
: '<div class="myapps-discover-tile-blank"></div>';
|
||||
h += `<span>${html_encode(item.title)}</span>`;
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div></div>';
|
||||
return h;
|
||||
},
|
||||
|
||||
/** Debounced catalogue lookup for the current search box contents. */
|
||||
_queueDiscover ($el_window) {
|
||||
clearTimeout(this._discoverTimer);
|
||||
const query = String($el_window.find('.myapps-search').val() || '').trim();
|
||||
if ( ! query ) {
|
||||
this._discover = null;
|
||||
return;
|
||||
}
|
||||
// Already answered — nothing to fetch, nothing to re-render.
|
||||
if ( this._discover?.query === query ) return;
|
||||
const self = this;
|
||||
this._discoverTimer = setTimeout(async () => {
|
||||
const seq = ++self._discoverSeq;
|
||||
let items = [];
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${window.api_origin}/marketplace/search?q=${encodeURIComponent(query)}&page=1&pageSize=${DISCOVER_PAGE_SIZE}`,
|
||||
);
|
||||
if ( resp.ok ) {
|
||||
const body = await resp.json();
|
||||
items = Array.isArray(body?.items) ? body.items : [];
|
||||
}
|
||||
} catch {
|
||||
// Offline or the catalogue is down — the empty state simply
|
||||
// stays as it was.
|
||||
}
|
||||
// A newer keystroke owns the strip now.
|
||||
if ( seq !== self._discoverSeq ) return;
|
||||
|
||||
// What the user already has isn't a discovery.
|
||||
const owned = new Set();
|
||||
for ( const app of self._apps || [] ) {
|
||||
if ( app.name ) owned.add(String(app.name).toLowerCase());
|
||||
if ( app.uid || app.uuid ) owned.add(String(app.uid || app.uuid).toLowerCase());
|
||||
}
|
||||
self._discover = {
|
||||
query,
|
||||
items: items
|
||||
.filter(item => item?.item_name
|
||||
&& ! owned.has(String(item.item_name).toLowerCase())
|
||||
&& ! owned.has(String(item.item_id || '').toLowerCase()))
|
||||
.map(item => ({
|
||||
name: String(item.item_name),
|
||||
title: String(item.item_title || item.item_name),
|
||||
icon: typeof item.icon_url === 'string' ? item.icon_url : null,
|
||||
})),
|
||||
};
|
||||
// Only the empty state renders the strip, so only re-render if
|
||||
// the query in the box is still the one this answer belongs to.
|
||||
const current = String($el_window.find('.myapps-search').val() || '').trim();
|
||||
if ( current === query ) {
|
||||
self.renderApps($el_window, { preservePage: true, instant: true });
|
||||
}
|
||||
}, DISCOVER_DEBOUNCE_MS);
|
||||
},
|
||||
|
||||
// Mark tiles whose app has a live window (visible OR minimized — both
|
||||
// are running) with the macOS-dock-style dot. A folder wears the dot when
|
||||
// anything inside it is running — the apps it hides are exactly the ones
|
||||
|
||||
@@ -913,6 +913,64 @@ input.myapps-search:disabled {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* "From the App Center" strip under a search with no local matches. */
|
||||
.myapps-discover {
|
||||
padding: 0 8px 16px;
|
||||
}
|
||||
|
||||
.myapps-discover-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dashboard-text-tertiary);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.myapps-discover-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.myapps-discover-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 96px;
|
||||
padding: 10px 4px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.myapps-discover-tile:hover,
|
||||
.myapps-discover-tile:focus-visible {
|
||||
background: var(--dashboard-hover-bg, rgba(127, 127, 127, 0.12));
|
||||
}
|
||||
|
||||
.myapps-discover-tile img,
|
||||
.myapps-discover-tile-blank {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.myapps-discover-tile-blank {
|
||||
background: rgba(127, 127, 127, 0.15);
|
||||
}
|
||||
|
||||
.myapps-discover-tile span {
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-text-secondary);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* iOS-style pager: pages snap horizontally, dots below, hover arrows */
|
||||
|
||||
.myapps-pager {
|
||||
|
||||
Reference in New Issue
Block a user