feat(ui): card layout replaces flat lists across the app

Addresses "the list on every page isn't good": adopt the approved mockup's
card design (icon well, name, sub, live state chips, footer toggle).
- New <x-device-card>: per-device card with online dot, cloud badge, entity
  chips and an inline switch/light toggle (active cards highlighted accent).
- TogglesEntities trait → dashboard, devices index, rooms show all drive the
  same toggle (H1, audited); card grids replace the divide-y rows.
- Dashboard + devices grouped into responsive card grids by room.
- Windows page: sensor cards showing closed/open/tilted prominently, low-battery
  chip, open sensors highlighted (screenshot-verified).
- Rooms index already card-based; left as-is.

Suite 51 green, 12/12 tabs clean, zero console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 08:32:01 +02:00
parent 5df94dddf0
commit d7d8544e52
8 changed files with 161 additions and 79 deletions

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\Entity;
use App\Services\DeviceCommandService;
/**
* Shared `toggleEntity` for any page whose cards carry an inline switch/light toggle. Routes
* through the command service (H1 audited + driver); the device echoes its new state back over
* MQTT, which re-renders the card live.
*/
trait TogglesEntities
{
public function toggleEntity(int $entityId): void
{
$entity = Entity::with('device', 'state')->find($entityId);
if ($entity === null || ! in_array($entity->type, ['switch', 'light'], true)) {
return;
}
app(DeviceCommandService::class)->toggle($entity);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Room;
use App\Services\HomeStatus;
use Livewire\Attributes\Layout;
@ -11,6 +12,8 @@ use Livewire\Component;
#[Layout('layouts.app')]
class Dashboard extends Component
{
use TogglesEntities;
/** Live update: any device state change re-renders (re-queries) the dashboard. */
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void

View File

@ -2,15 +2,22 @@
namespace App\Livewire\Devices;
use App\Livewire\Concerns\TogglesEntities;
use App\Models\Device;
use App\Models\Room;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
use TogglesEntities;
#[On('echo-private:home,.DeviceStateChanged')]
public function onDeviceStateChanged(): void {}
#[Url(as: 'q')]
public string $search = '';

View File

@ -0,0 +1,59 @@
@props(['device', 'controllable' => true])
@php
$entities = $device->entities;
$primary = $controllable ? $entities->first(fn ($e) => in_array($e->type, ['switch', 'light'], true)) : null;
$on = $primary ? (data_get($primary->state?->state, 'on') === true) : false;
$secondary = $primary ? $entities->reject(fn ($e) => $e->id === $primary->id) : $entities;
$online = $device->isOnline();
$icon = match (true) {
$device->isCloud() => 'doorbell',
$primary?->type === 'light' => 'bolt',
$entities->contains(fn ($e) => $e->type === 'contact') => 'window',
$entities->contains(fn ($e) => $e->type === 'motion') => 'activity',
default => 'devices',
};
@endphp
<div @class([
'group rounded-card border p-4 flex flex-col gap-3 transition-[border-color,transform] duration-150 hover:-translate-y-px',
'border-accent/40 bg-accent/[0.07]' => $on,
'border-line-soft bg-surface hover:border-line' => ! $on,
])>
<div class="flex items-center gap-3">
<span @class([
'grid place-items-center w-9 h-9 rounded-lg shrink-0 transition-colors',
'bg-accent/20 text-accent' => $on,
'bg-inset text-ink-2' => ! $on,
])><x-icon :name="$icon" :size="18" /></span>
<div class="min-w-0 flex-1">
<a href="{{ route('devices.show', $device) }}" wire:navigate
class="block text-[13.5px] font-bold text-ink truncate hover:text-accent transition-colors">{{ $device->name }}</a>
<div class="flex items-center gap-1.5 text-[11px] text-ink-3 truncate">
<span class="truncate">{{ $device->room?->name ?? __('devices.no_room') }}</span>
@if ($device->isCloud())
<span title="{{ __('addons.cloud') }}"><x-icon name="cloud" :size="10" /></span>
@endif
</div>
</div>
<x-status-dot :state="$online ? 'online' : 'offline'" :pulse="$online" />
</div>
@if ($secondary->isNotEmpty())
<div class="flex flex-wrap gap-1.5">
@foreach ($secondary as $entity)
<x-entity-state :entity="$entity" />
@endforeach
</div>
@endif
@if ($primary)
<div class="mt-auto flex items-center gap-2 border-t border-line-soft pt-3">
<span @class(['text-[12.5px] font-bold', 'text-accent' => $on, 'text-ink-3' => ! $on])>{{ $on ? __('devices.on') : __('devices.off') }}</span>
<x-toggle :on="$on" wire:click="toggleEntity({{ $primary->id }})" :label="$device->name"
wire:loading.attr="disabled" class="ml-auto" />
</div>
@endif
</div>

View File

@ -26,33 +26,21 @@
<x-kpi :label="__('dashboard.kpi_low_battery')" :value="$summary['low_battery']" icon="alert" :warnEdge="$summary['low_battery'] > 0" />
</div>
{{-- rooms + devices --}}
<div>
<h2 class="text-[13px] font-bold text-ink-2 uppercase tracking-[0.08em] mb-3">{{ __('dashboard.rooms_title') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{{-- rooms + devices a card per device, grouped by room --}}
<div class="flex flex-col gap-6">
@foreach ($groups as $group)
<x-panel :title="$group['name']" class="reveal">
<div class="flex flex-col divide-y divide-line-soft -my-1">
<div class="reveal">
<div class="flex items-center gap-2 mb-3">
<h2 class="text-[13px] font-bold text-ink-2 uppercase tracking-[0.08em]">{{ $group['name'] }}</h2>
<span class="font-mono tabular-nums text-[11px] text-ink-3">{{ count($group['devices']) }}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach ($group['devices'] as $device)
<a href="{{ route('devices.show', $device) }}" wire:navigate
class="flex flex-col gap-2 py-3 first:pt-1 last:pb-1 -mx-4 px-4 hover:bg-raised/50 transition-colors">
<div class="flex items-center gap-2.5">
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
<span class="text-[13px] font-semibold text-ink truncate">{{ $device->name }}</span>
<span class="ml-auto text-[11px] font-mono text-ink-3 truncate">{{ $device->model }}</span>
<x-icon name="chevron" :size="14" class="text-ink-3 shrink-0" />
</div>
<div class="flex flex-wrap gap-1.5">
@foreach ($device->entities as $entity)
<x-entity-state :entity="$entity" />
<x-device-card :device="$device" />
@endforeach
</div>
</a>
@endforeach
</div>
</x-panel>
@endforeach
</div>
</div>
</div>
</div>

View File

@ -31,23 +31,17 @@
<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">
<div>
<div class="flex items-center gap-2 mb-3">
<h2 class="text-[13px] font-bold text-ink-2 uppercase tracking-[0.08em]">{{ $roomName }}</h2>
<span class="font-mono tabular-nums text-[11px] text-ink-3">{{ $devices->count() }}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach ($devices as $device)
<a href="{{ route('devices.show', $device) }}" wire:navigate
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()" />
<span class="text-[13px] font-semibold text-ink truncate">{{ $device->name }}</span>
@if ($device->isCloud())
<span title="{{ __('addons.cloud_hint') }}" class="inline-flex items-center gap-1 rounded-full bg-inset px-1.5 py-[1px] text-[9.5px] font-bold text-ink-3 shrink-0"><x-icon name="cloud" :size="11" /> {{ __('addons.cloud') }}</span>
@endif
<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>
<x-device-card :device="$device" />
@endforeach
</div>
</x-panel>
</div>
@empty
<x-panel>
<p class="text-[13px] text-ink-3 text-center py-6">{{ __('devices.none_match') }}</p>

View File

@ -9,30 +9,15 @@
</x-slot:actions>
</x-topbar>
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-4 max-w-[1560px] mx-auto w-full">
@forelse ($room->devices as $device)
<x-panel>
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2.5">
<x-status-dot :state="$device->isOnline() ? 'online' : 'offline'" :pulse="$device->isOnline()" />
<a href="{{ route('devices.show', $device) }}" wire:navigate class="text-[14px] font-bold text-ink hover:text-accent transition-colors truncate">{{ $device->name }}</a>
<span class="ml-auto text-[11px] font-mono text-ink-3 truncate">{{ $device->model }}</span>
</div>
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
@foreach ($device->entities as $entity)
<div class="flex items-center gap-2">
<x-entity-state :entity="$entity" />
@if (in_array($entity->type, ['switch', 'light']))
<x-toggle :on="(bool) data_get($entity->state?->state, 'on', false)"
wire:click="toggleEntity({{ $entity->id }})" :label="$entity->name ?? $entity->key" />
@endif
</div>
<div class="px-5 lg:px-[26px] py-6 max-w-[1560px] mx-auto w-full">
@if ($room->devices->isEmpty())
<x-panel><p class="text-[13px] text-ink-3 text-center py-6">{{ __('rooms.no_devices') }}</p></x-panel>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach ($room->devices as $device)
<x-device-card :device="$device" />
@endforeach
</div>
</div>
</x-panel>
@empty
<x-panel><p class="text-[13px] text-ink-3 text-center py-6">{{ __('rooms.no_devices') }}</p></x-panel>
@endforelse
@endif
</div>
</div>

View File

@ -22,28 +22,49 @@
</section>
@foreach ($groups as $roomName => $sensors)
<x-panel :title="$roomName">
<div class="flex flex-col divide-y divide-line-soft -my-1">
<div>
<div class="flex items-center gap-2 mb-3">
<h2 class="text-[13px] font-bold text-ink-2 uppercase tracking-[0.08em]">{{ $roomName }}</h2>
<span class="font-mono tabular-nums text-[11px] text-ink-3">{{ $sensors->count() }}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach ($sensors as $sensor)
@php
$battery = $sensor->device->entities->firstWhere('type', 'battery');
$pct = $battery ? (int) data_get($battery->state, 'state.percent', 0) : null;
$pos = data_get($sensor->state, 'state.position') ?? (data_get($sensor->state, 'state.open') === true ? 'open' : 'closed');
$isMotion = $sensor->type === 'motion';
$alert = $isMotion ? (data_get($sensor->state, 'state.on') === true || data_get($sensor->state, 'state.active') === true) : $pos !== 'closed';
@endphp
<div class="flex items-center gap-3 py-3 first:pt-1 last:pb-1">
<span class="grid place-items-center w-8 h-8 rounded-lg bg-inset text-ink-2 shrink-0">
<x-icon :name="$sensor->type === 'motion' ? 'activity' : 'window'" :size="16" />
<a href="{{ route('devices.show', $sensor->device) }}" wire:navigate
@class([
'rounded-card border p-4 flex flex-col gap-3 transition-[border-color,transform] duration-150 hover:-translate-y-px',
'border-warning/40 bg-warning/[0.07]' => $alert,
'border-line-soft bg-surface hover:border-line' => ! $alert,
])>
<div class="flex items-center gap-3">
<span @class(['grid place-items-center w-9 h-9 rounded-lg shrink-0', 'bg-warning/20 text-warning' => $alert, 'bg-inset text-ink-2' => ! $alert])>
<x-icon :name="$isMotion ? 'activity' : 'window'" :size="18" />
</span>
<a href="{{ route('devices.show', $sensor->device) }}" wire:navigate class="text-[13px] font-semibold text-ink hover:text-accent transition-colors truncate">{{ $sensor->device->name }}</a>
<div class="ml-auto flex items-center gap-2 shrink-0">
<div class="min-w-0 flex-1">
<div class="text-[13.5px] font-bold text-ink truncate">{{ $sensor->device->name }}</div>
<div class="text-[11px] text-ink-3 truncate">{{ $sensor->name ?: $sensor->key }}</div>
</div>
</div>
<div class="flex items-center gap-2 border-t border-line-soft pt-3">
@if ($isMotion)
<span @class(['text-[13px] font-bold', 'text-warning' => $alert, 'text-ink-3' => ! $alert])>{{ $alert ? __('devices.motion') : __('devices.no_motion') }}</span>
@else
<span @class(['text-[13px] font-bold', 'text-warning' => $alert, 'text-online' => ! $alert])>{{ __('devices.contact_'.$pos) }}</span>
@endif
@if (! is_null($pct))
<x-status-pill :state="$pct < 20 ? 'offline' : 'neutral'">{{ __('devices.battery') }} · <span class="font-mono">{{ $pct }}%</span></x-status-pill>
<span class="ml-auto"><x-status-pill :state="$pct < 20 ? 'offline' : 'neutral'"><x-icon name="alert" :size="11" /> <span class="font-mono tabular-nums">{{ $pct }}%</span></x-status-pill></span>
@endif
<x-entity-state :entity="$sensor" />
</div>
</div>
</a>
@endforeach
</div>
</x-panel>
</div>
@endforeach
@endif
</div>