clusev/resources/views/livewire/versions/index.blade.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 h-11 items-center gap-1 rounded-md border border-line bg-raised px-2.5 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:h-7">
<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 h-11 items-center gap-1 rounded-md border border-line bg-raised px-2.5 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:h-7">
<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>