mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
add error handling for preview fix idx bug and add tests fix light mode styling and remove transitions add global styling class
415 lines
18 KiB
PHP
415 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Console\Commands\ExampleRecipesSeederCommand;
|
|
use App\Services\PluginImportService;
|
|
use Livewire\Volt\Component;
|
|
use Livewire\WithFileUploads;
|
|
use Illuminate\Support\Str;
|
|
|
|
new class extends Component {
|
|
use WithFileUploads;
|
|
|
|
public string $name;
|
|
public int $data_stale_minutes = 60;
|
|
public string $data_strategy = "polling";
|
|
public string $polling_url;
|
|
public string $polling_verb = "get";
|
|
public $polling_header;
|
|
public $polling_body;
|
|
public array $plugins;
|
|
public $zipFile;
|
|
|
|
public string $sortBy = 'date_asc';
|
|
|
|
public array $native_plugins = [
|
|
'markup' =>
|
|
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
|
|
'api' =>
|
|
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
|
|
'image-webhook' =>
|
|
['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'],
|
|
];
|
|
|
|
protected $rules = [
|
|
'name' => 'required|string|max:255',
|
|
'data_stale_minutes' => 'required|integer|min:1',
|
|
'data_strategy' => 'required|string|in:polling,webhook,static',
|
|
'polling_url' => 'required_if:data_strategy,polling|nullable|url',
|
|
'polling_verb' => 'required|string|in:get,post',
|
|
'polling_header' => 'nullable|string|max:255',
|
|
'polling_body' => 'nullable|string',
|
|
];
|
|
|
|
public function refreshPlugins(): void
|
|
{
|
|
// Only show recipe plugins in the main list (image_webhook has its own management page)
|
|
$userPlugins = auth()->user()?->plugins()
|
|
->where('plugin_type', 'recipe')
|
|
->get()
|
|
->makeHidden(['render_markup', 'data_payload'])
|
|
->toArray();
|
|
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
|
|
$allPlugins = array_values($allPlugins);
|
|
$allPlugins = $this->sortPlugins($allPlugins);
|
|
$this->plugins = $allPlugins;
|
|
}
|
|
|
|
protected function sortPlugins(array $plugins): array
|
|
{
|
|
$pluginsToSort = array_values($plugins);
|
|
|
|
switch ($this->sortBy) {
|
|
case 'name_asc':
|
|
usort($pluginsToSort, function($a, $b) {
|
|
return strcasecmp($a['name'] ?? '', $b['name'] ?? '');
|
|
});
|
|
break;
|
|
|
|
case 'name_desc':
|
|
usort($pluginsToSort, function($a, $b) {
|
|
return strcasecmp($b['name'] ?? '', $a['name'] ?? '');
|
|
});
|
|
break;
|
|
|
|
case 'date_desc':
|
|
usort($pluginsToSort, function($a, $b) {
|
|
$aDate = $a['created_at'] ?? '1970-01-01';
|
|
$bDate = $b['created_at'] ?? '1970-01-01';
|
|
return strcmp($bDate, $aDate);
|
|
});
|
|
break;
|
|
|
|
case 'date_asc':
|
|
usort($pluginsToSort, function($a, $b) {
|
|
$aDate = $a['created_at'] ?? '1970-01-01';
|
|
$bDate = $b['created_at'] ?? '1970-01-01';
|
|
return strcmp($aDate, $bDate);
|
|
});
|
|
break;
|
|
}
|
|
|
|
return $pluginsToSort;
|
|
}
|
|
|
|
public function mount(): void
|
|
{
|
|
$this->refreshPlugins();
|
|
}
|
|
|
|
public function updatedSortBy(): void
|
|
{
|
|
$this->refreshPlugins();
|
|
}
|
|
|
|
public function getListeners(): array
|
|
{
|
|
return [
|
|
'plugin-installed' => 'refreshPlugins',
|
|
];
|
|
}
|
|
|
|
public function addPlugin(): void
|
|
{
|
|
abort_unless(auth()->user() !== null, 403);
|
|
$this->validate();
|
|
|
|
\App\Models\Plugin::create([
|
|
'uuid' => Str::uuid(),
|
|
'user_id' => auth()->id(),
|
|
'name' => $this->name,
|
|
'data_stale_minutes' => $this->data_stale_minutes,
|
|
'data_strategy' => $this->data_strategy,
|
|
'polling_url' => $this->polling_url ?? null,
|
|
'polling_verb' => $this->polling_verb,
|
|
'polling_header' => $this->polling_header,
|
|
'polling_body' => $this->polling_body,
|
|
]);
|
|
|
|
$this->reset(['name', 'data_stale_minutes', 'data_strategy', 'polling_url', 'polling_verb', 'polling_header', 'polling_body']);
|
|
$this->refreshPlugins();
|
|
|
|
Flux::modal('add-plugin')->close();
|
|
}
|
|
|
|
public function seedExamplePlugins(): void
|
|
{
|
|
Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]);
|
|
$this->refreshPlugins();
|
|
}
|
|
|
|
|
|
public function importZip(PluginImportService $pluginImportService): void
|
|
{
|
|
abort_unless(auth()->user() !== null, 403);
|
|
|
|
$this->validate([
|
|
'zipFile' => 'required|file|mimes:zip|max:10240', // 10MB max
|
|
]);
|
|
|
|
try {
|
|
$plugin = $pluginImportService->importFromZip($this->zipFile, auth()->user());
|
|
|
|
$this->refreshPlugins();
|
|
$this->reset(['zipFile']);
|
|
|
|
Flux::modal('import-zip')->close();
|
|
} catch (\Exception $e) {
|
|
$this->addError('zipFile', 'Error installing plugin: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
};
|
|
?>
|
|
|
|
<div class="py-12" x-data="{
|
|
searchTerm: '',
|
|
showFilters: false,
|
|
filterPlugins(plugins) {
|
|
if (this.searchTerm.length <= 1) return plugins;
|
|
const search = this.searchTerm.toLowerCase();
|
|
return plugins.filter(p => p.name.toLowerCase().includes(search));
|
|
}
|
|
}">
|
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<h2 class="text-2xl font-semibold dark:text-gray-100">Plugins & Recipes</h2>
|
|
<div class="flex items-center space-x-2">
|
|
<flux:button icon="funnel" variant="ghost" @click="showFilters = !showFilters"></flux:button>
|
|
<flux:button.group>
|
|
<flux:modal.trigger name="add-plugin">
|
|
<flux:button icon="plus" variant="primary">Add Recipe</flux:button>
|
|
</flux:modal.trigger>
|
|
|
|
<flux:dropdown>
|
|
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
|
<flux:menu>
|
|
<flux:modal.trigger name="import-from-catalog">
|
|
<flux:menu.item icon="book-open">Import from OSS Catalog</flux:menu.item>
|
|
</flux:modal.trigger>
|
|
@if(config('services.trmnl.liquid_enabled'))
|
|
<flux:modal.trigger name="import-from-trmnl-catalog">
|
|
<flux:menu.item icon="book-open">Import from TRMNL Catalog</flux:menu.item>
|
|
</flux:modal.trigger>
|
|
@endif
|
|
<flux:separator />
|
|
<flux:modal.trigger name="import-zip">
|
|
<flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item>
|
|
</flux:modal.trigger>
|
|
<flux:separator />
|
|
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
|
|
</flux:menu>
|
|
</flux:dropdown>
|
|
</flux:button.group>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="showFilters" class="mb-6 flex flex-col sm:flex-row gap-4" style="display: none;">
|
|
<div class="flex-1">
|
|
<flux:input
|
|
x-model="searchTerm"
|
|
placeholder="Search plugins by name (min. 2 characters)..."
|
|
icon="magnifying-glass"
|
|
/>
|
|
</div>
|
|
<div class="sm:w-64">
|
|
<flux:select wire:model.live="sortBy">
|
|
<option value="date_asc">Oldest First</option>
|
|
<option value="date_desc">Newest First</option>
|
|
<option value="name_asc">Name (A-Z)</option>
|
|
<option value="name_desc">Name (Z-A)</option>
|
|
</flux:select>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="searchTerm.length > 1" class="mb-4" style="display: none;">
|
|
<p class="text-sm text-zinc-600 dark:text-zinc-400">
|
|
<span x-text="'Showing results for: ' + searchTerm"></span>
|
|
</p>
|
|
</div>
|
|
|
|
<flux:modal name="import-zip" class="md:w-96">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Import Recipe
|
|
<flux:badge color="blue" class="ml-2">Beta</flux:badge>
|
|
</flux:heading>
|
|
<flux:subheading>Upload a ZIP archive containing a TRMNL recipe — either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<flux:text>The archive must at least contain <code>settings.yml</code> and <code>full.liquid</code> files.</flux:text>
|
|
{{-- <p>The ZIP file should contain the following structure:</p>--}}
|
|
{{-- <pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs overflow-auto">--}}
|
|
{{--.--}}
|
|
{{--├── src--}}
|
|
{{--│ ├── full.liquid (required)--}}
|
|
{{--│ ├── settings.yml (required)--}}
|
|
{{--│ └── ...--}}
|
|
{{--└── ...--}}
|
|
{{-- </pre>--}}
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<flux:heading size="sm">Limitations</flux:heading>
|
|
<ul class="list-disc pl-5 mt-2">
|
|
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
|
|
<li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li>
|
|
<li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li>
|
|
{{-- <ul class="list-disc pl-5 mt-2">--}}
|
|
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
|
|
{{-- </ul>--}}
|
|
</ul>
|
|
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
|
|
</div>
|
|
|
|
<form wire:submit="importZip">
|
|
<div class="mb-4">
|
|
<flux:label for="zipFile">.zip Archive</flux:label>
|
|
<input
|
|
type="file"
|
|
wire:model="zipFile"
|
|
id="zipFile"
|
|
accept=".zip"
|
|
class="block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 p-2.5"
|
|
/>
|
|
@error('zipFile')
|
|
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}" class="mt-2" />
|
|
@enderror
|
|
</div>
|
|
|
|
<div class="flex">
|
|
<flux:spacer/>
|
|
<flux:button type="submit" variant="primary">Import</flux:button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</flux:modal>
|
|
|
|
<flux:modal name="import-from-catalog">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Import from Catalog
|
|
<flux:badge color="blue" class="ml-2">Beta</flux:badge>
|
|
</flux:heading>
|
|
<flux:subheading>Browse and install Recipes from the community. Add yours <a href="https://github.com/bnussbau/trmnl-recipe-catalog" class="underline" target="_blank">here</a>.</flux:subheading>
|
|
</div>
|
|
<livewire:catalog.index />
|
|
</div>
|
|
</flux:modal>
|
|
|
|
<flux:modal name="import-from-trmnl-catalog">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Import from TRMNL Recipe Catalog
|
|
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
|
|
</flux:heading>
|
|
<flux:callout class="mb-4 mt-4" color="yellow">
|
|
<flux:heading size="sm">Limitations</flux:heading>
|
|
<ul class="list-disc pl-5 mt-2">
|
|
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
|
|
<li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span> executable.</flux:text></li>
|
|
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
|
|
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
|
|
</ul>
|
|
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
|
|
</flux:callout>
|
|
</div>
|
|
<livewire:catalog.trmnl />
|
|
</div>
|
|
</flux:modal>
|
|
|
|
<flux:modal name="add-plugin" class="md:w-96">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<flux:heading size="lg">Add Recipe</flux:heading>
|
|
</div>
|
|
|
|
<form wire:submit="addPlugin">
|
|
<div class="mb-4">
|
|
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
|
name="name" autofocus/>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<flux:radio.group wire:model.live="data_strategy" label="Data Strategy" variant="segmented">
|
|
<flux:radio value="polling" label="Polling"/>
|
|
<flux:radio value="webhook" label="Webhook"/>
|
|
<flux:radio value="static" label="Static"/>
|
|
</flux:radio.group>
|
|
</div>
|
|
|
|
@if($data_strategy === 'polling')
|
|
<div class="mb-4">
|
|
<flux:input label="Polling URL" wire:model="polling_url" id="polling_url"
|
|
placeholder="https://example.com/api"
|
|
class="block mt-1 w-full" type="text" name="polling_url" autofocus/>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
|
|
<flux:radio value="get" label="GET"/>
|
|
<flux:radio value="post" label="POST"/>
|
|
</flux:radio.group>
|
|
</div>
|
|
|
|
<div class="mb-4">
|
|
<flux:input label="Polling Header" wire:model="polling_header" id="polling_header"
|
|
class="block mt-1 w-full" type="text" name="polling_header" autofocus/>
|
|
</div>
|
|
|
|
@if($polling_verb === 'post')
|
|
<div class="mb-4">
|
|
<flux:textarea
|
|
label="Polling Body"
|
|
wire:model="polling_body"
|
|
id="polling_body"
|
|
class="block mt-1 w-full font-mono"
|
|
name="polling_body"
|
|
rows="4"
|
|
placeholder=''
|
|
/>
|
|
</div>
|
|
@endif
|
|
<div class="mb-4">
|
|
<flux:input label="Data is stale after minutes" wire:model.live="data_stale_minutes"
|
|
id="data_stale_minutes"
|
|
class="block mt-1 w-full" type="number" name="data_stale_minutes" autofocus/>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="flex">
|
|
<flux:spacer/>
|
|
<flux:button type="submit" variant="primary">Create Recipe</flux:button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</flux:modal>
|
|
|
|
@php
|
|
$allPlugins = $this->plugins;
|
|
@endphp
|
|
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
|
@foreach($allPlugins as $index => $plugin)
|
|
<div
|
|
wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}"
|
|
x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }"
|
|
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
|
|
class="styled-container">
|
|
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
|
|
class="block h-full">
|
|
<div class="flex items-center space-x-4 px-10 py-8 h-full">
|
|
@isset($plugin['icon_url'])
|
|
<img src="{{ $plugin['icon_url'] }}" class="h-6"/>
|
|
@else
|
|
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}"
|
|
class="text-4xl text-accent"/>
|
|
@endif
|
|
<h3 class="text-lg font-medium dark:text-zinc-200">{{$plugin['name']}}</h3>
|
|
</div>
|
|
</a>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
</div>
|