From 61bfcbdc8358871fc414e61bcbc9d7f13ec1325f Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sun, 9 Aug 2026 17:37:09 -0700 Subject: [PATCH] feat(gui): add an add-an-app tile at the end of the Apps grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Apps grid showed what you have and offered no way to get more. A dashed plus tile now rides at the tail of every page of it: after every app, on the last page, and nowhere else. It wears the same tile skeleton as an app so hover, focus, and arrow keys treat it identically, but carries no app name or group id — which is what keeps it out of the saved order, the running dots, and the deep-link tile lookups. It cannot be picked up, is never a drop target (so no app can land past it), has no context menu, and sits still while the apps jiggle in reorder mode. Search results leave it out: a query asks which apps you have. Clicking it asks how you would like to get an app: find one in the App Center, have one built by the AI builder, or request one that does not exist yet. The request is a form in the same card rather than the stock Contact Us window — a titled pane one step deeper, whose Back arrow returns to the options with the draft intact. It posts the same /contactUs request that window did; a failure is said inside the form, where the request is still there to retry, rather than in an alert window the next click on the dashboard would bury. --- src/gui/src/UI/Dashboard/TabApps.js | 262 +++++++++++++++++++++++++++- src/gui/src/css/dashboard.css | 232 ++++++++++++++++++++++++ src/gui/src/i18n/translations/en.js | 12 ++ 3 files changed, 497 insertions(+), 9 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 821946f58..fbff12f27 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -51,6 +51,12 @@ const APP_NAMES_NO_UNINSTALL = new Set([ * along with this program. If not, see . */ +// -- Add-an-app tile -- +/** Puter's app marketplace — the add-an-app tile's route to installing one. */ +const APP_CENTER_APP_NAME = 'app-center'; +/** Puter's AI app builder, which generates an app from a description. */ +const BUILDER_APP_NAME = 'builder'; + // -- 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 @@ -222,6 +228,39 @@ function buildTileHtml (app) { return h; } +// The tile at the tail of the grid that is not an app but the way to GET one +// (see showAddAppModal). It wears the same .myapps-tile skeleton as an app so +// hover, focus, and keyboard navigation treat it identically, and carries +// neither an app name nor a group id — which is what keeps it out of the saved +// order, the running dots, and the deep-link tile lookups, all of which key +// off those attributes. +function buildAddTileHtml () { + const label = i18n('add_app'); + let h = `
`; + h += '
'; + h += ''; + h += '
'; + h += `${label}`; + h += '
'; + return h; +} + +// One instance per app when opened from the dashboard: un-hide a minimized +// instance / focus a visible one instead of launching a duplicate. Returns +// whether an existing window took the request. +function focusExistingAppWindow (appName) { + const $existing = $(`.window[data-app="${html_encode(appName)}"]`); + if ( ! $existing.length ) return false; + const $win = $existing.last(); + const minimized = $win.attr('data-is_minimized'); + if ( minimized === '1' || minimized === 'true' ) { + $win.showWindow(); + } else { + $win.focusWindow(); + } + return true; +} + // The label a folder shows: its own name, or the default one if a rename // somehow left it blank (a nameless tile is one the user can't tell apart). function groupLabel (group) { @@ -284,6 +323,7 @@ function parseTileGroupApps (tileEl) { } function buildGridItemHtml (item) { + if ( item.type === 'add' ) return buildAddTileHtml(); return item.type === 'group' ? buildGroupTileHtml(item.group, item.apps) : buildTileHtml(item.app); @@ -497,6 +537,189 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { }); } +// What the grid's add-an-app tile asks: which of the three ways to get an app +// do you want? Install one that already exists, have one built for you, or ask +// for one that doesn't exist yet. Uses the same modal shell as the uninstall +// confirmation above, with the options as rows rather than a question. +function showAddAppModal ({ $el_window }) { + const options = [ + { + key: 'browse', + icon: '', + title: i18n('add_app_browse'), + desc: i18n('add_app_browse_desc'), + }, + { + key: 'ai', + icon: '', + title: i18n('add_app_ai'), + desc: i18n('add_app_ai_desc'), + }, + { + key: 'request', + icon: '', + title: i18n('add_app_request'), + desc: i18n('add_app_request_desc'), + }, + ]; + + let h = '
'; + h += `'; + h += '
'; + + const $overlay = $(h); + $el_window.append($overlay); + + const close = () => { + $overlay.remove(); + $(document).off('keydown.add-app-modal'); + }; + + const currentPane = () => $overlay.find('.myapps-add-pane:not([hidden])').attr('data-pane'); + + const showPane = name => { + $overlay.find('.myapps-add-pane').attr('hidden', 'hidden'); + $overlay.find(`.myapps-add-pane[data-pane="${name}"]`).removeAttr('hidden'); + if ( name === 'request' ) { + $overlay.find('.myapps-add-message').focus(); + } else if ( name === 'sent' ) { + $overlay.find('[data-pane="sent"] .myapps-modal-cancel').focus(); + } else { + $overlay.find('.myapps-add-option').first().focus(); + } + }; + + $overlay.on('click', '.myapps-modal-cancel', close); + $overlay.on('click', function (e) { + if ( e.target === $overlay[0] ) close(); + }); + $overlay.on('click', '.myapps-add-back', () => showPane('options')); + $(document).on('keydown.add-app-modal', function (e) { + if ( e.key !== 'Escape' ) return; + // Escape steps back out of the form before it closes the modal, so a + // half-written request survives a mis-hit key. + if ( currentPane() === 'request' ) showPane('options'); + else close(); + }); + + // Maximized like a tile launch, and without a morph: the add tile is not + // the app's own icon, so there is nothing for the window to grow out of. + const openApp = appName => { + if ( focusExistingAppWindow(appName) ) return; + launch_app({ name: appName, maximized: true }).catch(err => { + console.error(`Failed to launch ${appName}:`, err); + UIAlert(i18n('something_went_wrong')); + }); + }; + + $overlay.on('click', '.myapps-add-option', function () { + const key = this.dataset.addOption; + if ( key === 'request' ) { + showPane('request'); + return; + } + // An app is opening — the modal has nothing left to say. + close(); + if ( key === 'browse' ) openApp(APP_CENTER_APP_NAME); + else if ( key === 'ai' ) openApp(BUILDER_APP_NAME); + }); + + // Nothing to send until something has been written. + $overlay.on('input', '.myapps-add-message', function () { + $overlay.find('.myapps-add-send').prop('disabled', this.value.trim() === ''); + }); + + const send = async () => { + const $btn = $overlay.find('.myapps-add-send'); + const message = String($overlay.find('.myapps-add-message').val() || '').trim(); + if ( ! message || $btn.prop('disabled') ) return; + $btn.prop('disabled', true); + $overlay.find('.myapps-add-error').attr('hidden', 'hidden'); + try { + const res = await fetch(`${window.api_origin}/contactUs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${puter.authToken}`, + }, + body: JSON.stringify({ message }), + }); + if ( ! res.ok ) throw new Error(`contactUs responded ${res.status}`); + showPane('sent'); + } catch ( err ) { + // Said inside the form rather than in an alert: the request is + // still here to retry, and an alert window over the modal is one + // the next click on the dashboard would bury. + console.error('Failed to send the app request:', err); + $overlay.find('.myapps-add-error').removeAttr('hidden'); + $btn.prop('disabled', false); + } + }; + + $overlay.on('click', '.myapps-add-send', send); + // Enter alone belongs to the textarea (a request may need paragraphs). + $overlay.on('keydown', '.myapps-add-message', function (e) { + if ( e.key === 'Enter' && (e.metaKey || e.ctrlKey) ) { + e.preventDefault(); + send(); + } + }); + + showPane('options'); +} + function revealWhenLoaded ($container) { const $pager = $container.find('.myapps-pager-loading'); if ( $pager.length === 0 ) return; @@ -676,6 +899,13 @@ const TabApps = { self._justDragged = false; return; } + // The tail tile isn't an app — it asks how you'd like to get one. + // Answered in every mode: it is never part of the arrangement + // reorder mode exists to edit. + if ( this.classList.contains('myapps-add-tile') ) { + showAddAppModal({ $el_window }); + return; + } // Folders open in every mode — inside reorder mode that is the // only way to rearrange or empty one. if ( this.dataset.groupId ) { @@ -712,15 +942,7 @@ const TabApps = { // One instance per app when launched from here: un-hide a // minimized instance / focus a visible one rather than // launching a duplicate. - const $existing = $(`.window[data-app="${html_encode(appName)}"]`); - if ( $existing.length ) { - const $win = $existing.last(); - const minimized = $win.attr('data-is_minimized'); - if ( minimized === '1' || minimized === 'true' ) { - $win.showWindow(); - } else { - $win.focusWindow(); - } + if ( focusExistingAppWindow(appName) ) { closeFolder(); return; } @@ -763,6 +985,11 @@ const TabApps = { // Context menu on right-click (and, where the platform fires it, // touch long-press). $el_window.on('contextmenu', '.myapps-tile', function (e) { + // The add-an-app tile has no app for a menu to act on. + if ( this.classList.contains('myapps-add-tile') ) { + e.preventDefault(); + return; + } // Reorder mode owns every tile gesture — no menu there; likewise // suppress the menu (and any long-press callout) mid-drag. if ( self._reorderMode || (self._drag && self._drag.started) ) { @@ -1088,6 +1315,14 @@ const TabApps = { return; } + // The add-an-app tile rides at the tail of the grid: after every app, + // on the last page, and nowhere else — a drag can't place an app past + // it (see _updatePlaceholder) and a newly installed app arrives before + // it. Pushed here, so it counts as a slot when the pages are laid out + // below. Search results leave it out: a query asks which apps you + // HAVE, and a tile that matches nothing has no business in the answer. + if ( ! query ) items.push({ type: 'add' }); + const layout = this.computeLayout($container); if ( ! layout ) { // Not laid out yet (e.g. hidden while the window enters full-page @@ -1847,6 +2082,10 @@ const TabApps = { // A press on the uninstall badge belongs to that button, not to a // drag pickup — a finger wobble while tapping × must not lift the tile. if ( oe.target && oe.target.closest && oe.target.closest('.myapps-tile-remove') ) return; + // The add-an-app tile holds the tail and isn't part of the + // arrangement: it can't be picked up, and nothing can be dropped onto + // it (see _updatePlaceholder). + if ( tileEl.classList.contains('myapps-add-tile') ) return; // Reordering a filtered subset is ambiguous — only reorder the full list. const query = String($el_window.find('.myapps-search').val() || '').trim(); if ( query ) return; @@ -2203,6 +2442,11 @@ const TabApps = { for ( let i = 0; i < tiles.length; i++ ) { const t = tiles[i]; if ( t === d.tileEl ) continue; + // The add-an-app tile is never a drop target, so an app can never + // be placed after it — the probe over its slot reads as a gap and + // leaves the arrangement alone. It stays in `tiles` so it still + // FLIP-slides along when a drop from another page pushes it. + if ( t.classList.contains('myapps-add-tile') ) continue; const r = t.__myappsRestRect || t.getBoundingClientRect(); const insetX = r.width * DRAG_HIT_INSET; const insetY = r.height * DRAG_HIT_INSET; diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 0d2c03c2f..24dd3ef5f 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -1158,6 +1158,36 @@ input.myapps-search:disabled { word-break: break-word; } +/* -- Add-an-app tile -- + The tile at the tail of the grid (see buildAddTileHtml): a dashed, empty + app-shaped slot with a plus in it, so it reads as room for an app rather + than as an installed one. The border takes currentColor so a single + (theme-aware) color drives both it and the plus. */ +.myapps-add-icon { + border: 2px dashed; + border-radius: 14px; + color: var(--dashboard-text-tertiary); +} + +.myapps-add-icon svg { + width: 24px; + height: 24px; +} + +.myapps-add-tile .myapps-tile-label { + color: var(--dashboard-text-secondary); +} + +@media (hover: hover) { + .myapps-add-tile:hover .myapps-add-icon { + color: var(--select-color); + } +} + +.myapps-add-tile:focus-visible .myapps-add-icon { + color: var(--select-color); +} + /* -- App folders -- A folder wears the same tile skeleton as an app, with its icon slot filled by iOS's miniature 3x3 grid of what is inside. */ @@ -1493,6 +1523,197 @@ input.myapps-group-name:focus { cursor: default; } +/* Add-an-app chooser (see showAddAppModal): the three ways to get an app, as + rows in the same modal shell. min-width gives the two-line rows room without + overflowing a narrow phone; the shell's own max-width caps the other end. */ +/* Stated rather than left to `.dashboard *`: the overlay is appended to the + dashboard WINDOW, which is outside that rule — without this, a width:100% + child (the option rows, the request form's message box) adds its padding + and border on top of the card's content width and hangs over the right + edge. Width is explicit for the same reason: it is the card's real width + only under border-box. */ +.myapps-add-modal, +.myapps-add-modal * { + box-sizing: border-box; +} +.myapps-add-modal { + width: min(440px, calc(100vw - 32px)); + min-width: 0; + max-width: none; +} +.myapps-add-options { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} +.myapps-add-option { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px; + text-align: left; + border: 1px solid var(--dashboard-border); + border-radius: 10px; + background: var(--dashboard-card-background); + color: var(--dashboard-text-primary); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +@media (hover: hover) { + .myapps-add-option:hover { + background: var(--dashboard-hover); + border-color: var(--select-ring); + } +} +.myapps-add-option:focus-visible { + outline: 2px solid var(--select-color); + outline-offset: -1px; +} +.myapps-add-option-icon { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 9px; + background: var(--select-tint); + color: var(--select-color); +} +.myapps-add-option-icon svg { + width: 19px; + height: 19px; +} +.myapps-add-option-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.myapps-add-option-title { + font-size: 14px; + font-weight: 600; +} +.myapps-add-option-desc { + font-size: 12px; + line-height: 1.35; + color: var(--dashboard-text-secondary); +} +.myapps-add-option-chevron { + flex: none; + width: 16px; + height: 16px; + margin-left: auto; + color: var(--dashboard-text-tertiary); +} + +/* The request form, one step deeper in the same card (see showAddAppModal): + a titled header whose Back arrow returns to the options, the message, and + the sent confirmation that replaces both. */ +.myapps-add-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} +.myapps-add-head h3 { + margin: 0; +} +.myapps-add-back { + flex: none; + display: flex; + align-items: center; + justify-content: center; + /* Pulled into the card's padding so the title, not the arrow, lines up + with the text below it. */ + width: 26px; + height: 26px; + margin-left: -6px; + padding: 0; + border: none; + border-radius: 6px; + background: none; + color: var(--dashboard-text-secondary); + cursor: pointer; +} +.myapps-add-back svg { + width: 17px; + height: 17px; +} +@media (hover: hover) { + .myapps-add-back:hover { + background: var(--dashboard-hover); + color: var(--dashboard-text-primary); + } +} +.myapps-add-back:focus-visible { + outline: 2px solid var(--select-color); + outline-offset: -1px; +} +.myapps-add-message { + display: block; + width: 100%; + min-height: 130px; + margin-bottom: 12px; + padding: 10px; + resize: vertical; + font-family: inherit; + font-size: 13px; + line-height: 1.45; + border: 1px solid var(--dashboard-border); + border-radius: 6px; + background: var(--dashboard-input-background); + color: var(--dashboard-text-primary); + outline: none; + transition: border-color 0.15s; +} +.myapps-add-message:focus { + border-color: var(--select-color); +} +.myapps-add-message::placeholder { + color: var(--dashboard-text-muted); +} +p.myapps-add-error { + margin: -4px 0 12px; + font-size: 12px; + color: var(--dashboard-error-text); +} +.myapps-add-send { + background: var(--select-color); + border-color: var(--select-color); + color: #fff; +} +@media (hover: hover) { + .myapps-add-send:not(:disabled):hover { + filter: brightness(0.94); + } +} +.myapps-add-send:disabled { + opacity: 0.55; + cursor: default; +} +.myapps-add-sent { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 10px 0 6px; + text-align: center; +} +.myapps-add-sent svg { + width: 40px; + height: 40px; + color: var(--dashboard-success-text); +} +.myapps-add-sent p { + margin: 0 0 4px; + font-size: 13px; + line-height: 1.45; + color: var(--dashboard-text-secondary); +} + /* -- Item properties modal -- Responsive, from-scratch replacement for UIWindowItemProperties in the Dashboard: a centered card on desktop, a bottom sheet on mobile. */ @@ -1980,6 +2201,13 @@ body.myapps-reordering .myapps-tile { .myapps-reorder-mode .myapps-tile:nth-child(3n) .myapps-tile-icon { animation-delay: -0.24s; } + + /* The add-an-app tile can't be dragged or uninstalled, so it has nothing + to say in reorder mode: it sits still while the apps wobble. Last, and + specific enough, to beat the nth-child rules above. */ + .myapps-reorder-mode .myapps-tile.myapps-add-tile .myapps-tile-icon { + animation: none; + } } @media (prefers-reduced-motion: reduce) { @@ -3421,6 +3649,10 @@ body.myapps-reordering .myapps-tile { border-radius: 12px; } + .myapps-add-icon { + border-radius: 12px; + } + .myapps-group-overlay { padding: 16px; } diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index 8a069f6d6..e23232026 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -26,6 +26,17 @@ const en = { account: 'Account', account_password: 'Verify Account Password', access_granted_to: 'Access Granted To', + add_app: 'Add an app', + add_app_ai: 'Create an app with AI', + add_app_ai_desc: 'Describe the app you want and have it built for you', + add_app_browse: 'Find an app', + add_app_browse_desc: 'Browse the App Center and install what you need', + add_app_request: 'Request an app', + add_app_request_c2a: 'Tell us what app you wish Puter had. Your request goes straight to our team.', + add_app_request_desc: 'Tell us what we should build next', + add_app_request_failed: "Couldn't send your request. Please try again.", + add_app_request_placeholder: 'The app I wish Puter had is…', + add_app_request_sent: 'Thanks — your request is with our team. If your account has an email, we may follow up on it.', add_existing_account: 'Add Existing Account', add_to_desktop: 'Add to Desktop', ai_app_unavailable: 'AI app is not available. Please try again later.', @@ -42,6 +53,7 @@ const en = { ascending: 'Ascending', associated_websites: 'Associated Websites', auto_arrange: 'Auto Arrange', + back: 'Back', background: 'Background', browse: 'Browse', browser: 'Browser',