frontend: polish app launch and release experience
This commit is contained in:
@@ -94,4 +94,20 @@ describe('useMarketplaceApp', () => {
|
||||
const app = getCurrentApp()
|
||||
expect(app!.description).toEqual({ short: 'Short desc', long: 'Long description' })
|
||||
})
|
||||
|
||||
it('preserves real screenshot metadata', () => {
|
||||
const { setCurrentApp, getCurrentApp } = useMarketplaceApp()
|
||||
setCurrentApp({
|
||||
id: 'test',
|
||||
screenshots: [
|
||||
'/screenshots/test-dashboard.png',
|
||||
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(getCurrentApp()!.screenshots).toEqual([
|
||||
'/screenshots/test-dashboard.png',
|
||||
{ src: '/screenshots/test-settings.png', alt: 'Settings view' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,10 +77,10 @@ describe('useOnboarding', () => {
|
||||
localStorage.setItem('neode_onboarding_complete', '1')
|
||||
|
||||
const promise = isOnboardingComplete()
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
await vi.advanceTimersByTimeAsync(8000)
|
||||
const result = await promise
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
describe('completeOnboarding', () => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { onBeforeUnmount, watch, type Ref } from 'vue'
|
||||
|
||||
let activeLocks = 0
|
||||
let previousOverflow = ''
|
||||
|
||||
function lock() {
|
||||
if (typeof document === 'undefined') return
|
||||
if (activeLocks === 0) {
|
||||
previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
activeLocks += 1
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
if (typeof document === 'undefined' || activeLocks === 0) return
|
||||
activeLocks -= 1
|
||||
if (activeLocks === 0) {
|
||||
document.body.style.overflow = previousOverflow
|
||||
previousOverflow = ''
|
||||
}
|
||||
}
|
||||
|
||||
export function useBodyScrollLock(active: Ref<boolean>) {
|
||||
watch(
|
||||
active,
|
||||
(isActive, wasActive) => {
|
||||
if (isActive && !wasActive) lock()
|
||||
if (!isActive && wasActive) unlock()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (active.value) unlock()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
|
||||
|
||||
export function useCollapsingHeaderTabs(
|
||||
headerRef: Ref<HTMLElement | null>,
|
||||
primaryRef: Ref<HTMLElement | null>,
|
||||
tabsProbeRef: Ref<HTMLElement | null>,
|
||||
minSearchWidth = 176
|
||||
) {
|
||||
const collapsed = ref(false)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function measure() {
|
||||
const header = headerRef.value
|
||||
const probe = tabsProbeRef.value
|
||||
if (!header || !probe) return
|
||||
|
||||
const primaryWidth = primaryRef.value?.getBoundingClientRect().width ?? 0
|
||||
const tabsWidth = probe.getBoundingClientRect().width
|
||||
const gapWidth = 48
|
||||
collapsed.value = primaryWidth + tabsWidth + minSearchWidth + gapWidth > header.clientWidth
|
||||
}
|
||||
|
||||
function scheduleMeasure() {
|
||||
nextTick(() => requestAnimationFrame(measure))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scheduleMeasure()
|
||||
resizeObserver = new ResizeObserver(scheduleMeasure)
|
||||
if (headerRef.value) resizeObserver.observe(headerRef.value)
|
||||
if (primaryRef.value) resizeObserver.observe(primaryRef.value)
|
||||
if (tabsProbeRef.value) resizeObserver.observe(tabsProbeRef.value)
|
||||
window.addEventListener('resize', scheduleMeasure)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', scheduleMeasure)
|
||||
})
|
||||
|
||||
return { collapsed, measure: scheduleMeasure }
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export interface MarketplaceAppInfo {
|
||||
dockerImage: string
|
||||
/** External web URL for iframe-based web apps (no container needed) */
|
||||
webUrl?: string
|
||||
screenshots?: AppScreenshot[]
|
||||
containerConfig?: {
|
||||
ports?: string[]
|
||||
volumes?: string[]
|
||||
@@ -25,6 +26,11 @@ export interface MarketplaceAppInfo {
|
||||
}
|
||||
}
|
||||
|
||||
export type AppScreenshot = string | {
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
// Simple in-memory store for the current marketplace app
|
||||
const currentMarketplaceApp = ref<MarketplaceAppInfo | null>(null)
|
||||
|
||||
@@ -46,6 +52,7 @@ export function useMarketplaceApp() {
|
||||
s9pkUrl: app.s9pkUrl ?? '',
|
||||
dockerImage: app.dockerImage ?? '',
|
||||
webUrl: (app as Record<string, unknown>).webUrl as string | undefined,
|
||||
screenshots: Array.isArray(app.screenshots) ? app.screenshots : undefined,
|
||||
containerConfig: (app as Record<string, unknown>).containerConfig as MarketplaceAppInfo['containerConfig'],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user