Compare commits
55 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3032c09778 | ||
|
|
f1903bcbe8 | ||
|
|
621c108e78 | ||
|
|
131d99a2e3 | ||
|
|
7d1e74183d | ||
|
|
3f98a70ad9 | ||
|
|
0d6079db8b | ||
|
|
a86315c5c7 | ||
|
|
887c4d130b | ||
|
|
74e9e1eba3 | ||
|
|
53d4a8399f | ||
|
|
043f683db7 | ||
|
|
36e1ad8441 | ||
|
|
a06a0879ff | ||
|
|
ddce3947c6 | ||
|
|
4bc42cc1d2 | ||
|
|
94d5fca879 | ||
|
|
dc676327c2 | ||
|
|
e3bb9ad4e2 | ||
|
|
e176f2828e | ||
|
|
164a990dfe | ||
|
|
6d02415b7d | ||
|
|
3def60ae3e | ||
|
|
809965e81c | ||
|
|
b855ccffcb | ||
|
|
32dd4c3d08 | ||
|
|
a3f792944c | ||
|
|
3e670d37c0 | ||
|
|
46e792bc6d | ||
|
|
9019561bb3 | ||
|
|
838b4fd33b | ||
|
|
4451361f15 | ||
|
|
265972ac24 | ||
|
|
7f97114f6e | ||
|
|
3250bb0402 | ||
|
|
50853728bc | ||
|
|
3cdc267809 | ||
|
|
1298814521 | ||
|
|
a5cb38421e | ||
|
|
e6d66af298 | ||
|
|
d4b5cf99d5 | ||
|
|
d81c1b99f1 | ||
|
|
0b2b5bf25f | ||
|
|
f1a9103f0d | ||
|
|
d49a2d4f6c | ||
|
|
be2bb637c9 | ||
|
|
f3538048d4 | ||
|
|
a7963947f8 | ||
|
|
b1467204f8 | ||
|
|
fb9469d9cd | ||
|
|
b6faa2f232 | ||
|
|
60f2a38169 | ||
|
|
838db288e7 | ||
|
|
8776c668b4 | ||
|
|
1096118e03 |
|
|
@ -9,7 +9,8 @@ RUN apk add --no-cache composer
|
|||
# Add Chromium and Image Magick for puppeteer.
|
||||
RUN apk add --no-cache \
|
||||
imagemagick-dev \
|
||||
chromium
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
|
@ -19,7 +20,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
|
|||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
# Composer uses its php binary, but we want it to use the container's one
|
||||
RUN rm -f /usr/bin/php84
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ RUN apk add --no-cache \
|
|||
nodejs \
|
||||
npm \
|
||||
imagemagick-dev \
|
||||
chromium
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
|
@ -24,7 +25,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
|
|||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
RUN rm -f /usr/bin/php84
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||
|
|
|
|||
5
.gitignore
vendored
|
|
@ -29,3 +29,8 @@ yarn-error.log
|
|||
/.junie/guidelines.md
|
||||
/CLAUDE.md
|
||||
/.mcp.json
|
||||
/.ai
|
||||
.DS_Store
|
||||
/boost.json
|
||||
/.gemini
|
||||
/GEMINI.md
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
########################
|
||||
# Base Image
|
||||
########################
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium AS base
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
|
||||
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
||||
|
||||
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||
It allows you to manage TRMNL devices, generate screens using **native plugins**, **recipes** (100+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), ~500 from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 25k downloads and 125+ stars, it’s the most popular community-driven BYOS.
|
||||
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.
|
||||
|
||||

|
||||

|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,6 @@ class Data extends FiltersProvider
|
|||
*/
|
||||
public function map_to_i(array $input): array
|
||||
{
|
||||
return array_map('intval', $input);
|
||||
return array_map(intval(...), $input);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@ class Device extends Model
|
|||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Set the MAC address attribute, normalizing to uppercase.
|
||||
*/
|
||||
public function setMacAddressAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null;
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'battery_notification_sent' => 'boolean',
|
||||
'proxy_cloud' => 'boolean',
|
||||
|
|
|
|||
|
|
@ -37,21 +37,32 @@ class Playlist extends Model
|
|||
return false;
|
||||
}
|
||||
|
||||
// Check weekday
|
||||
if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) {
|
||||
// Get user's timezone or fall back to app timezone
|
||||
$timezone = $this->device->user->timezone ?? config('app.timezone');
|
||||
$now = now($timezone);
|
||||
|
||||
// Check weekday (using timezone-aware time)
|
||||
if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->active_from !== null && $this->active_until !== null) {
|
||||
$now = now();
|
||||
// Create timezone-aware datetime objects for active_from and active_until
|
||||
$activeFrom = $now->copy()
|
||||
->setTimeFrom($this->active_from)
|
||||
->timezone($timezone);
|
||||
|
||||
$activeUntil = $now->copy()
|
||||
->setTimeFrom($this->active_until)
|
||||
->timezone($timezone);
|
||||
|
||||
// Handle time ranges that span across midnight
|
||||
if ($this->active_from > $this->active_until) {
|
||||
if ($activeFrom > $activeUntil) {
|
||||
// Time range spans midnight (e.g., 09:01 to 03:58)
|
||||
if ($now >= $this->active_from || $now <= $this->active_until) {
|
||||
if ($now >= $activeFrom || $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
} elseif ($now >= $this->active_from && $now <= $this->active_until) {
|
||||
} elseif ($now >= $activeFrom && $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters;
|
|||
use App\Liquid\Filters\StringMarkup;
|
||||
use App\Liquid\Filters\Uniqueness;
|
||||
use App\Liquid\Tags\TemplateTag;
|
||||
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||
use App\Services\PluginImportService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
|
|
@ -23,10 +24,10 @@ 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;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class Plugin extends Model
|
||||
{
|
||||
|
|
@ -44,6 +45,8 @@ class Plugin extends Model
|
|||
'no_bleed' => 'boolean',
|
||||
'dark_mode' => 'boolean',
|
||||
'preferred_renderer' => 'string',
|
||||
'plugin_type' => 'string',
|
||||
'alias' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
|
|
@ -55,6 +58,18 @@ class Plugin extends Model
|
|||
$model->uuid = Str::uuid();
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($model): void {
|
||||
// Reset image cache when markup changes
|
||||
if ($model->isDirty('render_markup')) {
|
||||
$model->current_image = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize configuration template on save
|
||||
static::saving(function ($model): void {
|
||||
$model->sanitizeTemplate();
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
|
|
@ -62,6 +77,25 @@ class Plugin extends Model
|
|||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
|
||||
protected function sanitizeTemplate(): void
|
||||
{
|
||||
$template = $this->configuration_template;
|
||||
|
||||
if (isset($template['custom_fields']) && is_array($template['custom_fields'])) {
|
||||
foreach ($template['custom_fields'] as &$field) {
|
||||
if (isset($field['description'])) {
|
||||
$field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']);
|
||||
}
|
||||
if (isset($field['help_text'])) {
|
||||
$field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->configuration_template = $template;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasMissingRequiredConfigurationFields(): bool
|
||||
{
|
||||
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
||||
|
|
@ -102,6 +136,11 @@ class Plugin extends Model
|
|||
|
||||
public function isDataStale(): bool
|
||||
{
|
||||
// Image webhook plugins don't use data staleness - images are pushed directly
|
||||
if ($this->plugin_type === 'image_webhook') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->data_strategy === 'webhook') {
|
||||
// Treat as stale if any webhook event has occurred in the past hour
|
||||
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
||||
|
|
@ -115,161 +154,88 @@ 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse HTTP response, handling both JSON and XML content types
|
||||
*/
|
||||
private function parseResponse(Response $httpResponse): array
|
||||
{
|
||||
if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) {
|
||||
$parsers = app(ResponseParserRegistry::class)->getParsers();
|
||||
|
||||
foreach ($parsers as $parser) {
|
||||
$parserName = class_basename($parser);
|
||||
|
||||
try {
|
||||
// Convert XML to array and wrap under 'rss' key
|
||||
$xml = simplexml_load_string($httpResponse->body());
|
||||
if ($xml === false) {
|
||||
throw new Exception('Invalid XML content');
|
||||
$result = $parser->parse($httpResponse);
|
||||
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Convert SimpleXML directly to array
|
||||
$xmlArray = $this->xmlToArray($xml);
|
||||
|
||||
return ['rss' => $xmlArray];
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to parse XML response: '.$e->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse XML response'];
|
||||
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt to parse it into JSON
|
||||
$json = $httpResponse->json();
|
||||
if ($json !== null) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
// Response doesn't seem to be JSON, wrap the response body text as a JSON object
|
||||
return ['data' => $httpResponse->body()];
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to parse JSON response: '.$e->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse JSON response'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SimpleXML object to array recursively
|
||||
*/
|
||||
private function xmlToArray(SimpleXMLElement $xml): array
|
||||
{
|
||||
$array = (array) $xml;
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if ($value instanceof SimpleXMLElement) {
|
||||
$array[$key] = $this->xmlToArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
return ['error' => 'Failed to parse response'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -451,6 +417,10 @@ 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.');
|
||||
}
|
||||
|
||||
if ($this->render_markup) {
|
||||
$renderedContent = '';
|
||||
|
||||
|
|
@ -558,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();
|
||||
}
|
||||
|
||||
|
|
@ -599,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
@ -280,6 +334,10 @@ class ImageGenerationService
|
|||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||
{
|
||||
if ($plugin?->id) {
|
||||
// Image webhook plugins have finalized images that shouldn't be reset
|
||||
if ($plugin->plugin_type === 'image_webhook') {
|
||||
return;
|
||||
}
|
||||
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
||||
$hasCustomDimensions = Device::query()
|
||||
->where(function ($query): void {
|
||||
|
|
@ -311,7 +369,7 @@ class ImageGenerationService
|
|||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -345,10 +403,10 @@ class ImageGenerationService
|
|||
/**
|
||||
* Generate a default screen image from Blade template
|
||||
*/
|
||||
public static function generateDefaultScreenImage(Device $device, string $imageType): string
|
||||
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||
}
|
||||
|
||||
|
|
@ -365,7 +423,7 @@ class ImageGenerationService
|
|||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||
|
||||
// Generate HTML from Blade template
|
||||
$html = self::generateDefaultScreenHtml($device, $imageType);
|
||||
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
||||
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
|
|
@ -445,12 +503,13 @@ class ImageGenerationService
|
|||
/**
|
||||
* Generate HTML from Blade template for default screens
|
||||
*/
|
||||
private static function generateDefaultScreenHtml(Device $device, string $imageType): string
|
||||
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Map image type to template name
|
||||
$templateName = match ($imageType) {
|
||||
'setup-logo' => 'default-screens.setup',
|
||||
'sleep' => 'default-screens.sleep',
|
||||
'error' => 'default-screens.error',
|
||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||
};
|
||||
|
||||
|
|
@ -461,14 +520,22 @@ class ImageGenerationService
|
|||
$scaleLevel = $device->scaleLevel();
|
||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||
|
||||
// Render the Blade template
|
||||
return view($templateName, [
|
||||
// Build view data
|
||||
$viewData = [
|
||||
'noBleed' => false,
|
||||
'darkMode' => $darkMode,
|
||||
'deviceVariant' => $deviceVariant,
|
||||
'deviceOrientation' => $deviceOrientation,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
])->render();
|
||||
];
|
||||
|
||||
// Add plugin name for error screens
|
||||
if ($imageType === 'error' && $pluginName !== null) {
|
||||
$viewData['pluginName'] = $pluginName;
|
||||
}
|
||||
|
||||
// Render the Blade template
|
||||
return view($templateName, $viewData)->render();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use om\IcalParser;
|
||||
|
||||
class IcalResponseParser implements ResponseParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IcalParser $parser = new IcalParser(),
|
||||
) {}
|
||||
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
$body = $response->body();
|
||||
|
||||
if (! $this->isIcalResponse($contentType, $body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->parser->parseString($body);
|
||||
|
||||
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
|
||||
$windowStart = now()->subDays(7);
|
||||
$windowEnd = now()->addDays(30);
|
||||
|
||||
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
|
||||
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
|
||||
|
||||
if (! $startDate instanceof Carbon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $startDate->between($windowStart, $windowEnd, true);
|
||||
}));
|
||||
|
||||
$normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents);
|
||||
|
||||
return ['ical' => $normalizedEvents];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse iCal response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse iCal response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function isIcalResponse(?string $contentType, string $body): bool
|
||||
{
|
||||
$normalizedContentType = $contentType ? mb_strtolower($contentType) : '';
|
||||
|
||||
if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($body, 'BEGIN:VCALENDAR');
|
||||
}
|
||||
|
||||
private function asCarbon(DateTimeInterface|string|null $value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse date value: '.$exception->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeIcalEvent(array $event): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($event as $key => $value) {
|
||||
$normalized[$key] = $this->normalizeIcalValue($value);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeIcalValue(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value)->toAtomString();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map($this->normalizeIcalValue(...), $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JsonOrTextResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): array
|
||||
{
|
||||
try {
|
||||
$json = $response->json();
|
||||
if ($json !== null) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
return ['data' => $response->body()];
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to parse JSON response: '.$e->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse JSON response'];
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
interface ResponseParser
|
||||
{
|
||||
/**
|
||||
* Attempt to parse the given response.
|
||||
*
|
||||
* Return null when the parser is not applicable so other parsers can run.
|
||||
*/
|
||||
public function parse(Response $response): ?array;
|
||||
}
|
||||
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
class ResponseParserRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<int, ResponseParser>
|
||||
*/
|
||||
private readonly array $parsers;
|
||||
|
||||
/**
|
||||
* @param array<int, ResponseParser> $parsers
|
||||
*/
|
||||
public function __construct(array $parsers = [])
|
||||
{
|
||||
$this->parsers = $parsers ?: [
|
||||
new XmlResponseParser(),
|
||||
new IcalResponseParser(),
|
||||
new JsonOrTextResponseParser(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ResponseParser>
|
||||
*/
|
||||
public function getParsers(): array
|
||||
{
|
||||
return $this->parsers;
|
||||
}
|
||||
}
|
||||
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class XmlResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
|
||||
if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($response->body());
|
||||
if ($xml === false) {
|
||||
throw new Exception('Invalid XML content');
|
||||
}
|
||||
|
||||
return ['rss' => $this->xmlToArray($xml)];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse XML response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function xmlToArray(SimpleXMLElement $xml): array
|
||||
{
|
||||
$array = (array) $xml;
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if ($value instanceof SimpleXMLElement) {
|
||||
$array[$key] = $this->xmlToArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
15
boost.json
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"agents": [
|
||||
"claude_code",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"phpstorm"
|
||||
],
|
||||
"editors": [
|
||||
"claude_code",
|
||||
"cursor",
|
||||
"phpstorm",
|
||||
"vscode"
|
||||
],
|
||||
"guidelines": []
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"keywords": [
|
||||
"trmnl",
|
||||
"trmnl-server",
|
||||
"trmnl-byos",
|
||||
"laravel"
|
||||
],
|
||||
"license": "MIT",
|
||||
|
|
@ -14,7 +15,7 @@
|
|||
"ext-imagick": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-zip": "*",
|
||||
"bnussbau/laravel-trmnl-blade": "2.0.*",
|
||||
"bnussbau/laravel-trmnl-blade": "2.1.*",
|
||||
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
||||
"keepsuit/laravel-liquid": "^0.5.2",
|
||||
"laravel/framework": "^12.1",
|
||||
|
|
@ -23,7 +24,9 @@
|
|||
"laravel/tinker": "^2.10.1",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/volt": "^1.7",
|
||||
"om/icalparser": "^3.2",
|
||||
"spatie/browsershot": "^5.0",
|
||||
"stevebauman/purify": "^6.3",
|
||||
"symfony/yaml": "^7.3",
|
||||
"wnx/sidecar-browsershot": "^2.6"
|
||||
},
|
||||
|
|
|
|||
1067
composer.lock
generated
6
config/trustedproxy.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Comma‑separated 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)),
|
||||
];
|
||||
|
|
@ -20,7 +20,7 @@ class DevicePaletteFactory extends Factory
|
|||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'test-' . $this->faker->unique()->slug(),
|
||||
'id' => 'test-'.$this->faker->unique()->slug(),
|
||||
'name' => $this->faker->words(3, true),
|
||||
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
||||
'colors' => $this->faker->optional()->passthrough([
|
||||
|
|
|
|||
|
|
@ -29,8 +29,24 @@ class PluginFactory extends Factory
|
|||
'icon_url' => null,
|
||||
'flux_icon_name' => null,
|
||||
'author_name' => $this->faker->name(),
|
||||
'plugin_type' => 'recipe',
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the plugin is an image webhook plugin.
|
||||
*/
|
||||
public function imageWebhook(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'plugin_type' => 'image_webhook',
|
||||
'data_strategy' => 'static',
|
||||
'data_stale_minutes' => 60,
|
||||
'polling_url' => null,
|
||||
'polling_verb' => 'get',
|
||||
'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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): void {
|
||||
$table->string('plugin_type')->default('recipe')->after('uuid');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table): void {
|
||||
$table->dropColumn('plugin_type');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -144,5 +144,42 @@ class ExampleRecipesSeeder extends Seeder
|
|||
'flux_icon_name' => 'flower',
|
||||
]
|
||||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
[
|
||||
'uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90',
|
||||
'name' => 'Holidays (iCal)',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
'data_stale_minutes' => 720,
|
||||
'data_strategy' => 'polling',
|
||||
'configuration_template' => [
|
||||
'custom_fields' => [
|
||||
[
|
||||
'keyname' => 'calendar',
|
||||
'field_type' => 'select',
|
||||
'name' => 'Public Holidays Calendar',
|
||||
'options' => [
|
||||
['USA' => 'usa'],
|
||||
['Austria' => 'austria'],
|
||||
['Australia' => 'australia'],
|
||||
['Canada' => 'canada'],
|
||||
['Germany' => 'germany'],
|
||||
['UK' => 'united-kingdom'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'configuration' => ['calendar' => 'usa'],
|
||||
'polling_url' => 'https://www.officeholidays.com/ics-clean/{{calendar}}',
|
||||
'polling_verb' => 'get',
|
||||
'polling_header' => null,
|
||||
'render_markup' => null,
|
||||
'render_markup_view' => 'recipes.holidays-ical',
|
||||
'detail_view_route' => null,
|
||||
'icon_url' => null,
|
||||
'flux_icon_name' => 'calendar',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1003
package-lock.json
generated
|
|
@ -24,7 +24,7 @@
|
|||
"codemirror": "^6.0.2",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"puppeteer": "24.17.0",
|
||||
"puppeteer": "24.30.0",
|
||||
"tailwindcss": "^4.0.7",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
|
|
|
|||
BIN
public/mirror/assets/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/mirror/assets/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/mirror/assets/apple-touch-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/mirror/assets/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/mirror/assets/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/mirror/assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 401 B |
BIN
public/mirror/assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 518 B |
BIN
public/mirror/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
139
public/mirror/assets/logo--brand.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
521
public/mirror/index.html
Normal 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>
|
||||
7
public/mirror/manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "TRMNL BYOS Laravel Mirror",
|
||||
"short_name": "TRMNL BYOS",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff"
|
||||
}
|
||||
|
|
@ -59,6 +59,10 @@
|
|||
@apply !mb-0 !leading-tight;
|
||||
}
|
||||
|
||||
[data-flux-description] a {
|
||||
@apply text-accent underline hover:opacity-80;
|
||||
}
|
||||
|
||||
input:focus[data-flux-control],
|
||||
textarea:focus[data-flux-control],
|
||||
select:focus[data-flux-control] {
|
||||
|
|
@ -68,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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
||||
import { ViewPlugin } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { indentWithTab, selectAll } from '@codemirror/commands';
|
||||
import { foldGutter, foldKeymap } from '@codemirror/language';
|
||||
import { history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
|
|
@ -154,7 +155,16 @@ export function createCodeMirror(element, options = {}) {
|
|||
createResizePlugin(),
|
||||
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
||||
...themeSupport,
|
||||
keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]),
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
...foldKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
{
|
||||
key: 'Mod-a',
|
||||
run: selectAll,
|
||||
},
|
||||
]),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '14px',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
23
resources/views/default-screens/error.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@props([
|
||||
'noBleed' => false,
|
||||
'darkMode' => false,
|
||||
'deviceVariant' => 'og',
|
||||
'deviceOrientation' => null,
|
||||
'colorDepth' => '1bit',
|
||||
'scaleLevel' => null,
|
||||
'pluginName' => 'Recipe',
|
||||
])
|
||||
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||
scale-level="{{$scaleLevel}}">
|
||||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
<x-trmnl::title>Error on {{ $pluginName }}</x-trmnl::title>
|
||||
<x-trmnl::content>Unable to render content. Please check server logs.</x-trmnl::content>
|
||||
</x-trmnl::richtext>
|
||||
</x-trmnl::layout>
|
||||
<x-trmnl::title-bar/>
|
||||
</x-trmnl::view>
|
||||
</x-trmnl::screen>
|
||||
|
|
@ -1,20 +1,26 @@
|
|||
<?php
|
||||
|
||||
use App\Services\PluginImportService;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Volt\Component;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Volt\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
new
|
||||
#[Lazy]
|
||||
class extends Component {
|
||||
class extends Component
|
||||
{
|
||||
public array $catalogPlugins = [];
|
||||
|
||||
public string $installingPlugin = '';
|
||||
|
||||
public string $previewingPlugin = '';
|
||||
|
||||
public array $previewData = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadCatalogPlugins();
|
||||
|
|
@ -49,7 +55,7 @@ class extends Component {
|
|||
return collect($catalog)
|
||||
->filter(function ($plugin) use ($currentVersion) {
|
||||
// Check if Laravel compatibility is true
|
||||
if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
||||
if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -79,8 +85,9 @@ class extends Component {
|
|||
})
|
||||
->sortBy('name')
|
||||
->toArray();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to load catalog from URL: ' . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to load catalog from URL: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
|
@ -92,8 +99,9 @@ class extends Component {
|
|||
|
||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||
|
||||
if (!$plugin || !$plugin['zip_url']) {
|
||||
if (! $plugin || ! $plugin['zip_url']) {
|
||||
$this->addError('installation', 'Plugin not found or no download URL available.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -105,24 +113,45 @@ 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');
|
||||
Flux::modal('import-from-catalog')->close();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
||||
} finally {
|
||||
$this->installingPlugin = '';
|
||||
}
|
||||
}
|
||||
|
||||
public function previewPlugin(string $pluginId): void
|
||||
{
|
||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||
|
||||
if (! $plugin) {
|
||||
$this->addError('preview', 'Plugin not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->previewingPlugin = $pluginId;
|
||||
$this->previewData = $plugin;
|
||||
}
|
||||
|
||||
public function closePreview(): void
|
||||
{
|
||||
$this->previewingPlugin = '';
|
||||
$this->previewData = [];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-4">
|
||||
@if(empty($catalogPlugins))
|
||||
<div class="text-center py-8">
|
||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<flux:heading class="mt-2">No plugins available</flux:heading>
|
||||
<flux:subheading>Catalog is empty</flux:subheading>
|
||||
</div>
|
||||
|
|
@ -133,30 +162,30 @@ class extends Component {
|
|||
@enderror
|
||||
|
||||
@foreach($catalogPlugins as $plugin)
|
||||
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||
<div wire:key="plugin-{{ $plugin['id'] }}" class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
@if($plugin['logo_url'])
|
||||
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||
@else
|
||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
||||
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
|
||||
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
|
||||
@if ($plugin['github'])
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
|
||||
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($plugin['license'])
|
||||
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||
@endif
|
||||
@if($plugin['repo_url'])
|
||||
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
||||
<flux:icon name="github" class="w-5 h-5" />
|
||||
</a>
|
||||
@endif
|
||||
|
|
@ -164,7 +193,7 @@ class extends Component {
|
|||
</div>
|
||||
|
||||
@if($plugin['description'])
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
|
||||
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-center space-x-3">
|
||||
|
|
@ -174,6 +203,19 @@ class extends Component {
|
|||
Install
|
||||
</flux:button>
|
||||
|
||||
@if($plugin['screenshot_url'])
|
||||
<flux:modal.trigger name="catalog-preview">
|
||||
<flux:button
|
||||
wire:click="previewPlugin('{{ $plugin['id'] }}')"
|
||||
variant="subtle"
|
||||
icon="eye">
|
||||
Preview
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
|
||||
|
||||
|
||||
@if($plugin['learn_more_url'])
|
||||
<flux:button
|
||||
href="{{ $plugin['learn_more_url'] }}"
|
||||
|
|
@ -189,4 +231,38 @@ class extends Component {
|
|||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<flux:modal name="catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
|
||||
@if($previewingPlugin && !empty($previewData))
|
||||
<div>
|
||||
<flux:heading size="lg">Preview {{ $previewData['name'] ?? 'Plugin' }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<img src="{{ $previewData['screenshot_url'] }}"
|
||||
alt="Preview of {{ $previewData['name'] }}"
|
||||
class="w-full h-auto max-h-[480px] object-contain">
|
||||
</div>
|
||||
|
||||
@if($previewData['description'])
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
|
||||
<flux:heading size="sm" class="mb-2">Description</flux:heading>
|
||||
<flux:text size="sm">{{ $previewData['description'] }}</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
|
||||
<flux:modal.close>
|
||||
<flux:button
|
||||
wire:click="installPlugin('{{ $previewingPlugin }}')"
|
||||
variant="primary">
|
||||
Install Plugin
|
||||
</flux:button>
|
||||
</flux:modal.close>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,30 @@
|
|||
<?php
|
||||
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Volt\Component;
|
||||
use App\Services\PluginImportService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\PluginImportService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new
|
||||
#[Lazy]
|
||||
class extends Component {
|
||||
class extends Component
|
||||
{
|
||||
public array $recipes = [];
|
||||
|
||||
public int $page = 1;
|
||||
|
||||
public bool $hasMore = false;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public bool $isSearching = false;
|
||||
|
||||
public string $previewingRecipe = '';
|
||||
|
||||
public array $previewData = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadNewest();
|
||||
|
|
@ -37,22 +47,36 @@ class extends Component {
|
|||
private function loadNewest(): void
|
||||
{
|
||||
try {
|
||||
$this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () {
|
||||
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
|
||||
$response = Cache::remember($cacheKey, 43200, function () {
|
||||
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||
'sort-by' => 'newest',
|
||||
'page' => $this->page,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new \RuntimeException('Failed to fetch TRMNL recipes');
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Failed to fetch TRMNL recipes');
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
$data = $json['data'] ?? [];
|
||||
return $this->mapRecipes($data);
|
||||
return $response->json();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('TRMNL catalog load error: ' . $e->getMessage());
|
||||
$this->recipes = [];
|
||||
|
||||
$data = $response['data'] ?? [];
|
||||
$mapped = $this->mapRecipes($data);
|
||||
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = $mapped;
|
||||
} else {
|
||||
$this->recipes = array_merge($this->recipes, $mapped);
|
||||
}
|
||||
|
||||
$this->hasMore = ! empty($response['next_page_url']);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('TRMNL catalog load error: '.$e->getMessage());
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = [];
|
||||
}
|
||||
$this->hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,38 +84,65 @@ class extends Component {
|
|||
{
|
||||
$this->isSearching = true;
|
||||
try {
|
||||
$cacheKey = 'trmnl_recipes_search_' . md5($term);
|
||||
$this->recipes = Cache::remember($cacheKey, 300, function () use ($term) {
|
||||
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
|
||||
$response = Cache::remember($cacheKey, 300, function () use ($term) {
|
||||
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
||||
'search' => $term,
|
||||
'sort-by' => 'newest',
|
||||
'page' => $this->page,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new \RuntimeException('Failed to search TRMNL recipes');
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Failed to search TRMNL recipes');
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
$data = $json['data'] ?? [];
|
||||
return $this->mapRecipes($data);
|
||||
return $response->json();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('TRMNL catalog search error: ' . $e->getMessage());
|
||||
$this->recipes = [];
|
||||
|
||||
$data = $response['data'] ?? [];
|
||||
$mapped = $this->mapRecipes($data);
|
||||
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = $mapped;
|
||||
} else {
|
||||
$this->recipes = array_merge($this->recipes, $mapped);
|
||||
}
|
||||
|
||||
$this->hasMore = ! empty($response['next_page_url']);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('TRMNL catalog search error: '.$e->getMessage());
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = [];
|
||||
}
|
||||
$this->hasMore = false;
|
||||
} finally {
|
||||
$this->isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function loadMore(): void
|
||||
{
|
||||
$this->page++;
|
||||
|
||||
$term = mb_trim($this->search);
|
||||
if ($term === '' || mb_strlen($term) < 2) {
|
||||
$this->loadNewest();
|
||||
} else {
|
||||
$this->searchRecipes($term);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$term = trim($this->search);
|
||||
$this->page = 1;
|
||||
$term = mb_trim($this->search);
|
||||
if ($term === '') {
|
||||
$this->loadNewest();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($term) < 2) {
|
||||
if (mb_strlen($term) < 2) {
|
||||
// Require at least 2 chars to avoid noisy calls
|
||||
return;
|
||||
}
|
||||
|
|
@ -113,43 +164,85 @@ 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');
|
||||
Flux::modal('import-from-trmnl-catalog')->close();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Plugin installation failed: ' . $e->getMessage());
|
||||
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
Log::error('Plugin installation failed: '.$e->getMessage());
|
||||
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function previewRecipe(string $recipeId): void
|
||||
{
|
||||
$this->previewingRecipe = $recipeId;
|
||||
$this->previewData = [];
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json");
|
||||
|
||||
if ($response->successful()) {
|
||||
$item = $response->json()['data'] ?? [];
|
||||
$this->previewData = $this->mapRecipe($item);
|
||||
} else {
|
||||
// Fallback to searching for the specific recipe if single endpoint doesn't exist
|
||||
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||
'search' => $recipeId,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json()['data'] ?? [];
|
||||
$item = collect($data)->firstWhere('id', $recipeId);
|
||||
if ($item) {
|
||||
$this->previewData = $this->mapRecipe($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('TRMNL catalog preview fetch error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
if (empty($this->previewData)) {
|
||||
$this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function mapRecipes(array $items): array
|
||||
{
|
||||
return collect($items)
|
||||
->map(function (array $item) {
|
||||
return [
|
||||
'id' => $item['id'] ?? null,
|
||||
'name' => $item['name'] ?? 'Untitled',
|
||||
'icon_url' => $item['icon_url'] ?? null,
|
||||
'screenshot_url' => $item['screenshot_url'] ?? null,
|
||||
'author_bio' => is_array($item['author_bio'] ?? null)
|
||||
? strip_tags($item['author_bio']['description'] ?? null)
|
||||
: null,
|
||||
'stats' => [
|
||||
'installs' => data_get($item, 'stats.installs'),
|
||||
'forks' => data_get($item, 'stats.forks'),
|
||||
],
|
||||
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null,
|
||||
];
|
||||
})
|
||||
->map(fn (array $item) => $this->mapRecipe($item))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapRecipe(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => $item['id'] ?? null,
|
||||
'name' => $item['name'] ?? 'Untitled',
|
||||
'icon_url' => $item['icon_url'] ?? null,
|
||||
'screenshot_url' => $item['screenshot_url'] ?? null,
|
||||
'author_bio' => is_array($item['author_bio'] ?? null)
|
||||
? strip_tags($item['author_bio']['description'] ?? null)
|
||||
: null,
|
||||
'stats' => [
|
||||
'installs' => data_get($item, 'stats.installs'),
|
||||
'forks' => data_get($item, 'stats.forks'),
|
||||
],
|
||||
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null,
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
|
@ -161,7 +254,7 @@ class extends Component {
|
|||
icon="magnifying-glass"
|
||||
/>
|
||||
</div>
|
||||
<flux:badge color="gray">Newest</flux:badge>
|
||||
<flux:badge color="zinc">Newest</flux:badge>
|
||||
</div>
|
||||
|
||||
@error('installation')
|
||||
|
|
@ -170,35 +263,36 @@ class extends Component {
|
|||
|
||||
@if(empty($recipes))
|
||||
<div class="text-center py-8">
|
||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<flux:heading class="mt-2">No recipes found</flux:heading>
|
||||
<flux:subheading>Try a different search term</flux:subheading>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
@foreach($recipes as $recipe)
|
||||
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<div wire:key="recipe-{{ $recipe['id'] }}" class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||
<div class="px-10 py-8 space-y-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
|
||||
@if($thumb)
|
||||
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||
@else
|
||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
||||
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $recipe['name'] }}</h3>
|
||||
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
|
||||
@if(data_get($recipe, 'stats.installs'))
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</p>
|
||||
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</flux:text>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($recipe['detail_url'])
|
||||
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
||||
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
|
||||
</a>
|
||||
@endif
|
||||
|
|
@ -206,7 +300,7 @@ class extends Component {
|
|||
</div>
|
||||
|
||||
@if($recipe['author_bio'])
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $recipe['author_bio'] }}</p>
|
||||
<flux:text class="mt-2" size="sm">{{ $recipe['author_bio'] }}</flux:text>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-center space-x-3">
|
||||
|
|
@ -218,19 +312,96 @@ class extends Component {
|
|||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if($recipe['detail_url'])
|
||||
<flux:button
|
||||
href="{{ $recipe['detail_url'] }}"
|
||||
target="_blank"
|
||||
variant="subtle">
|
||||
View on TRMNL
|
||||
</flux:button>
|
||||
@if($recipe['id'] && ($recipe['screenshot_url'] ?? null))
|
||||
<flux:modal.trigger name="trmnl-catalog-preview">
|
||||
<flux:button
|
||||
wire:click="previewRecipe('{{ $recipe['id'] }}')"
|
||||
variant="subtle"
|
||||
icon="eye">
|
||||
Preview
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($hasMore)
|
||||
<div class="flex justify-center mt-6">
|
||||
<flux:button wire:click="loadMore" variant="subtle" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="loadMore">Load next page</span>
|
||||
<span wire:loading wire:target="loadMore">Loading...</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<flux:modal name="trmnl-catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
|
||||
<div wire:loading wire:target="previewRecipe" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center space-x-2">
|
||||
<flux:icon.loading />
|
||||
<flux:text>Fetching recipe details...</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove wire:target="previewRecipe">
|
||||
@if($previewingRecipe && !empty($previewData))
|
||||
<div>
|
||||
<flux:heading size="lg" class="mb-2">Preview {{ $previewData['name'] ?? 'Recipe' }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<img src="{{ $previewData['screenshot_url'] }}"
|
||||
alt="Preview of {{ $previewData['name'] }}"
|
||||
class="w-full h-auto max-h-[480px] object-contain">
|
||||
</div>
|
||||
|
||||
@if($previewData['author_bio'])
|
||||
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||
<div class="px-10 py-8">
|
||||
<flux:heading size="sm" class="mb-2">Description</flux:heading>
|
||||
<flux:text size="sm">{{ $previewData['author_bio'] }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(data_get($previewData, 'stats.installs'))
|
||||
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||
<div class="px-10 py-8">
|
||||
<flux:heading size="sm" class="mb-2">Statistics</flux:heading>
|
||||
<flux:text size="sm">
|
||||
Installs: {{ data_get($previewData, 'stats.installs') }} ·
|
||||
Forks: {{ data_get($previewData, 'stats.forks') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
|
||||
@if($previewData['detail_url'])
|
||||
<flux:button
|
||||
href="{{ $previewData['detail_url'] }}"
|
||||
target="_blank"
|
||||
variant="subtle">
|
||||
View on TRMNL
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:modal.close>
|
||||
<flux:button
|
||||
wire:click="installPlugin('{{ $previewingRecipe }}')"
|
||||
variant="primary">
|
||||
Install Recipe
|
||||
</flux:button>
|
||||
</flux:modal.close>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
516
resources/views/livewire/plugins/config-modal.blade.php
Normal 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>
|
||||
|
|
@ -0,0 +1,298 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public Plugin $plugin;
|
||||
public string $name;
|
||||
public array $checked_devices = [];
|
||||
public array $device_playlists = [];
|
||||
public array $device_playlist_names = [];
|
||||
public array $device_weekdays = [];
|
||||
public array $device_active_from = [];
|
||||
public array $device_active_until = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||
abort_unless($this->plugin->plugin_type === 'image_webhook', 404);
|
||||
|
||||
$this->name = $this->plugin->name;
|
||||
}
|
||||
|
||||
protected array $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'checked_devices' => 'array',
|
||||
'device_playlist_names' => 'array',
|
||||
'device_playlists' => 'array',
|
||||
'device_weekdays' => 'array',
|
||||
'device_active_from' => 'array',
|
||||
'device_active_until' => 'array',
|
||||
];
|
||||
|
||||
public function updateName(): void
|
||||
{
|
||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||
$this->validate(['name' => 'required|string|max:255']);
|
||||
$this->plugin->update(['name' => $this->name]);
|
||||
}
|
||||
|
||||
|
||||
public function addToPlaylist()
|
||||
{
|
||||
$this->validate([
|
||||
'checked_devices' => 'required|array|min:1',
|
||||
]);
|
||||
|
||||
foreach ($this->checked_devices as $deviceId) {
|
||||
if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) {
|
||||
$this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->device_playlists[$deviceId] === 'new') {
|
||||
if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) {
|
||||
$this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->checked_devices as $deviceId) {
|
||||
$playlist = null;
|
||||
|
||||
if ($this->device_playlists[$deviceId] === 'new') {
|
||||
$playlist = \App\Models\Playlist::create([
|
||||
'device_id' => $deviceId,
|
||||
'name' => $this->device_playlist_names[$deviceId],
|
||||
'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null,
|
||||
'active_from' => $this->device_active_from[$deviceId] ?? null,
|
||||
'active_until' => $this->device_active_until[$deviceId] ?? null,
|
||||
]);
|
||||
} else {
|
||||
$playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]);
|
||||
}
|
||||
|
||||
$maxOrder = $playlist->items()->max('order') ?? 0;
|
||||
|
||||
// Image webhook plugins only support full layout
|
||||
$playlist->items()->create([
|
||||
'plugin_id' => $this->plugin->id,
|
||||
'order' => $maxOrder + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->reset([
|
||||
'checked_devices',
|
||||
'device_playlists',
|
||||
'device_playlist_names',
|
||||
'device_weekdays',
|
||||
'device_active_from',
|
||||
'device_active_until',
|
||||
]);
|
||||
Flux::modal('add-to-playlist')->close();
|
||||
}
|
||||
|
||||
public function getDevicePlaylists($deviceId)
|
||||
{
|
||||
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
||||
}
|
||||
|
||||
public function hasAnyPlaylistSelected(): bool
|
||||
{
|
||||
foreach ($this->checked_devices as $deviceId) {
|
||||
if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function deletePlugin(): void
|
||||
{
|
||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||
$this->plugin->delete();
|
||||
$this->redirect(route('plugins.image-webhook'));
|
||||
}
|
||||
|
||||
public function getImagePath(): ?string
|
||||
{
|
||||
if (!$this->plugin->current_image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$extensions = ['png', 'bmp'];
|
||||
foreach ($extensions as $ext) {
|
||||
$path = 'images/generated/'.$this->plugin->current_image.'.'.$ext;
|
||||
if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold dark:text-gray-100">Image Webhook – {{$plugin->name}}</h2>
|
||||
|
||||
<flux:button.group>
|
||||
<flux:modal.trigger name="add-to-playlist">
|
||||
<flux:button icon="play" variant="primary">Add to Playlist</flux:button>
|
||||
</flux:modal.trigger>
|
||||
|
||||
<flux:dropdown>
|
||||
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
||||
<flux:menu>
|
||||
<flux:modal.trigger name="delete-plugin">
|
||||
<flux:menu.item icon="trash" variant="danger">Delete Instance</flux:menu.item>
|
||||
</flux:modal.trigger>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</flux:button.group>
|
||||
</div>
|
||||
|
||||
<flux:modal name="add-to-playlist" class="min-w-2xl">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Add to Playlist</flux:heading>
|
||||
</div>
|
||||
|
||||
<form wire:submit="addToPlaylist">
|
||||
<flux:separator text="Device(s)" />
|
||||
<div class="mt-4 mb-4">
|
||||
<flux:checkbox.group wire:model.live="checked_devices">
|
||||
@foreach(auth()->user()->devices as $device)
|
||||
<flux:checkbox label="{{ $device->name }}" value="{{ $device->id }}"/>
|
||||
@endforeach
|
||||
</flux:checkbox.group>
|
||||
</div>
|
||||
|
||||
@if(count($checked_devices) > 0)
|
||||
<flux:separator text="Playlist Selection" />
|
||||
<div class="mt-4 mb-4 space-y-6">
|
||||
@foreach($checked_devices as $deviceId)
|
||||
@php
|
||||
$device = auth()->user()->devices->find($deviceId);
|
||||
@endphp
|
||||
<div class="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
{{ $device->name }}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:select wire:model.live.debounce="device_playlists.{{ $deviceId }}">
|
||||
<option value="">Select Playlist or Create New</option>
|
||||
@foreach($this->getDevicePlaylists($deviceId) as $playlist)
|
||||
<option value="{{ $playlist->id }}">{{ $playlist->name }}</option>
|
||||
@endforeach
|
||||
<option value="new">Create New Playlist</option>
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
@if(isset($device_playlists[$deviceId]) && $device_playlists[$deviceId] === 'new')
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<flux:input label="Playlist Name" wire:model="device_playlist_names.{{ $deviceId }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<flux:checkbox.group wire:model="device_weekdays.{{ $deviceId }}" label="Active Days (optional)">
|
||||
<flux:checkbox label="Monday" value="1"/>
|
||||
<flux:checkbox label="Tuesday" value="2"/>
|
||||
<flux:checkbox label="Wednesday" value="3"/>
|
||||
<flux:checkbox label="Thursday" value="4"/>
|
||||
<flux:checkbox label="Friday" value="5"/>
|
||||
<flux:checkbox label="Saturday" value="6"/>
|
||||
<flux:checkbox label="Sunday" value="0"/>
|
||||
</flux:checkbox.group>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<flux:input type="time" label="Active From (optional)" wire:model="device_active_from.{{ $deviceId }}"/>
|
||||
</div>
|
||||
<div>
|
||||
<flux:input type="time" label="Active Until (optional)" wire:model="device_active_until.{{ $deviceId }}"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary">Add to Playlist</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
<flux:modal name="delete-plugin" class="min-w-[22rem] space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Delete {{ $plugin->name }}?</flux:heading>
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<flux:spacer/>
|
||||
<flux:modal.close>
|
||||
<flux:button variant="ghost">Cancel</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button wire:click="deletePlugin" variant="danger">Delete instance</flux:button>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
<div class="grid lg:grid-cols-2 lg:gap-8">
|
||||
<div>
|
||||
<form wire:submit="updateName" class="mb-6">
|
||||
<div class="mb-4">
|
||||
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
||||
name="name" autofocus/>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary" class="w-full">Save</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mb-6">
|
||||
<flux:label>Webhook URL</flux:label>
|
||||
<flux:input
|
||||
:value="route('api.plugin_settings.image', ['uuid' => $plugin->uuid])"
|
||||
class="font-mono text-sm"
|
||||
readonly
|
||||
copyable
|
||||
/>
|
||||
<flux:description class="mt-2">POST an image (PNG or BMP) to this URL to update the displayed image.</flux:description>
|
||||
|
||||
<flux:callout variant="warning" icon="exclamation-circle" class="mt-4">
|
||||
<flux:callout.text>Images must be posted in a format that can directly be read by the device. You need to take care of image format, dithering, and bit-depth. Check device logs if the image is not shown.</flux:callout.text>
|
||||
</flux:callout>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<flux:label>Current Image</flux:label>
|
||||
@if($this->getImagePath())
|
||||
<img src="{{ url('storage/'.$this->getImagePath()) }}" alt="{{ $plugin->name }}" class="w-full h-auto rounded-lg border border-zinc-200 dark:border-zinc-700 mt-2" />
|
||||
@else
|
||||
<flux:callout variant="warning" class="mt-2">
|
||||
<flux:text>No image uploaded yet. POST an image to the webhook URL to get started.</flux:text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
163
resources/views/livewire/plugins/image-webhook.blade.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Livewire\Volt\Component;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
new class extends Component {
|
||||
public string $name = '';
|
||||
public array $instances = [];
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->refreshInstances();
|
||||
}
|
||||
|
||||
public function refreshInstances(): void
|
||||
{
|
||||
$this->instances = auth()->user()
|
||||
->plugins()
|
||||
->where('plugin_type', 'image_webhook')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function createInstance(): void
|
||||
{
|
||||
abort_unless(auth()->user() !== null, 403);
|
||||
$this->validate();
|
||||
|
||||
Plugin::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'user_id' => auth()->id(),
|
||||
'name' => $this->name,
|
||||
'plugin_type' => 'image_webhook',
|
||||
'data_strategy' => 'static', // Not used for image_webhook, but required
|
||||
'data_stale_minutes' => 60, // Not used for image_webhook, but required
|
||||
]);
|
||||
|
||||
$this->reset(['name']);
|
||||
$this->refreshInstances();
|
||||
|
||||
Flux::modal('create-instance')->close();
|
||||
}
|
||||
|
||||
public function deleteInstance(int $pluginId): void
|
||||
{
|
||||
abort_unless(auth()->user() !== null, 403);
|
||||
|
||||
$plugin = Plugin::where('id', $pluginId)
|
||||
->where('user_id', auth()->id())
|
||||
->where('plugin_type', 'image_webhook')
|
||||
->firstOrFail();
|
||||
|
||||
$plugin->delete();
|
||||
$this->refreshInstances();
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold dark:text-gray-100">Image Webhook
|
||||
<flux:badge size="sm" class="ml-2">Plugin</flux:badge>
|
||||
</h2>
|
||||
<flux:modal.trigger name="create-instance">
|
||||
<flux:button icon="plus" variant="primary">Create Instance</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
|
||||
<flux:modal name="create-instance" class="md:w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Create Image Webhook Instance</flux:heading>
|
||||
<flux:subheading>Create a new instance that accepts images via webhook</flux:subheading>
|
||||
</div>
|
||||
|
||||
<form wire:submit="createInstance">
|
||||
<div class="mb-4">
|
||||
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
||||
name="name" autofocus/>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary">Create Instance</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
@if(empty($instances))
|
||||
<div class="text-center py-12">
|
||||
<flux:callout>
|
||||
<flux:heading size="sm">No instances yet</flux:heading>
|
||||
<flux:text>Create your first Image Webhook instance to get started.</flux:text>
|
||||
</flux:callout>
|
||||
</div>
|
||||
@else
|
||||
<table
|
||||
class="min-w-full table-auto text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20"
|
||||
data-flux-table="">
|
||||
<thead data-flux-columns="">
|
||||
<tr>
|
||||
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||
data-flux-column="">
|
||||
<div class="whitespace-nowrap flex">Name</div>
|
||||
</th>
|
||||
<th class="py-3 px-3 first:pl-0 last:pr-0 text-right text-sm font-medium text-zinc-800 dark:text-white"
|
||||
data-flux-column="">
|
||||
<div class="whitespace-nowrap flex justify-end">Actions</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-zinc-800/10 dark:divide-white/20" data-flux-rows="">
|
||||
@foreach($instances as $instance)
|
||||
<tr data-flux-row="">
|
||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300">
|
||||
{{ $instance['name'] }}
|
||||
</td>
|
||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white text-right">
|
||||
<div class="flex items-center justify-end">
|
||||
<flux:button.group>
|
||||
<flux:button href="{{ route('plugins.image-webhook-instance', ['plugin' => $instance['id']]) }}" wire:navigate icon="pencil" iconVariant="outline">
|
||||
</flux:button>
|
||||
<flux:modal.trigger name="delete-instance-{{ $instance['id'] }}">
|
||||
<flux:button icon="trash" iconVariant="outline">
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</flux:button.group>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
@foreach($instances as $instance)
|
||||
<flux:modal name="delete-instance-{{ $instance['id'] }}" class="min-w-88 space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Delete {{ $instance['name'] }}?</flux:heading>
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<flux:spacer/>
|
||||
<flux:modal.close>
|
||||
<flux:button variant="ghost">Cancel</flux:button>
|
||||
</flux:modal.close>
|
||||
<flux:button wire:click="deleteInstance({{ $instance['id'] }})" variant="danger">Delete instance</flux:button>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -26,6 +26,8 @@ new class extends Component {
|
|||
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
|
||||
'api' =>
|
||||
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
|
||||
'image-webhook' =>
|
||||
['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'],
|
||||
];
|
||||
|
||||
protected $rules = [
|
||||
|
|
@ -40,7 +42,12 @@ new class extends Component {
|
|||
|
||||
public function refreshPlugins(): void
|
||||
{
|
||||
$userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray();
|
||||
// Only show recipe plugins in the main list (image_webhook has its own management page)
|
||||
$userPlugins = auth()->user()?->plugins()
|
||||
->where('plugin_type', 'recipe')
|
||||
->get()
|
||||
->makeHidden(['render_markup', 'data_payload'])
|
||||
->toArray();
|
||||
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
|
||||
$allPlugins = array_values($allPlugins);
|
||||
$allPlugins = $this->sortPlugins($allPlugins);
|
||||
|
|
@ -388,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">
|
||||
|
|
|
|||
|
|
@ -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,16 +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 ?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 {
|
||||
|
|
@ -74,6 +77,12 @@ new class extends Component {
|
|||
|
||||
$this->fillformFields();
|
||||
$this->data_payload_updated_at = $this->plugin->data_payload_updated_at;
|
||||
|
||||
// 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
|
||||
|
|
@ -129,6 +138,19 @@ new class extends Component {
|
|||
$validated = $this->validate();
|
||||
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
|
||||
$this->plugin->update($validated);
|
||||
|
||||
foreach ($this->configuration_template as $fieldKey => $field) {
|
||||
if (($field['field_type'] ?? null) !== 'multi_string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($this->multiValues[$fieldKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$validated[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
protected function validatePollingUrl(): void
|
||||
|
|
@ -254,39 +276,6 @@ new class extends Component {
|
|||
Flux::modal('add-to-playlist')->close();
|
||||
}
|
||||
|
||||
public function saveConfiguration()
|
||||
{
|
||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||
|
||||
$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();
|
||||
|
|
@ -307,8 +296,6 @@ new class extends Component {
|
|||
return $this->configuration[$key] ?? $default;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function renderExample(string $example)
|
||||
{
|
||||
switch ($example) {
|
||||
|
|
@ -377,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());
|
||||
|
|
@ -392,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);
|
||||
|
|
@ -399,42 +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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div class="py-12">
|
||||
|
|
@ -466,7 +478,6 @@ HTML;
|
|||
</flux:modal.trigger>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
|
||||
</flux:button.group>
|
||||
<flux:button.group>
|
||||
<flux:modal.trigger name="add-to-playlist">
|
||||
|
|
@ -476,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>
|
||||
|
|
@ -617,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">
|
||||
|
|
@ -626,269 +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'] ?? '');
|
||||
|
||||
// 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:input
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
/>
|
||||
@elseif($field['field_type'] === 'text')
|
||||
<flux:textarea
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
/>
|
||||
@elseif($field['field_type'] === 'code')
|
||||
<flux:textarea
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
rows="{{ $field['rows'] ?? 3 }}"
|
||||
placeholder="{{ $field['placeholder'] ?? null }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
class="font-mono"
|
||||
/>
|
||||
@elseif($field['field_type'] === 'password')
|
||||
<flux:input
|
||||
type="password"
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
viewable
|
||||
/>
|
||||
@elseif($field['field_type'] === 'copyable')
|
||||
<flux:input
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
value="{{ $field['value'] }}"
|
||||
copyable
|
||||
/>
|
||||
@elseif($field['field_type'] === 'time_zone')
|
||||
<flux:select
|
||||
label="{{ $field['name'] }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
>
|
||||
<option value="">Select timezone...</option>
|
||||
@foreach(timezone_identifiers_list() as $timezone)
|
||||
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
@elseif($field['field_type'] === 'number')
|
||||
<flux:input
|
||||
type="number"
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? $field['name'] }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
/>
|
||||
@elseif($field['field_type'] === 'boolean')
|
||||
<flux:checkbox
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? $field['name'] }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
:checked="$currentValue"
|
||||
/>
|
||||
@elseif($field['field_type'] === 'date')
|
||||
<flux:input
|
||||
type="date"
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? $field['name'] }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
/>
|
||||
@elseif($field['field_type'] === 'time')
|
||||
<flux:input
|
||||
type="time"
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? $field['name'] }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
/>
|
||||
@elseif($field['field_type'] === 'select')
|
||||
@if(isset($field['multiple']) && $field['multiple'] === true)
|
||||
<flux:checkbox.group
|
||||
label="{{ $field['name'] }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
>
|
||||
@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>
|
||||
@else
|
||||
<flux:select
|
||||
label="{{ $field['name'] }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
>
|
||||
<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>
|
||||
@endif
|
||||
@elseif($field['field_type'] === 'xhrSelect')
|
||||
<flux:select
|
||||
label="{{ $field['name'] }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
||||
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>
|
||||
@elseif($field['field_type'] === 'xhrSelectSearch')
|
||||
<div class="space-y-2">
|
||||
|
||||
<flux:label>{{ $field['name'] }}</flux:label>
|
||||
<flux:description>{{ $field['description'] ?? '' }}</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>{{ $field['help_text'] ?? '' }}</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:input
|
||||
label="{{ $field['name'] }}"
|
||||
description="{{ $field['description'] ?? '' }}"
|
||||
descriptionTrailing="{{ $field['help_text'] ?? 'Enter multiple values separated by commas' }}"
|
||||
wire:model="configuration.{{ $fieldKey }}"
|
||||
value="{{ $currentValue }}"
|
||||
placeholder="{{ $field['placeholder'] ?? 'value1,value2' }}"
|
||||
/>
|
||||
@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>
|
||||
|
|
@ -976,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
|
||||
|
|
@ -989,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">
|
||||
|
|
@ -1161,9 +971,6 @@ HTML;
|
|||
</div>
|
||||
</flux:field>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
@else
|
||||
<div class="flex items-center gap-6 mb-4 mt-4">
|
||||
|
|
|
|||
104
resources/views/livewire/plugins/recipes/settings.blade.php
Normal 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>
|
||||
97
resources/views/recipes/holidays-ical.blade.php
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
@props(['size' => 'full'])
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
|
||||
$today = Carbon::today(config('app.timezone'));
|
||||
|
||||
$events = collect($data['ical'] ?? [])
|
||||
->map(function (array $event): array {
|
||||
try {
|
||||
$start = isset($event['DTSTART'])
|
||||
? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone'))
|
||||
: null;
|
||||
} catch (Exception $e) {
|
||||
$start = null;
|
||||
}
|
||||
|
||||
try {
|
||||
$end = isset($event['DTEND'])
|
||||
? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone'))
|
||||
: null;
|
||||
} catch (Exception $e) {
|
||||
$end = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'summary' => $event['SUMMARY'] ?? 'Untitled event',
|
||||
'location' => $event['LOCATION'] ?? '—',
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
];
|
||||
})
|
||||
->filter(fn ($event) =>
|
||||
$event['start'] &&
|
||||
(
|
||||
$event['start']->greaterThanOrEqualTo($today) ||
|
||||
($event['end'] && $event['end']->greaterThanOrEqualTo($today))
|
||||
)
|
||||
)
|
||||
->sortBy('start')
|
||||
->take($size === 'quadrant' ? 5 : 8)
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
|
||||
<x-trmnl::view size="{{$size}}">
|
||||
<x-trmnl::layout class="layout--col gap--small">
|
||||
<x-trmnl::table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<x-trmnl::title>Date</x-trmnl::title>
|
||||
</th>
|
||||
<th>
|
||||
<x-trmnl::title>Time</x-trmnl::title>
|
||||
</th>
|
||||
<th>
|
||||
<x-trmnl::title>Event</x-trmnl::title>
|
||||
</th>
|
||||
<th>
|
||||
<x-trmnl::title>Location</x-trmnl::title>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($events as $event)
|
||||
<tr>
|
||||
<td>
|
||||
<x-trmnl::label>{{ $event['start']?->format('D, M j') }}</x-trmnl::label>
|
||||
</td>
|
||||
<td>
|
||||
<x-trmnl::label>
|
||||
{{ $event['start']?->format('H:i') }}
|
||||
@if($event['end'])
|
||||
– {{ $event['end']->format('H:i') }}
|
||||
@endif
|
||||
</x-trmnl::label>
|
||||
</td>
|
||||
<td>
|
||||
<x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label>
|
||||
</td>
|
||||
<td>
|
||||
<x-trmnl::label variant="inverted">{{ $event['location'] ?? '—' }}</x-trmnl::label>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<x-trmnl::label>No events available</x-trmnl::label>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</x-trmnl::table>
|
||||
</x-trmnl::layout>
|
||||
|
||||
<x-trmnl::title-bar title="Public Holidays" instance="updated: {{ now()->format('M j, H:i') }}"/>
|
||||
</x-trmnl::view>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
206
routes/api.php
|
|
@ -18,7 +18,7 @@ use Illuminate\Support\Str;
|
|||
Route::get('/display', function (Request $request) {
|
||||
$mac_address = $request->header('id');
|
||||
$access_token = $request->header('access-token');
|
||||
$device = Device::where('mac_address', $mac_address)
|
||||
$device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
|
||||
->where('api_key', $access_token)
|
||||
->first();
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ Route::get('/display', function (Request $request) {
|
|||
if ($auto_assign_user) {
|
||||
// Create a new device and assign it to this user
|
||||
$device = Device::create([
|
||||
'mac_address' => $mac_address,
|
||||
'mac_address' => mb_strtoupper($mac_address ?? ''),
|
||||
'api_key' => $access_token,
|
||||
'user_id' => $auto_assign_user->id,
|
||||
'name' => "{$auto_assign_user->name}'s TRMNL",
|
||||
|
|
@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) {
|
|||
// Check and update stale data if needed
|
||||
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
||||
$plugin->updateDataPayload();
|
||||
$markup = $plugin->render(device: $device);
|
||||
try {
|
||||
$markup = $plugin->render(device: $device);
|
||||
|
||||
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
||||
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
|
||||
// Generate error display
|
||||
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name);
|
||||
$device->update(['current_screen_image' => $errorImageUuid]);
|
||||
}
|
||||
}
|
||||
|
||||
$plugin->refresh();
|
||||
|
|
@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) {
|
|||
}
|
||||
}
|
||||
|
||||
$markup = $playlistItem->render(device: $device);
|
||||
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
||||
try {
|
||||
$markup = $playlistItem->render(device: $device);
|
||||
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage());
|
||||
// For mashups, show error for the first plugin or a generic error
|
||||
$firstPlugin = $plugins->first();
|
||||
$pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe';
|
||||
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName);
|
||||
$device->update(['current_screen_image' => $errorImageUuid]);
|
||||
}
|
||||
|
||||
$device->refresh();
|
||||
|
||||
|
|
@ -204,7 +220,7 @@ Route::get('/setup', function (Request $request) {
|
|||
], 404);
|
||||
}
|
||||
|
||||
$device = Device::where('mac_address', $mac_address)->first();
|
||||
$device = Device::where('mac_address', mb_strtoupper($mac_address))->first();
|
||||
|
||||
if (! $device) {
|
||||
// Check if there's a user with assign_new_devices enabled
|
||||
|
|
@ -219,7 +235,7 @@ Route::get('/setup', function (Request $request) {
|
|||
|
||||
// Create a new device and assign it to this user
|
||||
$device = Device::create([
|
||||
'mac_address' => $mac_address,
|
||||
'mac_address' => mb_strtoupper($mac_address),
|
||||
'api_key' => Str::random(22),
|
||||
'user_id' => $auto_assign_user->id,
|
||||
'name' => "{$auto_assign_user->name}'s TRMNL",
|
||||
|
|
@ -345,7 +361,7 @@ Route::post('/display/update', function (Request $request) {
|
|||
Route::post('/screens', function (Request $request) {
|
||||
$mac_address = $request->header('id');
|
||||
$access_token = $request->header('access-token');
|
||||
$device = Device::where('mac_address', $mac_address)
|
||||
$device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
|
||||
->where('api_key', $access_token)
|
||||
->first();
|
||||
|
||||
|
|
@ -533,6 +549,91 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) {
|
|||
return response()->json(['message' => 'Data updated successfully']);
|
||||
})->name('api.custom_plugins.webhook');
|
||||
|
||||
Route::post('plugin_settings/{uuid}/image', function (Request $request, string $uuid) {
|
||||
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
|
||||
|
||||
// Check if plugin is image_webhook type
|
||||
if ($plugin->plugin_type !== 'image_webhook') {
|
||||
return response()->json(['error' => 'Plugin is not an image webhook plugin'], 400);
|
||||
}
|
||||
|
||||
// Accept image from either multipart form or raw binary
|
||||
$image = null;
|
||||
$extension = null;
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$file = $request->file('image');
|
||||
$extension = mb_strtolower($file->getClientOriginalExtension());
|
||||
$image = $file->get();
|
||||
} elseif ($request->has('image')) {
|
||||
// Base64 encoded image
|
||||
$imageData = $request->input('image');
|
||||
if (preg_match('/^data:image\/(\w+);base64,/', $imageData, $matches)) {
|
||||
$extension = mb_strtolower($matches[1]);
|
||||
$image = base64_decode(mb_substr($imageData, mb_strpos($imageData, ',') + 1));
|
||||
} else {
|
||||
return response()->json(['error' => 'Invalid image format. Expected base64 data URI.'], 400);
|
||||
}
|
||||
} else {
|
||||
// Try raw binary
|
||||
$image = $request->getContent();
|
||||
$contentType = $request->header('Content-Type', '');
|
||||
$trimmedContent = mb_trim($image);
|
||||
|
||||
// Check if content is empty or just empty JSON
|
||||
if (empty($image) || $trimmedContent === '' || $trimmedContent === '{}') {
|
||||
return response()->json(['error' => 'No image data provided'], 400);
|
||||
}
|
||||
|
||||
// If it's a JSON request without image field, return error
|
||||
if (str_contains($contentType, 'application/json')) {
|
||||
return response()->json(['error' => 'No image data provided'], 400);
|
||||
}
|
||||
|
||||
// Detect image type from content
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mimeType = finfo_buffer($finfo, $image);
|
||||
finfo_close($finfo);
|
||||
|
||||
$extension = match ($mimeType) {
|
||||
'image/png' => 'png',
|
||||
'image/bmp' => 'bmp',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $extension) {
|
||||
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate extension
|
||||
$allowedExtensions = ['png', 'bmp'];
|
||||
if (! in_array($extension, $allowedExtensions)) {
|
||||
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
|
||||
}
|
||||
|
||||
// Generate a new UUID for each image upload to prevent device caching
|
||||
$imageUuid = Str::uuid()->toString();
|
||||
$filename = $imageUuid.'.'.$extension;
|
||||
$path = 'images/generated/'.$filename;
|
||||
|
||||
// Save image to storage
|
||||
Storage::disk('public')->put($path, $image);
|
||||
|
||||
// Update plugin's current_image field with the new UUID
|
||||
$plugin->update([
|
||||
'current_image' => $imageUuid,
|
||||
]);
|
||||
|
||||
// Clean up old images
|
||||
ImageGenerationService::cleanupFolder();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Image uploaded successfully',
|
||||
'image_url' => url('storage/'.$path),
|
||||
]);
|
||||
})->name('api.plugin_settings.image');
|
||||
|
||||
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
|
||||
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
|
||||
return response()->json([
|
||||
|
|
@ -577,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');
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ Route::middleware(['auth'])->group(function () {
|
|||
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
|
||||
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
|
||||
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
|
||||
Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook');
|
||||
Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance');
|
||||
Volt::route('playlists', 'playlists.index')->name('playlists.index');
|
||||
|
||||
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\Playlist;
|
|||
use App\Models\PlaylistItem;
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use App\Services\ImageGenerationService;
|
||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -954,3 +955,232 @@ test('setup endpoint handles non-existent device model gracefully', function ():
|
|||
expect($device)->not->toBeNull()
|
||||
->and($device->device_model_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('setup endpoint matches MAC address case-insensitively', function (): void {
|
||||
// Create device with lowercase MAC address
|
||||
$device = Device::factory()->create([
|
||||
'mac_address' => 'a1:b2:c3:d4:e5:f6',
|
||||
'api_key' => 'test-api-key',
|
||||
'friendly_id' => 'test-device',
|
||||
]);
|
||||
|
||||
// Request with uppercase MAC address should still match
|
||||
$response = $this->withHeaders([
|
||||
'id' => 'A1:B2:C3:D4:E5:F6',
|
||||
])->get('/api/setup');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'status' => 200,
|
||||
'api_key' => 'test-api-key',
|
||||
'friendly_id' => 'test-device',
|
||||
'message' => 'Welcome to TRMNL BYOS',
|
||||
]);
|
||||
});
|
||||
|
||||
test('display endpoint matches MAC address case-insensitively', function (): void {
|
||||
// Create device with lowercase MAC address
|
||||
$device = Device::factory()->create([
|
||||
'mac_address' => 'a1:b2:c3:d4:e5:f6',
|
||||
'api_key' => 'test-api-key',
|
||||
'current_screen_image' => 'test-image',
|
||||
]);
|
||||
|
||||
// Request with uppercase MAC address should still match
|
||||
$response = $this->withHeaders([
|
||||
'id' => 'A1:B2:C3:D4:E5:F6',
|
||||
'access-token' => $device->api_key,
|
||||
'rssi' => -70,
|
||||
'battery_voltage' => 3.8,
|
||||
'fw-version' => '1.0.0',
|
||||
])->get('/api/display');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'status' => '0',
|
||||
'filename' => 'test-image.bmp',
|
||||
]);
|
||||
});
|
||||
|
||||
test('screens endpoint matches MAC address case-insensitively', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
// Create device with uppercase MAC address
|
||||
$device = Device::factory()->create([
|
||||
'mac_address' => 'A1:B2:C3:D4:E5:F6',
|
||||
'api_key' => 'test-api-key',
|
||||
]);
|
||||
|
||||
// Request with lowercase MAC address should still match
|
||||
$response = $this->withHeaders([
|
||||
'id' => 'a1:b2:c3:d4:e5:f6',
|
||||
'access-token' => $device->api_key,
|
||||
])->post('/api/screens', [
|
||||
'image' => [
|
||||
'content' => '<div>Test content</div>',
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
Queue::assertPushed(GenerateScreenJob::class);
|
||||
});
|
||||
|
||||
test('display endpoint handles plugin rendering errors gracefully', function (): void {
|
||||
TrmnlPipeline::fake();
|
||||
|
||||
$device = Device::factory()->create([
|
||||
'mac_address' => '00:11:22:33:44:55',
|
||||
'api_key' => 'test-api-key',
|
||||
'proxy_cloud' => false,
|
||||
]);
|
||||
|
||||
// Create a plugin with Blade markup that will cause an exception when accessing data[0]
|
||||
// when data is not an array or doesn't have index 0
|
||||
$plugin = Plugin::factory()->create([
|
||||
'name' => 'Broken Recipe',
|
||||
'data_strategy' => 'polling',
|
||||
'polling_url' => null,
|
||||
'data_stale_minutes' => 1,
|
||||
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
|
||||
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail if data[0] doesn't exist
|
||||
'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail
|
||||
'data_payload_updated_at' => now()->subMinutes(2), // Make it stale
|
||||
'current_image' => null,
|
||||
]);
|
||||
|
||||
$playlist = Playlist::factory()->create([
|
||||
'device_id' => $device->id,
|
||||
'name' => 'test_playlist',
|
||||
'is_active' => true,
|
||||
'weekdays' => null,
|
||||
'active_from' => null,
|
||||
'active_until' => null,
|
||||
]);
|
||||
|
||||
PlaylistItem::factory()->create([
|
||||
'playlist_id' => $playlist->id,
|
||||
'plugin_id' => $plugin->id,
|
||||
'order' => 1,
|
||||
'is_active' => true,
|
||||
'last_displayed_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'rssi' => -70,
|
||||
'battery_voltage' => 3.8,
|
||||
'fw-version' => '1.0.0',
|
||||
])->get('/api/display');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Verify error screen was generated and set on device
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->not->toBeNull();
|
||||
|
||||
// Verify the error image exists
|
||||
$errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png");
|
||||
// The TrmnlPipeline is faked, so we just verify the UUID was set
|
||||
expect($device->current_screen_image)->toBeString();
|
||||
});
|
||||
|
||||
test('display endpoint handles mashup rendering errors gracefully', function (): void {
|
||||
TrmnlPipeline::fake();
|
||||
|
||||
$device = Device::factory()->create([
|
||||
'mac_address' => '00:11:22:33:44:55',
|
||||
'api_key' => 'test-api-key',
|
||||
'proxy_cloud' => false,
|
||||
]);
|
||||
|
||||
// Create plugins for mashup, one with invalid markup
|
||||
$plugin1 = Plugin::factory()->create([
|
||||
'name' => 'Working Plugin',
|
||||
'data_strategy' => 'polling',
|
||||
'polling_url' => null,
|
||||
'data_stale_minutes' => 1,
|
||||
'render_markup_view' => 'trmnl',
|
||||
'data_payload_updated_at' => now()->subMinutes(2),
|
||||
'current_image' => null,
|
||||
]);
|
||||
|
||||
$plugin2 = Plugin::factory()->create([
|
||||
'name' => 'Broken Plugin',
|
||||
'data_strategy' => 'polling',
|
||||
'polling_url' => null,
|
||||
'data_stale_minutes' => 1,
|
||||
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
|
||||
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail
|
||||
'data_payload' => ['error' => 'Failed to fetch data'],
|
||||
'data_payload_updated_at' => now()->subMinutes(2),
|
||||
'current_image' => null,
|
||||
]);
|
||||
|
||||
$playlist = Playlist::factory()->create([
|
||||
'device_id' => $device->id,
|
||||
'name' => 'test_playlist',
|
||||
'is_active' => true,
|
||||
'weekdays' => null,
|
||||
'active_from' => null,
|
||||
'active_until' => null,
|
||||
]);
|
||||
|
||||
// Create mashup playlist item
|
||||
$playlistItem = PlaylistItem::createMashup(
|
||||
$playlist,
|
||||
'1Lx1R',
|
||||
[$plugin1->id, $plugin2->id],
|
||||
'Test Mashup',
|
||||
1
|
||||
);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'rssi' => -70,
|
||||
'battery_voltage' => 3.8,
|
||||
'fw-version' => '1.0.0',
|
||||
])->get('/api/display');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
// Verify error screen was generated and set on device
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->not->toBeNull();
|
||||
|
||||
// Verify the error image UUID was set
|
||||
expect($device->current_screen_image)->toBeString();
|
||||
});
|
||||
|
||||
test('generateDefaultScreenImage creates error screen with plugin name', function (): void {
|
||||
TrmnlPipeline::fake();
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->makeDirectory('/images/generated');
|
||||
|
||||
$device = Device::factory()->create();
|
||||
|
||||
$errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name');
|
||||
|
||||
expect($errorUuid)->not->toBeEmpty();
|
||||
|
||||
// Verify the error image path would be created
|
||||
$errorPath = "images/generated/{$errorUuid}.png";
|
||||
// Since TrmnlPipeline is faked, we just verify the UUID was generated
|
||||
expect($errorUuid)->toBeString();
|
||||
});
|
||||
|
||||
test('generateDefaultScreenImage throws exception for invalid error image type', function (): void {
|
||||
$device = Device::factory()->create();
|
||||
|
||||
expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void {
|
||||
$device = new Device();
|
||||
$device->deviceModel = null;
|
||||
|
||||
$result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error');
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
|
|
|||
196
tests/Feature/Api/ImageWebhookTest.php
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->makeDirectory('/images/generated');
|
||||
});
|
||||
|
||||
test('can upload image to image webhook plugin via multipart form', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('test.png', 800, 480);
|
||||
|
||||
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
|
||||
'image' => $image,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'message',
|
||||
'image_url',
|
||||
]);
|
||||
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)
|
||||
->not->toBeNull()
|
||||
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
|
||||
|
||||
// File should exist with the new UUID
|
||||
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
|
||||
|
||||
// Image URL should contain the new UUID
|
||||
expect($response->json('image_url'))
|
||||
->toContain($plugin->current_image);
|
||||
});
|
||||
|
||||
test('can upload image to image webhook plugin via raw binary', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
// Create a simple PNG image binary
|
||||
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
||||
|
||||
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
|
||||
'CONTENT_TYPE' => 'image/png',
|
||||
], $pngData);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'message',
|
||||
'image_url',
|
||||
]);
|
||||
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)
|
||||
->not->toBeNull()
|
||||
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
|
||||
|
||||
// File should exist with the new UUID
|
||||
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
|
||||
|
||||
// Image URL should contain the new UUID
|
||||
expect($response->json('image_url'))
|
||||
->toContain($plugin->current_image);
|
||||
});
|
||||
|
||||
test('can upload image to image webhook plugin via base64 data URI', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
// Create a simple PNG image as base64 data URI
|
||||
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", [
|
||||
'image' => $base64Image,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'message',
|
||||
'image_url',
|
||||
]);
|
||||
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)
|
||||
->not->toBeNull()
|
||||
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
|
||||
|
||||
// File should exist with the new UUID
|
||||
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
|
||||
|
||||
// Image URL should contain the new UUID
|
||||
expect($response->json('image_url'))
|
||||
->toContain($plugin->current_image);
|
||||
});
|
||||
|
||||
test('returns 400 for non-image-webhook plugin', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'plugin_type' => 'recipe',
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('test.png', 800, 480);
|
||||
|
||||
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
|
||||
'image' => $image,
|
||||
]);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJson(['error' => 'Plugin is not an image webhook plugin']);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent plugin', function (): void {
|
||||
$image = UploadedFile::fake()->image('test.png', 800, 480);
|
||||
|
||||
$response = $this->post('/api/plugin_settings/'.Str::uuid().'/image', [
|
||||
'image' => $image,
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('returns 400 for unsupported image format', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
// Create a fake GIF file (not supported)
|
||||
$gifData = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||
|
||||
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
|
||||
'CONTENT_TYPE' => 'image/gif',
|
||||
], $gifData);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
|
||||
});
|
||||
|
||||
test('returns 400 for JPG image format', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
// Create a fake JPG file (not supported)
|
||||
$jpgData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A');
|
||||
|
||||
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
|
||||
'CONTENT_TYPE' => 'image/jpeg',
|
||||
], $jpgData);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
|
||||
});
|
||||
|
||||
test('returns 400 when no image data provided', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", []);
|
||||
|
||||
$response->assertStatus(400)
|
||||
->assertJson(['error' => 'No image data provided']);
|
||||
});
|
||||
|
||||
test('image webhook plugin isDataStale returns false', function (): void {
|
||||
$plugin = Plugin::factory()->imageWebhook()->create();
|
||||
|
||||
expect($plugin->isDataStale())->toBeFalse();
|
||||
});
|
||||
|
||||
test('image webhook plugin factory creates correct plugin type', function (): void {
|
||||
$plugin = Plugin::factory()->imageWebhook()->create();
|
||||
|
||||
expect($plugin)
|
||||
->plugin_type->toBe('image_webhook')
|
||||
->data_strategy->toBe('static');
|
||||
});
|
||||
|
|
@ -324,6 +324,30 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
|
|||
expect($plugin->current_image)->toBe('test-uuid');
|
||||
});
|
||||
|
||||
it('cache is reset when plugin markup changes', function (): void {
|
||||
// Create a plugin with cached image
|
||||
$plugin = App\Models\Plugin::factory()->create([
|
||||
'current_image' => 'cached-uuid',
|
||||
'render_markup' => '<div>Original markup</div>',
|
||||
]);
|
||||
|
||||
// Create devices with standard dimensions (cacheable)
|
||||
Device::factory()->count(2)->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rotate' => 0,
|
||||
]);
|
||||
|
||||
// Update the plugin markup
|
||||
$plugin->update([
|
||||
'render_markup' => '<div>Updated markup</div>',
|
||||
]);
|
||||
|
||||
// Assert cache was reset when markup changed
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)->toBeNull();
|
||||
});
|
||||
|
||||
it('determines correct image format from device model', function (): void {
|
||||
// Test BMP format detection
|
||||
$bmpModel = DeviceModel::factory()->create([
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,46 @@ it('loads plugins from catalog URL', function (): void {
|
|||
$component->assertSee('testuser');
|
||||
$component->assertSee('A test plugin');
|
||||
$component->assertSee('MIT');
|
||||
$component->assertSee('Preview');
|
||||
});
|
||||
|
||||
it('hides preview button when screenshot_url is missing', function (): void {
|
||||
// Clear cache first to ensure fresh data
|
||||
Cache::forget('catalog_plugins');
|
||||
|
||||
// Mock the HTTP response for the catalog URL without screenshot_url
|
||||
$catalogData = [
|
||||
'test-plugin' => [
|
||||
'name' => 'Test Plugin Without Screenshot',
|
||||
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
||||
'author_bio' => [
|
||||
'description' => 'A test plugin',
|
||||
],
|
||||
'license' => 'MIT',
|
||||
'trmnlp' => [
|
||||
'zip_url' => 'https://example.com/plugin.zip',
|
||||
],
|
||||
'byos' => [
|
||||
'byos_laravel' => [
|
||||
'compatibility' => true,
|
||||
],
|
||||
],
|
||||
'logo_url' => 'https://example.com/logo.png',
|
||||
'screenshot_url' => null,
|
||||
],
|
||||
];
|
||||
|
||||
$yamlContent = Yaml::dump($catalogData);
|
||||
|
||||
Http::fake([
|
||||
config('app.catalog_url') => Http::response($yamlContent, 200),
|
||||
]);
|
||||
|
||||
Livewire::withoutLazyLoading();
|
||||
|
||||
Volt::test('catalog.index')
|
||||
->assertSee('Test Plugin Without Screenshot')
|
||||
->assertDontSeeHtml('variant="subtle" icon="eye"');
|
||||
});
|
||||
|
||||
it('shows error when plugin not found', function (): void {
|
||||
|
|
@ -114,3 +154,46 @@ it('shows error when zip_url is missing', function (): void {
|
|||
$component->assertHasErrors();
|
||||
|
||||
});
|
||||
|
||||
it('can preview a plugin', function (): void {
|
||||
// Clear cache first to ensure fresh data
|
||||
Cache::forget('catalog_plugins');
|
||||
|
||||
// Mock the HTTP response for the catalog URL
|
||||
$catalogData = [
|
||||
'test-plugin' => [
|
||||
'name' => 'Test Plugin',
|
||||
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
||||
'author_bio' => [
|
||||
'description' => 'A test plugin description',
|
||||
],
|
||||
'license' => 'MIT',
|
||||
'trmnlp' => [
|
||||
'zip_url' => 'https://example.com/plugin.zip',
|
||||
],
|
||||
'byos' => [
|
||||
'byos_laravel' => [
|
||||
'compatibility' => true,
|
||||
],
|
||||
],
|
||||
'logo_url' => 'https://example.com/logo.png',
|
||||
'screenshot_url' => 'https://example.com/screenshot.png',
|
||||
],
|
||||
];
|
||||
|
||||
$yamlContent = Yaml::dump($catalogData);
|
||||
|
||||
Http::fake([
|
||||
config('app.catalog_url') => Http::response($yamlContent, 200),
|
||||
]);
|
||||
|
||||
Livewire::withoutLazyLoading();
|
||||
|
||||
Volt::test('catalog.index')
|
||||
->assertSee('Test Plugin')
|
||||
->call('previewPlugin', 'test-plugin')
|
||||
->assertSet('previewingPlugin', 'test-plugin')
|
||||
->assertSet('previewData.name', 'Test Plugin')
|
||||
->assertSee('Preview Test Plugin')
|
||||
->assertSee('A test plugin description');
|
||||
});
|
||||
|
|
|
|||
124
tests/Feature/Livewire/Plugins/ConfigModalTest.php
Normal 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');
|
||||
});
|
||||
112
tests/Feature/Livewire/Plugins/RecipeSettingsTest.php
Normal 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();
|
||||
});
|
||||
|
|
@ -130,3 +130,48 @@ test('playlist isActiveNow handles normal time ranges correctly', function (): v
|
|||
Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0));
|
||||
expect($playlist->isActiveNow())->toBeFalse();
|
||||
});
|
||||
|
||||
test('playlist scheduling respects user timezone preference', function (): void {
|
||||
// Create a user with a timezone that's UTC+1 (e.g., Europe/Berlin)
|
||||
// This simulates the bug where setting 00:15 doesn't work until one hour later
|
||||
$user = User::factory()->create([
|
||||
'timezone' => 'Europe/Berlin', // UTC+1 in winter, UTC+2 in summer
|
||||
]);
|
||||
|
||||
$device = Device::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
// Create a playlist that should be active from 00:15 to 01:00 in the user's timezone
|
||||
$playlist = Playlist::factory()->create([
|
||||
'device_id' => $device->id,
|
||||
'is_active' => true,
|
||||
'active_from' => '00:15',
|
||||
'active_until' => '01:00',
|
||||
'weekdays' => null,
|
||||
]);
|
||||
|
||||
// Set test time to 00:15 in the user's timezone (Europe/Berlin)
|
||||
// In January, Europe/Berlin is UTC+1, so 00:15 Berlin time = 23:15 UTC the previous day
|
||||
// But Carbon::setTestNow uses UTC by default, so we need to set it to the UTC equivalent
|
||||
// For January 1, 2024 at 00:15 Berlin time (UTC+1), that's December 31, 2023 at 23:15 UTC
|
||||
$berlinTime = Carbon::create(2024, 1, 1, 0, 15, 0, 'Europe/Berlin');
|
||||
Carbon::setTestNow($berlinTime->utc());
|
||||
|
||||
// The playlist should be active at 00:15 in the user's timezone
|
||||
// This test should pass after the fix, but will fail with the current bug
|
||||
expect($playlist->isActiveNow())->toBeTrue();
|
||||
|
||||
// Test at 00:30 in user's timezone - should still be active
|
||||
$berlinTime = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Berlin');
|
||||
Carbon::setTestNow($berlinTime->utc());
|
||||
expect($playlist->isActiveNow())->toBeTrue();
|
||||
|
||||
// Test at 01:15 in user's timezone - should NOT be active (past the end time)
|
||||
$berlinTime = Carbon::create(2024, 1, 1, 1, 15, 0, 'Europe/Berlin');
|
||||
Carbon::setTestNow($berlinTime->utc());
|
||||
expect($playlist->isActiveNow())->toBeFalse();
|
||||
|
||||
// Test at 00:10 in user's timezone - should NOT be active (before start time)
|
||||
$berlinTime = Carbon::create(2024, 1, 1, 0, 10, 0, 'Europe/Berlin');
|
||||
Carbon::setTestNow($berlinTime->utc());
|
||||
expect($playlist->isActiveNow())->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('plugin parses JSON responses correctly', function (): void {
|
||||
|
|
@ -191,3 +192,96 @@ test('plugin handles POST requests with XML responses', function (): void {
|
|||
expect($plugin->data_payload['rss']['status'])->toBe('success');
|
||||
expect($plugin->data_payload['rss']['data'])->toBe('test');
|
||||
});
|
||||
|
||||
test('plugin parses iCal responses and filters to recent window', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
|
||||
|
||||
$icalContent = <<<'ICS'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:event-1@example.com
|
||||
DTSTAMP:20250101T120000Z
|
||||
DTSTART:20250110T090000Z
|
||||
DTEND:20250110T100000Z
|
||||
SUMMARY:Past within window
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event-2@example.com
|
||||
DTSTAMP:20250101T120000Z
|
||||
DTSTART:20250301T090000Z
|
||||
DTEND:20250301T100000Z
|
||||
SUMMARY:Far future
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:event-3@example.com
|
||||
DTSTAMP:20250101T120000Z
|
||||
DTSTART:20250120T090000Z
|
||||
DTEND:20250120T100000Z
|
||||
SUMMARY:Upcoming within window
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
ICS;
|
||||
|
||||
Http::fake([
|
||||
'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
|
||||
]);
|
||||
|
||||
$plugin = Plugin::factory()->create([
|
||||
'data_strategy' => 'polling',
|
||||
'polling_url' => 'https://example.com/calendar.ics',
|
||||
'polling_verb' => 'get',
|
||||
]);
|
||||
|
||||
$plugin->updateDataPayload();
|
||||
$plugin->refresh();
|
||||
|
||||
$ical = $plugin->data_payload['ical'];
|
||||
|
||||
expect($ical)->toHaveCount(2);
|
||||
expect($ical[0]['SUMMARY'])->toBe('Past within window');
|
||||
expect($ical[1]['SUMMARY'])->toBe('Upcoming within window');
|
||||
expect(collect($ical)->pluck('SUMMARY'))->not->toContain('Far future');
|
||||
expect($ical[0]['DTSTART'])->toBe('2025-01-10T09:00:00+00:00');
|
||||
expect($ical[1]['DTSTART'])->toBe('2025-01-20T09:00:00+00:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('plugin detects iCal content without calendar content type', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
|
||||
|
||||
$icalContent = <<<'ICS'
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
BEGIN:VEVENT
|
||||
UID:event-body-detected@example.com
|
||||
DTSTAMP:20250101T120000Z
|
||||
DTSTART:20250116T090000Z
|
||||
DTEND:20250116T100000Z
|
||||
SUMMARY:Detected by body
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
ICS;
|
||||
|
||||
Http::fake([
|
||||
'example.com/calendar-body.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/plain']),
|
||||
]);
|
||||
|
||||
$plugin = Plugin::factory()->create([
|
||||
'data_strategy' => 'polling',
|
||||
'polling_url' => 'https://example.com/calendar-body.ics',
|
||||
'polling_verb' => 'get',
|
||||
]);
|
||||
|
||||
$plugin->updateDataPayload();
|
||||
$plugin->refresh();
|
||||
|
||||
expect($plugin->data_payload)->toHaveKey('ical');
|
||||
expect($plugin->data_payload['ical'])->toHaveCount(1);
|
||||
expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Detected by body');
|
||||
expect($plugin->data_payload['ical'][0]['DTSTART'])->toBe('2025-01-16T09:00:00+00:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
|
@ -28,9 +28,33 @@ it('loads newest TRMNL recipes on mount', function (): void {
|
|||
Volt::test('catalog.trmnl')
|
||||
->assertSee('Weather Chum')
|
||||
->assertSee('Install')
|
||||
->assertDontSeeHtml('variant="subtle" icon="eye"')
|
||||
->assertSee('Installs: 10');
|
||||
});
|
||||
|
||||
it('shows preview button when screenshot_url is provided', function (): void {
|
||||
Http::fake([
|
||||
'usetrmnl.com/recipes.json*' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 123,
|
||||
'name' => 'Weather Chum',
|
||||
'icon_url' => 'https://example.com/icon.png',
|
||||
'screenshot_url' => 'https://example.com/screenshot.png',
|
||||
'author_bio' => null,
|
||||
'stats' => ['installs' => 10, 'forks' => 2],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::withoutLazyLoading();
|
||||
|
||||
Volt::test('catalog.trmnl')
|
||||
->assertSee('Weather Chum')
|
||||
->assertSee('Preview');
|
||||
});
|
||||
|
||||
it('searches TRMNL recipes when search term is provided', function (): void {
|
||||
Http::fake([
|
||||
// First call (mount -> newest)
|
||||
|
|
@ -152,3 +176,111 @@ it('shows error when plugin installation fails', function (): void {
|
|||
->call('installPlugin', '123')
|
||||
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
|
||||
});
|
||||
|
||||
it('previews a recipe with async fetch', function (): void {
|
||||
Http::fake([
|
||||
'usetrmnl.com/recipes.json*' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 123,
|
||||
'name' => 'Weather Chum',
|
||||
'icon_url' => 'https://example.com/icon.png',
|
||||
'screenshot_url' => 'https://example.com/old.png',
|
||||
'author_bio' => null,
|
||||
'stats' => ['installs' => 10, 'forks' => 2],
|
||||
],
|
||||
],
|
||||
], 200),
|
||||
'usetrmnl.com/recipes/123.json' => Http::response([
|
||||
'data' => [
|
||||
'id' => 123,
|
||||
'name' => 'Weather Chum Updated',
|
||||
'icon_url' => 'https://example.com/icon.png',
|
||||
'screenshot_url' => 'https://example.com/new.png',
|
||||
'author_bio' => ['description' => 'New bio'],
|
||||
'stats' => ['installs' => 11, 'forks' => 3],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::withoutLazyLoading();
|
||||
|
||||
Volt::test('catalog.trmnl')
|
||||
->assertSee('Weather Chum')
|
||||
->call('previewRecipe', '123')
|
||||
->assertSet('previewingRecipe', '123')
|
||||
->assertSet('previewData.name', 'Weather Chum Updated')
|
||||
->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
|
||||
->assertSee('Preview Weather Chum Updated')
|
||||
->assertSee('New bio');
|
||||
});
|
||||
|
||||
it('supports pagination and loading more recipes', function (): void {
|
||||
Http::fake([
|
||||
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 1,
|
||||
'name' => 'Recipe Page 1',
|
||||
'icon_url' => null,
|
||||
'screenshot_url' => null,
|
||||
'author_bio' => null,
|
||||
'stats' => ['installs' => 1, 'forks' => 0],
|
||||
],
|
||||
],
|
||||
'next_page_url' => '/recipes.json?page=2',
|
||||
], 200),
|
||||
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
|
||||
'data' => [
|
||||
[
|
||||
'id' => 2,
|
||||
'name' => 'Recipe Page 2',
|
||||
'icon_url' => null,
|
||||
'screenshot_url' => null,
|
||||
'author_bio' => null,
|
||||
'stats' => ['installs' => 2, 'forks' => 0],
|
||||
],
|
||||
],
|
||||
'next_page_url' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
Livewire::withoutLazyLoading();
|
||||
|
||||
Volt::test('catalog.trmnl')
|
||||
->assertSee('Recipe Page 1')
|
||||
->assertDontSee('Recipe Page 2')
|
||||
->assertSee('Load next page')
|
||||
->call('loadMore')
|
||||
->assertSee('Recipe Page 1')
|
||||
->assertSee('Recipe Page 2')
|
||||
->assertDontSee('Load next page');
|
||||
});
|
||||
|
||||
it('resets pagination when search term changes', function (): void {
|
||||
Http::fake([
|
||||
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
|
||||
->push([
|
||||
'data' => [['id' => 1, 'name' => 'Initial 1']],
|
||||
'next_page_url' => '/recipes.json?page=2',
|
||||
])
|
||||
->push([
|
||||
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
|
||||
'next_page_url' => null,
|
||||
]),
|
||||
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
|
||||
'data' => [['id' => 2, 'name' => 'Weather Result']],
|
||||
'next_page_url' => null,
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::withoutLazyLoading();
|
||||
|
||||
Volt::test('catalog.trmnl')
|
||||
->assertSee('Initial 1')
|
||||
->call('loadMore')
|
||||
->set('search', 'weather')
|
||||
->assertSee('Weather Result')
|
||||
->assertDontSee('Initial 1')
|
||||
->assertSet('page', 1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -679,3 +708,233 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context
|
|||
->toContain('America/Chicago')
|
||||
->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
|
||||
});
|
||||
|
||||
/**
|
||||
* Plugin security: XSS Payload Dataset
|
||||
* [Input, Expected Result, Forbidden String]
|
||||
*/
|
||||
dataset('xss_vectors', [
|
||||
'standard_script' => ['Safe <script>alert(1)</script>', 'Safe ', '<script>'],
|
||||
'attribute_event_handlers' => ['<a onmouseover="alert(1)">Link</a>', '<a>Link</a>', 'onmouseover'],
|
||||
'javascript_protocol' => ['<a href="javascript:alert(1)">Click</a>', '<a>Click</a>', 'javascript:'],
|
||||
'iframe_injection' => ['Watch <iframe src="https://x.com"></iframe>', 'Watch ', '<iframe>'],
|
||||
'img_onerror_fallback' => ['Photo <img src=x onerror=alert(1)>', 'Photo <img src="x" alt="x">', 'onerror'],
|
||||
]);
|
||||
|
||||
test('plugin model sanitizes template fields on save', function (string $input, string $expected, string $forbidden): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
// We test the Model logic directly. This triggers the static::saving hook.
|
||||
$plugin = Plugin::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Security Test',
|
||||
'data_stale_minutes' => 15,
|
||||
'data_strategy' => 'static',
|
||||
'polling_verb' => 'get',
|
||||
'configuration_template' => [
|
||||
'custom_fields' => [
|
||||
[
|
||||
'keyname' => 'test_field',
|
||||
'description' => $input,
|
||||
'help_text' => $input,
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$field = $plugin->fresh()->configuration_template['custom_fields'][0];
|
||||
|
||||
// Assert the saved data is clean
|
||||
expect($field['description'])->toBe($expected)
|
||||
->and($field['help_text'])->toBe($expected)
|
||||
->and($field['description'])->not->toContain($forbidden);
|
||||
})->with('xss_vectors');
|
||||
|
||||
test('plugin model preserves multi_string csv format', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$plugin = Plugin::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Multi-string Test',
|
||||
'data_stale_minutes' => 15,
|
||||
'data_strategy' => 'static',
|
||||
'polling_verb' => 'get',
|
||||
'configuration' => [
|
||||
'tags' => 'laravel,pest,security',
|
||||
],
|
||||
]);
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||