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',