feat: external iframes, container startup UX, release notes modal

- Add https: to CSP frame-src so external site iframes (BotFights,
  484 Kitchen, etc.) load without being blocked by Content-Security-Policy
- Show spinner + "Starting..." on marketplace cards for containers that
  are booting up, preventing users from re-installing running apps
- Add spinner to transitional state badges (starting/stopping/installing)
  on installed app cards in Apps view
- Add "What's New" button to Settings version card with release notes
  modal covering recent highlights in layman-friendly language

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-18 11:15:32 +00:00
co-authored by Claude Opus 4.6
parent 7278397209
commit b9c9881e4b
4 changed files with 115 additions and 22 deletions
+50 -18
View File
@@ -177,37 +177,49 @@
</p>
<div class="flex gap-2 mt-auto">
<span
v-if="isInstalled(app.id)"
<!-- Installed & starting up (transitional state) -->
<span
v-if="isInstalled(app.id) && isStartingUp(app.id)"
class="flex-1 px-4 py-2 bg-yellow-500/15 border border-yellow-500/30 rounded-lg text-yellow-200 text-sm font-medium text-center cursor-default flex items-center justify-center gap-2"
>
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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>
{{ getInstalledState(app.id) === 'installing' ? 'Installing...' : 'Starting...' }}
</span>
<!-- Installed & ready -->
<span
v-else-if="isInstalled(app.id)"
class="flex-1 px-4 py-2 bg-white/20 rounded-lg text-white/60 text-sm font-medium text-center cursor-default"
>
{{ t('marketplace.alreadyInstalled') }}
</span>
>
{{ t('marketplace.alreadyInstalled') }}
</span>
<button
v-if="isInstalled(app.id)"
v-if="isInstalled(app.id) && !isStartingUp(app.id)"
@click.stop="launchInstalledApp(app)"
class="px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium"
>
{{ t('common.launch') }}
</button>
<button
v-else-if="app.source === 'local' || app.dockerImage"
v-else-if="!isInstalled(app.id) && (app.source === 'local' || app.dockerImage)"
data-controller-install-btn
@click.stop="app.source === 'local' ? installApp(app) : installCommunityApp(app)"
:disabled="installingApps.has(app.id)"
:disabled="installingApps.has(app.id)"
class="flex-1 px-4 py-2 glass-button glass-button-sm rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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>
{{ installingApps.get(app.id)?.message || t('common.installing') }}
</span>
<span v-else>{{ t('common.install') }}</span>
>
<span v-if="installingApps.has(app.id)" class="flex items-center justify-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" 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>
{{ installingApps.get(app.id)?.message || t('common.installing') }}
</span>
<span v-else>{{ t('common.install') }}</span>
</button>
<button
v-else
v-else-if="!isInstalled(app.id)"
disabled
class="flex-1 px-4 py-2 bg-white/10 rounded-lg text-white/40 text-sm font-medium cursor-not-allowed"
>
@@ -640,6 +652,26 @@ function isInstalled(appId: string): boolean {
return aliases ? aliases.some((a) => a in installedPackages.value) : false
}
/** Get the package state for an installed app (checks aliases too). */
function getInstalledState(appId: string): string | null {
const pkg = installedPackages.value[appId]
if (pkg) return pkg.state
const aliases = INSTALLED_ALIASES[appId]
if (aliases) {
for (const a of aliases) {
const aliasPkg = installedPackages.value[a]
if (aliasPkg) return aliasPkg.state
}
}
return null
}
/** True if installed and currently in a transitional state (not yet ready). */
function isStartingUp(appId: string): boolean {
const state = getInstalledState(appId)
return state !== null && state !== 'running' && state !== 'stopped' && state !== 'exited'
}
function launchInstalledApp(app: MarketplaceApp) {
appLauncher.openSession(app.id)
}