Fix mobile sidebar readability; rework device list for scale
- Sidebar drawer is now opaque (.sidebar-tint solid), so on mobile the bright content no longer bleeds through and it stays readable. - Devices index reworked for ~50 devices: live search (name/model/vendor), room + online/offline filters (URL-persisted), grouped by room with counts — scannable instead of one long list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/phase-1-bootstrap
parent
d950abe375
commit
aceb140aac
|
|
@ -3,19 +3,50 @@
|
|||
namespace App\Livewire\Devices;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Room;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
#[Url(as: 'q')]
|
||||
public string $search = '';
|
||||
|
||||
#[Url]
|
||||
public string $room = '';
|
||||
|
||||
#[Url]
|
||||
public string $status = '';
|
||||
|
||||
public function clearFilters(): void
|
||||
{
|
||||
$this->reset('search', 'room', 'status');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$devices = Device::query()
|
||||
->with(['room', 'entities.state'])
|
||||
->when($this->search, fn ($q) => $q->where(fn ($w) => $w
|
||||
->where('name', 'ilike', "%{$this->search}%")
|
||||
->orWhere('model', 'ilike', "%{$this->search}%")
|
||||
->orWhere('vendor', 'ilike', "%{$this->search}%")))
|
||||
->when($this->room, fn ($q) => $q->whereHas('room', fn ($r) => $r->where('uuid', $this->room)))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
if ($this->status === 'online') {
|
||||
$devices = $devices->filter->isOnline()->values();
|
||||
} elseif ($this->status === 'offline') {
|
||||
$devices = $devices->reject->isOnline()->values();
|
||||
}
|
||||
|
||||
return view('livewire.devices.index', [
|
||||
'devices' => Device::query()
|
||||
->with(['room', 'entities.state'])
|
||||
->orderBy('name')
|
||||
->get(),
|
||||
'groups' => $devices->groupBy(fn ($d) => $d->room?->name ?? __('devices.no_room')),
|
||||
'total' => $devices->count(),
|
||||
'rooms' => Room::orderBy('sort')->orderBy('name')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ return [
|
|||
// index
|
||||
'index_subtitle' => 'Alle Geräte im Haus.',
|
||||
'empty' => 'Noch keine Geräte.',
|
||||
'search_placeholder' => 'Geräte suchen…',
|
||||
'all_rooms' => 'Alle Räume',
|
||||
'all_status' => 'Alle Zustände',
|
||||
'clear' => 'Zurücksetzen',
|
||||
'none_match' => 'Keine Geräte gefunden.',
|
||||
'count' => '{0}Keine Geräte|{1}1 Gerät|[2,*]:count Geräte',
|
||||
|
||||
// detail
|
||||
'device' => 'Gerät',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ return [
|
|||
// index
|
||||
'index_subtitle' => 'All devices in the home.',
|
||||
'empty' => 'No devices yet.',
|
||||
'search_placeholder' => 'Search devices…',
|
||||
'all_rooms' => 'All rooms',
|
||||
'all_status' => 'All states',
|
||||
'clear' => 'Clear',
|
||||
'none_match' => 'No devices found.',
|
||||
'count' => '{0}No devices|{1}1 device|[2,*]:count devices',
|
||||
|
||||
// detail
|
||||
'device' => 'Device',
|
||||
|
|
|
|||
|
|
@ -104,7 +104,8 @@
|
|||
background: color-mix(in srgb, var(--color-base) 85%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.sidebar-tint { background: color-mix(in srgb, #0A101E 72%, transparent); }
|
||||
/* Opaque — the mobile drawer overlays bright content, so it must be fully readable. */
|
||||
.sidebar-tint { background: #0A101E; }
|
||||
|
||||
.pulse-live { animation: pulse 2.2s ease-in-out infinite; }
|
||||
.reveal { opacity: 0; transform: translateY(10px); animation: reveal .5s cubic-bezier(.2,.7,.3,1) forwards; }
|
||||
|
|
|
|||
|
|
@ -2,35 +2,53 @@
|
|||
<x-topbar :title="__('nav.devices')" :subtitle="__('devices.index_subtitle')" />
|
||||
|
||||
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
|
||||
<x-panel :title="__('nav.devices')">
|
||||
<x-slot:actions>
|
||||
<x-badge>{{ $devices->count() }}</x-badge>
|
||||
</x-slot:actions>
|
||||
{{-- filters --}}
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-ink-3"><x-icon name="devices" :size="15" /></span>
|
||||
<input wire:model.live.debounce.300ms="search" type="search" placeholder="{{ __('devices.search_placeholder') }}"
|
||||
class="w-full rounded-lg bg-raised border border-line pl-9 pr-3 py-2.5 text-sm text-ink placeholder:text-ink-3 outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
</div>
|
||||
<select wire:model.live="room" class="rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
|
||||
<option value="">{{ __('devices.all_rooms') }}</option>
|
||||
@foreach ($rooms as $r)
|
||||
<option value="{{ $r->uuid }}">{{ $r->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select wire:model.live="status" class="rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
|
||||
<option value="">{{ __('devices.all_status') }}</option>
|
||||
<option value="online">{{ __('devices.online') }}</option>
|
||||
<option value="offline">{{ __('devices.offline') }}</option>
|
||||
</select>
|
||||
@if ($search || $room || $status)
|
||||
<button type="button" wire:click="clearFilters"
|
||||
class="rounded-lg border border-line px-3 py-2.5 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">
|
||||
{{ __('devices.clear') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($devices->isEmpty())
|
||||
<p class="text-[13px] text-ink-3">{{ __('devices.empty') }}</p>
|
||||
@else
|
||||
<div class="flex flex-col divide-y divide-line-soft -my-1">
|
||||
<div class="text-[12px] text-ink-3">{{ trans_choice('devices.count', $total, ['count' => $total]) }}</div>
|
||||
|
||||
@forelse ($groups as $roomName => $devices)
|
||||
<x-panel :title="$roomName">
|
||||
<x-slot:actions><x-badge>{{ $devices->count() }}</x-badge></x-slot:actions>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-x-6 divide-y xl:divide-y-0 divide-line-soft">
|
||||
@foreach ($devices as $device)
|
||||
<a href="{{ route('devices.show', $device) }}" wire:navigate
|
||||
class="flex items-center gap-3 py-3 first:pt-1 last:pb-1 -mx-4 px-4 hover:bg-raised/50 transition-colors">
|
||||
class="flex items-center gap-3 py-2.5 -mx-4 px-4 rounded-lg hover:bg-raised/50 transition-colors">
|
||||
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-semibold text-ink truncate">{{ $device->name }}</div>
|
||||
<div class="text-[11.5px] text-ink-3 truncate">
|
||||
{{ $device->room?->name ?? __('devices.no_room') }} · <span class="font-mono">{{ $device->model }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto hidden md:flex flex-wrap justify-end gap-1.5 max-w-[45%]">
|
||||
@foreach ($device->entities->take(3) as $entity)
|
||||
<x-entity-state :entity="$entity" />
|
||||
@endforeach
|
||||
</div>
|
||||
<x-icon name="chevron" :size="15" class="text-ink-3 shrink-0" />
|
||||
<span class="text-[13px] font-semibold text-ink truncate">{{ $device->name }}</span>
|
||||
<span class="ml-auto text-[11px] font-mono text-ink-3 truncate shrink-0">{{ $device->model }}</span>
|
||||
<x-icon name="chevron" :size="14" class="text-ink-3 shrink-0" />
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</x-panel>
|
||||
</x-panel>
|
||||
@empty
|
||||
<x-panel>
|
||||
<p class="text-[13px] text-ink-3 text-center py-6">{{ __('devices.none_match') }}</p>
|
||||
</x-panel>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue