306 lines
22 KiB
PHP
306 lines
22 KiB
PHP
@php
|
|
// CHANGELOG.md uses German Keep-a-Changelog headings as the canonical section keys;
|
|
// map each to a localized display label + a badge tone.
|
|
$sectionKey = [
|
|
'Hinzugefügt' => 'added',
|
|
'Geändert' => 'changed',
|
|
'Behoben' => 'fixed',
|
|
'Sicherheit' => 'security',
|
|
'Entfernt' => 'removed',
|
|
'Veraltet' => 'deprecated',
|
|
];
|
|
$sectionTone = [
|
|
'added' => 'accent', 'changed' => 'cyan', 'fixed' => 'neutral',
|
|
'security' => 'accent', 'removed' => 'neutral', 'deprecated' => 'neutral',
|
|
];
|
|
$sectionLabel = fn (string $g): string => isset($sectionKey[$g]) ? __('versions.section_'.$sectionKey[$g]) : $g;
|
|
$tone = fn (string $g): string => $sectionTone[$sectionKey[$g] ?? ''] ?? 'neutral';
|
|
// Toned glance-dots on each collapsed release row (static classes so Tailwind's scanner sees them).
|
|
$dotClass = ['accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'neutral' => 'bg-ink-4'];
|
|
|
|
$isUpdate = $updateState === 'update';
|
|
@endphp
|
|
|
|
{{-- Auto-check for updates on load (async, non-blocking) so an available update + button show
|
|
without the operator clicking "check" first. autoCheck() no-ops while an update is running. --}}
|
|
<div class="space-y-5" wire:init="autoCheck">
|
|
{{-- Header --}}
|
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('versions.eyebrow') }}</p>
|
|
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('versions.heading') }}</h2>
|
|
</div>
|
|
<x-btn variant="secondary" wire:click="checkUpdates" wire:target="checkUpdates" wire:loading.attr="disabled">
|
|
<svg wire:loading wire:target="checkUpdates" 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="checkUpdates" />
|
|
{{ __('versions.check_updates') }}
|
|
</x-btn>
|
|
</div>
|
|
|
|
{{-- Honest status banner — installed version vs. newest release tag in the channel --}}
|
|
<div @class([
|
|
'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-line' => ! $isUpdate,
|
|
])>
|
|
{{-- Icon + version/badges block --}}
|
|
<div class="flex items-start gap-4 sm:contents">
|
|
<span @class([
|
|
'grid h-12 w-12 shrink-0 place-items-center rounded-xl border',
|
|
'border-warning/30 bg-warning/10 text-warning' => $isUpdate,
|
|
'border-accent/25 bg-accent/10 text-accent-text' => ! $isUpdate,
|
|
])>
|
|
<x-icon :name="$isUpdate ? 'alert' : 'tag'" class="h-6 w-6" />
|
|
</span>
|
|
<div class="min-w-0 flex-1">
|
|
<p class="flex flex-wrap items-center gap-2 font-display text-base font-semibold text-ink">
|
|
Clusev <span class="font-mono text-accent-text">v{{ $version }}</span>
|
|
<x-badge tone="accent">{{ __('versions.channel_badge', ['channel' => $channel]) }}</x-badge>
|
|
@if ($latestTag)
|
|
<x-badge tone="neutral">{{ __('versions.latest_release_badge', ['tag' => $latestTag]) }}</x-badge>
|
|
@endif
|
|
</p>
|
|
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">
|
|
@if ($updateStatus)
|
|
{{ $updateStatus }}
|
|
@else
|
|
{{ __('versions.banner_default') }}
|
|
@endif
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{{-- 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
|
|
data-destructive, so no modal). Only offered once a check found a newer release. --}}
|
|
@if ($updateRequested)
|
|
{{-- 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. --}}
|
|
<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') }}
|
|
</p>
|
|
@elseif ($isUpdate)
|
|
@can('manage-panel')
|
|
<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>
|
|
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestUpdate" />
|
|
{{ __('versions.update_now') }}
|
|
</x-btn>
|
|
@endcan
|
|
@endif
|
|
@if ($lastChecked)
|
|
<span class="font-mono text-[11px] text-ink-4 sm:shrink-0">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- What's new in the available release — read BEFORE updating (fetched from the remote
|
|
CHANGELOG.md at the latest tag). Only shown when behind a published release. --}}
|
|
@if ($isUpdate && ! empty($availableUpdates))
|
|
<x-panel :title="__('versions.whats_new_title', ['tag' => 'v'.$latestTag])" :subtitle="__('versions.whats_new_subtitle')">
|
|
<div class="space-y-5">
|
|
@foreach ($availableUpdates as $rel)
|
|
<div wire:key="whatsnew-{{ $rel['version'] }}" @class(['pt-5 border-t border-line' => ! $loop->first])>
|
|
<p class="font-mono text-[12px] font-semibold text-accent-text">
|
|
v{{ $rel['version'] }}@if ($rel['date']) <span class="text-ink-4">· {{ $rel['date'] }}</span>@endif
|
|
</p>
|
|
<div class="mt-2 space-y-3">
|
|
@foreach ($rel['groups'] as $group => $items)
|
|
<div>
|
|
<x-badge :tone="$tone($group)" class="shrink-0">{{ $sectionLabel($group) }}</x-badge>
|
|
<ul class="mt-1.5 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>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</x-panel>
|
|
@endif
|
|
|
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
|
|
{{-- Release history — grouped into major.minor series, paginated (no endless scroll) --}}
|
|
<x-panel :title="__('versions.changelog_title')" :subtitle="__('versions.changelog_subtitle', ['count' => $totalReleases])" :padded="false">
|
|
@if (empty($seriesList))
|
|
<p class="px-4 py-4 font-mono text-[11px] text-ink-3 sm:px-5">{{ __('versions.changelog_empty') }}</p>
|
|
@else
|
|
<div class="grid grid-cols-1 xl:grid-cols-[172px_minmax(0,1fr)]">
|
|
{{-- Series index: horizontal pills on small/medium screens, a vertical rail from xl --}}
|
|
<nav aria-label="{{ __('versions.series_nav') }}"
|
|
class="flex gap-2 overflow-x-auto border-b border-line p-3 xl:flex-col xl:gap-1 xl:overflow-visible xl:border-b-0 xl:border-r xl:p-2.5">
|
|
@foreach ($seriesList as $s)
|
|
<button type="button" wire:key="series-{{ $s['key'] }}" wire:click="selectSeries('{{ $s['key'] }}')"
|
|
@class([
|
|
'group flex min-h-11 shrink-0 items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors xl:min-h-0 xl:shrink',
|
|
'border-accent/30 bg-accent/10' => $s['key'] === $activeKey,
|
|
'border-transparent hover:border-line hover:bg-raised/60' => $s['key'] !== $activeKey,
|
|
])
|
|
@if ($s['key'] === $activeKey) aria-current="true" @endif>
|
|
@if ($s['key'] === $currentSeries)
|
|
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-online" title="{{ __('versions.series_installed') }}"></span>
|
|
@endif
|
|
<span @class([
|
|
'font-mono text-sm font-semibold',
|
|
'text-accent-text' => $s['key'] === $activeKey,
|
|
'text-ink-2 group-hover:text-ink' => $s['key'] !== $activeKey,
|
|
])>{{ $s['label'] }}</span>
|
|
<span @class([
|
|
'ml-auto rounded-full px-1.5 py-px font-mono text-[10px] tabular-nums',
|
|
'bg-accent/15 text-accent-text' => $s['key'] === $activeKey,
|
|
'bg-raised text-ink-4' => $s['key'] !== $activeKey,
|
|
])>{{ $s['count'] }}</span>
|
|
</button>
|
|
@endforeach
|
|
</nav>
|
|
|
|
{{-- Releases for the active series --}}
|
|
<div class="min-w-0">
|
|
@if ($activeSeries)
|
|
{{-- Series sub-header: which series, how many releases, latest date --}}
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line px-4 py-3 sm:px-5">
|
|
<span class="font-display text-sm font-semibold text-ink">
|
|
@if ($activeSeries['unreleased'] || $activeSeries['key'] === 'other')
|
|
{{ $activeSeries['label'] }}
|
|
@else
|
|
{{ __('versions.series_label', ['series' => $activeSeries['label']]) }}
|
|
@endif
|
|
</span>
|
|
<span class="font-mono text-[11px] text-ink-4">{{ trans_choice('versions.series_count', $activeSeries['count'], ['count' => $activeSeries['count']]) }}</span>
|
|
@if ($activeSeries['latest'])
|
|
<span class="ml-auto font-mono text-[11px] text-ink-4">{{ $activeSeries['latest'] }}</span>
|
|
@endif
|
|
</div>
|
|
|
|
{{-- Accordion: the first release of each page is open, the rest collapse --}}
|
|
<div class="divide-y divide-line px-4 sm:px-5">
|
|
@foreach ($pagedReleases as $rel)
|
|
{{-- wire:ignore.self keeps a user's open/closed toggle from being reset when an
|
|
unrelated re-render (autoCheck / check-updates / the in-update poll) morphs the
|
|
page; a series/page change uses a fresh wire:key, so it re-defaults to first-open. --}}
|
|
<details wire:key="rel-{{ $rel['version'] }}" wire:ignore.self class="group" @if ($loop->first) open @endif>
|
|
<summary class="flex cursor-pointer list-none items-center gap-2.5 py-3.5 [&::-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
|
|
{{-- Toned glance-dots: which change categories this release has --}}
|
|
<span class="ml-auto flex items-center gap-1" aria-hidden="true">
|
|
@foreach (array_unique(array_map($tone, array_keys($rel['groups']))) as $t)
|
|
<span class="h-1.5 w-1.5 rounded-full {{ $dotClass[$t] ?? 'bg-ink-4' }}"></span>
|
|
@endforeach
|
|
</span>
|
|
</summary>
|
|
<div class="space-y-3 pb-4 pl-[26px]">
|
|
@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>
|
|
@endforeach
|
|
</div>
|
|
|
|
{{-- Pagination within the active series --}}
|
|
@if ($totalPages > 1)
|
|
<nav aria-label="{{ __('versions.pagination_nav') }}" class="flex items-center justify-between gap-3 border-t border-line px-4 py-3 sm:px-5">
|
|
<button type="button" wire:click="gotoChangelogPage({{ $changelogPage - 1 }})" @disabled($changelogPage <= 1)
|
|
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
|
|
<x-icon name="chevron-left" class="h-3.5 w-3.5" />
|
|
<span class="hidden sm:inline">{{ __('versions.pagination_prev') }}</span>
|
|
</button>
|
|
|
|
<div class="flex items-center gap-1">
|
|
@foreach ($pageWindow as $p)
|
|
@if ($p === '…')
|
|
<span class="px-1 font-mono text-[11px] text-ink-4" aria-hidden="true">…</span>
|
|
@else
|
|
<button type="button" wire:key="page-{{ $p }}" wire:click="gotoChangelogPage({{ $p }})"
|
|
@class([
|
|
'grid h-11 min-w-11 place-items-center rounded-md border px-2 font-mono text-[11px] tabular-nums transition-colors lg:h-7 lg:min-w-[28px]',
|
|
'border-accent/40 bg-accent/15 text-accent-text' => $p === $changelogPage,
|
|
'border-line bg-raised text-ink-2 hover:border-accent/30 hover:text-ink' => $p !== $changelogPage,
|
|
])
|
|
@if ($p === $changelogPage) aria-current="page" @endif>{{ $p }}</button>
|
|
@endif
|
|
@endforeach
|
|
</div>
|
|
|
|
<button type="button" wire:click="gotoChangelogPage({{ $changelogPage + 1 }})" @disabled($changelogPage >= $totalPages)
|
|
class="inline-flex items-center gap-1 rounded-md border border-line bg-raised px-2.5 py-3 font-mono text-[11px] text-ink-2 transition-colors hover:border-accent/30 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:text-ink-2 lg:py-1.5">
|
|
<span class="hidden sm:inline">{{ __('versions.pagination_next') }}</span>
|
|
<x-icon name="chevron-right" class="h-3.5 w-3.5" />
|
|
</button>
|
|
</nav>
|
|
@endif
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</x-panel>
|
|
|
|
{{-- Aside --}}
|
|
<div class="space-y-5">
|
|
<x-panel :title="__('versions.installed_title')" :padded="false">
|
|
<div class="space-y-3 px-4 py-4 sm:px-5">
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="font-display text-2xl font-bold text-ink">v{{ $version }}</span>
|
|
<span class="inline-flex items-center gap-1.5 font-mono text-[11px] text-online"><x-status-dot status="online" />{{ __('versions.running') }}</span>
|
|
</div>
|
|
<dl class="space-y-2 font-mono text-[11px]">
|
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.build') }}</dt><dd class="text-ink-2">{{ $build['sha'] ?? '—' }}</dd></div>
|
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.branch') }}</dt><dd class="truncate text-ink-2">{{ $build['branch'] }}</dd></div>
|
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.channel') }}</dt><dd class="text-ink-2">{{ $channel }}</dd></div>
|
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.latest_release') }}</dt><dd class="text-ink-2">{{ $latestTag ? 'v'.$latestTag : '—' }}</dd></div>
|
|
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">{{ __('versions.license') }}</dt><dd class="text-ink-2">{{ $license }}</dd></div>
|
|
</dl>
|
|
</div>
|
|
</x-panel>
|
|
|
|
<x-panel :title="__('versions.update_title')" :padded="false">
|
|
<div class="space-y-3 px-4 py-4 sm:px-5">
|
|
<p class="font-mono text-[11px] leading-relaxed text-ink-3">{{ __('versions.update_hint') }}</p>
|
|
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">sudo clusev update</pre>
|
|
</div>
|
|
</x-panel>
|
|
|
|
<x-panel :title="__('versions.project_title')" :padded="false">
|
|
<div class="space-y-3 px-4 py-4 sm:px-5">
|
|
@foreach ($repositories as $repo)
|
|
<a href="{{ $repo['url'] }}" target="_blank" rel="noopener noreferrer" wire:key="repo-{{ $loop->index }}" class="flex items-center gap-3 rounded-md border border-line bg-inset px-3 py-2.5 transition-colors hover:border-accent/30">
|
|
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-raised text-ink-3"><x-icon name="git-branch" class="h-4 w-4" /></span>
|
|
<span class="min-w-0 flex-1">
|
|
<span class="block truncate font-mono text-xs text-ink">{{ $repo['path'] }}</span>
|
|
<span class="block truncate font-mono text-[11px] text-ink-3">{{ $repo['host'] }}</span>
|
|
</span>
|
|
<x-icon name="chevron-left" class="h-4 w-4 shrink-0 rotate-180 text-ink-4" />
|
|
</a>
|
|
@endforeach
|
|
<p class="flex items-center gap-2 font-mono text-[11px] text-ink-3"><x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-cyan" /> {{ __('versions.open_core', ['license' => $license]) }}</p>
|
|
</div>
|
|
</x-panel>
|
|
</div>
|
|
</div>
|
|
</div>
|