feat(neode-ui): instant press feedback + launching spinner on app icons

Tapping a dashboard app icon now scales it down immediately (CSS :active)
and shows a per-icon spinner until the app overlay opens, so the tap is
acknowledged even while the app session spins up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-06-19 16:20:48 +01:00
parent aa95e42383
commit 993f30456f

View File

@ -58,6 +58,13 @@
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- Launching overlay instant tap feedback while the app opens -->
<div v-if="launchingId === id" class="app-icon-installing">
<svg class="animate-spin h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
<!-- Label -->
<span class="app-icon-label">{{ getTitle(id, pkg) }}</span>
@ -114,7 +121,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { AppCredential, AppCredentialsResponse, PackageDataEntry } from '@/types/api'
@ -152,6 +159,28 @@ const activePage = ref(0)
const longPressTriggered = ref(false)
let longPressTimer: ReturnType<typeof setTimeout> | null = null
// Per-icon "launching" spinner so a tap is acknowledged instantly even while
// the app session/iframe is still spinning up. Cleared when the launcher
// overlay opens, with a fallback timeout for the open-in-new-tab path.
const launchingId = ref<string | null>(null)
let launchClearTimer: ReturnType<typeof setTimeout> | null = null
function markLaunching(id: string) {
launchingId.value = id
if (launchClearTimer) clearTimeout(launchClearTimer)
launchClearTimer = setTimeout(() => {
if (launchingId.value === id) launchingId.value = null
}, 4000)
}
// Clear the spinner as soon as the app overlay actually opens.
watch(() => appLauncher.isOpen, (open) => {
if (open) {
launchingId.value = null
if (launchClearTimer) { clearTimeout(launchClearTimer); launchClearTimer = null }
}
})
const pages = computed(() => {
const result: [string, PackageDataEntry][][] = []
for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) {
@ -216,6 +245,7 @@ function openAppOptions(id: string) {
}
function launchNow(id: string, pkg: PackageDataEntry) {
markLaunching(id)
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
const webOnlyUrl = WEB_ONLY_APP_URLS[id]
if (webOnlyUrl) {
@ -305,6 +335,15 @@ function scrollToPage(index: number) {
</script>
<style scoped>
/* Instant press feedback: the icon scales down the moment it's touched, so the
tap is acknowledged even before the app finishes launching. */
.app-icon-frame {
transition: transform 0.12s ease;
}
.app-icon-item:active .app-icon-frame {
transform: scale(0.88);
}
.sideload-modal {
display: flex;
flex-direction: column;