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
HomeOS Bootstrap 2026-07-17 23:30:15 +02:00
parent d950abe375
commit aceb140aac
5 changed files with 90 additions and 28 deletions

View File

@ -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(),
]);
}
}

View File

@ -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',

View File

@ -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',

View File

@ -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; }

View File

@ -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>