mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
Compare commits
4 commits
3cdc267809
...
265972ac24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
265972ac24 | ||
|
|
7f97114f6e | ||
|
|
3250bb0402 | ||
|
|
50853728bc |
8 changed files with 678 additions and 298 deletions
|
|
@ -311,7 +311,7 @@ class ImageGenerationService
|
||||||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,10 +345,10 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate a default screen image from Blade template
|
* Generate a default screen image from Blade template
|
||||||
*/
|
*/
|
||||||
public static function generateDefaultScreenImage(Device $device, string $imageType): string
|
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,7 +365,7 @@ class ImageGenerationService
|
||||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||||
|
|
||||||
// Generate HTML from Blade template
|
// Generate HTML from Blade template
|
||||||
$html = self::generateDefaultScreenHtml($device, $imageType);
|
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
||||||
|
|
||||||
// Create custom Browsershot instance if using AWS Lambda
|
// Create custom Browsershot instance if using AWS Lambda
|
||||||
$browsershotInstance = null;
|
$browsershotInstance = null;
|
||||||
|
|
@ -445,12 +445,13 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate HTML from Blade template for default screens
|
* Generate HTML from Blade template for default screens
|
||||||
*/
|
*/
|
||||||
private static function generateDefaultScreenHtml(Device $device, string $imageType): string
|
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
||||||
{
|
{
|
||||||
// Map image type to template name
|
// Map image type to template name
|
||||||
$templateName = match ($imageType) {
|
$templateName = match ($imageType) {
|
||||||
'setup-logo' => 'default-screens.setup',
|
'setup-logo' => 'default-screens.setup',
|
||||||
'sleep' => 'default-screens.sleep',
|
'sleep' => 'default-screens.sleep',
|
||||||
|
'error' => 'default-screens.error',
|
||||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -461,14 +462,22 @@ class ImageGenerationService
|
||||||
$scaleLevel = $device->scaleLevel();
|
$scaleLevel = $device->scaleLevel();
|
||||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||||
|
|
||||||
// Render the Blade template
|
// Build view data
|
||||||
return view($templateName, [
|
$viewData = [
|
||||||
'noBleed' => false,
|
'noBleed' => false,
|
||||||
'darkMode' => $darkMode,
|
'darkMode' => $darkMode,
|
||||||
'deviceVariant' => $deviceVariant,
|
'deviceVariant' => $deviceVariant,
|
||||||
'deviceOrientation' => $deviceOrientation,
|
'deviceOrientation' => $deviceOrientation,
|
||||||
'colorDepth' => $colorDepth,
|
'colorDepth' => $colorDepth,
|
||||||
'scaleLevel' => $scaleLevel,
|
'scaleLevel' => $scaleLevel,
|
||||||
])->render();
|
];
|
||||||
|
|
||||||
|
// Add plugin name for error screens
|
||||||
|
if ($imageType === 'error' && $pluginName !== null) {
|
||||||
|
$viewData['pluginName'] = $pluginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the Blade template
|
||||||
|
return view($templateName, $viewData)->render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
23
resources/views/default-screens/error.blade.php
Normal file
23
resources/views/default-screens/error.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
@props([
|
||||||
|
'noBleed' => false,
|
||||||
|
'darkMode' => false,
|
||||||
|
'deviceVariant' => 'og',
|
||||||
|
'deviceOrientation' => null,
|
||||||
|
'colorDepth' => '1bit',
|
||||||
|
'scaleLevel' => null,
|
||||||
|
'pluginName' => 'Recipe',
|
||||||
|
])
|
||||||
|
|
||||||
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||||
|
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||||
|
scale-level="{{$scaleLevel}}">
|
||||||
|
<x-trmnl::view>
|
||||||
|
<x-trmnl::layout>
|
||||||
|
<x-trmnl::richtext gapSize="large" align="center">
|
||||||
|
<x-trmnl::title>Error on {{ $pluginName }}</x-trmnl::title>
|
||||||
|
<x-trmnl::content>Unable to render content. Please check server logs.</x-trmnl::content>
|
||||||
|
</x-trmnl::richtext>
|
||||||
|
</x-trmnl::layout>
|
||||||
|
<x-trmnl::title-bar/>
|
||||||
|
</x-trmnl::view>
|
||||||
|
</x-trmnl::screen>
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
use Livewire\Attributes\Lazy;
|
|
||||||
use Livewire\Volt\Component;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Lazy]
|
#[Lazy]
|
||||||
class extends Component {
|
class extends Component
|
||||||
|
{
|
||||||
public array $catalogPlugins = [];
|
public array $catalogPlugins = [];
|
||||||
|
|
||||||
public string $installingPlugin = '';
|
public string $installingPlugin = '';
|
||||||
|
|
||||||
public string $previewingPlugin = '';
|
public string $previewingPlugin = '';
|
||||||
|
|
||||||
public array $previewData = [];
|
public array $previewData = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
|
|
@ -51,7 +55,7 @@ class extends Component {
|
||||||
return collect($catalog)
|
return collect($catalog)
|
||||||
->filter(function ($plugin) use ($currentVersion) {
|
->filter(function ($plugin) use ($currentVersion) {
|
||||||
// Check if Laravel compatibility is true
|
// Check if Laravel compatibility is true
|
||||||
if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,8 +85,9 @@ class extends Component {
|
||||||
})
|
})
|
||||||
->sortBy('name')
|
->sortBy('name')
|
||||||
->toArray();
|
->toArray();
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('Failed to load catalog from URL: ' . $e->getMessage());
|
Log::error('Failed to load catalog from URL: '.$e->getMessage());
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -94,8 +99,9 @@ class extends Component {
|
||||||
|
|
||||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||||
|
|
||||||
if (!$plugin || !$plugin['zip_url']) {
|
if (! $plugin || ! $plugin['zip_url']) {
|
||||||
$this->addError('installation', 'Plugin not found or no download URL available.');
|
$this->addError('installation', 'Plugin not found or no download URL available.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,8 +119,8 @@ class extends Component {
|
||||||
$this->dispatch('plugin-installed');
|
$this->dispatch('plugin-installed');
|
||||||
Flux::modal('import-from-catalog')->close();
|
Flux::modal('import-from-catalog')->close();
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
$this->installingPlugin = '';
|
$this->installingPlugin = '';
|
||||||
}
|
}
|
||||||
|
|
@ -124,32 +130,27 @@ class extends Component {
|
||||||
{
|
{
|
||||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||||
|
|
||||||
if (!$plugin) {
|
if (! $plugin) {
|
||||||
$this->addError('preview', 'Plugin not found.');
|
$this->addError('preview', 'Plugin not found.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->previewingPlugin = $pluginId;
|
$this->previewingPlugin = $pluginId;
|
||||||
$this->previewData = $plugin;
|
$this->previewData = $plugin;
|
||||||
|
|
||||||
// Store scroll position for restoration later
|
|
||||||
$this->dispatch('store-scroll-position');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function closePreview(): void
|
public function closePreview(): void
|
||||||
{
|
{
|
||||||
$this->previewingPlugin = '';
|
$this->previewingPlugin = '';
|
||||||
$this->previewData = [];
|
$this->previewData = [];
|
||||||
|
|
||||||
// Restore scroll position when returning to catalog
|
|
||||||
$this->dispatch('restore-scroll-position');
|
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@if(empty($catalogPlugins))
|
@if(empty($catalogPlugins))
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||||
<flux:heading class="mt-2">No plugins available</flux:heading>
|
<flux:heading class="mt-2">No plugins available</flux:heading>
|
||||||
<flux:subheading>Catalog is empty</flux:subheading>
|
<flux:subheading>Catalog is empty</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,30 +161,30 @@ class extends Component {
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
@foreach($catalogPlugins as $plugin)
|
@foreach($catalogPlugins as $plugin)
|
||||||
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
<div wire:key="plugin-{{ $plugin['id'] }}" class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@if($plugin['logo_url'])
|
@if($plugin['logo_url'])
|
||||||
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||||
@else
|
@else
|
||||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
|
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
|
||||||
@if ($plugin['github'])
|
@if ($plugin['github'])
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
|
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@if($plugin['license'])
|
@if($plugin['license'])
|
||||||
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
|
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||||
@endif
|
@endif
|
||||||
@if($plugin['repo_url'])
|
@if($plugin['repo_url'])
|
||||||
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
||||||
<flux:icon name="github" class="w-5 h-5" />
|
<flux:icon name="github" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -191,7 +192,7 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($plugin['description'])
|
@if($plugin['description'])
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
|
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center space-x-3">
|
<div class="mt-4 flex items-center space-x-3">
|
||||||
|
|
@ -201,6 +202,7 @@ class extends Component {
|
||||||
Install
|
Install
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
|
||||||
|
@if($plugin['screenshot_url'])
|
||||||
<flux:modal.trigger name="catalog-preview">
|
<flux:modal.trigger name="catalog-preview">
|
||||||
<flux:button
|
<flux:button
|
||||||
wire:click="previewPlugin('{{ $plugin['id'] }}')"
|
wire:click="previewPlugin('{{ $plugin['id'] }}')"
|
||||||
|
|
@ -209,6 +211,7 @@ class extends Component {
|
||||||
Preview
|
Preview
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -236,34 +239,20 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@if($previewData['screenshot_url'])
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
<img src="{{ $previewData['screenshot_url'] }}"
|
<img src="{{ $previewData['screenshot_url'] }}"
|
||||||
alt="Preview of {{ $previewData['name'] }}"
|
alt="Preview of {{ $previewData['name'] }}"
|
||||||
class="w-full h-auto max-h-[480px] object-contain">
|
class="w-full h-auto max-h-[480px] object-contain">
|
||||||
</div>
|
</div>
|
||||||
@elseif($previewData['logo_url'])
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
|
|
||||||
<img src="{{ $previewData['logo_url'] }}"
|
|
||||||
alt="{{ $previewData['name'] }} logo"
|
|
||||||
class="mx-auto h-32 w-auto object-contain mb-4">
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">No preview image available</p>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
|
|
||||||
<flux:icon name="puzzle-piece" class="mx-auto h-32 w-32 text-gray-400 mb-4" />
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">No preview available</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($previewData['description'])
|
@if($previewData['description'])
|
||||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
|
||||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Description</h4>
|
<flux:heading size="sm" class="mb-2">Description</flux:heading>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $previewData['description'] }}</p>
|
<flux:text size="sm">{{ $previewData['description'] }}</flux:text>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
|
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
|
||||||
<flux:modal.close>
|
<flux:modal.close>
|
||||||
<flux:button
|
<flux:button
|
||||||
wire:click="installPlugin('{{ $previewingPlugin }}')"
|
wire:click="installPlugin('{{ $previewingPlugin }}')"
|
||||||
|
|
@ -276,54 +265,3 @@ class extends Component {
|
||||||
@endif
|
@endif
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@script
|
|
||||||
<script>
|
|
||||||
let catalogScrollPosition = 0;
|
|
||||||
|
|
||||||
$wire.on('store-scroll-position', () => {
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
|
|
||||||
catalogScrollPosition = scrollContainer.scrollTop || 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$wire.on('restore-scroll-position', () => {
|
|
||||||
// Small delay to ensure modal is fully rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
|
|
||||||
scrollContainer.scrollTop = catalogScrollPosition;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for when the catalog modal is opened and restore scroll position
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const observer = new MutationObserver(function(mutations) {
|
|
||||||
mutations.forEach(function(mutation) {
|
|
||||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-flux-modal-open') {
|
|
||||||
const target = mutation.target;
|
|
||||||
if (target.getAttribute('data-flux-modal') === 'import-from-catalog' &&
|
|
||||||
target.getAttribute('data-flux-modal-open') === 'true') {
|
|
||||||
// Modal was opened, restore scroll position
|
|
||||||
setTimeout(() => {
|
|
||||||
const scrollContainer = target.querySelector('.space-y-4') || target;
|
|
||||||
if (catalogScrollPosition > 0) {
|
|
||||||
scrollContainer.scrollTop = catalogScrollPosition;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
observer.observe(catalogModal, { attributes: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@endscript
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,28 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Livewire\Attributes\Lazy;
|
use App\Services\PluginImportService;
|
||||||
use Livewire\Volt\Component;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Livewire\Attributes\Lazy;
|
||||||
use App\Services\PluginImportService;
|
use Livewire\Volt\Component;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Lazy]
|
#[Lazy]
|
||||||
class extends Component {
|
class extends Component
|
||||||
|
{
|
||||||
public array $recipes = [];
|
public array $recipes = [];
|
||||||
|
|
||||||
|
public int $page = 1;
|
||||||
|
|
||||||
|
public bool $hasMore = false;
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
public bool $isSearching = false;
|
public bool $isSearching = false;
|
||||||
|
|
||||||
public string $previewingRecipe = '';
|
public string $previewingRecipe = '';
|
||||||
|
|
||||||
public array $previewData = [];
|
public array $previewData = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
|
|
@ -39,61 +47,102 @@ class extends Component {
|
||||||
private function loadNewest(): void
|
private function loadNewest(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () {
|
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
|
||||||
|
$response = Cache::remember($cacheKey, 43200, function () {
|
||||||
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
|
'page' => $this->page,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new \RuntimeException('Failed to fetch TRMNL recipes');
|
throw new RuntimeException('Failed to fetch TRMNL recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = $response->json();
|
return $response->json();
|
||||||
$data = $json['data'] ?? [];
|
|
||||||
return $this->mapRecipes($data);
|
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('TRMNL catalog load error: ' . $e->getMessage());
|
$data = $response['data'] ?? [];
|
||||||
|
$mapped = $this->mapRecipes($data);
|
||||||
|
|
||||||
|
if ($this->page === 1) {
|
||||||
|
$this->recipes = $mapped;
|
||||||
|
} else {
|
||||||
|
$this->recipes = array_merge($this->recipes, $mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hasMore = ! empty($response['next_page_url']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('TRMNL catalog load error: '.$e->getMessage());
|
||||||
|
if ($this->page === 1) {
|
||||||
$this->recipes = [];
|
$this->recipes = [];
|
||||||
}
|
}
|
||||||
|
$this->hasMore = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function searchRecipes(string $term): void
|
private function searchRecipes(string $term): void
|
||||||
{
|
{
|
||||||
$this->isSearching = true;
|
$this->isSearching = true;
|
||||||
try {
|
try {
|
||||||
$cacheKey = 'trmnl_recipes_search_' . md5($term);
|
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
|
||||||
$this->recipes = Cache::remember($cacheKey, 300, function () use ($term) {
|
$response = Cache::remember($cacheKey, 300, function () use ($term) {
|
||||||
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
||||||
'search' => $term,
|
'search' => $term,
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
|
'page' => $this->page,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new \RuntimeException('Failed to search TRMNL recipes');
|
throw new RuntimeException('Failed to search TRMNL recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = $response->json();
|
return $response->json();
|
||||||
$data = $json['data'] ?? [];
|
|
||||||
return $this->mapRecipes($data);
|
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('TRMNL catalog search error: ' . $e->getMessage());
|
$data = $response['data'] ?? [];
|
||||||
|
$mapped = $this->mapRecipes($data);
|
||||||
|
|
||||||
|
if ($this->page === 1) {
|
||||||
|
$this->recipes = $mapped;
|
||||||
|
} else {
|
||||||
|
$this->recipes = array_merge($this->recipes, $mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hasMore = ! empty($response['next_page_url']);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('TRMNL catalog search error: '.$e->getMessage());
|
||||||
|
if ($this->page === 1) {
|
||||||
$this->recipes = [];
|
$this->recipes = [];
|
||||||
|
}
|
||||||
|
$this->hasMore = false;
|
||||||
} finally {
|
} finally {
|
||||||
$this->isSearching = false;
|
$this->isSearching = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function loadMore(): void
|
||||||
|
{
|
||||||
|
$this->page++;
|
||||||
|
|
||||||
|
$term = mb_trim($this->search);
|
||||||
|
if ($term === '' || mb_strlen($term) < 2) {
|
||||||
|
$this->loadNewest();
|
||||||
|
} else {
|
||||||
|
$this->searchRecipes($term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function updatedSearch(): void
|
public function updatedSearch(): void
|
||||||
{
|
{
|
||||||
$term = trim($this->search);
|
$this->page = 1;
|
||||||
|
$term = mb_trim($this->search);
|
||||||
if ($term === '') {
|
if ($term === '') {
|
||||||
$this->loadNewest();
|
$this->loadNewest();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strlen($term) < 2) {
|
if (mb_strlen($term) < 2) {
|
||||||
// Require at least 2 chars to avoid noisy calls
|
// Require at least 2 chars to avoid noisy calls
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -121,35 +170,44 @@ class extends Component {
|
||||||
$this->dispatch('plugin-installed');
|
$this->dispatch('plugin-installed');
|
||||||
Flux::modal('import-from-trmnl-catalog')->close();
|
Flux::modal('import-from-trmnl-catalog')->close();
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('Plugin installation failed: ' . $e->getMessage());
|
Log::error('Plugin installation failed: '.$e->getMessage());
|
||||||
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function previewRecipe(string $recipeId): void
|
public function previewRecipe(string $recipeId): void
|
||||||
{
|
{
|
||||||
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
|
|
||||||
|
|
||||||
if (!$recipe) {
|
|
||||||
$this->addError('preview', 'Recipe not found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->previewingRecipe = $recipeId;
|
$this->previewingRecipe = $recipeId;
|
||||||
$this->previewData = $recipe;
|
|
||||||
|
|
||||||
// Store scroll position for restoration later
|
|
||||||
$this->dispatch('store-scroll-position');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function closePreview(): void
|
|
||||||
{
|
|
||||||
$this->previewingRecipe = '';
|
|
||||||
$this->previewData = [];
|
$this->previewData = [];
|
||||||
|
|
||||||
// Restore scroll position when returning to catalog
|
try {
|
||||||
$this->dispatch('restore-scroll-position');
|
$response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json");
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$item = $response->json()['data'] ?? [];
|
||||||
|
$this->previewData = $this->mapRecipe($item);
|
||||||
|
} else {
|
||||||
|
// Fallback to searching for the specific recipe if single endpoint doesn't exist
|
||||||
|
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||||
|
'search' => $recipeId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$data = $response->json()['data'] ?? [];
|
||||||
|
$item = collect($data)->firstWhere('id', $recipeId);
|
||||||
|
if ($item) {
|
||||||
|
$this->previewData = $this->mapRecipe($item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('TRMNL catalog preview fetch error: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->previewData)) {
|
||||||
|
$this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -159,7 +217,16 @@ class extends Component {
|
||||||
private function mapRecipes(array $items): array
|
private function mapRecipes(array $items): array
|
||||||
{
|
{
|
||||||
return collect($items)
|
return collect($items)
|
||||||
->map(function (array $item) {
|
->map(fn (array $item) => $this->mapRecipe($item))
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $item
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function mapRecipe(array $item): array
|
||||||
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $item['id'] ?? null,
|
'id' => $item['id'] ?? null,
|
||||||
'name' => $item['name'] ?? 'Untitled',
|
'name' => $item['name'] ?? 'Untitled',
|
||||||
|
|
@ -172,10 +239,8 @@ class extends Component {
|
||||||
'installs' => data_get($item, 'stats.installs'),
|
'installs' => data_get($item, 'stats.installs'),
|
||||||
'forks' => data_get($item, 'stats.forks'),
|
'forks' => data_get($item, 'stats.forks'),
|
||||||
],
|
],
|
||||||
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null,
|
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null,
|
||||||
];
|
];
|
||||||
})
|
|
||||||
->toArray();
|
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
|
|
@ -188,7 +253,7 @@ class extends Component {
|
||||||
icon="magnifying-glass"
|
icon="magnifying-glass"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<flux:badge color="gray">Newest</flux:badge>
|
<flux:badge color="zinc">Newest</flux:badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@error('installation')
|
@error('installation')
|
||||||
|
|
@ -197,22 +262,22 @@ class extends Component {
|
||||||
|
|
||||||
@if(empty($recipes))
|
@if(empty($recipes))
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||||
<flux:heading class="mt-2">No recipes found</flux:heading>
|
<flux:heading class="mt-2">No recipes found</flux:heading>
|
||||||
<flux:subheading>Try a different search term</flux:subheading>
|
<flux:subheading>Try a different search term</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
@foreach($recipes as $recipe)
|
@foreach($recipes as $recipe)
|
||||||
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
<div wire:key="recipe-{{ $recipe['id'] }}" class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||||
<div class="px-10 py-8 space-y-6">
|
<div class="px-10 py-8 space-y-6">
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
|
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
|
||||||
@if($thumb)
|
@if($thumb)
|
||||||
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||||
@else
|
@else
|
||||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
@ -221,12 +286,12 @@ class extends Component {
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
|
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
|
||||||
@if(data_get($recipe, 'stats.installs'))
|
@if(data_get($recipe, 'stats.installs'))
|
||||||
<flux:text size="sm" class="text-gray-500 dark:text-gray-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</flux:text>
|
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</flux:text>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@if($recipe['detail_url'])
|
@if($recipe['detail_url'])
|
||||||
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
||||||
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
|
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -246,7 +311,7 @@ class extends Component {
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($recipe['id'])
|
@if($recipe['id'] && ($recipe['screenshot_url'] ?? null))
|
||||||
<flux:modal.trigger name="trmnl-catalog-preview">
|
<flux:modal.trigger name="trmnl-catalog-preview">
|
||||||
<flux:button
|
<flux:button
|
||||||
wire:click="previewRecipe('{{ $recipe['id'] }}')"
|
wire:click="previewRecipe('{{ $recipe['id'] }}')"
|
||||||
|
|
@ -263,35 +328,38 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if($hasMore)
|
||||||
|
<div class="flex justify-center mt-6">
|
||||||
|
<flux:button wire:click="loadMore" variant="subtle" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.remove wire:target="loadMore">Load next page</span>
|
||||||
|
<span wire:loading wire:target="loadMore">Loading...</span>
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Preview Modal -->
|
<!-- Preview Modal -->
|
||||||
<flux:modal name="trmnl-catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
|
<flux:modal name="trmnl-catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
|
||||||
|
<div wire:loading wire:target="previewRecipe" class="flex items-center justify-center py-12">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<flux:icon.loading />
|
||||||
|
<flux:text>Fetching recipe details...</flux:text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div wire:loading.remove wire:target="previewRecipe">
|
||||||
@if($previewingRecipe && !empty($previewData))
|
@if($previewingRecipe && !empty($previewData))
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">Preview {{ $previewData['name'] ?? 'Recipe' }}</flux:heading>
|
<flux:heading size="lg" class="mb-2">Preview {{ $previewData['name'] ?? 'Recipe' }}</flux:heading>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@if($previewData['screenshot_url'])
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
<img src="{{ $previewData['screenshot_url'] }}"
|
<img src="{{ $previewData['screenshot_url'] }}"
|
||||||
alt="Preview of {{ $previewData['name'] }}"
|
alt="Preview of {{ $previewData['name'] }}"
|
||||||
class="w-full h-auto max-h-[480px] object-contain">
|
class="w-full h-auto max-h-[480px] object-contain">
|
||||||
</div>
|
</div>
|
||||||
@elseif($previewData['icon_url'])
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
|
|
||||||
<img src="{{ $previewData['icon_url'] }}"
|
|
||||||
alt="{{ $previewData['name'] }} icon"
|
|
||||||
class="mx-auto h-32 w-auto object-contain mb-4">
|
|
||||||
<flux:text class="text-gray-600 dark:text-gray-400">No preview image available</flux:text>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
|
|
||||||
<flux:icon name="puzzle-piece" class="mx-auto h-32 w-32 text-gray-400 mb-4" />
|
|
||||||
<flux:text class="text-gray-600 dark:text-gray-400">No preview available</flux:text>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($previewData['author_bio'])
|
@if($previewData['author_bio'])
|
||||||
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||||
|
|
@ -314,7 +382,7 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
|
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
|
||||||
@if($previewData['detail_url'])
|
@if($previewData['detail_url'])
|
||||||
<flux:button
|
<flux:button
|
||||||
href="{{ $previewData['detail_url'] }}"
|
href="{{ $previewData['detail_url'] }}"
|
||||||
|
|
@ -333,56 +401,6 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
</div>
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@script
|
|
||||||
<script>
|
|
||||||
let trmnlCatalogScrollPosition = 0;
|
|
||||||
|
|
||||||
$wire.on('store-scroll-position', () => {
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-trmnl-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
|
|
||||||
trmnlCatalogScrollPosition = scrollContainer.scrollTop || 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$wire.on('restore-scroll-position', () => {
|
|
||||||
// Small delay to ensure modal is fully rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-trmnl-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
|
|
||||||
scrollContainer.scrollTop = trmnlCatalogScrollPosition;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for when the catalog modal is opened and restore scroll position
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const observer = new MutationObserver(function(mutations) {
|
|
||||||
mutations.forEach(function(mutation) {
|
|
||||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-flux-modal-open') {
|
|
||||||
const target = mutation.target;
|
|
||||||
if (target.getAttribute('data-flux-modal') === 'import-from-trmnl-catalog' &&
|
|
||||||
target.getAttribute('data-flux-modal-open') === 'true') {
|
|
||||||
// Modal was opened, restore scroll position
|
|
||||||
setTimeout(() => {
|
|
||||||
const scrollContainer = target.querySelector('.space-y-4') || target;
|
|
||||||
if (trmnlCatalogScrollPosition > 0) {
|
|
||||||
scrollContainer.scrollTop = trmnlCatalogScrollPosition;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-trmnl-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
observer.observe(catalogModal, { attributes: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@endscript
|
|
||||||
|
|
|
||||||
|
|
@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) {
|
||||||
// Check and update stale data if needed
|
// Check and update stale data if needed
|
||||||
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
||||||
$plugin->updateDataPayload();
|
$plugin->updateDataPayload();
|
||||||
|
try {
|
||||||
$markup = $plugin->render(device: $device);
|
$markup = $plugin->render(device: $device);
|
||||||
|
|
||||||
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
|
||||||
|
// Generate error display
|
||||||
|
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name);
|
||||||
|
$device->update(['current_screen_image' => $errorImageUuid]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$plugin->refresh();
|
$plugin->refresh();
|
||||||
|
|
@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
$markup = $playlistItem->render(device: $device);
|
$markup = $playlistItem->render(device: $device);
|
||||||
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage());
|
||||||
|
// For mashups, show error for the first plugin or a generic error
|
||||||
|
$firstPlugin = $plugins->first();
|
||||||
|
$pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe';
|
||||||
|
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName);
|
||||||
|
$device->update(['current_screen_image' => $errorImageUuid]);
|
||||||
|
}
|
||||||
|
|
||||||
$device->refresh();
|
$device->refresh();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Models\Playlist;
|
||||||
use App\Models\PlaylistItem;
|
use App\Models\PlaylistItem;
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\ImageGenerationService;
|
||||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -1023,3 +1024,163 @@ test('screens endpoint matches MAC address case-insensitively', function (): voi
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
Queue::assertPushed(GenerateScreenJob::class);
|
Queue::assertPushed(GenerateScreenJob::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('display endpoint handles plugin rendering errors gracefully', function (): void {
|
||||||
|
TrmnlPipeline::fake();
|
||||||
|
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => '00:11:22:33:44:55',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'proxy_cloud' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a plugin with Blade markup that will cause an exception when accessing data[0]
|
||||||
|
// when data is not an array or doesn't have index 0
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'name' => 'Broken Recipe',
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => null,
|
||||||
|
'data_stale_minutes' => 1,
|
||||||
|
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
|
||||||
|
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail if data[0] doesn't exist
|
||||||
|
'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail
|
||||||
|
'data_payload_updated_at' => now()->subMinutes(2), // Make it stale
|
||||||
|
'current_image' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$playlist = Playlist::factory()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'name' => 'test_playlist',
|
||||||
|
'is_active' => true,
|
||||||
|
'weekdays' => null,
|
||||||
|
'active_from' => null,
|
||||||
|
'active_until' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
PlaylistItem::factory()->create([
|
||||||
|
'playlist_id' => $playlist->id,
|
||||||
|
'plugin_id' => $plugin->id,
|
||||||
|
'order' => 1,
|
||||||
|
'is_active' => true,
|
||||||
|
'last_displayed_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'id' => $device->mac_address,
|
||||||
|
'access-token' => $device->api_key,
|
||||||
|
'rssi' => -70,
|
||||||
|
'battery_voltage' => 3.8,
|
||||||
|
'fw-version' => '1.0.0',
|
||||||
|
])->get('/api/display');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
// Verify error screen was generated and set on device
|
||||||
|
$device->refresh();
|
||||||
|
expect($device->current_screen_image)->not->toBeNull();
|
||||||
|
|
||||||
|
// Verify the error image exists
|
||||||
|
$errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png");
|
||||||
|
// The TrmnlPipeline is faked, so we just verify the UUID was set
|
||||||
|
expect($device->current_screen_image)->toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display endpoint handles mashup rendering errors gracefully', function (): void {
|
||||||
|
TrmnlPipeline::fake();
|
||||||
|
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => '00:11:22:33:44:55',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'proxy_cloud' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create plugins for mashup, one with invalid markup
|
||||||
|
$plugin1 = Plugin::factory()->create([
|
||||||
|
'name' => 'Working Plugin',
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => null,
|
||||||
|
'data_stale_minutes' => 1,
|
||||||
|
'render_markup_view' => 'trmnl',
|
||||||
|
'data_payload_updated_at' => now()->subMinutes(2),
|
||||||
|
'current_image' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin2 = Plugin::factory()->create([
|
||||||
|
'name' => 'Broken Plugin',
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => null,
|
||||||
|
'data_stale_minutes' => 1,
|
||||||
|
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
|
||||||
|
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail
|
||||||
|
'data_payload' => ['error' => 'Failed to fetch data'],
|
||||||
|
'data_payload_updated_at' => now()->subMinutes(2),
|
||||||
|
'current_image' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$playlist = Playlist::factory()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'name' => 'test_playlist',
|
||||||
|
'is_active' => true,
|
||||||
|
'weekdays' => null,
|
||||||
|
'active_from' => null,
|
||||||
|
'active_until' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create mashup playlist item
|
||||||
|
$playlistItem = PlaylistItem::createMashup(
|
||||||
|
$playlist,
|
||||||
|
'1Lx1R',
|
||||||
|
[$plugin1->id, $plugin2->id],
|
||||||
|
'Test Mashup',
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'id' => $device->mac_address,
|
||||||
|
'access-token' => $device->api_key,
|
||||||
|
'rssi' => -70,
|
||||||
|
'battery_voltage' => 3.8,
|
||||||
|
'fw-version' => '1.0.0',
|
||||||
|
])->get('/api/display');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
// Verify error screen was generated and set on device
|
||||||
|
$device->refresh();
|
||||||
|
expect($device->current_screen_image)->not->toBeNull();
|
||||||
|
|
||||||
|
// Verify the error image UUID was set
|
||||||
|
expect($device->current_screen_image)->toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateDefaultScreenImage creates error screen with plugin name', function (): void {
|
||||||
|
TrmnlPipeline::fake();
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::disk('public')->makeDirectory('/images/generated');
|
||||||
|
|
||||||
|
$device = Device::factory()->create();
|
||||||
|
|
||||||
|
$errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name');
|
||||||
|
|
||||||
|
expect($errorUuid)->not->toBeEmpty();
|
||||||
|
|
||||||
|
// Verify the error image path would be created
|
||||||
|
$errorPath = "images/generated/{$errorUuid}.png";
|
||||||
|
// Since TrmnlPipeline is faked, we just verify the UUID was generated
|
||||||
|
expect($errorUuid)->toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateDefaultScreenImage throws exception for invalid error image type', function (): void {
|
||||||
|
$device = Device::factory()->create();
|
||||||
|
|
||||||
|
expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type'))
|
||||||
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void {
|
||||||
|
$device = new Device();
|
||||||
|
$device->deviceModel = null;
|
||||||
|
|
||||||
|
$result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error');
|
||||||
|
expect($result)->toBeNull();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,46 @@ it('loads plugins from catalog URL', function (): void {
|
||||||
$component->assertSee('testuser');
|
$component->assertSee('testuser');
|
||||||
$component->assertSee('A test plugin');
|
$component->assertSee('A test plugin');
|
||||||
$component->assertSee('MIT');
|
$component->assertSee('MIT');
|
||||||
|
$component->assertSee('Preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides preview button when screenshot_url is missing', function (): void {
|
||||||
|
// Clear cache first to ensure fresh data
|
||||||
|
Cache::forget('catalog_plugins');
|
||||||
|
|
||||||
|
// Mock the HTTP response for the catalog URL without screenshot_url
|
||||||
|
$catalogData = [
|
||||||
|
'test-plugin' => [
|
||||||
|
'name' => 'Test Plugin Without Screenshot',
|
||||||
|
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
||||||
|
'author_bio' => [
|
||||||
|
'description' => 'A test plugin',
|
||||||
|
],
|
||||||
|
'license' => 'MIT',
|
||||||
|
'trmnlp' => [
|
||||||
|
'zip_url' => 'https://example.com/plugin.zip',
|
||||||
|
],
|
||||||
|
'byos' => [
|
||||||
|
'byos_laravel' => [
|
||||||
|
'compatibility' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'logo_url' => 'https://example.com/logo.png',
|
||||||
|
'screenshot_url' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$yamlContent = Yaml::dump($catalogData);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
config('app.catalog_url') => Http::response($yamlContent, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.index')
|
||||||
|
->assertSee('Test Plugin Without Screenshot')
|
||||||
|
->assertDontSeeHtml('variant="subtle" icon="eye"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when plugin not found', function (): void {
|
it('shows error when plugin not found', function (): void {
|
||||||
|
|
@ -114,3 +154,46 @@ it('shows error when zip_url is missing', function (): void {
|
||||||
$component->assertHasErrors();
|
$component->assertHasErrors();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can preview a plugin', function (): void {
|
||||||
|
// Clear cache first to ensure fresh data
|
||||||
|
Cache::forget('catalog_plugins');
|
||||||
|
|
||||||
|
// Mock the HTTP response for the catalog URL
|
||||||
|
$catalogData = [
|
||||||
|
'test-plugin' => [
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
||||||
|
'author_bio' => [
|
||||||
|
'description' => 'A test plugin description',
|
||||||
|
],
|
||||||
|
'license' => 'MIT',
|
||||||
|
'trmnlp' => [
|
||||||
|
'zip_url' => 'https://example.com/plugin.zip',
|
||||||
|
],
|
||||||
|
'byos' => [
|
||||||
|
'byos_laravel' => [
|
||||||
|
'compatibility' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'logo_url' => 'https://example.com/logo.png',
|
||||||
|
'screenshot_url' => 'https://example.com/screenshot.png',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$yamlContent = Yaml::dump($catalogData);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
config('app.catalog_url') => Http::response($yamlContent, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.index')
|
||||||
|
->assertSee('Test Plugin')
|
||||||
|
->call('previewPlugin', 'test-plugin')
|
||||||
|
->assertSet('previewingPlugin', 'test-plugin')
|
||||||
|
->assertSet('previewData.name', 'Test Plugin')
|
||||||
|
->assertSee('Preview Test Plugin')
|
||||||
|
->assertSee('A test plugin description');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,33 @@ it('loads newest TRMNL recipes on mount', function (): void {
|
||||||
Volt::test('catalog.trmnl')
|
Volt::test('catalog.trmnl')
|
||||||
->assertSee('Weather Chum')
|
->assertSee('Weather Chum')
|
||||||
->assertSee('Install')
|
->assertSee('Install')
|
||||||
|
->assertDontSeeHtml('variant="subtle" icon="eye"')
|
||||||
->assertSee('Installs: 10');
|
->assertSee('Installs: 10');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows preview button when screenshot_url is provided', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'Weather Chum',
|
||||||
|
'icon_url' => 'https://example.com/icon.png',
|
||||||
|
'screenshot_url' => 'https://example.com/screenshot.png',
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 10, 'forks' => 2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Weather Chum')
|
||||||
|
->assertSee('Preview');
|
||||||
|
});
|
||||||
|
|
||||||
it('searches TRMNL recipes when search term is provided', function (): void {
|
it('searches TRMNL recipes when search term is provided', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
// First call (mount -> newest)
|
// First call (mount -> newest)
|
||||||
|
|
@ -152,3 +176,111 @@ it('shows error when plugin installation fails', function (): void {
|
||||||
->call('installPlugin', '123')
|
->call('installPlugin', '123')
|
||||||
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
|
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('previews a recipe with async fetch', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'Weather Chum',
|
||||||
|
'icon_url' => 'https://example.com/icon.png',
|
||||||
|
'screenshot_url' => 'https://example.com/old.png',
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 10, 'forks' => 2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
'usetrmnl.com/recipes/123.json' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'Weather Chum Updated',
|
||||||
|
'icon_url' => 'https://example.com/icon.png',
|
||||||
|
'screenshot_url' => 'https://example.com/new.png',
|
||||||
|
'author_bio' => ['description' => 'New bio'],
|
||||||
|
'stats' => ['installs' => 11, 'forks' => 3],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Weather Chum')
|
||||||
|
->call('previewRecipe', '123')
|
||||||
|
->assertSet('previewingRecipe', '123')
|
||||||
|
->assertSet('previewData.name', 'Weather Chum Updated')
|
||||||
|
->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
|
||||||
|
->assertSee('Preview Weather Chum Updated')
|
||||||
|
->assertSee('New bio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports pagination and loading more recipes', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
|
'name' => 'Recipe Page 1',
|
||||||
|
'icon_url' => null,
|
||||||
|
'screenshot_url' => null,
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 1, 'forks' => 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'next_page_url' => '/recipes.json?page=2',
|
||||||
|
], 200),
|
||||||
|
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 2,
|
||||||
|
'name' => 'Recipe Page 2',
|
||||||
|
'icon_url' => null,
|
||||||
|
'screenshot_url' => null,
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 2, 'forks' => 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'next_page_url' => null,
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Recipe Page 1')
|
||||||
|
->assertDontSee('Recipe Page 2')
|
||||||
|
->assertSee('Load next page')
|
||||||
|
->call('loadMore')
|
||||||
|
->assertSee('Recipe Page 1')
|
||||||
|
->assertSee('Recipe Page 2')
|
||||||
|
->assertDontSee('Load next page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets pagination when search term changes', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
|
||||||
|
->push([
|
||||||
|
'data' => [['id' => 1, 'name' => 'Initial 1']],
|
||||||
|
'next_page_url' => '/recipes.json?page=2',
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
|
||||||
|
'next_page_url' => null,
|
||||||
|
]),
|
||||||
|
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
|
||||||
|
'data' => [['id' => 2, 'name' => 'Weather Result']],
|
||||||
|
'next_page_url' => null,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Initial 1')
|
||||||
|
->call('loadMore')
|
||||||
|
->set('search', 'weather')
|
||||||
|
->assertSee('Weather Result')
|
||||||
|
->assertDontSee('Initial 1')
|
||||||
|
->assertSet('page', 1);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue