feat: mobile UI overhaul — iPhone-style app grid, icon-only tab bar, fullscreen app sessions
- Add AppIconGrid: 4-column swipeable icon grid with page dots for My Apps on mobile - Tab bar: remove text labels, square icon-only buttons (w-14 h-14), doubled padding - Hide tab bar and top context tabs when app session is open - App session header hidden on mobile, replaced with floating glass close button - App sessions now render fullscreen on mobile without nav chrome Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7288bff6e0
commit
6bd515cb82
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="app-icon-grid-wrap">
|
||||
<!-- Swipeable pages -->
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
class="app-icon-pages"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(page, pageIndex) in pages"
|
||||
:key="pageIndex"
|
||||
class="app-icon-page"
|
||||
>
|
||||
<div
|
||||
v-for="([id, pkg]) in page"
|
||||
:key="id"
|
||||
class="app-icon-item"
|
||||
role="button"
|
||||
:tabindex="0"
|
||||
:aria-label="getTitle(id, pkg)"
|
||||
@click="handleTap(id, pkg)"
|
||||
@keydown.enter="handleTap(id, pkg)"
|
||||
>
|
||||
<!-- Icon with status indicator -->
|
||||
<div class="app-icon-frame">
|
||||
<img
|
||||
:src="getIcon(id, pkg)"
|
||||
:alt="getTitle(id, pkg)"
|
||||
class="app-icon-img"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<!-- Status dot -->
|
||||
<span
|
||||
v-if="pkg.state === 'running'"
|
||||
class="app-icon-status app-icon-status-running"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="pkg.state === 'exited'"
|
||||
class="app-icon-status app-icon-status-error"
|
||||
></span>
|
||||
<span
|
||||
v-else-if="pkg.state === 'starting' || pkg.state === 'stopping' || pkg.state === 'installing'"
|
||||
class="app-icon-status app-icon-status-transition"
|
||||
></span>
|
||||
<!-- Installing overlay -->
|
||||
<div
|
||||
v-if="serverStore.isInstalling(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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page dots -->
|
||||
<div v-if="pages.length > 1" class="app-icon-dots">
|
||||
<button
|
||||
v-for="(_, i) in pages"
|
||||
:key="i"
|
||||
class="app-icon-dot"
|
||||
:class="{ 'app-icon-dot-active': i === activePage }"
|
||||
:aria-label="`Page ${i + 1}`"
|
||||
@click="scrollToPage(i)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import type { PackageDataEntry } from '@/types/api'
|
||||
import { canLaunch, handleImageError } from './appsConfig'
|
||||
import { getCuratedAppList } from '../discover/curatedApps'
|
||||
|
||||
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
|
||||
|
||||
const serverStore = useServerStore()
|
||||
const appLauncher = useAppLauncherStore()
|
||||
|
||||
const curatedMap = new Map(getCuratedAppList().map(a => [a.id, a]))
|
||||
|
||||
const props = defineProps<{
|
||||
apps: [string, PackageDataEntry][]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
goToApp: [id: string]
|
||||
}>()
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const activePage = ref(0)
|
||||
|
||||
const pages = computed(() => {
|
||||
const result: [string, PackageDataEntry][][] = []
|
||||
for (let i = 0; i < props.apps.length; i += ITEMS_PER_PAGE) {
|
||||
result.push(props.apps.slice(i, i + ITEMS_PER_PAGE))
|
||||
}
|
||||
return result.length ? result : [[]]
|
||||
})
|
||||
|
||||
function getTitle(id: string, pkg: PackageDataEntry): string {
|
||||
const t = pkg.manifest?.title
|
||||
if (t && t !== id) return t
|
||||
return curatedMap.get(id)?.title || t || id
|
||||
}
|
||||
|
||||
function getIcon(id: string, pkg: PackageDataEntry): string {
|
||||
const i = pkg['static-files']?.icon
|
||||
return i || curatedMap.get(id)?.icon || `/assets/img/app-icons/${id}.png`
|
||||
}
|
||||
|
||||
function handleTap(id: string, pkg: PackageDataEntry) {
|
||||
if (canLaunch(pkg)) {
|
||||
appLauncher.openSession(id)
|
||||
} else {
|
||||
emit('goToApp', id)
|
||||
}
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
const pageWidth = el.clientWidth
|
||||
if (pageWidth === 0) return
|
||||
activePage.value = Math.round(el.scrollLeft / pageWidth)
|
||||
}
|
||||
|
||||
function scrollToPage(index: number) {
|
||||
const el = scrollContainer.value
|
||||
if (!el) return
|
||||
el.scrollTo({ left: index * el.clientWidth, behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user