feat(r5): wire-elements/modal confirmations for destructive actions
Add a generic ConfirmAction modal (LivewireUI\Modal\ModalComponent). On confirm it persists exactly one AuditEvent and re-dispatches a page event so the origin applies its own state change — domain-agnostic and reused everywhere (R5). Wired the destructive actions, each writing an audit row: - Services: start/stop/restart -> confirm + audit; service state reflects result - Files: delete -> confirm + audit; entry removed - Server-Details: revoke SSH key (new per-row trash button) -> confirm + audit (carries server_id) Supporting changes: - Publish + restyle the modal container for the dark theme (void backdrop + surface panel + shadow-pop instead of the package's gray/white defaults) - Toaster island in the app layout that catches the `notify` browser event - Add alert/power/rotate/trash icons to x-icon Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/v1-foundation
parent
dc432e7335
commit
218806727c
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Files;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -52,6 +53,39 @@ class Index extends Component
|
|||
return $crumbs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file (R5): opens the confirm modal, which writes the AuditEvent
|
||||
* and re-dispatches `fileConfirmed`. SFTP unlink is wired in behind this later.
|
||||
*/
|
||||
public function confirmDelete(string $name): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => 'Datei löschen',
|
||||
'body' => "„{$name}“ wird unwiderruflich aus {$this->path} entfernt.",
|
||||
'confirmLabel' => 'Löschen',
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
'auditAction' => 'file.delete',
|
||||
'auditTarget' => rtrim($this->path, '/')."/{$name}",
|
||||
'event' => 'fileConfirmed',
|
||||
'params' => ['name' => $name],
|
||||
'notify' => "„{$name}“ gelöscht.",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies the confirmed deletion (mock until SFTP unlink lands). */
|
||||
#[On('fileConfirmed')]
|
||||
public function deleteEntry(string $name): void
|
||||
{
|
||||
$this->entries = array_values(array_filter(
|
||||
$this->entries,
|
||||
fn (array $e): bool => $e['name'] !== $name,
|
||||
));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.files.index');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/**
|
||||
* Generic R5 confirmation dialog for destructive / stateful actions.
|
||||
*
|
||||
* The opener passes copy + an audit descriptor + a follow-up event. On confirm
|
||||
* we persist exactly one AuditEvent and re-dispatch the opener's event so the
|
||||
* originating page can apply its own state change. The modal stays
|
||||
* domain-agnostic and is reused for every confirm in the app (R5).
|
||||
*/
|
||||
class ConfirmAction extends ModalComponent
|
||||
{
|
||||
public string $heading = 'Aktion bestätigen';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public string $confirmLabel = 'Bestätigen';
|
||||
|
||||
public bool $danger = false;
|
||||
|
||||
public string $icon = '';
|
||||
|
||||
/** Audit descriptor — one row written on confirm. */
|
||||
public string $auditAction = '';
|
||||
|
||||
public ?string $auditTarget = null;
|
||||
|
||||
public ?int $serverId = null;
|
||||
|
||||
/** Follow-up event re-dispatched to the page after a successful confirm. */
|
||||
public string $event = '';
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
public array $params = [];
|
||||
|
||||
public string $notify = 'Aktion ausgeführt.';
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'md';
|
||||
}
|
||||
|
||||
public function confirm(): void
|
||||
{
|
||||
if ($this->auditAction !== '') {
|
||||
AuditEvent::create([
|
||||
'user_id' => Auth::id(),
|
||||
'server_id' => $this->serverId,
|
||||
'actor' => Auth::user()?->name ?? 'system',
|
||||
'action' => $this->auditAction,
|
||||
'target' => $this->auditTarget,
|
||||
'ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->event !== '') {
|
||||
$this->dispatch($this->event, ...$this->params);
|
||||
}
|
||||
|
||||
$this->dispatch('notify', message: $this->notify);
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.confirm-action');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Livewire\Servers;
|
|||
|
||||
use App\Models\Server;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -57,6 +58,40 @@ class Show extends Component
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke an SSH key (R5): opens the confirm modal, which writes the
|
||||
* AuditEvent and re-dispatches `keyRemoved`. SSH layer removal lands later.
|
||||
*/
|
||||
public function confirmKeyRemoval(string $fingerprint, string $comment): void
|
||||
{
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => 'SSH-Schlüssel entfernen',
|
||||
'body' => "Der Schlüssel „{$comment}“ verliert den Zugang zu {$this->server->name}.",
|
||||
'confirmLabel' => 'Entfernen',
|
||||
'danger' => true,
|
||||
'icon' => 'trash',
|
||||
'auditAction' => 'ssh_key.remove',
|
||||
'auditTarget' => "{$comment} · {$this->server->name}",
|
||||
'serverId' => $this->server->id,
|
||||
'event' => 'keyRemoved',
|
||||
'params' => ['fingerprint' => $fingerprint],
|
||||
'notify' => "Schlüssel „{$comment}“ entfernt.",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/** Applies the confirmed key removal (mock until SSH layer lands). */
|
||||
#[On('keyRemoved')]
|
||||
public function removeKey(string $fingerprint): void
|
||||
{
|
||||
$this->sshKeys = array_values(array_filter(
|
||||
$this->sshKeys,
|
||||
fn (array $k): bool => $k['fingerprint'] !== $fingerprint,
|
||||
));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.servers.show');
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Services;
|
||||
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -63,22 +64,44 @@ class Index extends Component
|
|||
}
|
||||
|
||||
/**
|
||||
* Service control. Wired to the SSH layer (systemctl start/stop/restart)
|
||||
* behind a confirmation modal later — see R5 markers in the view.
|
||||
* Service control (R5): opens the wire-elements/modal confirm dialog. The
|
||||
* modal writes the AuditEvent and re-dispatches `serviceConfirmed`, which
|
||||
* applyService() handles. SSH exec (systemctl) is wired in behind this later.
|
||||
*/
|
||||
public function start(string $name): void
|
||||
public function confirm(string $op, string $name): void
|
||||
{
|
||||
// R5: routed through wire-elements/modal + SSH exec later.
|
||||
[$heading, $phrase, $label, $action, $icon] = match ($op) {
|
||||
'start' => ['Dienst starten', 'wird gestartet', 'Starten', 'service.start', 'power'],
|
||||
'stop' => ['Dienst stoppen', 'wird gestoppt', 'Stoppen', 'service.stop', 'power'],
|
||||
default => ['Dienst neu starten', 'wird neu gestartet', 'Neu starten', 'service.restart', 'rotate'],
|
||||
};
|
||||
|
||||
$this->dispatch('openModal',
|
||||
component: 'modals.confirm-action',
|
||||
arguments: [
|
||||
'heading' => $heading,
|
||||
'body' => "{$name} {$phrase}. Ausgeführt über systemctl auf {$this->server}.",
|
||||
'confirmLabel' => $label,
|
||||
'danger' => $op === 'stop',
|
||||
'icon' => $icon,
|
||||
'auditAction' => $action,
|
||||
'auditTarget' => "{$name} · {$this->server}",
|
||||
'event' => 'serviceConfirmed',
|
||||
'params' => ['op' => $op, 'name' => $name],
|
||||
'notify' => "{$name}: {$label} ausgeführt.",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function stop(string $name): void
|
||||
/** Applies the confirmed service state change (mock until SSH exec lands). */
|
||||
#[On('serviceConfirmed')]
|
||||
public function applyService(string $op, string $name): void
|
||||
{
|
||||
// R5: routed through wire-elements/modal + SSH exec later.
|
||||
foreach ($this->services as $i => $svc) {
|
||||
if ($svc['name'] === $name) {
|
||||
$this->services[$i]['status'] = $op === 'stop' ? 'offline' : 'online';
|
||||
}
|
||||
}
|
||||
|
||||
public function restart(string $name): void
|
||||
{
|
||||
// R5: routed through wire-elements/modal + SSH exec later.
|
||||
}
|
||||
|
||||
/** @return array<int, array{name:string,status:string,desc:string,enabled:bool}> */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Include CSS
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The modal uses TailwindCSS, if you don't use TailwindCSS you will need
|
||||
| to set this parameter to true. This includes the modern-normalize css.
|
||||
|
|
||||
*/
|
||||
'include_css' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Include JS
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire UI will inject the required Javascript in your blade template.
|
||||
| If you want to bundle the required Javascript you can set this to false
|
||||
| and add `require('vendor/wire-elements/modal/resources/js/modal');`
|
||||
| to your script bundler like webpack.
|
||||
|
|
||||
*/
|
||||
'include_js' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Modal Component Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Configure the default properties for a modal component.
|
||||
|
|
||||
| Supported modal_max_width
|
||||
| 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl'
|
||||
*/
|
||||
'component_defaults' => [
|
||||
'modal_max_width' => '2xl',
|
||||
|
||||
'close_modal_on_click_away' => true,
|
||||
|
||||
'close_modal_on_escape' => true,
|
||||
|
||||
'close_modal_on_escape_is_forceful' => true,
|
||||
|
||||
'dispatch_close_event' => false,
|
||||
|
||||
'destroy_on_close' => false,
|
||||
],
|
||||
];
|
||||
|
|
@ -16,6 +16,10 @@
|
|||
'shield' => '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
'logout' => '<path d="m16 17 5-5-5-5"/><path d="M21 12H9"/><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>',
|
||||
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||
'power' => '<path d="M12 2v10"/><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>',
|
||||
'rotate' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||
'trash' => '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/>',
|
||||
];
|
||||
@endphp
|
||||
<svg {{ $attributes->merge(['class' => $class]) }} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,30 @@
|
|||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{{-- Toaster: catches Livewire-dispatched `notify` browser events --}}
|
||||
<div
|
||||
x-data="{ toasts: [] }"
|
||||
x-on:notify.window="
|
||||
const msg = $event.detail?.message ?? $event.detail?.[0]?.message ?? 'Erledigt';
|
||||
const id = (window.crypto?.randomUUID?.() ?? String(Date.now() + Math.random()));
|
||||
toasts.push({ id, msg });
|
||||
setTimeout(() => { toasts = toasts.filter(t => t.id !== id) }, 4000);
|
||||
"
|
||||
class="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4 sm:items-end sm:px-6"
|
||||
aria-live="polite"
|
||||
>
|
||||
<template x-for="t in toasts" :key="t.id">
|
||||
<div
|
||||
x-transition.opacity.duration.200ms
|
||||
class="pointer-events-auto flex items-center gap-2.5 rounded-md border border-line bg-raised px-4 py-2.5 shadow-pop"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-online"></span>
|
||||
<span class="font-mono text-xs text-ink-2" x-text="t.msg"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<livewire:wire-elements-modal />
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -118,8 +118,8 @@
|
|||
<button type="button" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-ink-2 hover:bg-line hover:text-ink">
|
||||
Bearbeiten
|
||||
</button>
|
||||
{{-- R5: wire to wire-elements/modal --}}
|
||||
<button type="button" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-offline hover:bg-offline/10">
|
||||
{{-- R5: destructive → wire-elements/modal confirm + audit --}}
|
||||
<button type="button" wire:click="confirmDelete('{{ $e['name'] }}')" class="inline-flex min-h-11 items-center rounded-sm px-2.5 py-1 font-mono text-[11px] text-offline hover:bg-offline/10">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<div class="p-5 sm:p-6">
|
||||
<div class="flex items-start gap-3.5">
|
||||
<span @class([
|
||||
'grid h-10 w-10 shrink-0 place-items-center rounded-md border',
|
||||
'border-offline/30 bg-offline/10 text-offline' => $danger,
|
||||
'border-accent/30 bg-accent/10 text-accent-text' => ! $danger,
|
||||
])>
|
||||
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-5 w-5" />
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-display text-base font-semibold text-ink">{{ $heading }}</h2>
|
||||
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ $body }}</p>
|
||||
@if ($auditTarget)
|
||||
<p class="mt-2 truncate font-mono text-[11px] text-ink-4">{{ $auditTarget }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="$dispatch('closeModal')"
|
||||
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-4 text-sm text-ink-2 transition-colors hover:bg-raised hover:text-ink"
|
||||
>Abbrechen</button>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="confirm"
|
||||
wire:loading.attr="disabled"
|
||||
@class([
|
||||
'inline-flex min-h-11 items-center gap-2 rounded-md px-4 font-display text-sm font-semibold uppercase tracking-wide transition-colors disabled:opacity-50',
|
||||
'bg-offline text-void hover:bg-offline/90' => $danger,
|
||||
'bg-accent text-void hover:bg-accent-bright' => ! $danger,
|
||||
])
|
||||
>
|
||||
<x-icon :name="$icon ?: ($danger ? 'alert' : 'activity')" class="h-4 w-4" />
|
||||
{{ $confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,6 +161,13 @@
|
|||
</div>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
|
||||
</div>
|
||||
{{-- R5: destructive → wire-elements/modal confirm + audit --}}
|
||||
<button type="button"
|
||||
wire:click="confirmKeyRemoval('{{ $key['fingerprint'] }}', '{{ $key['comment'] }}')"
|
||||
class="inline-flex min-h-11 shrink-0 items-center rounded-sm px-2 text-ink-3 transition-colors hover:bg-offline/10 hover:text-offline"
|
||||
title="Schlüssel entfernen">
|
||||
<x-icon name="trash" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -63,19 +63,19 @@
|
|||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="start('{{ $svc['name'] }}')"
|
||||
wire:click="confirm('start', '{{ $svc['name'] }}')"
|
||||
@disabled($svc['status'] === 'online')
|
||||
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-online/30 hover:bg-online/10 hover:text-online disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
|
||||
>Start</button>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="stop('{{ $svc['name'] }}')"
|
||||
wire:click="confirm('stop', '{{ $svc['name'] }}')"
|
||||
@disabled($svc['status'] === 'offline')
|
||||
class="inline-flex min-h-11 items-center rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:border-offline/30 hover:bg-offline/10 hover:text-offline disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-line disabled:hover:bg-inset disabled:hover:text-ink-2"
|
||||
>Stopp</button>
|
||||
<button
|
||||
type="button"
|
||||
wire:click="restart('{{ $svc['name'] }}')"
|
||||
wire:click="confirm('restart', '{{ $svc['name'] }}')"
|
||||
@disabled($svc['status'] === 'offline')
|
||||
class="inline-flex min-h-11 items-center rounded-md border border-accent/25 bg-accent/10 px-3 text-xs text-accent-text transition-colors hover:bg-accent/15 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-accent/10"
|
||||
>Neustart</button>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
<div>
|
||||
@isset($jsPath)
|
||||
<script>{!! file_get_contents($jsPath) !!}</script>
|
||||
@endisset
|
||||
@isset($cssPath)
|
||||
<style>{!! file_get_contents($cssPath) !!}</style>
|
||||
@endisset
|
||||
|
||||
<div
|
||||
x-data="LivewireUIModal()"
|
||||
x-on:close.stop="setShowPropertyTo(false)"
|
||||
x-on:keydown.escape.window="show && closeModalOnEscape()"
|
||||
x-show="show"
|
||||
class="fixed inset-0 z-10 overflow-y-auto"
|
||||
style="display: none;"
|
||||
>
|
||||
<div class="flex items-end justify-center min-h-dvh px-4 pt-4 pb-10 text-center sm:block sm:p-0">
|
||||
<div
|
||||
x-show="show"
|
||||
x-on:click="closeModalOnClickAway()"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 transition-all transform"
|
||||
>
|
||||
<div class="absolute inset-0 bg-void/80 backdrop-blur-sm"></div>
|
||||
</div>
|
||||
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div
|
||||
x-show="show && showActiveComponent"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-bind:class="modalWidth"
|
||||
class="inline-block w-full align-bottom bg-surface border border-line rounded-lg text-left overflow-hidden shadow-pop transform transition-all sm:my-8 sm:align-middle sm:w-full"
|
||||
id="modal-container"
|
||||
x-trap.noscroll.inert="show && showActiveComponent"
|
||||
aria-modal="true"
|
||||
>
|
||||
@forelse($components as $id => $component)
|
||||
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
|
||||
@livewire($component['name'], $component['arguments'], key($id))
|
||||
</div>
|
||||
@empty
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Reference in New Issue