Compare commits

...

21 commits
0.24.0 ... main

Author SHA1 Message Date
Benjamin Nussbaum
3032c09778 fix: markup for recipe 'Zen Quotes'
Some checks are pending
tests / ci (push) Waiting to run
2026-01-12 17:58:22 +01:00
Benjamin Nussbaum
f1903bcbe8 chore: change button variant 2026-01-12 17:42:25 +01:00
Benjamin Nussbaum
621c108e78 chore: Alias improve wording 2026-01-12 16:32:26 +01:00
Benjamin Nussbaum
131d99a2e3 feat(#154): add support for trusted proxies
Some checks are pending
tests / ci (push) Waiting to run
2026-01-11 21:50:35 +01:00
Benjamin Nussbaum
7d1e74183d fix: recipe with shared.liquid template only should pass validation 2026-01-11 20:41:12 +01:00
Benjamin Nussbaum
3f98a70ad9 feat(#102): added support for Alias plugin 2026-01-11 20:28:51 +01:00
Benjamin Nussbaum
0d6079db8b feat(#150): add trmnlp settings modal 2026-01-11 17:51:40 +01:00
Benjamin Nussbaum
a86315c5c7 fix: init exception
Some checks failed
tests / ci (push) Has been cancelled
2026-01-10 22:10:37 +01:00
Benjamin Nussbaum
887c4d130b chore: gitignore 2026-01-10 19:55:35 +01:00
Benjamin Nussbaum
74e9e1eba3 chore: update dependencies 2026-01-10 19:54:26 +01:00
jerremyng
53d4a8399f feat(#152): preview polling url
add error handling for preview

fix idx bug and add tests

fix light mode styling and remove transitions

add global styling class
2026-01-10 17:44:51 +01:00
Benjamin Nussbaum
043f683db7 feat(#138): add device model dropdown in preview model
Some checks are pending
tests / ci (push) Waiting to run
2026-01-09 22:37:40 +01:00
Benjamin Nussbaum
36e1ad8441 feat: add Mirror URL modal for device configuration
Some checks are pending
tests / ci (push) Waiting to run
2026-01-09 21:11:28 +01:00
Benjamin Nussbaum
a06a0879ff chore: gitignore 2026-01-09 20:23:24 +01:00
Gabriele Lauricella
ddce3947c6 feat: enhanced web mirror trmnl client
Some checks are pending
tests / ci (push) Waiting to run
2026-01-09 11:14:31 +01:00
Gabriele Lauricella
4bc42cc1d2 feat: add web mirror trmnl client 2026-01-09 11:14:31 +01:00
Benjamin Nussbaum
94d5fca879 fix: half and quadrant layout for recipes with render_markup_view
Some checks failed
tests / ci (push) Has been cancelled
2026-01-06 20:23:14 +01:00
Benjamin Nussbaum
dc676327c2 fix(#121): allow multiple instances of the same plugin 2026-01-06 20:23:14 +01:00
Benjamin Nussbaum
e3bb9ad4e2 feat: implement Plugin duplicate action 2026-01-06 20:23:14 +01:00
jerremyng
e176f2828e add checks for comma when importing recipies 2026-01-06 19:38:12 +01:00
jerremyng
164a990dfe 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
2026-01-06 19:38:12 +01:00
40 changed files with 2709 additions and 747 deletions

5
.gitignore vendored
View file

@ -29,3 +29,8 @@ yarn-error.log
/.junie/guidelines.md
/CLAUDE.md
/.mcp.json
/.ai
.DS_Store
/boost.json
/.gemini
/GEMINI.md

View file

@ -199,6 +199,7 @@ final class FetchDeviceModelsJob implements ShouldQueue
'offset_x' => $modelData['offset_x'] ?? 0,
'offset_y' => $modelData['offset_y'] ?? 0,
'published_at' => $modelData['published_at'] ?? null,
'kind' => $modelData['kind'] ?? null,
'source' => 'api',
];

View file

@ -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;
@ -46,6 +46,7 @@ class Plugin extends Model
'dark_mode' => 'boolean',
'preferred_renderer' => 'string',
'plugin_type' => 'string',
'alias' => 'boolean',
];
protected static function boot()
@ -153,105 +154,67 @@ class Plugin extends Model
public function updateDataPayload(): void
{
if ($this->data_strategy === 'polling' && $this->polling_url) {
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
if ($this->polling_header) {
// Resolve Liquid variables in the polling header
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", mb_trim($resolvedHeader));
foreach ($headerLines as $line) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
}
}
}
// Resolve Liquid variables in the entire polling_url field first, then split by newline
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
$urls = array_filter(
array_map('trim', explode("\n", $resolvedPollingUrls)),
fn ($url): bool => ! empty($url)
);
// If only one URL, use the original logic without nesting
if (count($urls) === 1) {
$url = reset($urls);
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// URL is already resolved, use it directly
$resolvedUrl = $url;
try {
// Make the request based on the verb
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
$response = $this->parseResponse($httpResponse);
$this->update([
'data_payload' => $response,
'data_payload_updated_at' => now(),
]);
} catch (Exception $e) {
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$this->update([
'data_payload' => ['error' => 'Failed to fetch data'],
'data_payload_updated_at' => now(),
]);
}
return;
}
// Multiple URLs - use nested response logic
$combinedResponse = [];
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// URL is already resolved, use it directly
$resolvedUrl = $url;
try {
// Make the request based on the verb
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
$response = $this->parseResponse($httpResponse);
// Check if response is an array at root level
if (array_keys($response) === range(0, count($response) - 1)) {
// Response is a sequential array, nest under .data
$combinedResponse["IDX_{$index}"] = ['data' => $response];
} else {
// Response is an object or associative array, keep as is
$combinedResponse["IDX_{$index}"] = $response;
}
} catch (Exception $e) {
// Log error and continue with other URLs
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
}
}
$this->update([
'data_payload' => $combinedResponse,
'data_payload_updated_at' => now(),
]);
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
return;
}
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
// resolve headers
if ($this->polling_header) {
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", mb_trim($resolvedHeader));
foreach ($headerLines as $line) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
}
}
}
// resolve and clean URLs
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
array_map('trim', explode("\n", $resolvedPollingUrls)),
fn ($url): bool => filled($url)
));
$combinedResponse = [];
// Loop through all URLs (Handles 1 or many)
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
try {
$httpResponse = ($this->polling_verb === 'post')
? $httpRequest->post($url)
: $httpRequest->get($url);
$response = $this->parseResponse($httpResponse);
// Nest if it's a sequential array
if (array_keys($response) === range(0, count($response) - 1)) {
$combinedResponse["IDX_{$index}"] = ['data' => $response];
} else {
$combinedResponse["IDX_{$index}"] = $response;
}
} catch (Exception $e) {
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
}
}
// unwrap IDX_0 if only one URL
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
$this->update([
'data_payload' => $finalPayload,
'data_payload_updated_at' => now(),
]);
}
private function parseResponse(Response $httpResponse): array
@ -455,7 +418,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) {
@ -565,17 +528,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();
}
@ -606,4 +582,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);
}
}

View file

@ -26,11 +26,44 @@ class ImageGenerationService
public static function generateImage(string $markup, $deviceId): string
{
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
$uuid = self::generateImageFromModel(
markup: $markup,
deviceModel: $device->deviceModel,
user: $device->user,
palette: $device->palette ?? $device->deviceModel?->palette,
device: $device
);
$device->update(['current_screen_image' => $uuid]);
Log::info("Device $device->id: updated with new image: $uuid");
return $uuid;
}
/**
* Generate an image from markup using a DeviceModel
*
* @param string $markup The HTML markup to render
* @param DeviceModel|null $deviceModel The device model to use for image generation
* @param \App\Models\User|null $user Optional user for timezone settings
* @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
* @param Device|null $device Optional device for legacy devices without DeviceModel
* @return string The UUID of the generated image
*/
public static function generateImageFromModel(
string $markup,
?DeviceModel $deviceModel = null,
?\App\Models\User $user = null,
?\App\Models\DevicePalette $palette = null,
?Device $device = null
): string {
$uuid = Uuid::uuid4()->toString();
try {
// Get image generation settings from DeviceModel if available, otherwise use device settings
$imageSettings = self::getImageSettings($device);
// Get image generation settings from DeviceModel or Device (for legacy devices)
$imageSettings = $deviceModel
? self::getImageSettingsFromModel($deviceModel)
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
@ -45,7 +78,7 @@ class ImageGenerationService
$browserStage->html($markup);
// Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone');
$timezone = $user?->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') {
@ -65,12 +98,12 @@ class ImageGenerationService
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
}
// Get palette from device or fallback to device model's default palette
$palette = $device->palette ?? $device->deviceModel?->palette;
// Get palette from parameter or fallback to device model's default palette
$colorPalette = null;
if ($palette && $palette->colors) {
$colorPalette = $palette->colors;
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
$colorPalette = $deviceModel->palette->colors;
}
$imageStage = new ImageStage();
@ -107,8 +140,7 @@ class ImageGenerationService
throw new RuntimeException('Image file is empty: '.$outputPath);
}
$device->update(['current_screen_image' => $uuid]);
Log::info("Device $device->id: updated with new image: $uuid");
Log::info("Generated image: $uuid");
return $uuid;
@ -125,22 +157,7 @@ class ImageGenerationService
{
// If device has a DeviceModel, use its settings
if ($device->deviceModel) {
/** @var DeviceModel $model */
$model = $device->deviceModel;
return [
'width' => $model->width,
'height' => $model->height,
'colors' => $model->colors,
'bit_depth' => $model->bit_depth,
'scale_factor' => $model->scale_factor,
'rotation' => $model->rotation,
'mime_type' => $model->mime_type,
'offset_x' => $model->offset_x,
'offset_y' => $model->offset_y,
'image_format' => self::determineImageFormatFromModel($model),
'use_model_settings' => true,
];
return self::getImageSettingsFromModel($device->deviceModel);
}
// Fallback to device settings
@ -164,6 +181,43 @@ class ImageGenerationService
];
}
/**
* Get image generation settings from a DeviceModel
*/
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
{
if ($deviceModel) {
return [
'width' => $deviceModel->width,
'height' => $deviceModel->height,
'colors' => $deviceModel->colors,
'bit_depth' => $deviceModel->bit_depth,
'scale_factor' => $deviceModel->scale_factor,
'rotation' => $deviceModel->rotation,
'mime_type' => $deviceModel->mime_type,
'offset_x' => $deviceModel->offset_x,
'offset_y' => $deviceModel->offset_y,
'image_format' => self::determineImageFormatFromModel($deviceModel),
'use_model_settings' => true,
];
}
// Default settings if no device model provided
return [
'width' => 800,
'height' => 480,
'colors' => 2,
'bit_depth' => 1,
'scale_factor' => 1.0,
'rotation' => 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
'image_format' => ImageFormat::AUTO->value,
'use_model_settings' => false,
];
}
/**
* Determine the appropriate ImageFormat based on DeviceModel settings
*/

View file

@ -17,6 +17,34 @@ 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
*
@ -47,32 +75,55 @@ class PluginImportService
$zip->extractTo($tempDir);
$zip->close();
// Find the required files (settings.yml and full.liquid/full.blade.php)
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php
if (! $filePaths['settingsYamlPath']) {
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
}
// Validate that we have at least one template file
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
}
// Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings);
// Read full.liquid content
$fullLiquid = File::get($filePaths['fullLiquidPath']);
// Prepend shared.liquid content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
// Determine which template file to use and read its content
$templatePath = null;
$markupLanguage = 'blade';
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
if ($filePaths['fullLiquidPath']) {
$templatePath = $filePaths['fullLiquidPath'];
$fullLiquid = File::get($templatePath);
// Prepend shared.liquid or shared.blade.php content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$sharedBlade = File::get($filePaths['sharedBladePath']);
$fullLiquid = $sharedBlade."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade';
}
// Ensure custom_fields is properly formatted
@ -144,11 +195,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);
@ -176,32 +228,55 @@ class PluginImportService
$zip->extractTo($tempDir);
$zip->close();
// Find the required files (settings.yml and full.liquid/full.blade.php)
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
if (! $filePaths['settingsYamlPath']) {
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
}
// Validate that we have at least one template file
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
}
// Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings);
// Read full.liquid content
$fullLiquid = File::get($filePaths['fullLiquidPath']);
// Prepend shared.liquid content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
// Determine which template file to use and read its content
$templatePath = null;
$markupLanguage = 'blade';
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
if ($filePaths['fullLiquidPath']) {
$templatePath = $filePaths['fullLiquidPath'];
$fullLiquid = File::get($templatePath);
// Prepend shared.liquid or shared.blade.php content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$sharedBlade = File::get($filePaths['sharedBladePath']);
$fullLiquid = $sharedBlade."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade';
}
// Ensure custom_fields is properly formatted
@ -217,17 +292,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,
@ -272,6 +356,7 @@ class PluginImportService
$settingsYamlPath = null;
$fullLiquidPath = null;
$sharedLiquidPath = null;
$sharedBladePath = null;
// If zipEntryPath is specified, look for files in that specific directory first
if ($zipEntryPath) {
@ -289,6 +374,8 @@ class PluginImportService
if (File::exists($targetDir.'/shared.liquid')) {
$sharedLiquidPath = $targetDir.'/shared.liquid';
} elseif (File::exists($targetDir.'/shared.blade.php')) {
$sharedBladePath = $targetDir.'/shared.blade.php';
}
}
@ -304,15 +391,18 @@ class PluginImportService
if (File::exists($targetDir.'/src/shared.liquid')) {
$sharedLiquidPath = $targetDir.'/src/shared.liquid';
} elseif (File::exists($targetDir.'/src/shared.blade.php')) {
$sharedBladePath = $targetDir.'/src/shared.blade.php';
}
}
// If we found the required files in the target directory, return them
if ($settingsYamlPath && $fullLiquidPath) {
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
return [
'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath,
];
}
}
@ -329,9 +419,11 @@ class PluginImportService
$fullLiquidPath = $tempDir.'/src/full.blade.php';
}
// Check for shared.liquid in the same directory
// Check for shared.liquid or shared.blade.php in the same directory
if (File::exists($tempDir.'/src/shared.liquid')) {
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
$sharedBladePath = $tempDir.'/src/shared.blade.php';
}
} else {
// Search for the files in the extracted directory structure
@ -348,20 +440,24 @@ class PluginImportService
$fullLiquidPath = $filepath;
} elseif ($filename === 'shared.liquid') {
$sharedLiquidPath = $filepath;
} elseif ($filename === 'shared.blade.php') {
$sharedBladePath = $filepath;
}
}
// Check if shared.liquid exists in the same directory as full.liquid
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) {
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
$fullLiquidDir = dirname((string) $fullLiquidPath);
if (File::exists($fullLiquidDir.'/shared.liquid')) {
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
} elseif (File::exists($fullLiquidDir.'/shared.blade.php')) {
$sharedBladePath = $fullLiquidDir.'/shared.blade.php';
}
}
// If we found the files but they're not in the src folder,
// check if they're in the root of the ZIP or in a subfolder
if ($settingsYamlPath && $fullLiquidPath) {
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
// If the files are in the root of the ZIP, create a src folder and move them there
$srcDir = dirname((string) $settingsYamlPath);
@ -372,17 +468,25 @@ class PluginImportService
// Copy the files to the src directory
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
File::copy($fullLiquidPath, $newSrcDir.'/full.liquid');
// Copy shared.liquid if it exists
// Copy full.liquid or full.blade.php if it exists
if ($fullLiquidPath) {
$extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION);
File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension);
$fullLiquidPath = $newSrcDir.'/full.'.$extension;
}
// Copy shared.liquid or shared.blade.php if it exists
if ($sharedLiquidPath) {
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
} elseif ($sharedBladePath) {
File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php');
$sharedBladePath = $newSrcDir.'/shared.blade.php';
}
// Update the paths
$settingsYamlPath = $newSrcDir.'/settings.yml';
$fullLiquidPath = $newSrcDir.'/full.liquid';
}
}
}
@ -391,6 +495,7 @@ class PluginImportService
'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath,
];
}

View file

@ -1,15 +0,0 @@
{
"agents": [
"claude_code",
"copilot",
"cursor",
"phpstorm"
],
"editors": [
"claude_code",
"cursor",
"phpstorm",
"vscode"
],
"guidelines": []
}

146
composer.lock generated
View file

@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.369.6",
"version": "3.369.10",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "b1e1846a4b6593b6916764d86fc0890a31727370"
"reference": "e179090bf2d658be7be37afc146111966ba6f41b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b1e1846a4b6593b6916764d86fc0890a31727370",
"reference": "b1e1846a4b6593b6916764d86fc0890a31727370",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/e179090bf2d658be7be37afc146111966ba6f41b",
"reference": "e179090bf2d658be7be37afc146111966ba6f41b",
"shasum": ""
},
"require": {
@ -153,9 +153,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.369.6"
"source": "https://github.com/aws/aws-sdk-php/tree/3.369.10"
},
"time": "2026-01-02T19:09:23+00:00"
"time": "2026-01-09T19:08:12+00:00"
},
{
"name": "bnussbau/laravel-trmnl-blade",
@ -877,16 +877,16 @@
},
{
"name": "firebase/php-jwt",
"version": "v6.11.1",
"version": "v7.0.2",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66"
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"reference": "d1e91ecf8c598d073d0995afa8cd5c75c6e19e66",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65",
"shasum": ""
},
"require": {
@ -934,9 +934,9 @@
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v6.11.1"
"source": "https://github.com/firebase/php-jwt/tree/v7.0.2"
},
"time": "2025-04-09T20:32:01+00:00"
"time": "2025-12-16T22:17:28+00:00"
},
{
"name": "fruitcake/php-cors",
@ -1678,16 +1678,16 @@
},
{
"name": "laravel/framework",
"version": "v12.44.0",
"version": "v12.46.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "592bbf1c036042958332eb98e3e8131b29102f33"
"reference": "9dcff48d25a632c1fadb713024c952fec489c4ae"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/592bbf1c036042958332eb98e3e8131b29102f33",
"reference": "592bbf1c036042958332eb98e3e8131b29102f33",
"url": "https://api.github.com/repos/laravel/framework/zipball/9dcff48d25a632c1fadb713024c952fec489c4ae",
"reference": "9dcff48d25a632c1fadb713024c952fec489c4ae",
"shasum": ""
},
"require": {
@ -1896,7 +1896,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-12-23T15:29:43+00:00"
"time": "2026-01-07T23:26:53+00:00"
},
{
"name": "laravel/prompts",
@ -1959,16 +1959,16 @@
},
{
"name": "laravel/sanctum",
"version": "v4.2.1",
"version": "v4.2.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664"
"reference": "fd447754d2d3f56950d53b930128af2e3b617de9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/f5fb373be39a246c74a060f2cf2ae2c2145b3664",
"reference": "f5fb373be39a246c74a060f2cf2ae2c2145b3664",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/fd447754d2d3f56950d53b930128af2e3b617de9",
"reference": "fd447754d2d3f56950d53b930128af2e3b617de9",
"shasum": ""
},
"require": {
@ -2018,7 +2018,7 @@
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2025-11-21T13:59:03+00:00"
"time": "2026-01-06T23:11:51+00:00"
},
{
"name": "laravel/serializable-closure",
@ -2083,21 +2083,21 @@
},
{
"name": "laravel/socialite",
"version": "v5.24.0",
"version": "v5.24.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
"reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd"
"reference": "25e28c14d55404886777af1d77cf030e0f633142"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/socialite/zipball/1d19358c28e8951dde6e36603b89d8f09e6cfbfd",
"reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd",
"url": "https://api.github.com/repos/laravel/socialite/zipball/25e28c14d55404886777af1d77cf030e0f633142",
"reference": "25e28c14d55404886777af1d77cf030e0f633142",
"shasum": ""
},
"require": {
"ext-json": "*",
"firebase/php-jwt": "^6.4",
"firebase/php-jwt": "^6.4|^7.0",
"guzzlehttp/guzzle": "^6.0|^7.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
@ -2151,20 +2151,20 @@
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
"time": "2025-12-09T15:37:06+00:00"
"time": "2026-01-01T02:57:21+00:00"
},
{
"name": "laravel/tinker",
"version": "v2.10.2",
"version": "v2.11.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/tinker.git",
"reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c"
"reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/tinker/zipball/3bcb5f62d6f837e0f093a601e26badafb127bd4c",
"reference": "3bcb5f62d6f837e0f093a601e26badafb127bd4c",
"url": "https://api.github.com/repos/laravel/tinker/zipball/3d34b97c9a1747a81a3fde90482c092bd8b66468",
"reference": "3d34b97c9a1747a81a3fde90482c092bd8b66468",
"shasum": ""
},
"require": {
@ -2173,7 +2173,7 @@
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0",
"php": "^7.2.5|^8.0",
"psy/psysh": "^0.11.1|^0.12.0",
"symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0"
"symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"mockery/mockery": "~1.3.3|^1.4.2",
@ -2215,9 +2215,9 @@
],
"support": {
"issues": "https://github.com/laravel/tinker/issues",
"source": "https://github.com/laravel/tinker/tree/v2.10.2"
"source": "https://github.com/laravel/tinker/tree/v2.11.0"
},
"time": "2025-11-20T16:29:12+00:00"
"time": "2025-12-19T19:16:45+00:00"
},
{
"name": "league/commonmark",
@ -8096,16 +8096,16 @@
"packages-dev": [
{
"name": "brianium/paratest",
"version": "v7.16.0",
"version": "v7.16.1",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
"reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6"
"reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/a10878ed0fe0bbc2f57c980f7a08065338b970b6",
"reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b",
"reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b",
"shasum": ""
},
"require": {
@ -8116,10 +8116,10 @@
"fidry/cpu-core-counter": "^1.3.0",
"jean85/pretty-package-versions": "^2.1.1",
"php": "~8.3.0 || ~8.4.0 || ~8.5.0",
"phpunit/php-code-coverage": "^12.5.1",
"phpunit/php-code-coverage": "^12.5.2",
"phpunit/php-file-iterator": "^6",
"phpunit/php-timer": "^8",
"phpunit/phpunit": "^12.5.2",
"phpunit/phpunit": "^12.5.4",
"sebastian/environment": "^8.0.3",
"symfony/console": "^7.3.4 || ^8.0.0",
"symfony/process": "^7.3.4 || ^8.0.0"
@ -8131,7 +8131,7 @@
"ext-posix": "*",
"phpstan/phpstan": "^2.1.33",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.10",
"phpstan/phpstan-phpunit": "^2.0.11",
"phpstan/phpstan-strict-rules": "^2.0.7",
"symfony/filesystem": "^7.3.2 || ^8.0.0"
},
@ -8173,7 +8173,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.16.0"
"source": "https://github.com/paratestphp/paratest/tree/v7.16.1"
},
"funding": [
{
@ -8185,7 +8185,7 @@
"type": "paypal"
}
],
"time": "2025-12-09T20:03:26+00:00"
"time": "2026-01-08T07:23:06+00:00"
},
{
"name": "doctrine/deprecations",
@ -8674,16 +8674,16 @@
},
{
"name": "laravel/boost",
"version": "v1.8.7",
"version": "v1.8.9",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
"reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c"
"reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/7a5709a8134ed59d3e7f34fccbd74689830e296c",
"reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c",
"url": "https://api.github.com/repos/laravel/boost/zipball/1f2c2d41b5216618170fb6730ec13bf894c5bffd",
"reference": "1f2c2d41b5216618170fb6730ec13bf894c5bffd",
"shasum": ""
},
"require": {
@ -8736,20 +8736,20 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
"time": "2025-12-19T15:04:12+00:00"
"time": "2026-01-07T18:43:11+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.5.1",
"version": "v0.5.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4"
"reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4",
"reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4",
"url": "https://api.github.com/repos/laravel/mcp/zipball/b9bdd8d6f8b547c8733fe6826b1819341597ba3c",
"reference": "b9bdd8d6f8b547c8733fe6826b1819341597ba3c",
"shasum": ""
},
"require": {
@ -8809,7 +8809,7 @@
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2025-12-17T06:14:23+00:00"
"time": "2025-12-19T19:32:34+00:00"
},
{
"name": "laravel/pail",
@ -8892,16 +8892,16 @@
},
{
"name": "laravel/pint",
"version": "v1.26.0",
"version": "v1.27.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
"reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f"
"reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/pint/zipball/69dcca060ecb15e4b564af63d1f642c81a241d6f",
"reference": "69dcca060ecb15e4b564af63d1f642c81a241d6f",
"url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90",
"reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90",
"shasum": ""
},
"require": {
@ -8912,9 +8912,9 @@
"php": "^8.2.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.90.0",
"illuminate/view": "^12.40.1",
"larastan/larastan": "^3.8.0",
"friendsofphp/php-cs-fixer": "^3.92.4",
"illuminate/view": "^12.44.0",
"larastan/larastan": "^3.8.1",
"laravel-zero/framework": "^12.0.4",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.3.3",
@ -8955,7 +8955,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
"time": "2025-11-25T21:15:52+00:00"
"time": "2026-01-05T16:49:17+00:00"
},
{
"name": "laravel/roster",
@ -9020,16 +9020,16 @@
},
{
"name": "laravel/sail",
"version": "v1.51.0",
"version": "v1.52.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
"reference": "1c74357df034e869250b4365dd445c9f6ba5d068"
"reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/1c74357df034e869250b4365dd445c9f6ba5d068",
"reference": "1c74357df034e869250b4365dd445c9f6ba5d068",
"url": "https://api.github.com/repos/laravel/sail/zipball/64ac7d8abb2dbcf2b76e61289451bae79066b0b3",
"reference": "64ac7d8abb2dbcf2b76e61289451bae79066b0b3",
"shasum": ""
},
"require": {
@ -9079,7 +9079,7 @@
"issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail"
},
"time": "2025-12-09T13:33:49+00:00"
"time": "2026-01-01T02:46:03+00:00"
},
{
"name": "mockery/mockery",
@ -11806,16 +11806,16 @@
},
{
"name": "webmozart/assert",
"version": "2.0.0",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
"reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54"
"reference": "bdbabc199a7ba9965484e4725d66170e5711323b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/1b34b004e35a164bc5bb6ebd33c844b2d8069a54",
"reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54",
"url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b",
"reference": "bdbabc199a7ba9965484e4725d66170e5711323b",
"shasum": ""
},
"require": {
@ -11862,9 +11862,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
"source": "https://github.com/webmozarts/assert/tree/2.0.0"
"source": "https://github.com/webmozarts/assert/tree/2.1.1"
},
"time": "2025-12-16T21:36:00+00:00"
"time": "2026-01-08T11:28:40+00:00"
}
],
"aliases": [],

6
config/trustedproxy.php Normal file
View file

@ -0,0 +1,6 @@
<?php
return [
// Commaseparated list from .env, e.g. "10.0.0.0/8,172.16.0.0/12" or '*'
'proxies' => ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)),
];

View file

@ -0,0 +1,33 @@
<?php
use App\Models\DeviceModel;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->string('kind')->nullable()->index();
});
// Set existing og_png and og_plus to kind "trmnl"
DeviceModel::whereIn('name', ['og_png', 'og_plus'])->update(['kind' => 'trmnl']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->dropIndex(['kind']);
$table->dropColumn('kind');
});
}
};

View file

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Find and handle duplicate (user_id, trmnlp_id) combinations
$duplicates = DB::table('plugins')
->select('user_id', 'trmnlp_id', DB::raw('COUNT(*) as count'))
->whereNotNull('trmnlp_id')
->groupBy('user_id', 'trmnlp_id')
->having('count', '>', 1)
->get();
// For each duplicate combination, keep the first one (by id) and set others to null
foreach ($duplicates as $duplicate) {
$plugins = DB::table('plugins')
->where('user_id', $duplicate->user_id)
->where('trmnlp_id', $duplicate->trmnlp_id)
->orderBy('id')
->get();
// Keep the first one, set the rest to null
$keepFirst = true;
foreach ($plugins as $plugin) {
if ($keepFirst) {
$keepFirst = false;
continue;
}
DB::table('plugins')
->where('id', $plugin->id)
->update(['trmnlp_id' => null]);
}
}
Schema::table('plugins', function (Blueprint $table) {
$table->unique(['user_id', 'trmnlp_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropUnique(['user_id', 'trmnlp_id']);
});
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->boolean('alias')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn('alias');
});
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

521
public/mirror/index.html Normal file
View file

@ -0,0 +1,521 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<title>TRMNL BYOS Laravel Mirror</title>
<link rel="manifest" href="/mirror/manifest.json" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-76x76.png" sizes="76x76" />
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-120x120.png" sizes="120x120" />
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-152x152.png" sizes="152x152" />
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-167x167.png" sizes="167x167" />
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-180x180.png" sizes="180x180" />
<link rel="icon" type="image/png" href="/mirror/assets/favicon-16x16.png" sizes="16x16" />
<link rel="icon" type="image/png" href="/mirror/assets/favicon-32x32.png" sizes="32x32" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<script>
var trmnl = {
STORAGE_KEY: "byos_laravel_mirror_settings",
refreshTimer: null,
renderedAt: 0,
ui: {},
showStatus: function (message) {
trmnl.ui.img.style.display = "none";
trmnl.ui.errorContainer.style.display = "flex";
trmnl.ui.errorMessage.textContent = message;
},
showScreen: function (src) {
trmnl.ui.img.src = src;
trmnl.ui.img.style.display = "block";
trmnl.ui.errorContainer.style.display = "none";
},
showSetupForm: function () {
var data = trmnl.getSettings();
trmnl.ui.apiKeyInput.value = data.api_key || "";
trmnl.ui.baseURLInput.value = data.base_url || "";
trmnl.ui.macAddressInput.value = data.mac_address || "";
trmnl.ui.displayModeSelect.value = data.display_mode || "";
trmnl.ui.setup.style.display = "flex";
},
saveSetup: function (event) {
event.preventDefault();
var apiKey = trmnl.ui.apiKeyInput.value;
var baseURL = trmnl.ui.baseURLInput.value;
var macAddress = trmnl.ui.macAddressInput.value;
var displayMode = trmnl.ui.displayModeSelect.value;
if (!apiKey) {
return;
}
trmnl.saveSettings({
api_key: apiKey,
base_url: baseURL,
mac_address: macAddress,
display_mode: displayMode
});
trmnl.fetchDisplay();
},
hideSetupForm: function () {
trmnl.ui.setup.style.display = "none";
},
fetchDisplay: function (opts) {
opts = opts || {};
clearTimeout(trmnl.refreshTimer);
if (!opts.quiet) {
trmnl.hideSetupForm();
trmnl.showStatus("Loading...");
}
var setup = trmnl.getSettings();
var apiKey = setup.api_key;
var displayMode = setup.display_mode;
var baseURL = setup.base_url || "https://your-byos-trmnl.com";
var macAddress = setup.mac_address || "00:00:00:00:00:01";
document.body.classList.remove("dark", "night")
if (displayMode) {
document.body.classList.add(displayMode)
}
var headers = {
"Access-Token": apiKey,
"id": macAddress
};
var url = baseURL + "/api/display";
var xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
for (var headerName in headers) {
if (headers.hasOwnProperty(headerName)) {
xhr.setRequestHeader(headerName, headers[headerName]);
}
}
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
try {
var data = JSON.parse(xhr.responseText);
console.log("Display response:", data);
if (data.status !== 0) {
trmnl.showStatus(
"Error: " + (data.error || data.message || data.status)
);
return;
}
trmnl.showScreen(data.image_url);
trmnl.renderedAt = new Date();
if (data.refresh_rate) {
var refreshRate = 30;
refreshRate = data.refresh_rate;
console.log("Refreshing in " + refreshRate + " seconds...");
trmnl.refreshTimer = setTimeout(
function () { trmnl.fetchDisplay({ quiet: true }); },
1000 * refreshRate
);
}
} catch (e) {
trmnl.showStatus("Error processing response: " + e.message);
}
} else {
trmnl.showStatus(
"Failed to fetch screen: " + xhr.status + " " + xhr.statusText
);
}
};
xhr.onerror = function () {
trmnl.showStatus("Network error: Failed to connect or request was blocked.");
};
xhr.send();
},
getSettings: function () {
try {
return JSON.parse(localStorage.getItem(trmnl.STORAGE_KEY)) || {};
} catch (e) {
return {};
}
},
saveSettings: function (data) {
var settings = trmnl.getSettings();
for (var key in data) {
if (data.hasOwnProperty(key)) {
settings[key] = data[key];
}
}
localStorage.setItem(trmnl.STORAGE_KEY, JSON.stringify(settings));
console.log("Settings saved:", settings);
},
cleanUrl: function () {
if (window.history && window.history.replaceState) {
try {
window.history.replaceState(
{},
document.title,
window.location.pathname
);
} catch (e) {
// iOS 9 / UIWebView: silent ignore
}
}
},
applySettingsFromUrl: function () {
var query = window.location.search.substring(1);
if (!query) return;
var pairs = query.split("&");
var newSettings = {};
var hasOverrides = false;
for (var i = 0; i < pairs.length; i++) {
var parts = pairs[i].split("=");
if (parts.length !== 2) continue;
var key = decodeURIComponent(parts[0]);
var value = decodeURIComponent(parts[1]);
if (key === "api_key" && value) {
newSettings.api_key = value;
hasOverrides = true;
}
if (key === "base_url" && value) {
newSettings.base_url = value;
hasOverrides = true;
}
if (key === "mac_address" && value) {
newSettings.mac_address = value;
hasOverrides = true;
}
}
if (hasOverrides) {
trmnl.saveSettings(newSettings);
console.log("Settings overridden from URL:", newSettings);
}
},
setDefaultBaseUrlIfMissing: function () {
var settings = trmnl.getSettings();
if (settings && settings.base_url) {
return;
}
var protocol = window.location.protocol;
var host = window.location.hostname;
var port = window.location.port;
var origin = protocol + "//" + host;
if (port) {
origin += ":" + port;
}
trmnl.saveSettings({
base_url: origin
});
console.log("Default base_url set to:", origin);
},
clearSettings: function () {
try {
localStorage.removeItem(trmnl.STORAGE_KEY);
} catch (e) {
// fallback ultra-safe
localStorage.setItem(trmnl.STORAGE_KEY, "{}");
}
console.log("Settings cleared");
window.location.reload();
},
init: function () {
// override settings from GET params
trmnl.applySettingsFromUrl();
trmnl.cleanUrl();
// default base_url
trmnl.setDefaultBaseUrlIfMissing();
// screen
trmnl.ui.img = document.getElementById("screen");
trmnl.ui.errorContainer = document.getElementById("error-container");
trmnl.ui.errorMessage = document.getElementById("error-message");
// settings
trmnl.ui.apiKeyInput = document.getElementById("api_key");
trmnl.ui.baseURLInput = document.getElementById("base_url");
trmnl.ui.macAddressInput = document.getElementById("mac_address");
trmnl.ui.displayModeSelect = document.getElementById("display_mode");
trmnl.ui.setup = document.getElementById("setup");
var settings = trmnl.getSettings();
if (!settings || !settings.api_key) {
trmnl.showSetupForm();
} else {
trmnl.fetchDisplay();
}
}
};
document.addEventListener("DOMContentLoaded", function () {
trmnl.init();
});
</script>
<style>
body {
overflow: hidden;
font-family: sans-serif;
margin: 0;
padding: 0;
}
a {
color: #f54900;
}
#screen-container,
#setup {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
overflow-y: scroll;
}
#setup {
background-color: #3d3d3e;
}
#setup-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #ffffff;
padding: 2em;
margin: 1em;
border-radius: 1em;
box-shadow: 0 0.5em 2em rgba(0, 0, 0, 1);
}
#setup-panel img {
margin-bottom: 2em;
}
#screen {
cursor: pointer;
width: 100vw;
height: 100vh;
object-fit: contain;
background-color: #000000;
z-index: 1;
}
body.dark #screen,
body.night #screen {
filter: invert(1) hue-rotate(180deg);
background-color: #ffffff;
}
#red-overlay {
background-color: #ff0000;
mix-blend-mode: darken;
display: none;
}
body.night #red-overlay {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
pointer-events: none;
}
#error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.dark #error-container,
.dark #screen-container,
.night #error-container,
.night #screen-container {
background-color: #000000;
color: #ffffff;
}
#error-message {
font-size: 1.5em;
margin-bottom: 1em;
}
#setup {
z-index: 2;
}
.form-control {
font-size: 1.25em;
width: 14em;
padding: 0.5em;
border: 1px solid #ccc;
border-radius: 0.5em;
display: block;
}
label,
summary {
font-size: 1.25em;
margin-bottom: 0.5em;
cursor: pointer;
}
label {
display: block;
}
fieldset {
border: none;
margin: 0;
padding: 0;
margin-bottom: 2em;
}
.btn {
font-size: 1.25em;
padding: 0.5em 1em;
background-color: #f54900;
color: white;
border: none;
border-radius: 0.5em;
cursor: pointer;
display: block;
width: 100%;
}
.btn-clear {
margin-top: 1em;
background-color: #777;
}
#error-container .btn {
margin-left: 0.5em;
margin-right: 0.5em;
}
.night #error-container .btn {
color: #000000;
}
select {
display: block;
width: 100%;
font-size: 1.25em;
padding: 0.5em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #ffffff;
}
#unsupported {
color: red;
}
</style>
</head>
<body>
<div id="setup" style="display: none;">
<div id="setup-panel">
<img src="/mirror/assets/logo--brand.svg" alt="TRMNL Logo" />
<form onsubmit="return trmnl.saveSetup(event)">
<fieldset>
<label for="mac_address">Device MAC Address</label>
<input name="mac_address" id="mac_address" type="text" placeholder="00:00:00:00:00:01" class="form-control"
required />
</fieldset>
<fieldset>
<label for="api_key">Device API Key</label>
<input name="api_key" id="api_key" type="text" placeholder="API Key" class="form-control" required />
</fieldset>
<fieldset>
<select id="display_mode" name="display_mode">
<option value="" selected="selected">Light Mode</option>
<option value="dark">Dark Mode</option>
<option value="night">Night Mode</option>
</select>
</fieldset>
<fieldset>
<label for="base_url">Custom Server URL</label>
<input name="base_url" id="base_url" type="text" placeholder="https://your-byos-trmnl.com"
class="form-control" value="" />
</fieldset>
<button class="btn">Save</button>
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()">
Clear settings
</button>
</form>
</div>
</div>
<div id="screen-container">
<div id="red-overlay"></div>
<img id="screen" onclick="trmnl.showSetupForm()" style="display: none;">
<div id="error-container" style="display: none">
<div id="error-message"></div>
<div style="display: flex; margin-top: 1em">
<button class="btn" onclick="trmnl.showSetupForm()">Setup</button>
<button class="btn" onclick="trmnl.fetchDisplay()">Retry</button>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,7 @@
{
"name": "TRMNL BYOS Laravel Mirror",
"short_name": "TRMNL BYOS",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff"
}

View file

@ -72,3 +72,39 @@ select:focus[data-flux-control] {
/* \[:where(&)\]:size-4 {
@apply size-4;
} */
@layer components {
/* standard container for app */
.styled-container,
.tab-button {
@apply rounded-xl border bg-white dark:bg-stone-950 dark:border-zinc-700 text-stone-800 shadow-xs;
}
.tab-button {
@apply flex items-center gap-2 px-4 py-2 text-sm font-medium;
@apply rounded-b-none shadow-none bg-inherit;
/* This makes the button sit slightly over the box border */
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab-button.is-active {
@apply text-zinc-700 dark:text-zinc-300;
@apply border-b-white dark:border-b-zinc-800;
/* Z-index 10 ensures the bottom border of the tab hides the top border of the box */
z-index: 10;
}
.tab-button:not(.is-active) {
@apply text-zinc-500 border-transparent;
}
.tab-button:not(.is-active):hover {
@apply text-zinc-700 dark:text-zinc-300;
@apply border-zinc-300 dark:border-zinc-700;
cursor: pointer;
}
}

View file

@ -15,7 +15,7 @@
</a>
<div class="flex flex-col gap-6">
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="styled-container">
<div class="px-10 py-8">{{ $slot }}</div>
</div>
</div>

View file

@ -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');

View file

@ -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');

View file

@ -16,7 +16,7 @@ new class extends Component {
@if($devices->isEmpty())
<div class="flex flex-col gap-6">
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
class="styled-container">
<div class="px-10 py-8">
<h1 class="text-xl font-medium dark:text-zinc-200">Add your first device</h1>
<flux:button href="{{ route('devices') }}" class="mt-4" icon="plus-circle" variant="primary"
@ -30,7 +30,7 @@ new class extends Component {
@foreach($devices as $device)
<div class="flex flex-col gap-6">
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
class="styled-container">
<div class="px-10 py-8">
@php
$current_image_uuid =$device->current_screen_image;

View file

@ -309,7 +309,7 @@ new class extends Component {
<div class="bg-muted flex flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex flex-col gap-6">
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
class="styled-container">
<div class="px-10 py-8">
@php
$current_image_uuid =$device->current_screen_image;
@ -368,6 +368,10 @@ new class extends Component {
<flux:menu.item icon="arrow-up-circle">Update Firmware</flux:menu.item>
</flux:modal.trigger>
<flux:menu.item icon="bars-3" href="{{ route('devices.logs', $device) }}" wire:navigate>Show Logs</flux:menu.item>
<flux:modal.trigger name="mirror-url">
<flux:menu.item icon="link">Mirror URL</flux:menu.item>
</flux:modal.trigger>
<flux:menu.separator/>
<flux:modal.trigger name="delete-device">
<flux:menu.item icon="trash" variant="danger">Delete Device</flux:menu.item>
</flux:modal.trigger>
@ -498,6 +502,26 @@ new class extends Component {
</flux:modal>
<flux:modal name="mirror-url" class="md:w-96">
@php
$mirrorUrl = url('/mirror/index.html') . '?mac_address=' . urlencode($device->mac_address) . '&api_key=' . urlencode($device->api_key);
@endphp
<div class="space-y-6">
<div>
<flux:heading size="lg">Mirror WebUI</flux:heading>
<flux:subheading>Mirror this device onto older devices with a web browser Safari is supported back to iOS 9.</flux:subheading>
</div>
<flux:input
label="Mirror URL"
value="{{$mirrorUrl}}"
readonly
copyable
/>
</div>
</flux:modal>
@if(!$device->mirror_device_id)
@if($current_image_path)
<flux:separator class="mt-6 mb-6" text="Screen"/>

View file

@ -332,7 +332,7 @@ new class extends Component {
@endforeach
@if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty()))
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<div class="styled-container">
<div class="px-10 py-8">
<h1 class="text-xl font-medium dark:text-zinc-200">No playlists found</h1>
<p class="text-sm dark:text-zinc-400 mt-2">Add playlists to your devices to see them here.</p>

View file

@ -0,0 +1,516 @@
<?php
use App\Models\Plugin;
use Livewire\Volt\Component;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/*
* This component contains the configuation modal
*/
new class extends Component {
public Plugin $plugin;
public array $configuration_template = [];
public array $configuration = []; // holds config data
public array $multiValues = []; // UI boxes for multi_string
public array $xhrSelectOptions = [];
public array $searchQueries = [];
// ------------------------------------This section contains one-off functions for the form------------------------------------------------
public function mount(): void
{
$this -> 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);
}
}
};?>
<flux:modal name="configuration-modal" @close="resetForm" class="md:w-96">
<div wire:key="config-form-{{ $resetIndex }}" class="space-y-6">
<div class="space-y-6">
<div>
<flux:heading size="lg">Configuration</flux:heading>
<flux:subheading>Configure your plugin settings</flux:subheading>
</div>
<form wire:submit="saveConfiguration">
@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
<div class="mb-4">
@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')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'text')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:textarea
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'code')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:textarea
rows="{{ $field['rows'] ?? 3 }}"
placeholder="{{ $field['placeholder'] ?? null }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
class="font-mono"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'password')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="password"
wire:model="local_configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
viewable
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'copyable')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
value="{{ $field['value'] }}"
copyable
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'time_zone')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select
wire:model="configuration.{{ $fieldKey }}"
value="{{ $field['value'] }}"
>
<option value="">Select timezone...</option>
@foreach(timezone_identifiers_list() as $timezone)
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
@endforeach
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'number')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="number"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'boolean')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:checkbox
wire:model="configuration.{{ $fieldKey }}"
:checked="$currentValue"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'date')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="date"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'time')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="time"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'select')
@if(isset($field['multiple']) && $field['multiple'] === true)
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:checkbox.group wire:model="configuration.{{ $fieldKey }}">
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<flux:checkbox label="{{ $option }}" value="{{ $key }}"/>
@endif
@endforeach
@endif
</flux:checkbox.group>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@else
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select wire:model="configuration.{{ $fieldKey }}">
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@endif
@elseif($field['field_type'] === 'xhrSelect')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select
wire:model="configuration.{{ $fieldKey }}"
wire:init="loadXhrSelectOptions('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
>
<option value="">Select {{ $field['name'] }}...</option>
@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' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'xhrSelectSearch')
<div class="space-y-2">
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input.group>
<flux:input
wire:model="searchQueries.{{ $fieldKey }}"
placeholder="Enter search query..."
/>
<flux:button
wire:click="searchXhrSelect('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
icon="magnifying-glass"/>
</flux:input.group>
<flux:description>{!! $safeHelp !!}</flux:description>
@if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue))
<flux:select
wire:model="configuration.{{ $fieldKey }}"
>
<option value="">Select {{ $field['name'] }}...</option>
@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' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
@if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey])))
{{-- Show current value even if no options are loaded --}}
<option value="{{ $currentValue }}" selected>{{ $currentValue }}</option>
@endif
</flux:select>
@endif
</div>
@elseif($field['field_type'] === 'multi_string')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<div class="space-y-2 mt-2">
@foreach($multiValues[$fieldKey] as $index => $item)
<div class="flex gap-2 items-center"
wire:key="multi-{{ $fieldKey }}-{{ $index }}">
<flux:input
wire:model.live.debounce="multiValues.{{ $fieldKey }}.{{ $index }}"
:placeholder="$field['placeholder'] ?? 'Value...'"
:invalid="$errors->has('multiValues.'.$fieldKey.'.'.$index)"
class="flex-1"
/>
@if(count($multiValues[$fieldKey]) > 1)
<flux:button
variant="ghost"
icon="trash"
size="sm"
wire:click="removeMultiItem('{{ $fieldKey }}', {{ $index }})"
/>
@endif
</div>
@error("multiValues.{$fieldKey}.{$index}")
<div class="flex items-center gap-2 mt-1 text-amber-600">
<flux:icon name="exclamation-triangle" variant="micro" />
{{-- $message comes from thrown error --}}
<span class="text-xs font-medium">{{ $message }}</span>
</div>
@enderror
@endforeach
<flux:button
variant="ghost"
size="sm"
icon="plus"
wire:click="addMultiItem('{{ $fieldKey }}')"
>
Add Item
</flux:button>
</div>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@else
<flux:callout variant="warning">Field type "{{ $field['field_type'] }}" not yet supported</flux:callout>
@endif
</div>
@endforeach
@endif
<div class="flex-col space-y-2 items-end w-full">
<flux:spacer/>
<flux:button
type="submit"
variant="primary"
:disabled="$errors->any()"
class="disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale"
>
Save Configuration
</flux:button>
@if($errors->any())
<div class="flex items-center gap-2 text-amber-600">
<flux:icon name="exclamation-circle" variant="micro" />
<span class="text-sm font-medium">
Fix errors before saving.
</span>
</div>
@endif
</div>
</form>
</div>
</div>
</flux:modal>

View file

@ -395,7 +395,7 @@ new class extends Component {
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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
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">

View file

@ -1,12 +1,16 @@
<?php
use App\Models\Device;
use App\Models\Plugin;
use App\Models\DeviceModel;
use Illuminate\Support\Carbon;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Livewire\Volt\Component;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\On;
use Livewire\Attributes\Computed;
new class extends Component {
public Plugin $plugin;
@ -34,17 +38,15 @@ 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 ?int $preview_device_model_id = null;
public string $preview_size = 'full';
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 {
@ -76,24 +78,11 @@ 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)))
: [''];
// Set default preview device model
if ($this->preview_device_model_id === null) {
$defaultModel = DeviceModel::where('name', 'og_plus')->first() ?? DeviceModel::first();
$this->preview_device_model_id = $defaultModel?->id;
}
}
public function fillFormFields(): void
@ -287,47 +276,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();
@ -348,8 +296,6 @@ new class extends Component {
return $this->configuration[$key] ?? $default;
}
public function renderExample(string $example)
{
switch ($example) {
@ -418,13 +364,17 @@ HTML;
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->preview_size = $size;
// If data strategy is polling and data_payload is null, fetch the data first
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
$this->updateData();
}
try {
$previewMarkup = $this->plugin->render($size);
// Create a device object with og_plus model and the selected bitdepth
$device = $this->createPreviewDevice();
$previewMarkup = $this->plugin->render($size, true, $device);
$this->dispatch('preview-updated', preview: $previewMarkup);
} catch (LiquidException $e) {
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
@ -433,6 +383,38 @@ HTML;
}
}
private function createPreviewDevice(): \App\Models\Device
{
$deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id)
?? DeviceModel::with(['palette'])->first();
$device = new Device();
$device->setRelation('deviceModel', $deviceModel);
return $device;
}
public function getDeviceModels()
{
return DeviceModel::whereKind('trmnl')->orderBy('label')->get();
}
public function updatedPreviewDeviceModelId(): void
{
$this->renderPreview($this->preview_size);
}
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);
@ -440,58 +422,31 @@ 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] = [];
}
// Laravel Livewire computed property: access with $this->parsed_urls
#[Computed]
private function parsedUrls()
{
if (!isset($this->polling_url)) {
return null;
}
public function searchXhrSelect(string $fieldKey, string $endpoint): void
{
$query = $this->searchQueries[$fieldKey] ?? '';
if (!empty($query)) {
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
}
try {
return $this->plugin->resolveLiquidVariables($this->polling_url);
} catch (\Exception $e) {
return 'PARSE_ERROR: ' . $e->getMessage();
}
}
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][] = '';
}
}
}
?>
<div class="py-12">
@ -523,7 +478,6 @@ HTML;
</flux:modal.trigger>
</flux:menu>
</flux:dropdown>
</flux:button.group>
<flux:button.group>
<flux:modal.trigger name="add-to-playlist">
@ -533,6 +487,11 @@ HTML;
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:modal.trigger name="trmnlp-settings">
<flux:menu.item icon="cog">Recipe Settings</flux:menu.item>
</flux:modal.trigger>
<flux:menu.separator />
<flux:menu.item icon="document-duplicate" wire:click="duplicatePlugin">Duplicate Plugin</flux:menu.item>
<flux:modal.trigger name="delete-plugin">
<flux:menu.item icon="trash" variant="danger">Delete Plugin</flux:menu.item>
</flux:modal.trigger>
@ -674,8 +633,15 @@ HTML;
</flux:modal>
<flux:modal name="preview-plugin" class="min-w-[850px] min-h-[480px] space-y-6">
<div>
<div class="flex items-center gap-4">
<flux:heading size="lg">Preview {{ $plugin->name }}</flux:heading>
<flux:field class="w-48">
<flux:select wire:model.live="preview_device_model_id">
@foreach($this->getDeviceModels() as $model)
<option value="{{ $model->id }}">{{ $model->label ?? $model->name }}</option>
@endforeach
</flux:select>
</flux:field>
</div>
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
@ -683,355 +649,9 @@ HTML;
</div>
</flux:modal>
<flux:modal name="configuration-modal" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Configuration</flux:heading>
<flux:subheading>Configure your plugin settings</flux:subheading>
</div>
<livewire:plugins.recipes.settings :plugin="$plugin" />
<form wire:submit="saveConfiguration">
@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
<div class="mb-4">
@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')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'text')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:textarea
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'code')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:textarea
rows="{{ $field['rows'] ?? 3 }}"
placeholder="{{ $field['placeholder'] ?? null }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
class="font-mono"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'password')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="password"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
viewable
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'copyable')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
value="{{ $field['value'] }}"
copyable
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'time_zone')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select
wire:model="configuration.{{ $fieldKey }}"
value="{{ $field['value'] }}"
>
<option value="">Select timezone...</option>
@foreach(timezone_identifiers_list() as $timezone)
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
@endforeach
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'number')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="number"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'boolean')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:checkbox
wire:model="configuration.{{ $fieldKey }}"
:checked="$currentValue"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'date')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="date"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'time')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="time"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'select')
@if(isset($field['multiple']) && $field['multiple'] === true)
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:checkbox.group wire:model="configuration.{{ $fieldKey }}">
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<flux:checkbox label="{{ $option }}" value="{{ $key }}"/>
@endif
@endforeach
@endif
</flux:checkbox.group>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@else
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select wire:model="configuration.{{ $fieldKey }}">
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@endif
@elseif($field['field_type'] === 'xhrSelect')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select
wire:model="configuration.{{ $fieldKey }}"
wire:init="loadXhrSelectOptions('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
>
<option value="">Select {{ $field['name'] }}...</option>
@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' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'xhrSelectSearch')
<div class="space-y-2">
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input.group>
<flux:input
wire:model="searchQueries.{{ $fieldKey }}"
placeholder="Enter search query..."
/>
<flux:button
wire:click="searchXhrSelect('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
icon="magnifying-glass"/>
</flux:input.group>
<flux:description>{!! $safeHelp !!}</flux:description>
@if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue))
<flux:select
wire:model="configuration.{{ $fieldKey }}"
>
<option value="">Select {{ $field['name'] }}...</option>
@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' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
@if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey])))
{{-- Show current value even if no options are loaded --}}
<option value="{{ $currentValue }}" selected>{{ $currentValue }}</option>
@endif
</flux:select>
@endif
</div>
@elseif($field['field_type'] === 'multi_string')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<div class="space-y-2 mt-2">
@foreach($multiValues[$fieldKey] as $index => $item)
<div class="flex gap-2 items-center"
wire:key="multi-{{ $fieldKey }}-{{ $index }}">
<flux:input
wire:model.defer="multiValues.{{ $fieldKey }}.{{ $index }}"
:placeholder="$field['placeholder'] ?? 'Value...'"
class="flex-1"
pattern="[^,]*"
title="Commas are not allowed in this field"
/>
@if(count($multiValues[$fieldKey]) > 1)
<flux:button
variant="ghost"
icon="trash"
size="sm"
wire:click="removeMultiItem('{{ $fieldKey }}', {{ $index }})"
/>
@endif
</div>
@endforeach
<flux:button
variant="ghost"
size="sm"
icon="plus"
wire:click="addMultiItem('{{ $fieldKey }}')"
>
Add Item
</flux:button>
</div>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
{{-- @elseif($field['field_type'] === 'multi_string')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
{{-- <flux:input
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
placeholder="{{ $field['placeholder'] ?? 'value1,value2' }}"
/> --}}
{{-- <flux:description>{!! $safeHelp !!}</flux:description>
</flux:field> --}}
@else
<flux:callout variant="warning">Field type "{{ $field['field_type'] }}" not yet supported</flux:callout>
@endif
</div>
@endforeach
@endif
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Save Configuration</flux:button>
</div>
</form>
</div>
</flux:modal>
<livewire:plugins.config-modal :plugin="$plugin" />
<div class="mt-5 mb-5">
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
@ -1119,7 +739,7 @@ HTML;
@endif
<div class="mb-4">
<flux:modal.trigger name="configuration-modal">
<flux:button icon="cog" class="block mt-1 w-full">Configuration</flux:button>
<flux:button icon="variable" class="block mt-1 w-full">Configuration Fields</flux:button>
</flux:modal.trigger>
</div>
@endif
@ -1132,15 +752,62 @@ HTML;
</div>
@if($data_strategy === 'polling')
<div class="mb-4">
<flux:textarea label="Polling URL" description="You can use configuration variables with Liquid syntax. Supports multiple requests via line break separation" wire:model="polling_url" id="polling_url"
<flux:label>Polling URL</flux:label>
<div x-data="{ subTab: 'settings' }" class="mt-2 mb-4">
<div class="flex">
<button
@click="subTab = 'settings'"
class="tab-button"
:class="subTab === 'settings' ? 'is-active' : ''"
>
<flux:icon.cog-6-tooth class="size-4"/>
Settings
</button>
<button
@click="subTab = 'preview'"
class="tab-button"
:class="subTab === 'preview' ? 'is-active' : ''"
>
<flux:icon.eye class="size-4" />
Preview URL
</button>
</div>
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
<div x-show="subTab === 'settings'">
<flux:field>
<flux:description>Enter the URL(s) to poll for data:</flux:description>
<flux:textarea
wire:model.live="polling_url"
placeholder="https://example.com/api"
class="block w-full" type="text" name="polling_url" autofocus>
</flux:input>
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full">
rows="5"
/>
<flux:description>
{!! 'Hint: Supports multiple requests via line break separation. You can also use configuration variables with <a href="https://help.usetrmnl.com/en/articles/12689499-dynamic-polling-urls">Liquid syntax</a>. ' !!}
</flux:description>
</flux:field>
</div>
<div x-show="subTab === 'preview'" x-cloak>
<flux:field>
<flux:description>Preview computed URLs here (readonly):</flux:description>
<flux:textarea
readonly
placeholder="Nothing to show..."
rows="5"
>
{{ $this->parsed_urls }}
</flux:textarea>
</flux:field>
</div>
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="w-full mt-4">
Fetch data now
</flux:button>
</div>
</div>
<div class="mb-4">
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
@ -1304,9 +971,6 @@ HTML;
</div>
</flux:field>
</div>
@else
<div class="flex items-center gap-6 mb-4 mt-4">

View file

@ -0,0 +1,104 @@
<?php
use App\Models\Plugin;
use Illuminate\Validation\Rule;
use Livewire\Volt\Component;
/*
* This component contains the TRMNL Plugin Settings modal
*/
new class extends Component {
public Plugin $plugin;
public string|null $trmnlp_id = null;
public string|null $uuid = null;
public bool $alias = false;
public int $resetIndex = 0;
public function mount(): void
{
$this->resetErrorBag();
// Reload data
$this->plugin = $this->plugin->fresh();
$this->trmnlp_id = $this->plugin->trmnlp_id;
$this->uuid = $this->plugin->uuid;
$this->alias = $this->plugin->alias ?? false;
}
public function saveTrmnlpId(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->validate([
'trmnlp_id' => [
'nullable',
'string',
'max:255',
Rule::unique('plugins', 'trmnlp_id')
->where('user_id', auth()->id())
->ignore($this->plugin->id),
],
'alias' => 'boolean',
]);
$this->plugin->update([
'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
'alias' => $this->alias,
]);
Flux::modal('trmnlp-settings')->close();
}
public function getAliasUrlProperty(): string
{
return url("/api/display/{$this->uuid}/alias");
}
};?>
<flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6">
<div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6">
<div>
<flux:heading size="lg">Recipe Settings</flux:heading>
</div>
<form wire:submit="saveTrmnlpId">
<div class="grid gap-6">
{{-- <flux:input label="UUID" wire:model="uuid" readonly copyable /> --}}
<flux:field>
<flux:label>TRMNLP Recipe ID</flux:label>
<flux:input
wire:model="trmnlp_id"
placeholder="TRMNL Recipe ID"
/>
<flux:error name="trmnlp_id" />
<flux:description>Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with <code>trmnlp</code>. </flux:description>
</flux:field>
<flux:field>
<flux:checkbox wire:model.live="alias" label="Enable Alias" />
<flux:description>Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. <a href="https://help.usetrmnl.com/en/articles/10701448-alias-plugin">Docs</a></flux:description>
</flux:field>
@if($alias)
<flux:field>
<flux:label>Alias URL</flux:label>
<flux:input
value="{{ $this->aliasUrl }}"
readonly
copyable
/>
<flux:description>Copy this URL to your TRMNL Dashboard. By default, image is created for TRMNL OG; use parameter <code>?device-model=</code> to specify a device model.</flux:description>
</flux:field>
@endif
</div>
<div class="flex gap-2 mt-4">
<flux:spacer/>
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button type="submit" variant="primary">Save</flux:button>
</div>
</form>
</div>
</flux:modal>

View file

@ -3,11 +3,11 @@
<x-trmnl::view size="{{ $size }}">
<x-trmnl::layout>
<x-trmnl::layout class="layout--col">
<div class="b-h-gray-1">{{$data[0]['a']}}</div>
@if (strlen($data[0]['q']) < 300 && $size != 'quadrant')
<p class="value">{{ $data[0]['q'] }}</p>
<div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div>
@if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
<p class="value">{{ $data['data'][0]['q'] ?? '' }}</p>
@else
<p class="value--small">{{ $data[0]['q'] }}</p>
<p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p>
@endif
</x-trmnl::layout>
</x-trmnl::layout>

View file

@ -613,7 +613,7 @@ Route::post('plugin_settings/{uuid}/image', function (Request $request, string $
}
// Generate a new UUID for each image upload to prevent device caching
$imageUuid = \Illuminate\Support\Str::uuid()->toString();
$imageUuid = Str::uuid()->toString();
$filename = $imageUuid.'.'.$extension;
$path = 'images/generated/'.$filename;
@ -678,3 +678,90 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s
],
]);
})->middleware('auth:sanctum');
Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
// Check if alias is active
if (! $plugin->alias) {
return response()->json([
'message' => 'Alias is not active for this plugin',
], 403);
}
// Get device model name from query parameter, default to 'og_png'
$deviceModelName = $request->query('device-model', 'og_png');
$deviceModel = DeviceModel::where('name', $deviceModelName)->first();
if (! $deviceModel) {
return response()->json([
'message' => "Device model '{$deviceModelName}' not found",
], 404);
}
// Check if we can use cached image (only for og_png and if data is not stale)
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
if ($useCache) {
// Return cached image
$imageUuid = $plugin->current_image;
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
$imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension;
// Check if image exists, otherwise fall back to generation
if (Storage::disk('public')->exists($imagePath)) {
return response()->file(Storage::disk('public')->path($imagePath), [
'Content-Type' => $deviceModel->mime_type,
]);
}
}
// Generate new image
try {
// Update data if needed
if ($plugin->isDataStale()) {
$plugin->updateDataPayload();
$plugin->refresh();
}
// Load device model with palette relationship
$deviceModel->load('palette');
// Create a virtual device for rendering (Plugin::render needs a Device object)
$virtualDevice = new Device();
$virtualDevice->setRelation('deviceModel', $deviceModel);
$virtualDevice->setRelation('user', $plugin->user);
$virtualDevice->setRelation('palette', $deviceModel->palette);
// Render the plugin markup
$markup = $plugin->render(device: $virtualDevice);
// Generate image using the new method that doesn't require a device
$imageUuid = ImageGenerationService::generateImageFromModel(
markup: $markup,
deviceModel: $deviceModel,
user: $plugin->user,
palette: $deviceModel->palette
);
// Update plugin cache if using og_png
if ($deviceModelName === 'og_png') {
$plugin->update(['current_image' => $imageUuid]);
}
// Return the generated image
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
$imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension);
return response()->file($imagePath, [
'Content-Type' => $deviceModel->mime_type,
]);
} catch (Exception $e) {
Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
return response()->json([
'message' => 'Failed to generate image',
'error' => $e->getMessage(),
], 500);
}
})->name('api.display.alias');

View file

@ -44,6 +44,7 @@ test('fetch device models job handles successful api response', function (): voi
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
'kind' => 'trmnl',
'published_at' => '2023-01-01T00:00:00Z',
],
],
@ -74,6 +75,7 @@ test('fetch device models job handles successful api response', function (): voi
expect($deviceModel->mime_type)->toBe('image/png');
expect($deviceModel->offset_x)->toBe(0);
expect($deviceModel->offset_y)->toBe(0);
// expect($deviceModel->kind)->toBe('trmnl');
expect($deviceModel->source)->toBe('api');
});
@ -312,6 +314,7 @@ test('fetch device models job handles device model with partial data', function
expect($deviceModel->mime_type)->toBe('');
expect($deviceModel->offset_x)->toBe(0);
expect($deviceModel->offset_y)->toBe(0);
expect($deviceModel->kind)->toBeNull();
expect($deviceModel->source)->toBe('api');
});

View file

@ -0,0 +1,124 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
test('config modal correctly loads multi_string defaults into UI 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',
'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');
});

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Volt\Volt;
uses(RefreshDatabase::class);
test('recipe settings can save trmnlp_id', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'trmnlp_id' => null,
]);
$trmnlpId = (string) Str::uuid();
Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('trmnlp_id', $trmnlpId)
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId);
});
test('recipe settings validates trmnlp_id is unique per user', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$existingPlugin = Plugin::factory()->create([
'user_id' => $user->id,
'trmnlp_id' => 'existing-id-123',
]);
$newPlugin = Plugin::factory()->create([
'user_id' => $user->id,
'trmnlp_id' => null,
]);
Volt::test('plugins.recipes.settings', ['plugin' => $newPlugin])
->set('trmnlp_id', 'existing-id-123')
->call('saveTrmnlpId')
->assertHasErrors(['trmnlp_id' => 'unique']);
expect($newPlugin->fresh()->trmnlp_id)->toBeNull();
});
test('recipe settings allows same trmnlp_id for different users', function (): void {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$plugin1 = Plugin::factory()->create([
'user_id' => $user1->id,
'trmnlp_id' => 'shared-id-123',
]);
$plugin2 = Plugin::factory()->create([
'user_id' => $user2->id,
'trmnlp_id' => null,
]);
$this->actingAs($user2);
Volt::test('plugins.recipes.settings', ['plugin' => $plugin2])
->set('trmnlp_id', 'shared-id-123')
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin2->fresh()->trmnlp_id)->toBe('shared-id-123');
});
test('recipe settings allows same trmnlp_id for the same plugin', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$trmnlpId = (string) Str::uuid();
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'trmnlp_id' => $trmnlpId,
]);
Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('trmnlp_id', $trmnlpId)
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId);
});
test('recipe settings can clear trmnlp_id', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'trmnlp_id' => 'some-id',
]);
Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('trmnlp_id', '')
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin->fresh()->trmnlp_id)->toBeNull();
});

View file

@ -83,19 +83,34 @@ it('throws exception for invalid zip file', function (): void {
->toThrow(Exception::class, 'Could not open the ZIP file.');
});
it('throws exception for missing required files', function (): void {
it('throws exception for missing settings.yml', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
// Missing full.liquid
'src/full.liquid' => getValidFullLiquid(),
// Missing settings.yml
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.');
->toThrow(Exception::class, 'Invalid ZIP structure. Required file settings.yml is missing.');
});
it('throws exception for missing template files', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
// Missing all template files
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
->toThrow(Exception::class, 'Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
});
it('sets default values when settings are missing', function (): void {
@ -427,6 +442,103 @@ 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 = <<<'YAML'
name: Test Plugin
refresh_interval: 30
strategy: static
polling_verb: get
static_data: '{"test": "data"}'
custom_fields:
- keyname: api_key
field_type: multi_string
default: default-api-key1,default-api-key2
label: API Key
YAML;
$zipContent = createMockZipFile([
'src/settings.yml' => $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 = <<<'YAML'
name: Test Plugin
refresh_interval: 30
strategy: static
polling_verb: get
static_data: '{"test": "data"}'
custom_fields:
- keyname: api_key
field_type: multi_string
default: default-api-key
label: API Key
placeholder: "value1, value2"
YAML;
$zipContent = createMockZipFile([
'src/settings.yml' => $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.');
});
it('imports plugin with only shared.liquid file', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/shared.liquid' => '<div class="shared-content">{{ data.title }}</div>',
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->markup_language)->toBe('liquid')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
->and($plugin->render_markup)->toContain('<div class="shared-content">{{ data.title }}</div>');
});
it('imports plugin with only shared.blade.php file', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/shared.blade.php' => '<div class="shared-content">{{ $data["title"] }}</div>',
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->markup_language)->toBe('blade')
->and($plugin->render_markup)->toBe('<div class="shared-content">{{ $data["title"] }}</div>')
->and($plugin->render_markup)->not->toContain('<div class="view view--{{ size }}">');
});
// Helper methods
function createMockZipFile(array $files): string
{

View file

@ -99,6 +99,35 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function ():
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
});
test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
// empty lines and extra spaces between the URL to generate empty entries
'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ",
'polling_verb' => 'get',
]);
// Mock only the valid URLs
Http::fake([
'https://api1.example.com/data' => Http::response(['item' => 'first'], 200),
'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200),
]);
$plugin->updateDataPayload();
// payload should only have 2 items, and they should be indexed 0 and 1
expect($plugin->data_payload)->toHaveCount(2);
expect($plugin->data_payload)->toHaveKey('IDX_0');
expect($plugin->data_payload)->toHaveKey('IDX_1');
// data is correct
expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']);
expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']);
// no empty index exists
expect($plugin->data_payload)->not->toHaveKey('IDX_2');
});
test('updateDataPayload handles single URL without nesting', function (): void {
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
@ -737,3 +766,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' => '<div>Test markup</div>',
'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 = '<div class="test-view">Test Content</div>';
// 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 = '<div class="test-view">{{ data.message }}</div>';
// 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);
});