feat(ui): unify resource nav sidebars and polish project layouts

Extract application/database/service configuration sidebars and
unified headings; improve clone-to-environment, embedded deployments,
storage actions, OAuth icons, terminal, and shared form components.
This commit is contained in:
Andras Bacsai committed 2026-08-05 17:56:59 +02:00
1 parent 2291c3fa1c
commit 5de6f97256
85 files changed
+2087 -981

No files matched your search

@@ -29,15 +29,49 @@
@if ($backup->database_type === 'App\Models\StandalonePostgresql' && $backup->database_id !== 0
|| $backup->database_type === 'App\Models\StandaloneMysql'
|| $backup->database_type === 'App\Models\StandaloneMariadb')
<div class="grid gap-4 sm:grid-cols-2">
<div class="grid w-full gap-4">
<x-forms.listbox id="dumpAll" label="Database selection" onChange="instantSave" :options="[
['value' => true, 'label' => 'Back up all databases'],
['value' => false, 'label' => 'Choose databases'],
]" />
@if (! $backup->dump_all)
<x-forms.input label="Databases to back up"
helper="Comma-separated database names. Leave empty to include the default database."
id="databasesToBackup" />
<div class="w-full" x-data="{
value: @entangle('databasesToBackup').live,
draft: '',
get databases() {
return (this.value || '').split(',').map(name => name.trim()).filter(Boolean);
},
addDatabase() {
const names = this.draft.split(',').map(name => name.trim()).filter(Boolean);
if (names.length === 0) return;
this.value = [...new Set([...this.databases, ...names])].join(',');
this.draft = '';
},
removeDatabase(index) {
this.value = this.databases.filter((_, itemIndex) => itemIndex !== index).join(',');
},
}">
<label class="mb-1.5 block text-sm font-medium">Databases to back up</label>
<div class="chip-input">
<template x-for="(database, index) in databases" :key="database">
<span class="chip font-mono">
<span x-text="database"></span>
<button type="button" @click="removeDatabase(index)"
class="chip-remove"
:aria-label="`Remove ${database}`">
<x-reicon name="x" class="size-3" />
</button>
</span>
</template>
<input x-model="draft" @keydown.enter.prevent="addDatabase()"
@keydown="if ($event.key === ',') { $event.preventDefault(); addDatabase(); }"
@blur="addDatabase()" type="text"
placeholder="Type a database and press Enter" />
</div>
<p class="mt-1.5 text-xs text-neutral-500 dark:text-fg-dim">
Add one or more database names. Leave empty to include the default database.
</p>
</div>
@endif
</div>
@elseif ($backup->database_type === 'App\Models\StandaloneMongodb')
@@ -5,22 +5,42 @@
<livewire:project.database.heading :database="$database" />
<div class="mt-8 w-full max-w-[1180px] lg:mt-3">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Scheduled backups</h2>
<p>Automate database backups and track the latest execution for each schedule.</p>
</div>
<div class="application-settings-workspace mt-4 grid w-full max-w-[1180px] min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<x-database.configuration-sidebar :database="$database" current-route="project.database.backup.index" />
<div class="application-settings-form flex min-w-0 flex-col gap-6">
<x-application.settings-section title="Database backups"
description="Automate database backups and track the latest execution for each schedule.">
@can('update', $database)
<x-modal-input buttonTitle="+ Add" title="New Scheduled Backup" isHighlightedButton>
<livewire:project.database.create-scheduled-backup :database="$database" />
</x-modal-input>
<x-slot:actions>
<x-modal-input title="New Scheduled Backup" isHighlightedButton buttonTitle="+ Add">
<livewire:project.database.create-scheduled-backup :database="$database" />
</x-modal-input>
</x-slot:actions>
@endcan
</div>
<div class="application-settings-section-body p-0!">
<livewire:project.database.scheduled-backups :database="$database" />
</div>
</section>
<div class="grid gap-4 sm:grid-cols-3">
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Schedules</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $database->scheduledBackups->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Enabled</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $database->scheduledBackups->where('enabled', true)->count() }}
</p>
</div>
<div>
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">Total executions</p>
<p class="mt-1 text-xl font-semibold tabular-nums text-neutral-950 dark:text-fg">
{{ $database->scheduledBackups->sum('executions_count') }}
</p>
</div>
</div>
</x-application.settings-section>
<livewire:project.database.scheduled-backups :database="$database" />
</div>
</div>
</div>
@@ -5,76 +5,10 @@
<livewire:project.database.heading :database="$database" />
@php
$databaseRouteParameters = [
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'database_uuid' => $database->uuid,
];
$configurationItems = [
['label' => 'General', 'route' => 'project.database.configuration', 'icon' => 'settings'],
['label' => 'Environment Variables', 'route' => 'project.database.environment-variables', 'icon' => 'variables'],
['label' => 'Servers', 'route' => 'project.database.servers', 'icon' => 'servers'],
['label' => 'Persistent Storage', 'route' => 'project.database.persistent-storage', 'icon' => 'storages'],
[
'label' => 'Import Backup',
'route' => 'project.database.import-backup',
'icon' => 'upload',
'visible' => auth()->user()?->can('update', $database),
],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
['label' => 'Metrics', 'route' => 'project.database.metrics', 'icon' => 'graph'],
['label' => 'Tags', 'route' => 'project.database.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.database.danger', 'icon' => 'shield-alert'],
];
$configurationItems = collect($configurationItems)
->filter(fn (array $item): bool => $item['visible'] ?? true)
->map(fn (array $item): array => [
...$item,
'active' => $currentRoute === $item['route'],
]);
$menuGroups = [
'Settings' => ['General', 'Environment Variables', 'Servers', 'Persistent Storage', 'Import Backup'],
'Automation' => ['Webhooks', 'Healthcheck'],
'Operations' => ['Resource Limits', 'Resource Operations', 'Metrics', 'Tags', 'Danger Zone'],
];
$groupedItems = collect($menuGroups)
->map(fn (array $labels) => $configurationItems->whereIn('label', $labels)->values())
->filter(fn ($items) => $items->isNotEmpty());
@endphp
<section class="application-settings-workspace mt-4 w-full max-w-[1180px] lg:mt-0">
<div class="grid min-w-0 gap-8 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-10">
<aside class="application-settings-navigation min-w-0 xl:self-start">
<nav aria-label="Database settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
<div class="nav-section hidden xl:block">{{ $groupLabel }}</div>
@foreach ($groupItems as $menuItem)
<a @class([
'menu-item',
'menu-item-active' => $menuItem['active'],
])
{{ wireNavigate() }}
href="{{ route($menuItem['route'], $databaseRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
</a>
@endforeach
@endforeach
</nav>
</aside>
<x-database.configuration-sidebar :database="$database" :current-route="$currentRoute" />
<div class="min-w-0">
@if ($currentRoute === 'project.database.configuration')
@@ -30,7 +30,7 @@
'navigate' => false,
],
[
'label' => 'Console',
'label' => 'Terminal',
'route' => 'project.database.command',
'active' => request()->routeIs('project.database.command'),
'navigate' => false,
@@ -77,7 +77,7 @@
@endteleport
<div x-data>
<div class="mb-3 w-full lg:hidden">
<div class="mb-3 w-full xl:hidden">
<div class="flex min-w-0 flex-wrap items-center gap-2">
<h1 class="min-w-0 truncate text-[24px]! leading-7! font-semibold! tracking-tight! text-black dark:text-fg">
{{ $database->name }}
@@ -86,7 +86,7 @@
</div>
</div>
<div class="w-full md:hidden">
<div class="w-full xl:hidden">
@if ($database->destination->server->isFunctional())
<div id="database-mobile-actions" class="relative mb-3"
x-data="{ open: false }" @click.outside="open = false"
@@ -149,56 +149,37 @@
</div>
@endif
<div
class="flex min-w-0 items-center gap-0.5 rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="min-w-0 flex-1">
@foreach ($databasePageItems as $menuItem)
<a @class([
'app-tab shrink-0',
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
</x-resource-heading-tabs>
</div>
</div>
<div class="hidden w-full items-center md:flex lg:fixed lg:top-12 lg:right-0 lg:z-30 lg:h-12 lg:w-auto lg:border-b lg:border-neutral-200 lg:bg-white/95 lg:pr-4 lg:pl-2 lg:backdrop-blur lg:transition-[left] lg:duration-200 lg:dark:border-white/[0.06] lg:dark:bg-panel/95"
:class="[typeof collapsed !== 'undefined' && collapsed ? 'lg:left-16' : 'lg:left-56']">
<div class="hidden w-full items-center xl:fixed xl:top-14 xl:right-4 xl:z-30 xl:flex xl:h-12 xl:w-auto">
<div
class="resource-heading-navbar application-heading-actions flex w-full min-w-0 items-center justify-between gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<x-resource-heading-tabs class="min-w-0">
@foreach ($databasePageItems as $menuItem)
<a wire:key="database-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0',
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $parameters) }}">
{{ $menuItem['label'] }}
</a>
@endforeach
</x-resource-heading-tabs>
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5 border-l border-neutral-200 pl-1 dark:border-white/[0.08]">
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-2 overflow-visible rounded-[10px] border border-neutral-200 bg-neutral-100 p-1 dark:border-white/[0.07] dark:bg-white/[0.035]">
<div class="resource-heading-actions flex shrink-0 items-center gap-0.5">
@if ($database->destination->server->isFunctional())
@if (! $databaseStatus->startsWith('exited'))
<x-forms.button canGate="manage" :canResource="$database"
@click="document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-4 text-orange-500 dark:text-warning" />
Restart
</x-forms.button>
<x-forms.button canGate="manage" :canResource="$database" isError
@click="document.getElementById('database-stop-trigger')?.click()">
<x-reicon name="stop" class="size-4 text-error" />
Stop
</x-forms.button>
<div id="database-desktop-actions" class="relative" x-data="{ open: false }"
@click.outside="open = false" @keydown.escape.window="open = false">
<button type="button" class="button" @click="open = !open" :aria-expanded="open">
<x-reicon name="play-circle" class="size-3.5 opacity-70" />
Actions
<x-reicon name="chevron-down" class="size-3 opacity-55" />
</button>
<div x-cloak x-show="open" x-transition.origin.top.right
class="listbox-panel top-full! right-0! left-auto! mt-1! w-52! min-w-0!" role="menu">
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-restart-trigger')?.click()">
<x-reicon name="restart" class="size-3.5 text-orange-500 dark:text-warning" />
Restart
</button>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@disabled(!auth()->user()->can('manage', $database))
@click="open = false; document.getElementById('database-stop-trigger')?.click()">
<x-reicon name="stop" class="size-3.5 text-error" />
Stop
</button>
</div>
</div>
@else
<x-forms.button canGate="manage" :canResource="$database"
@click="$wire.dispatch('startEvent')">
@@ -213,7 +194,6 @@
</div>
</div>
<div class="hidden lg:block lg:h-12" aria-hidden="true"></div>
</div>
@if ($database->destination->server->isFunctional())
@@ -1,4 +1,4 @@
<div x-data="{
<div class="flex min-w-0 flex-col gap-3" x-data="{
search: '',
backups: @js($database->scheduledBackups->map(fn ($backup) => [
'name' => strtolower($database->name),
@@ -41,12 +41,12 @@
</form>
@else
@if ($database->scheduledBackups->isNotEmpty())
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.06]">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input type="search" x-model="search"
class="input h-8! pl-8! text-[13px]!" placeholder="Search backup schedules…" />
<input type="search" x-model="search" aria-label="Search backups"
class="input h-8! w-full pl-8! text-[13px]!" placeholder="Search backups" />
</div>
</div>
@endif
@@ -56,7 +56,7 @@
description="Create a schedule to start protecting this database."
icon-name="storages" />
@else
<div x-cloak x-show="search === '' || hasMatches()" class="data-table">
<div x-cloak x-show="search === '' || hasMatches()" class="data-table overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div class="data-table-header scheduled-backups-table-grid">
<span>Schedule</span>
<span>Latest run</span>
@@ -83,10 +83,9 @@
|| @js(strtolower($backup->s3?->name ?? '')).includes(search.toLowerCase())"
class="data-table-row scheduled-backups-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.06]">
<div class="min-w-0">
<a class="block truncate text-[12px] font-semibold text-black dark:text-fg"
{{ wireNavigate() }} href="{{ $backupRoute }}">
<span class="block truncate text-[12px] font-semibold text-black dark:text-fg">
{{ $backup->frequency }}
</a>
</span>
</div>
<div class="flex items-center gap-2">
<x-status-badge :status="$statusLabel" :type="$statusType" />
@@ -98,7 +97,7 @@
{{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }}
</div>
<div class="text-[11px] text-neutral-600 dark:text-fg-dim">
{{ $backup->executions()->count() }}
{{ $backup->executions_count ?? $backup->executions()->count() }}
</div>
<div class="flex justify-end">
<a class="button" {{ wireNavigate() }} href="{{ $backupRoute }}">Manage</a>