Tablet control panel: touch tiles, drag-reorder, light colour modal
New "Steuerung" page for tablet control: - Large touch tiles for every light/switch — tap toggles (through DeviceCommandService), highlighted when on, live via Echo. - Drag-to-reorder via SortableJS (handle per tile); order persisted to entities.panel_sort. - Lights get a colour/brightness modal → Light.Set (brightness + rgb) on the Shelly, with preset swatches + a custom colour picker. Driver contract gains setLight(); command service audits it (H1). Nav check 11/11 tabs clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/phase-1-bootstrap
parent
a4edbbf801
commit
124e667a0a
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\Entity;
|
||||
use App\Services\DeviceCommandService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class LightControl extends ModalComponent
|
||||
{
|
||||
public string $entityUuid = '';
|
||||
|
||||
public int $brightness = 100;
|
||||
|
||||
public string $color = '#FFD9A0';
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'md';
|
||||
}
|
||||
|
||||
public function mount(string $entity): void
|
||||
{
|
||||
$model = Entity::where('uuid', $entity)->firstOrFail();
|
||||
$this->entityUuid = $model->uuid;
|
||||
|
||||
$state = $model->state?->state ?? [];
|
||||
$this->brightness = (int) ($state['brightness'] ?? 100);
|
||||
|
||||
if (isset($state['rgb']) && is_array($state['rgb'])) {
|
||||
$rgb = array_pad(array_map('intval', $state['rgb']), 3, 255);
|
||||
$this->color = sprintf('#%02X%02X%02X', $rgb[0], $rgb[1], $rgb[2]);
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function entity(): Entity
|
||||
{
|
||||
return Entity::with('device', 'state')->where('uuid', $this->entityUuid)->firstOrFail();
|
||||
}
|
||||
|
||||
public function apply(): void
|
||||
{
|
||||
$rgb = sscanf($this->color, '#%02x%02x%02x');
|
||||
|
||||
app(DeviceCommandService::class)->setLight($this->entity(), [
|
||||
'on' => true,
|
||||
'brightness' => $this->brightness,
|
||||
'rgb' => $rgb,
|
||||
]);
|
||||
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function turnOff(): void
|
||||
{
|
||||
app(DeviceCommandService::class)->setOn($this->entity(), false);
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.light-control', [
|
||||
'presets' => ['#FFD9A0', '#FFFFFF', '#FF6B6B', '#4FC1FF', '#34D399', '#A78BFA', '#FBBF24'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Entity;
|
||||
use App\Services\DeviceCommandService;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Tablet control panel — big touch tiles for lights/switches with quick on/off,
|
||||
* a colour/brightness modal for lights, and drag-to-reorder (persisted per entity).
|
||||
*/
|
||||
#[Layout('layouts.app')]
|
||||
class Panel extends Component
|
||||
{
|
||||
#[On('echo-private:home,.DeviceStateChanged')]
|
||||
public function onDeviceStateChanged(): void {}
|
||||
|
||||
public function toggle(int $entityId): void
|
||||
{
|
||||
$entity = Entity::with('device', 'state')->find($entityId);
|
||||
|
||||
if ($entity !== null && in_array($entity->type, ['switch', 'light'], true)) {
|
||||
app(DeviceCommandService::class)->toggle($entity);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a new tile order from the drag interaction. */
|
||||
public function reorder(array $order): void
|
||||
{
|
||||
foreach (array_values($order) as $index => $uuid) {
|
||||
Entity::where('uuid', $uuid)->update(['panel_sort' => $index]);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$tiles = Entity::query()
|
||||
->whereIn('type', ['switch', 'light'])
|
||||
->where('panel_hidden', false)
|
||||
->with('device.room', 'state')
|
||||
->orderByRaw('panel_sort IS NULL, panel_sort')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return view('livewire.panel', ['tiles' => $tiles]);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@ class Entity extends Model
|
|||
{
|
||||
use HasUuid;
|
||||
|
||||
protected $fillable = ['uuid', 'device_id', 'type', 'key', 'name', 'capabilities'];
|
||||
protected $fillable = ['uuid', 'device_id', 'type', 'key', 'name', 'capabilities', 'panel_sort', 'panel_hidden'];
|
||||
|
||||
protected $casts = ['capabilities' => 'array'];
|
||||
protected $casts = ['capabilities' => 'array', 'panel_hidden' => 'boolean'];
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ class DeviceCommandService
|
|||
return $this->run($device, null, 'reboot', [], $source, fn (DeviceDriver $driver) => $driver->reboot($device));
|
||||
}
|
||||
|
||||
public function setLight(Entity $entity, array $params, string $source = 'user'): Command
|
||||
{
|
||||
return $this->run($entity->device, $entity, 'set_light', $params, $source, fn (DeviceDriver $driver) => $driver->setLight($entity, $params));
|
||||
}
|
||||
|
||||
private function run(Device $device, ?Entity $entity, string $command, array $payload, string $source, callable $action): Command
|
||||
{
|
||||
$result = 'ok';
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ interface DeviceDriver
|
|||
/** Apply a desired state (e.g. ['on' => true]). */
|
||||
public function setState(Entity $entity, array $state): void;
|
||||
|
||||
/** Set a light: ['on' => bool, 'brightness' => 0-100, 'rgb' => [r,g,b]]. */
|
||||
public function setLight(Entity $entity, array $params): void;
|
||||
|
||||
/** Reboot the whole device. */
|
||||
public function reboot(Device $device): void;
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,25 @@ class ShellyMqttDriver implements DeviceDriver
|
|||
$this->rpc($device, 'Shelly.Reboot');
|
||||
}
|
||||
|
||||
/** Light.Set with optional brightness (0–100) and rgb ([r,g,b]) for RGBW lights. */
|
||||
public function setLight(Entity $entity, array $params): void
|
||||
{
|
||||
[, $id] = array_pad(explode(':', $entity->key, 2), 2, '0');
|
||||
|
||||
$rpc = ['id' => (int) $id];
|
||||
if (array_key_exists('on', $params)) {
|
||||
$rpc['on'] = (bool) $params['on'];
|
||||
}
|
||||
if (isset($params['brightness'])) {
|
||||
$rpc['brightness'] = max(0, min(100, (int) $params['brightness']));
|
||||
}
|
||||
if (isset($params['rgb']) && is_array($params['rgb'])) {
|
||||
$rpc['rgb'] = array_map(fn ($v) => max(0, min(255, (int) $v)), array_slice($params['rgb'], 0, 3));
|
||||
}
|
||||
|
||||
$this->rpc($entity->device, 'Light.Set', $rpc);
|
||||
}
|
||||
|
||||
public function capabilities(): array
|
||||
{
|
||||
return ['switch', 'light'];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('entities', function (Blueprint $table) {
|
||||
$table->integer('panel_sort')->nullable()->after('capabilities');
|
||||
$table->boolean('panel_hidden')->default(false)->after('panel_sort');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('entities', function (Blueprint $table) {
|
||||
$table->dropColumn(['panel_sort', 'panel_hidden']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ return [
|
|||
|
||||
// items
|
||||
'dashboard' => 'Dashboard',
|
||||
'panel' => 'Steuerung',
|
||||
'rooms' => 'Räume',
|
||||
'devices' => 'Geräte',
|
||||
'persons' => 'Personen & Anwesenheit',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'subtitle' => 'Schnellzugriff — tippen zum Schalten.',
|
||||
'empty' => 'Keine schaltbaren Geräte.',
|
||||
'reorder_hint' => 'Kacheln am Griff ziehen, um sie anzuordnen.',
|
||||
'drag' => 'Verschieben',
|
||||
'light_settings' => 'Lichtsteuerung',
|
||||
'brightness' => 'Helligkeit',
|
||||
'color' => 'Farbe',
|
||||
'apply' => 'Übernehmen',
|
||||
];
|
||||
|
|
@ -9,6 +9,7 @@ return [
|
|||
|
||||
// items
|
||||
'dashboard' => 'Dashboard',
|
||||
'panel' => 'Control',
|
||||
'rooms' => 'Rooms',
|
||||
'devices' => 'Devices',
|
||||
'persons' => 'People & Presence',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'subtitle' => 'Quick access — tap to switch.',
|
||||
'empty' => 'No switchable devices.',
|
||||
'reorder_hint' => 'Drag tiles by the handle to arrange them.',
|
||||
'drag' => 'Move',
|
||||
'light_settings' => 'Light control',
|
||||
'brightness' => 'Brightness',
|
||||
'color' => 'Color',
|
||||
'apply' => 'Apply',
|
||||
];
|
||||
|
|
@ -7,7 +7,8 @@
|
|||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.5.0"
|
||||
"pusher-js": "^8.5.0",
|
||||
"sortablejs": "^1.15.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
|
|
@ -1395,6 +1396,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"dependencies": {
|
||||
"chart.js": "^4.5.1",
|
||||
"laravel-echo": "^2.4.0",
|
||||
"pusher-js": "^8.5.0"
|
||||
"pusher-js": "^8.5.0",
|
||||
"sortablejs": "^1.15.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,27 @@
|
|||
import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import Sortable from 'sortablejs';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
// Drag-to-reorder tile grid → persists order via $wire.reorder([uuid, …]).
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('sortableGrid', () => ({
|
||||
init() {
|
||||
Sortable.create(this.$refs.grid, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'opacity-40',
|
||||
onEnd: () => {
|
||||
const order = [...this.$refs.grid.children].map((c) => c.dataset.uuid).filter(Boolean);
|
||||
this.$wire.reorder(order);
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Themed line-chart island (Livewire bundles Alpine). Usage:
|
||||
// <div x-data="lineChart(@js($config))"><canvas x-ref="canvas"></canvas></div>
|
||||
// where $config = { labels: [...], series: [{ key, label, color, data, axis }], units: {...} }
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
$groups = [
|
||||
'nav.section_overview' => [
|
||||
['key' => 'dashboard', 'icon' => 'dashboard', 'route' => 'dashboard', 'lock' => false],
|
||||
['key' => 'panel', 'icon' => 'bolt', 'route' => 'panel', 'lock' => false],
|
||||
['key' => 'rooms', 'icon' => 'rooms', 'route' => 'rooms.index', 'active' => 'rooms.*', 'lock' => false],
|
||||
['key' => 'devices', 'icon' => 'devices', 'route' => 'devices.index', 'active' => 'devices.*', 'lock' => false],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<div class="flex flex-col">
|
||||
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
|
||||
<span class="grid place-items-center w-9 h-9 rounded-lg bg-accent/10 text-accent shrink-0"><x-icon name="bolt" :size="18" /></span>
|
||||
<h2 class="text-[15px] font-bold text-ink truncate">{{ $this->entity->device->name }}</h2>
|
||||
<button type="button" wire:click="closeModal" class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors" aria-label="{{ __('common.close') }}">
|
||||
<x-icon name="close" :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="p-5 flex flex-col gap-5">
|
||||
{{-- brightness --}}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between text-[12px] font-semibold text-ink-2">
|
||||
<span>{{ __('panel.brightness') }}</span>
|
||||
<span class="font-mono tabular-nums text-ink">{{ $brightness }}%</span>
|
||||
</div>
|
||||
<input type="range" min="1" max="100" wire:model.live="brightness"
|
||||
class="w-full accent-[color:var(--color-accent)]">
|
||||
</div>
|
||||
|
||||
{{-- color --}}
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-[12px] font-semibold text-ink-2">{{ __('panel.color') }}</span>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($presets as $preset)
|
||||
<button type="button" wire:click="$set('color', '{{ $preset }}')"
|
||||
@style(["background-color: {$preset}"])
|
||||
@class(['w-8 h-8 rounded-full border-2 transition-transform hover:scale-110', 'border-ink' => strtoupper($color) === strtoupper($preset), 'border-line-soft' => strtoupper($color) !== strtoupper($preset)])
|
||||
aria-label="{{ $preset }}"></button>
|
||||
@endforeach
|
||||
<label class="w-8 h-8 rounded-full border-2 border-line-soft grid place-items-center cursor-pointer overflow-hidden relative">
|
||||
<input type="color" wire:model.live="color" class="absolute inset-0 opacity-0 cursor-pointer">
|
||||
<x-icon name="plus" :size="14" class="text-ink-3" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<button type="button" wire:click="turnOff" class="rounded-lg border border-line px-3.5 py-2.5 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('devices.off') }}</button>
|
||||
<button type="button" wire:click="apply" class="ml-auto rounded-lg bg-accent px-4 py-2.5 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('panel.apply') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<div wire:poll.30s>
|
||||
<x-topbar :title="__('nav.panel')" :subtitle="__('panel.subtitle')" />
|
||||
|
||||
<div class="px-5 lg:px-[26px] py-6 max-w-[1560px] mx-auto w-full">
|
||||
@if ($tiles->isEmpty())
|
||||
<x-panel><p class="text-[13px] text-ink-3 text-center py-8">{{ __('panel.empty') }}</p></x-panel>
|
||||
@else
|
||||
<p class="text-[11.5px] text-ink-3 mb-3">{{ __('panel.reorder_hint') }}</p>
|
||||
<div x-data="sortableGrid()" x-ref="grid"
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
|
||||
@foreach ($tiles as $tile)
|
||||
@php $on = data_get($tile->state?->state, 'on') === true; @endphp
|
||||
<div wire:key="tile-{{ $tile->id }}" data-uuid="{{ $tile->uuid }}"
|
||||
@class([
|
||||
'relative rounded-card border p-4 min-h-[7.5rem] flex flex-col select-none transition-colors',
|
||||
'border-accent/40 bg-accent/10' => $on,
|
||||
'border-line-soft bg-surface' => ! $on,
|
||||
])>
|
||||
<div class="flex items-start">
|
||||
<span @class(['grid place-items-center w-10 h-10 rounded-lg', 'bg-accent/20 text-accent' => $on, 'bg-inset text-ink-3' => ! $on])>
|
||||
<x-icon :name="$tile->type === 'light' ? 'bolt' : 'devices'" :size="20" />
|
||||
</span>
|
||||
<button type="button" class="drag-handle ml-auto -mr-1 -mt-1 p-1 cursor-grab touch-none text-ink-3 hover:text-ink" aria-label="{{ __('panel.drag') }}">
|
||||
<x-icon name="menu" :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" wire:click="toggle({{ $tile->id }})" class="text-left mt-auto pt-2">
|
||||
<div class="text-[14px] font-bold text-ink truncate">{{ $tile->device->name }}</div>
|
||||
<div class="text-[11px] text-ink-3 truncate">{{ $tile->device->room?->name ?? '—' }}</div>
|
||||
<div @class(['mt-1 text-[13px] font-bold', 'text-accent' => $on, 'text-ink-3' => ! $on])>{{ $on ? __('devices.on') : __('devices.off') }}</div>
|
||||
</button>
|
||||
|
||||
@if ($tile->type === 'light')
|
||||
<button type="button"
|
||||
wire:click="$dispatch('openModal', { component: 'modals.light-control', arguments: { entity: @js($tile->uuid) } })"
|
||||
class="absolute bottom-3 right-3 grid place-items-center w-8 h-8 rounded-lg bg-raised text-ink-2 hover:text-ink transition-colors" aria-label="{{ __('panel.light_settings') }}">
|
||||
<x-icon name="settings" :size="15" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -8,6 +8,7 @@ use App\Livewire\Devices\Index as DeviceIndex;
|
|||
use App\Livewire\Devices\Show as DeviceShow;
|
||||
use App\Livewire\Discovery\Index as DiscoveryIndex;
|
||||
use App\Livewire\Host;
|
||||
use App\Livewire\Panel;
|
||||
use App\Livewire\Persons\Index as PersonIndex;
|
||||
use App\Livewire\Rooms\Index as RoomIndex;
|
||||
use App\Livewire\Rooms\Show as RoomShow;
|
||||
|
|
@ -24,6 +25,7 @@ Route::middleware('guest')->group(function () {
|
|||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/dashboard', Dashboard::class)->name('dashboard');
|
||||
Route::get('/panel', Panel::class)->name('panel');
|
||||
Route::get('/rooms', RoomIndex::class)->name('rooms.index');
|
||||
Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show');
|
||||
Route::get('/devices', DeviceIndex::class)->name('devices.index');
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ class MqttTest extends TestCase
|
|||
|
||||
public function setState(Entity $entity, array $state): void {}
|
||||
|
||||
public function setLight(Entity $entity, array $params): void {}
|
||||
|
||||
public function reboot(Device $device): void {}
|
||||
|
||||
public function capabilities(): array
|
||||
|
|
|
|||
Loading…
Reference in New Issue