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
parent
5df94dddf0
commit
d7d8544e52
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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">
|
||||
@foreach ($groups as $group)
|
||||
<x-panel :title="$group['name']" class="reveal">
|
||||
<div class="flex flex-col divide-y divide-line-soft -my-1">
|
||||
@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" />
|
||||
@endforeach
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-panel>
|
||||
@endforeach
|
||||
</div>
|
||||
{{-- rooms + devices — a card per device, grouped by room --}}
|
||||
<div class="flex flex-col gap-6">
|
||||
@foreach ($groups as $group)
|
||||
<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)
|
||||
<x-device-card :device="$device" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</x-panel>
|
||||
@empty
|
||||
<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>
|
||||
@endforelse
|
||||
@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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
</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">
|
||||
@if (! is_null($pct))
|
||||
<x-status-pill :state="$pct < 20 ? 'offline' : 'neutral'">{{ __('devices.battery') }} · <span class="font-mono">{{ $pct }}%</span></x-status-pill>
|
||||
@endif
|
||||
<x-entity-state :entity="$sensor" />
|
||||
<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>
|
||||
<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>
|
||||
<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))
|
||||
<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
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-panel>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue