From 164a990dfe111dfb7cb6c135f39a0c0f656826c0 Mon Sep 17 00:00:00 2001 From: jerremyng Date: Mon, 5 Jan 2026 18:42:29 +0000 Subject: [PATCH 01/21] add validation for config_modal Commas are now not allowed in multistring inputs. config_modal was also refactored and extracted as its own file (code was getting messy) some basic tests were also created --- .../livewire/plugins/config-modal.blade.php | 516 ++++++++++++++++++ .../views/livewire/plugins/recipe.blade.php | 474 +--------------- .../Livewire/Plugins/ConfigModalTest.php | 124 +++++ 3 files changed, 650 insertions(+), 464 deletions(-) create mode 100644 resources/views/livewire/plugins/config-modal.blade.php create mode 100644 tests/Feature/Livewire/Plugins/ConfigModalTest.php diff --git a/resources/views/livewire/plugins/config-modal.blade.php b/resources/views/livewire/plugins/config-modal.blade.php new file mode 100644 index 0000000..7aaacbb --- /dev/null +++ b/resources/views/livewire/plugins/config-modal.blade.php @@ -0,0 +1,516 @@ + loadData(); + } + + public function loadData(): void + { + $this->resetErrorBag(); + // Reload data + $this->plugin = $this->plugin->fresh(); + + $this->configuration_template = $this->plugin->configuration_template ?? []; + $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; + + // Initialize multiValues by exploding the CSV strings from the DB + foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { + if (($field['field_type'] ?? null) === 'multi_string') { + $fieldKey = $field['keyname']; + $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); + + $currentValue = is_array($rawValue) ? '' : (string)$rawValue; + + $this->multiValues[$fieldKey] = $currentValue !== '' + ? array_values(array_filter(explode(',', $currentValue))) + : ['']; + } + } + } + + /** + * Triggered by @close on the modal to discard any typed but unsaved changes + */ + public int $resetIndex = 0; // Add this property + public function resetForm(): void + { + $this->loadData(); + $this->resetIndex++; // Increment to force DOM refresh + } + + public function saveConfiguration() + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // final validation layer + $this->validate([ + 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], + ], [ + 'multiValues.*.*.regex' => 'Items cannot contain commas.', + ]); + + // Prepare config copy to send to db + $finalValues = $this->configuration; + foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { + $fieldKey = $field['keyname']; + + // Handle multi_string: Join array back to CSV string + if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) { + $finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); + } + + // Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior) + if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) { + $decoded = json_decode($finalValues[$fieldKey], true); + if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) { + $finalValues[$fieldKey] = $decoded; + } + } + } + + // send to db + $this->plugin->update(['configuration' => $finalValues]); + $this->configuration = $finalValues; // update local state + $this->dispatch('config-updated'); // notifies listeners + Flux::modal('configuration-modal')->close(); + } + + // ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------ + public function addMultiItem(string $fieldKey): void + { + $this->multiValues[$fieldKey][] = ''; + } + + public function removeMultiItem(string $fieldKey, int $index): void + { + unset($this->multiValues[$fieldKey][$index]); + + $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]); + + if (empty($this->multiValues[$fieldKey])) { + $this->multiValues[$fieldKey][] = ''; + } + } + + // Livewire magic method to validate MultiValue input boxes + // Runs on every debounce + public function updatedMultiValues($value, $key) + { + $this->validate([ + 'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'], + ], [ + 'multiValues.*.*.regex' => 'Items cannot contain commas.', + ]); + } + + public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + try { + $requestData = []; + if ($query !== null) { + $requestData = [ + 'function' => $fieldKey, + 'query' => $query + ]; + } + + $response = $query !== null + ? Http::post($endpoint, $requestData) + : Http::post($endpoint); + + if ($response->successful()) { + $this->xhrSelectOptions[$fieldKey] = $response->json(); + } else { + $this->xhrSelectOptions[$fieldKey] = []; + } + } catch (\Exception $e) { + $this->xhrSelectOptions[$fieldKey] = []; + } + } + + public function searchXhrSelect(string $fieldKey, string $endpoint): void + { + $query = $this->searchQueries[$fieldKey] ?? ''; + if (!empty($query)) { + $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); + } + } +};?> + + +
+
+
+ Configuration + Configure your plugin settings +
+ +
+ @if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields'])) + @foreach($configuration_template['custom_fields'] as $field) + @php + $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; + $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); + + # These are sanitized at Model/Plugin level, safe to render HTML + $safeDescription = $field['description'] ?? ''; + $safeHelp = $field['help_text'] ?? ''; + + // For code fields, if the value is an array, JSON encode it + if ($field['field_type'] === 'code' && is_array($rawValue)) { + $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } else { + $currentValue = is_array($rawValue) ? '' : (string) $rawValue; + } + @endphp +
+ @if($field['field_type'] === 'author_bio') + @continue + @endif + + @if($field['field_type'] === 'copyable_webhook_url') + @continue + @endif + + @if($field['field_type'] === 'string' || $field['field_type'] === 'url') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'text') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'code') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'password') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'copyable') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'time_zone') + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'number') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'boolean') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'date') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'time') + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'select') + @if(isset($field['multiple']) && $field['multiple'] === true) + + {{ $field['name'] }} + {!! $safeDescription !!} + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + @else + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + @endif + + @elseif($field['field_type'] === 'xhrSelect') + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + + @elseif($field['field_type'] === 'xhrSelectSearch') +
+ + {{ $field['name'] }} + {!! $safeDescription !!} + + + + + {!! $safeHelp !!} + @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) + + + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) + @if(is_array($option)) + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif + @else + + @endif + @endforeach + @endif + @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) + {{-- Show current value even if no options are loaded --}} + + @endif + + @endif +
+ @elseif($field['field_type'] === 'multi_string') + + {{ $field['name'] }} + {!! $safeDescription !!} + +
+ @foreach($multiValues[$fieldKey] as $index => $item) +
+ + + + @if(count($multiValues[$fieldKey]) > 1) + + @endif +
+ @error("multiValues.{$fieldKey}.{$index}") +
+ + {{-- $message comes from thrown error --}} + {{ $message }} +
+ @enderror + @endforeach + + + Add Item + +
+ {!! $safeHelp !!} +
+ @else + Field type "{{ $field['field_type'] }}" not yet supported + @endif +
+ @endforeach + @endif + +
+ + + Save Configuration + + @if($errors->any()) +
+ + + Fix errors before saving. + +
+ @endif +
+
+
+
+
diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index bda8221..4482d5a 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -7,6 +7,7 @@ use Livewire\Volt\Component; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; +use Livewire\Attributes\On; new class extends Component { public Plugin $plugin; @@ -34,17 +35,13 @@ new class extends Component { public string $mashup_layout = 'full'; public array $mashup_plugins = []; public array $configuration_template = []; - public array $configuration = []; - public array $xhrSelectOptions = []; - public array $searchQueries = []; - public array $multiValues = []; public function mount(): void { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); $this->blade_code = $this->plugin->render_markup; + // required to render some stuff $this->configuration_template = $this->plugin->configuration_template ?? []; - $this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : []; if ($this->plugin->render_markup_view) { try { @@ -75,25 +72,6 @@ new class extends Component { $this->fillformFields(); $this->data_payload_updated_at = $this->plugin->data_payload_updated_at; - - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - if (($field['field_type'] ?? null) !== 'multi_string') { - continue; - } - - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - - // Get the existing value from the plugin's configuration - $rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? ''); - - $currentValue = is_array($rawValue) ? '' : (string)$rawValue; - - // Split CSV into array for UI boxes - $this->multiValues[$fieldKey] = $currentValue !== '' - ? array_values(array_filter(explode(',', $currentValue))) - : ['']; - } - } public function fillFormFields(): void @@ -287,47 +265,6 @@ new class extends Component { Flux::modal('add-to-playlist')->close(); } - public function saveConfiguration() - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); - - foreach ($this->configuration_template['custom_fields'] ?? [] as $field) { - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - - if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) { - // Join the boxes into a CSV string, trimming whitespace and filtering empties - $this->configuration[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey]))); - } - } - $configurationValues = []; - if (isset($this->configuration_template['custom_fields'])) { - foreach ($this->configuration_template['custom_fields'] as $field) { - $fieldKey = $field['keyname']; - if (isset($this->configuration[$fieldKey])) { - $value = $this->configuration[$fieldKey]; - - // For code fields, if the value is a JSON string and the original was an array, decode it - if ($field['field_type'] === 'code' && is_string($value)) { - $decoded = json_decode($value, true); - // If it's valid JSON and decodes to an array/object, use the decoded value - // Otherwise, keep the string as-is - if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) { - $value = $decoded; - } - } - - $configurationValues[$fieldKey] = $value; - } - } - } - - $this->plugin->update([ - 'configuration' => $configurationValues - ]); - - Flux::modal('configuration-modal')->close(); - } - public function getDevicePlaylists($deviceId) { return \App\Models\Playlist::where('device_id', $deviceId)->get(); @@ -440,58 +377,15 @@ HTML; $this->redirect(route('plugins.index')); } - public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void - { - abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + #[On('config-updated')] + public function refreshPlugin() + { + // This pulls the fresh 'configuration' from the DB + // and re-triggers the @if check in the Blade template + $this->plugin = $this->plugin->fresh(); + } - try { - $requestData = []; - if ($query !== null) { - $requestData = [ - 'function' => $fieldKey, - 'query' => $query - ]; - } - - $response = $query !== null - ? Http::post($endpoint, $requestData) - : Http::post($endpoint); - - if ($response->successful()) { - $this->xhrSelectOptions[$fieldKey] = $response->json(); - } else { - $this->xhrSelectOptions[$fieldKey] = []; - } - } catch (\Exception $e) { - $this->xhrSelectOptions[$fieldKey] = []; - } - } - - public function searchXhrSelect(string $fieldKey, string $endpoint): void - { - $query = $this->searchQueries[$fieldKey] ?? ''; - if (!empty($query)) { - $this->loadXhrSelectOptions($fieldKey, $endpoint, $query); - } - } - - public function addMultiItem(string $fieldKey): void - { - $this->multiValues[$fieldKey][] = ''; - } - - public function removeMultiItem(string $fieldKey, int $index): void - { - unset($this->multiValues[$fieldKey][$index]); - - $this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]); - - if (empty($this->multiValues[$fieldKey])) { - $this->multiValues[$fieldKey][] = ''; - } - } } - ?>
@@ -683,355 +577,7 @@ HTML;
- -
-
- Configuration - Configure your plugin settings -
- -
- @if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields'])) - @foreach($configuration_template['custom_fields'] as $field) - @php - $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; - $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); - - # These are sanitized at Model/Plugin level, safe to render HTML - $safeDescription = $field['description'] ?? ''; - $safeHelp = $field['help_text'] ?? ''; - - // For code fields, if the value is an array, JSON encode it - if ($field['field_type'] === 'code' && is_array($rawValue)) { - $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } else { - $currentValue = is_array($rawValue) ? '' : (string) $rawValue; - } - @endphp -
- @if($field['field_type'] === 'author_bio') - @continue - @endif - - @if($field['field_type'] === 'copyable_webhook_url') - @continue - @endif - - @if($field['field_type'] === 'string' || $field['field_type'] === 'url') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'text') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'code') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'password') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'copyable') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time_zone') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @foreach(timezone_identifiers_list() as $timezone) - - @endforeach - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'number') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'boolean') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'date') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'time') - - {{ $field['name'] }} - {!! $safeDescription !!} - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'select') - @if(isset($field['multiple']) && $field['multiple'] === true) - - {{ $field['name'] }} - {!! $safeDescription !!} - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - @else - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - @endif - - @elseif($field['field_type'] === 'xhrSelect') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - - {!! $safeHelp !!} - - - @elseif($field['field_type'] === 'xhrSelectSearch') -
- - {{ $field['name'] }} - {!! $safeDescription !!} - - - - - {!! $safeHelp !!} - @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - @if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey]))) - {{-- Show current value even if no options are loaded --}} - - @endif - - @endif -
- @elseif($field['field_type'] === 'multi_string') - - {{ $field['name'] }} - {!! $safeDescription !!} - -
- @foreach($multiValues[$fieldKey] as $index => $item) -
- - - - @if(count($multiValues[$fieldKey]) > 1) - - @endif -
- @endforeach - - - Add Item - -
- - - {!! $safeHelp !!} -
- {{-- @elseif($field['field_type'] === 'multi_string') - - {{ $field['name'] }} - {!! $safeDescription !!} - - - {{-- --}} - - {{-- {!! $safeHelp !!} - --}} - @else - Field type "{{ $field['field_type'] }}" not yet supported - @endif -
- @endforeach - @endif - -
- - Save Configuration -
-
-
-
+

Settings

diff --git a/tests/Feature/Livewire/Plugins/ConfigModalTest.php b/tests/Feature/Livewire/Plugins/ConfigModalTest.php new file mode 100644 index 0000000..4372991 --- /dev/null +++ b/tests/Feature/Livewire/Plugins/ConfigModalTest.php @@ -0,0 +1,124 @@ +create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + 'default' => 'alpha,beta', + ]] + ], + 'configuration' => ['tags' => 'alpha,beta'] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->assertSet('multiValues.tags', ['alpha', 'beta']); +}); + +test('config modal validates against commas in multi_string boxes', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'tags', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + ]] + ] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('multiValues.tags.0', 'no,commas,allowed') + ->call('saveConfiguration') + ->assertHasErrors(['multiValues.tags.0' => 'regex']); + + // Assert DB remains unchanged + expect($plugin->fresh()->configuration['tags'] ?? '')->not->toBe('no,commas,allowed'); +}); + +test('config modal merges multi_string boxes into a single CSV string on save', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration_template' => [ + 'custom_fields' => [[ + 'keyname' => 'items', + 'field_type' => 'multi_string', + 'name' => 'Reading Days', + ]] + ], + 'configuration' => [] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('multiValues.items.0', 'First') + ->call('addMultiItem', 'items') + ->set('multiValues.items.1', 'Second') + ->call('saveConfiguration') + ->assertHasNoErrors(); + + expect($plugin->fresh()->configuration['items'])->toBe('First,Second'); +}); + +test('config modal resetForm clears dirty state and increments resetIndex', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static', + 'configuration' => ['simple_key' => 'original_value'] + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->set('configuration.simple_key', 'dirty_value') + ->call('resetForm') + ->assertSet('configuration.simple_key', 'original_value') + ->assertSet('resetIndex', 1); +}); + +test('config modal dispatches update event for parent warning refresh', function (): void { + $user = User::factory()->create(); + $this->actingAs($user); + + $plugin = Plugin::create([ + 'uuid' => Str::uuid(), + 'user_id' => $user->id, + 'name' => 'Test Plugin', + 'data_strategy' => 'static' + ]); + + Volt::test('plugins.config-modal', ['plugin' => $plugin]) + ->call('saveConfiguration') + ->assertDispatched('config-updated'); +}); From e176f2828ede6ce5bb809d70153fabc608b86f37 Mon Sep 17 00:00:00 2001 From: jerremyng Date: Tue, 6 Jan 2026 15:09:07 +0000 Subject: [PATCH 02/21] add checks for comma when importing recipies --- app/Services/PluginImportService.php | 28 +++++++++++++ tests/Feature/PluginImportTest.php | 59 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 9207e3e..ddd052d 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -17,6 +17,32 @@ use ZipArchive; class PluginImportService { + /** + * Validate YAML settings + * + * @param array $settings The parsed YAML settings + * @throws Exception + */ + private function validateYAML(array $settings): void + { + if (!isset($settings['custom_fields']) || !is_array($settings['custom_fields'])) { + return; + } + + foreach ($settings['custom_fields'] as $field) { + if (isset($field['field_type']) && $field['field_type'] === 'multi_string') { + + if (isset($field['default']) && str_contains($field['default'], ',')) { + throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas."); + } + + if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) { + throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas."); + } + + } + } + } /** * Import a plugin from a ZIP file * @@ -58,6 +84,7 @@ class PluginImportService // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); + $this->validateYAML($settings); // Read full.liquid content $fullLiquid = File::get($filePaths['fullLiquidPath']); @@ -187,6 +214,7 @@ class PluginImportService // Parse settings.yml $settingsYaml = File::get($filePaths['settingsYamlPath']); $settings = Yaml::parse($settingsYaml); + $this->validateYAML($settings); // Read full.liquid content $fullLiquid = File::get($filePaths['fullLiquidPath']); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index 1b20f93..fae28a8 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -427,6 +427,65 @@ YAML; ->and($displayIncidentField['default'])->toBe('true'); }); +it('throws exception when multi_string default value contains a comma', function (): void { + $user = User::factory()->create(); + + // YAML with a comma in the 'default' field of a multi_string + $invalidYaml = << $invalidYaml, + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('invalid-default.zip', $zipContent); + $pluginImportService = new PluginImportService(); + + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, "Validation Error: The default value for multistring fields like `api_key` cannot contain commas."); +}); + +it('throws exception when multi_string placeholder contains a comma', function (): void { + $user = User::factory()->create(); + + // YAML with a comma in the 'placeholder' field + $invalidYaml = << $invalidYaml, + 'src/full.liquid' => getValidFullLiquid(), + ]); + + $zipFile = UploadedFile::fake()->createWithContent('invalid-placeholder.zip', $zipContent); + $pluginImportService = new PluginImportService(); + + expect(fn () => $pluginImportService->importFromZip($zipFile, $user)) + ->toThrow(Exception::class, "Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas."); +}); + // Helper methods function createMockZipFile(array $files): string { From e3bb9ad4e2dc717068608d111e0b35deb46be2de Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 6 Jan 2026 14:11:53 +0100 Subject: [PATCH 03/21] feat: implement Plugin duplicate action --- app/Models/Plugin.php | 61 ++++++- .../views/livewire/plugins/recipe.blade.php | 12 ++ tests/Unit/Models/PluginTest.php | 172 ++++++++++++++++++ 3 files changed, 243 insertions(+), 2 deletions(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index c4b45c8..2a3e6c5 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,7 +11,6 @@ use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; -use App\Services\ImageGenerationService; use App\Services\Plugin\Parsers\ResponseParserRegistry; use App\Services\PluginImportService; use Carbon\Carbon; @@ -25,6 +24,7 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; +use InvalidArgumentException; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; use Keepsuit\Liquid\Extensions\StandardExtension; @@ -455,7 +455,7 @@ class Plugin extends Model public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string { if ($this->plugin_type !== 'recipe') { - throw new \InvalidArgumentException('Render method is only applicable for recipe plugins.'); + throw new InvalidArgumentException('Render method is only applicable for recipe plugins.'); } if ($this->render_markup) { @@ -606,4 +606,61 @@ class Plugin extends Model default => '1Tx1B', }; } + + /** + * Duplicate the plugin, copying all attributes and handling render_markup_view + * + * @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id. + * @return Plugin The newly created duplicate plugin + */ + public function duplicate(?int $userId = null): self + { + // Get all attributes except id and uuid + // Use toArray() to get cast values (respects JSON casts) + $attributes = $this->toArray(); + unset($attributes['id'], $attributes['uuid']); + + // Handle render_markup_view - copy file content to render_markup + if ($this->render_markup_view) { + try { + $basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view)); + $paths = [ + $basePath.'.blade.php', + $basePath.'.liquid', + ]; + + $fileContent = null; + $markupLanguage = null; + foreach ($paths as $path) { + if (file_exists($path)) { + $fileContent = file_get_contents($path); + // Determine markup language based on file extension + $markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade'; + break; + } + } + + if ($fileContent !== null) { + $attributes['render_markup'] = $fileContent; + $attributes['markup_language'] = $markupLanguage; + $attributes['render_markup_view'] = null; + } else { + // File doesn't exist, remove the view reference + $attributes['render_markup_view'] = null; + } + } catch (Exception $e) { + // If file reading fails, remove the view reference + $attributes['render_markup_view'] = null; + } + } + + // Append " (Copy)" to the name + $attributes['name'] = $this->name.' (Copy)'; + + // Set user_id - use provided userId or fall back to original plugin's user_id + $attributes['user_id'] = $userId ?? $this->user_id; + + // Create and return the new plugin + return self::create($attributes); + } } diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 4482d5a..47b356a 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -370,6 +370,17 @@ HTML; } } + public function duplicatePlugin(): void + { + abort_unless(auth()->user()->plugins->contains($this->plugin), 403); + + // Use the model's duplicate method + $newPlugin = $this->plugin->duplicate(auth()->id()); + + // Redirect to the new plugin's detail page + $this->redirect(route('plugins.recipe', ['plugin' => $newPlugin])); + } + public function deletePlugin(): void { abort_unless(auth()->user()->plugins->contains($this->plugin), 403); @@ -427,6 +438,7 @@ HTML; + Duplicate Plugin Delete Plugin diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index 0847e36..2771f81 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -737,3 +737,175 @@ test('plugin model preserves multi_string csv format', function (): void { expect($plugin->fresh()->configuration['tags'])->toBe('laravel,pest,security'); }); + +test('plugin duplicate copies all attributes except id and uuid', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Original Plugin', + 'data_stale_minutes' => 30, + 'data_strategy' => 'polling', + 'polling_url' => 'https://api.example.com/data', + 'polling_verb' => 'get', + 'polling_header' => 'Authorization: Bearer token123', + 'polling_body' => '{"query": "test"}', + 'render_markup' => '
Test markup
', + 'markup_language' => 'blade', + 'configuration' => ['api_key' => 'secret123'], + 'configuration_template' => [ + 'custom_fields' => [ + [ + 'keyname' => 'api_key', + 'field_type' => 'string', + ], + ], + ], + 'no_bleed' => true, + 'dark_mode' => true, + 'data_payload' => ['test' => 'data'], + ]); + + $duplicate = $original->duplicate(); + + // Refresh to ensure casts are applied + $original->refresh(); + $duplicate->refresh(); + + expect($duplicate->id)->not->toBe($original->id) + ->and($duplicate->uuid)->not->toBe($original->uuid) + ->and($duplicate->name)->toBe('Original Plugin (Copy)') + ->and($duplicate->user_id)->toBe($original->user_id) + ->and($duplicate->data_stale_minutes)->toBe($original->data_stale_minutes) + ->and($duplicate->data_strategy)->toBe($original->data_strategy) + ->and($duplicate->polling_url)->toBe($original->polling_url) + ->and($duplicate->polling_verb)->toBe($original->polling_verb) + ->and($duplicate->polling_header)->toBe($original->polling_header) + ->and($duplicate->polling_body)->toBe($original->polling_body) + ->and($duplicate->render_markup)->toBe($original->render_markup) + ->and($duplicate->markup_language)->toBe($original->markup_language) + ->and($duplicate->configuration)->toBe($original->configuration) + ->and($duplicate->configuration_template)->toBe($original->configuration_template) + ->and($duplicate->no_bleed)->toBe($original->no_bleed) + ->and($duplicate->dark_mode)->toBe($original->dark_mode) + ->and($duplicate->data_payload)->toBe($original->data_payload) + ->and($duplicate->render_markup_view)->toBeNull(); +}); + +test('plugin duplicate copies render_markup_view file content to render_markup', function (): void { + $user = User::factory()->create(); + + // Create a test blade file + $testViewPath = resource_path('views/recipes/test-duplicate.blade.php'); + $testContent = '
Test Content
'; + + // Ensure directory exists + if (! is_dir(dirname($testViewPath))) { + mkdir(dirname($testViewPath), 0755, true); + } + + file_put_contents($testViewPath, $testContent); + + try { + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'View Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.test-duplicate', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup)->toBe($testContent) + ->and($duplicate->markup_language)->toBe('blade') + ->and($duplicate->render_markup_view)->toBeNull() + ->and($duplicate->name)->toBe('View Plugin (Copy)'); + } finally { + // Clean up test file + if (file_exists($testViewPath)) { + unlink($testViewPath); + } + } +}); + +test('plugin duplicate handles liquid file extension', function (): void { + $user = User::factory()->create(); + + // Create a test liquid file + $testViewPath = resource_path('views/recipes/test-duplicate-liquid.liquid'); + $testContent = '
{{ data.message }}
'; + + // Ensure directory exists + if (! is_dir(dirname($testViewPath))) { + mkdir(dirname($testViewPath), 0755, true); + } + + file_put_contents($testViewPath, $testContent); + + try { + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Liquid Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.test-duplicate-liquid', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup)->toBe($testContent) + ->and($duplicate->markup_language)->toBe('liquid') + ->and($duplicate->render_markup_view)->toBeNull(); + } finally { + // Clean up test file + if (file_exists($testViewPath)) { + unlink($testViewPath); + } + } +}); + +test('plugin duplicate handles missing view file gracefully', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Missing View Plugin', + 'render_markup' => null, + 'render_markup_view' => 'recipes.nonexistent-view', + 'markup_language' => null, + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->render_markup_view)->toBeNull() + ->and($duplicate->name)->toBe('Missing View Plugin (Copy)'); +}); + +test('plugin duplicate uses provided user_id', function (): void { + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user1->id, + 'name' => 'Original Plugin', + ]); + + $duplicate = $original->duplicate($user2->id); + + expect($duplicate->user_id)->toBe($user2->id) + ->and($duplicate->user_id)->not->toBe($original->user_id); +}); + +test('plugin duplicate falls back to original user_id when no user_id provided', function (): void { + $user = User::factory()->create(); + + $original = Plugin::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Original Plugin', + ]); + + $duplicate = $original->duplicate(); + + expect($duplicate->user_id)->toBe($original->user_id); +}); From dc676327c2450ee9112457efd74b263f491cfb2b Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 6 Jan 2026 14:28:39 +0100 Subject: [PATCH 04/21] fix(#121): allow multiple instances of the same plugin --- app/Services/PluginImportService.php | 18 ++++++++++++++---- .../views/livewire/catalog/index.blade.php | 3 ++- .../views/livewire/catalog/trmnl.blade.php | 3 ++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index ddd052d..eeb5835 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -171,11 +171,12 @@ class PluginImportService * @param string|null $zipEntryPath Optional path to specific plugin in monorepo * @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid') * @param string|null $iconUrl Optional icon URL to set on the plugin + * @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists * @return Plugin The created plugin instance * * @throws Exception If the ZIP file is invalid or required files are missing */ - public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -245,17 +246,26 @@ class PluginImportService 'custom_fields' => $settings['custom_fields'], ]; - $plugin_updated = isset($settings['id']) + // Determine the trmnlp_id to use + $trmnlpId = $settings['id'] ?? Uuid::v7(); + + // If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID + if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) { + $trmnlpId = Uuid::v7(); + } + + $plugin_updated = ! $allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists(); + // Create a new plugin $plugin = Plugin::updateOrCreate( [ - 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'user_id' => $user->id, 'trmnlp_id' => $trmnlpId, ], [ 'user_id' => $user->id, 'name' => $settings['name'] ?? 'Imported Plugin', - 'trmnlp_id' => $settings['id'] ?? Uuid::v7(), + 'trmnlp_id' => $trmnlpId, 'data_stale_minutes' => $settings['refresh_interval'] ?? 15, 'data_strategy' => $settings['strategy'] ?? 'static', 'polling_url' => $settings['polling_url'] ?? null, diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 7257ab0..fdf7f34 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -113,7 +113,8 @@ class extends Component auth()->user(), $plugin['zip_entry_path'] ?? null, null, - $plugin['logo_url'] ?? null + $plugin['logo_url'] ?? null, + allowDuplicate: true ); $this->dispatch('plugin-installed'); diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 9ecad1a..cc8b070 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -164,7 +164,8 @@ class extends Component auth()->user(), null, config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null, - $recipe['icon_url'] ?? null + $recipe['icon_url'] ?? null, + allowDuplicate: true ); $this->dispatch('plugin-installed'); From 94d5fca879b0f7e60b083e067273c821b82b54ad Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 6 Jan 2026 14:43:33 +0100 Subject: [PATCH 05/21] fix: half and quadrant layout for recipes with render_markup_view --- app/Models/Plugin.php | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 2a3e6c5..c791333 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -565,17 +565,30 @@ class Plugin extends Model if ($this->render_markup_view) { if ($standalone) { - return view('trmnl-layouts.single', [ + $renderedView = view($this->render_markup_view, [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ])->render(); + + if ($size === 'full') { + return view('trmnl-layouts.single', [ + 'colorDepth' => $device?->colorDepth(), + 'deviceVariant' => $device?->deviceVariant() ?? 'og', + 'noBleed' => $this->no_bleed, + 'darkMode' => $this->dark_mode, + 'scaleLevel' => $device?->scaleLevel(), + 'slot' => $renderedView, + ])->render(); + } + + return view('trmnl-layouts.mashup', [ + 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), 'colorDepth' => $device?->colorDepth(), 'deviceVariant' => $device?->deviceVariant() ?? 'og', - 'noBleed' => $this->no_bleed, 'darkMode' => $this->dark_mode, 'scaleLevel' => $device?->scaleLevel(), - 'slot' => view($this->render_markup_view, [ - 'size' => $size, - 'data' => $this->data_payload, - 'config' => $this->configuration ?? [], - ])->render(), + 'slot' => $renderedView, ])->render(); } From 4bc42cc1d29e8b3ea3f8bca98ca0a9b27faa2234 Mon Sep 17 00:00:00 2001 From: Gabriele Lauricella Date: Tue, 6 Jan 2026 19:11:47 +0100 Subject: [PATCH 06/21] feat: add web mirror trmnl client --- .../assets/apple-touch-icon-120x120.png | Bin 0 -> 2913 bytes .../assets/apple-touch-icon-152x152.png | Bin 0 -> 4145 bytes .../assets/apple-touch-icon-167x167.png | Bin 0 -> 4805 bytes .../assets/apple-touch-icon-180x180.png | Bin 0 -> 3868 bytes .../mirror/assets/apple-touch-icon-76x76.png | Bin 0 -> 1706 bytes public/mirror/assets/favicon-16x16.png | Bin 0 -> 401 bytes public/mirror/assets/favicon-32x32.png | Bin 0 -> 518 bytes public/mirror/assets/favicon.ico | Bin 0 -> 15086 bytes public/mirror/assets/logo--brand.svg | 139 ++++++ public/mirror/index.html | 409 ++++++++++++++++++ public/mirror/manifest.json | 7 + 11 files changed, 555 insertions(+) create mode 100644 public/mirror/assets/apple-touch-icon-120x120.png create mode 100644 public/mirror/assets/apple-touch-icon-152x152.png create mode 100644 public/mirror/assets/apple-touch-icon-167x167.png create mode 100644 public/mirror/assets/apple-touch-icon-180x180.png create mode 100644 public/mirror/assets/apple-touch-icon-76x76.png create mode 100644 public/mirror/assets/favicon-16x16.png create mode 100644 public/mirror/assets/favicon-32x32.png create mode 100644 public/mirror/assets/favicon.ico create mode 100644 public/mirror/assets/logo--brand.svg create mode 100644 public/mirror/index.html create mode 100644 public/mirror/manifest.json diff --git a/public/mirror/assets/apple-touch-icon-120x120.png b/public/mirror/assets/apple-touch-icon-120x120.png new file mode 100644 index 0000000000000000000000000000000000000000..5e51318792b2b0fa6e0db8ce97f88c8e5d6f5bcf GIT binary patch literal 2913 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 LNkvXXu0mjfY<6_9 literal 0 HcmV?d00001 diff --git a/public/mirror/assets/apple-touch-icon-152x152.png b/public/mirror/assets/apple-touch-icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..9f8d9e3c969a582470fd1bd4ad8706419dd720e9 GIT binary patch literal 4145 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 Q{67-_^wdzZM%_O8Ke$_C&;S4c literal 0 HcmV?d00001 diff --git a/public/mirror/assets/apple-touch-icon-180x180.png b/public/mirror/assets/apple-touch-icon-180x180.png new file mode 100644 index 0000000000000000000000000000000000000000..0499ff44d642554ba710f850649b26e039a315cb GIT binary patch literal 3868 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 AX#fBK literal 0 HcmV?d00001 diff --git a/public/mirror/assets/favicon-16x16.png b/public/mirror/assets/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..b36f23b0ca81871ebca8eac6e7faadfee98dfd4f GIT binary patch literal 401 zcmV;C0dD?@P)z@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUz4@pEpR5;6}lRZwuKoEt$wGA8DhMVLB2+|UXCK4xrGq|8kMPaT$ zx^$M@5Q!F$E7Q^C7{RfEjXf0k5g|5-lh>?vcHWz}JF^Pl^O9GkI0FoJLn%UZ^2~)= zkk8ozBD|@jOVOb6h7;>I`S|vpqn-zbS(9Fnhd$Zk-7;dRq>C(f&eJ#P^(it-EBUHr zV+RwaSJoz@;j|==^1poj5AY({UO#lFTCIA3{ga82g0001h=l}q9FaQARU;qF* zm;eA5aGbhPJOBUzgh@m}R9M5^moZPmKorM+trx5T9C0#67hUvYn3RFR-N85*H%RnT zNW!QNgn``=6F!D4x@ZiGFiMGqAa$TLNJ=T~$wmI#-Rr&gyS~@nJ&g%jCO5z-kO4I0 z$^qIyHTB5#c+v}EWqHM6mt{!fEmdRB16XSCDG1Gd?N_K#QL{`wfGtG;E!{0RRmzB2 z)vm2W;;e)?J_jQm&Ar`WP%qQ36mWX}l^|xVO`MewxqQ6+8EMZ)@2=oaV3|06hUCL1 z7-{Uw1HRv1BE?_Z*~hqj2D?M|WYhN*8px zZJzY70Fwcx$3Zq}0AqEtS|pn^0NGkCfMm^412Akh0>?oTG62GUa;s4TQSvMWAWoj8 z)Igj(|I-?~4RfPam^{tue=G6NSxD!R7<+jq&3(oaj90v#Z0S0P>K5+OyjsO4v07*qo IM6N<$g7Med=Kufz literal 0 HcmV?d00001 diff --git a/public/mirror/assets/favicon.ico b/public/mirror/assets/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..da17cd582e3306fa59c8f0b3782a99735fc61f4c GIT binary patch literal 15086 zcmeHOJ!lj`6rP|U*ccl-@fy)Gje=HI;zbMX1RKkMMH)#dS1FjnTEQwJ7B*T33>I2z zEg@)=MhWKfXD(UiyUlm^?q%n0c6Rr67j9tk-n@A~-^|U=?##xRDRaWi%os}>=G{eO zt{P(+jid6kF{@TKZ7J&KP8##nDqOZY*2Fxt8S+%=&h|CaY%klqVe_HQQAruGfnnJI zlTT`HeJKg6M;}>iu5@$zylHO!sV;Vo0gDk>b*cq+(FTdX(qo30H9ZV!GC;gyZYq%!fy0rX)kKM8O-x9_o8zTwu8p6D^`}VL>h&F`8bj1qsMH`n-Dc3xH6WXVt(oY(@VY)7#QVzVIzV$b_y5UFrfthK7-|jUz zt2=f{BGGpz+8aB+QtPABePWsVu7k*&JnxqrfF05hn2P%`4XW?Bw zwcLeY%B2l?iH$N)DbK>Yd}_JG37#>f9BY&;Yn<9AePp37pIR<)x$sl2ZHdn%XMG5D z`PA|W{_4-)s*WEymcSDy#;Y7xS`TG8re(d9XW?BwI|s6+&tqN3-o%*Tlb%yy0Uy)@ zl}XC8@GhUvKX$tDWj%Q8NpfEW51CHD4j&Sm)RFQmd=Wlfc#4QM;IoLc2R^42^!{G= z^OL%tU)BBm5ND}q1AYfNmgKz1gNJX#=$?K0pWphOL355(i}HSTU=4PyZ55Qqcf-}{ z(=>e<-)q+uKkwx0e1FP&;5y%5qYZWcexUa|)b#re;s@>DYnOe$%Jtp({md@#UNB|@ z^o%hppeOpYh4K{sOj4R2XhGTb+X|@YPxdqW9p7m+Y`$T$_jg*&_M+X3*KIE1*#kb| zd(lEVo@U#twl2*c7p{v~GL17f+ei#?pNF@F{=z7K@3P_-HP(FF;~wXCx1tvx_M^W$ z;co!2g%4wWr5)R_%PVy;q#Pe?_oca=)Y#cp*doCfWxkYc_tkglh58A#(>Ox?XuIz} z2M_g7*0GYx+E0>Bl1$sNENPORmQi2JN0qgmEK5GEpHcd(*D+*S^3-?HG`iAxITId;cx_`2){Pz`%ZBKc=Cu?LPYqfBtem1Ezdt(!ae&neRls zx#VG!r*8Z{R(v~MnB#ZBGT*=W9ym@6eD`B)(h}ak*Ni!}VowrUV?2#PEW}*1v2jje z|HZ6(Rx$FN;@0M8J3kIm%#i~F8>HCQH4cZ%)5q2R1~5N+|1a@=n`;~Huc6~=C+bh% zbH7ufPT${4Ux + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/mirror/index.html b/public/mirror/index.html new file mode 100644 index 0000000..2c5fcf6 --- /dev/null +++ b/public/mirror/index.html @@ -0,0 +1,409 @@ + + + + + + TRMNL BYOS Laravel Mirror + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ + + \ No newline at end of file diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json new file mode 100644 index 0000000..4d44e44 --- /dev/null +++ b/public/mirror/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "TRMNL BYOS Laravel Mirror", + "short_name": "TRMNL BYOS", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff" +} From ddce3947c61a703c77a695f98835662778a8932a Mon Sep 17 00:00:00 2001 From: Gabriele Lauricella Date: Thu, 8 Jan 2026 19:04:21 +0100 Subject: [PATCH 07/21] feat: enhanced web mirror trmnl client --- public/mirror/index.html | 116 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/public/mirror/index.html b/public/mirror/index.html index 2c5fcf6..64746fe 100644 --- a/public/mirror/index.html +++ b/public/mirror/index.html @@ -18,6 +18,7 @@