feat(versions): professional update phase labels, responsive update card, changelog accordion

Also update UpdateProgressTest assertions to match the new phase label strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-20 20:40:35 +02:00
parent 542ad1a4d8
commit a1b37751a0
4 changed files with 82 additions and 52 deletions

View File

@ -7,10 +7,10 @@ return [
'subtitle' => 'Der Stack wird neu gebaut. Diese Seite erkennt automatisch, wann er wieder erreichbar ist.', 'subtitle' => 'Der Stack wird neu gebaut. Diese Seite erkennt automatisch, wann er wieder erreichbar ist.',
// Phase labels (displayed in the checklist) // Phase labels (displayed in the checklist)
'phase_fetch' => 'Holen', 'phase_fetch' => 'Update wird abgerufen',
'phase_build' => 'Bauen', 'phase_build' => 'Image wird erstellt',
'phase_migrate' => 'Migrieren', 'phase_migrate' => 'Datenbank wird migriert',
'phase_restart' => 'Neustart', 'phase_restart' => 'Dienste werden neu gestartet',
// Status messages // Status messages
'elapsed' => 'Vergangene Zeit', 'elapsed' => 'Vergangene Zeit',

View File

@ -7,10 +7,10 @@ return [
'subtitle' => 'The stack is rebuilding. This page will automatically detect when it is back up.', 'subtitle' => 'The stack is rebuilding. This page will automatically detect when it is back up.',
// Phase labels (displayed in the checklist) // Phase labels (displayed in the checklist)
'phase_fetch' => 'Fetch', 'phase_fetch' => 'Fetching update',
'phase_build' => 'Build', 'phase_build' => 'Building image',
'phase_migrate' => 'Migrate', 'phase_migrate' => 'Migrating database',
'phase_restart' => 'Restart', 'phase_restart' => 'Restarting services',
// Status messages // Status messages
'elapsed' => 'Elapsed time', 'elapsed' => 'Elapsed time',

View File

@ -40,32 +40,35 @@
{{-- Honest status banner installed version vs. newest release tag in the channel --}} {{-- Honest status banner installed version vs. newest release tag in the channel --}}
<div @class([ <div @class([
'flex flex-wrap items-center gap-4 rounded-xl border bg-surface p-5 shadow-panel', 'flex flex-col gap-4 rounded-xl border bg-surface p-5 shadow-panel sm:flex-row sm:items-center',
'border-warning/30' => $isUpdate, 'border-warning/30' => $isUpdate,
'border-line' => ! $isUpdate, 'border-line' => ! $isUpdate,
])> ])>
<span @class([ {{-- Icon + version/badges block --}}
'grid h-12 w-12 shrink-0 place-items-center rounded-xl border', <div class="flex items-start gap-4 sm:contents">
'border-warning/30 bg-warning/10 text-warning' => $isUpdate, <span @class([
'border-accent/25 bg-accent/10 text-accent-text' => ! $isUpdate, 'grid h-12 w-12 shrink-0 place-items-center rounded-xl border',
])> 'border-warning/30 bg-warning/10 text-warning' => $isUpdate,
<x-icon :name="$isUpdate ? 'alert' : 'tag'" class="h-6 w-6" /> 'border-accent/25 bg-accent/10 text-accent-text' => ! $isUpdate,
</span> ])>
<div class="min-w-0 flex-1"> <x-icon :name="$isUpdate ? 'alert' : 'tag'" class="h-6 w-6" />
<p class="flex flex-wrap items-center gap-2 font-display text-base font-semibold text-ink"> </span>
Clusev <span class="font-mono text-accent-text">v{{ $version }}</span> <div class="min-w-0 flex-1">
<x-badge tone="accent">{{ __('versions.channel_badge', ['channel' => $channel]) }}</x-badge> <p class="flex flex-wrap items-center gap-2 font-display text-base font-semibold text-ink">
@if ($latestTag) Clusev <span class="font-mono text-accent-text">v{{ $version }}</span>
<x-badge tone="neutral">{{ __('versions.latest_release_badge', ['tag' => $latestTag]) }}</x-badge> <x-badge tone="accent">{{ __('versions.channel_badge', ['channel' => $channel]) }}</x-badge>
@endif @if ($latestTag)
</p> <x-badge tone="neutral">{{ __('versions.latest_release_badge', ['tag' => $latestTag]) }}</x-badge>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3"> @endif
@if ($updateStatus) </p>
{{ $updateStatus }} <p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">
@else @if ($updateStatus)
{{ __('versions.banner_default') }} {{ $updateStatus }}
@endif @else
</p> {{ __('versions.banner_default') }}
@endif
</p>
</div>
</div> </div>
{{-- One-click update: writes the update sentinel; a host watcher runs update.sh. Mirrors {{-- One-click update: writes the update sentinel; a host watcher runs update.sh. Mirrors
the System restart button (direct click "running" notice via a flag not the System restart button (direct click "running" notice via a flag not
@ -73,34 +76,30 @@
@if ($updateRequested) @if ($updateRequested)
{{-- Poll until the rebuilt stack reports a newer version (pollUpdate flips the state + {{-- Poll until the rebuilt stack reports a newer version (pollUpdate flips the state +
fires a success toast). Survives the rebuild downtime: failed polls just retry. --}} fires a success toast). Survives the rebuild downtime: failed polls just retry. --}}
<p class="flex shrink-0 items-center gap-1.5 font-mono text-[11px] text-warning" wire:poll.5s="pollUpdate"> <p class="flex w-full items-center gap-1.5 font-mono text-[11px] text-warning sm:w-auto sm:shrink-0" wire:poll.5s="pollUpdate">
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />{{ __('versions.update_running') }} <x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />{{ __('versions.update_running') }}
</p> </p>
@elseif ($isUpdate) @elseif ($isUpdate)
<x-btn variant="primary" wire:click="requestUpdate" wire:target="requestUpdate" wire:loading.attr="disabled" class="shrink-0"> <x-btn variant="primary" wire:click="requestUpdate" wire:target="requestUpdate" wire:loading.attr="disabled" class="w-full sm:w-auto sm:shrink-0">
<svg wire:loading wire:target="requestUpdate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg> <svg wire:loading wire:target="requestUpdate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestUpdate" /> <x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestUpdate" />
{{ __('versions.update_now') }} {{ __('versions.update_now') }}
</x-btn> </x-btn>
@endif @endif
@if ($lastChecked) @if ($lastChecked)
<span class="shrink-0 font-mono text-[11px] text-ink-4">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span> <span class="font-mono text-[11px] text-ink-4 sm:shrink-0">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>
@endif @endif
</div> </div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]"> <div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
{{-- Release history BY VERSION --}} {{-- Release history BY VERSION --}}
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => count($releases)])" :padded="false"> <x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => count($releases)])" :padded="false">
<div class="px-4 py-4 sm:px-5"> <div class="divide-y divide-line px-4 py-2 sm:px-5">
@forelse ($releases as $rel) @forelse ($releases as $rel)
<div class="flex gap-4 pb-6 last:pb-0"> @if ($loop->first)
<div class="flex flex-col items-center"> {{-- Latest version: always open, not collapsible --}}
<span @class(['mt-1 h-2.5 w-2.5 shrink-0 rounded-full', 'bg-accent' => $loop->first, 'bg-ink-4' => ! $loop->first])></span> <div class="py-4">
@unless ($loop->last)<span class="mt-1 w-px flex-1 bg-line"></span>@endunless <div class="mb-2.5 flex flex-wrap items-center gap-2">
</div>
<div class="-mt-0.5 min-w-0 flex-1">
{{-- Version + date header --}}
<div class="flex flex-wrap items-center gap-2">
@if ($rel['unreleased']) @if ($rel['unreleased'])
<span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span> <span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span>
<x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge> <x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge>
@ -111,9 +110,7 @@
@endif @endif
@endif @endif
</div> </div>
<div class="space-y-3">
{{-- Grouped changes --}}
<div class="mt-2.5 space-y-3">
@foreach ($rel['groups'] as $group => $items) @foreach ($rel['groups'] as $group => $items)
<div> <div>
<div class="mb-1.5 flex items-center gap-2"> <div class="mb-1.5 flex items-center gap-2">
@ -131,9 +128,42 @@
@endforeach @endforeach
</div> </div>
</div> </div>
</div> @else
{{-- Older versions: collapsible <details> accordion --}}
<details class="group py-1">
<summary class="flex cursor-pointer list-none items-center gap-2 py-3 [&::-webkit-details-marker]:hidden">
<x-icon name="chevron-right" class="h-4 w-4 shrink-0 text-ink-4 transition-transform duration-200 [details[open]_&]:rotate-90" />
@if ($rel['unreleased'])
<span class="font-display text-sm font-semibold text-ink">{{ __('versions.unreleased') }}</span>
<x-badge tone="cyan">{{ __('versions.in_progress') }}</x-badge>
@else
<span class="font-mono text-sm font-semibold text-accent-text">v{{ $rel['version'] }}</span>
@if ($rel['date'])
<span class="font-mono text-[11px] text-ink-4">{{ $rel['date'] }}</span>
@endif
@endif
</summary>
<div class="pb-4 pt-1 space-y-3">
@foreach ($rel['groups'] as $group => $items)
<div>
<div class="mb-1.5 flex items-center gap-2">
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge>
</div>
<ul class="space-y-1.5 pl-0.5">
@foreach ($items as $text)
<li class="flex items-start gap-2.5">
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-ink-4"></span>
<span class="text-sm leading-relaxed text-ink-2">{{ $text }}</span>
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
</details>
@endif
@empty @empty
<p class="font-mono text-[11px] text-ink-3">{{ __('versions.changelog_empty') }}</p> <p class="py-4 font-mono text-[11px] text-ink-3">{{ __('versions.changelog_empty') }}</p>
@endforelse @endforelse
</div> </div>
</x-panel> </x-panel>

View File

@ -188,9 +188,9 @@ class UpdateProgressTest extends TestCase
$body = $this->get(route('update.progress'))->content(); $body = $this->get(route('update.progress'))->content();
// German labels (default locale in tests) // German labels (default locale in tests)
$this->assertStringContainsString('Holen', $body); $this->assertStringContainsString('Update wird abgerufen', $body);
$this->assertStringContainsString('Bauen', $body); $this->assertStringContainsString('Image wird erstellt', $body);
$this->assertStringContainsString('Migrieren', $body); $this->assertStringContainsString('Datenbank wird migriert', $body);
$this->assertStringContainsString('Neustart', $body); $this->assertStringContainsString('Dienste werden neu gestartet', $body);
} }
} }