Compare commits
No commits in common. "main" and "0.22.1" have entirely different histories.
|
|
@ -9,8 +9,7 @@ RUN apk add --no-cache composer
|
||||||
# Add Chromium and Image Magick for puppeteer.
|
# Add Chromium and Image Magick for puppeteer.
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
imagemagick-dev \
|
imagemagick-dev \
|
||||||
chromium \
|
chromium
|
||||||
libzip-dev
|
|
||||||
|
|
||||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
ENV PUPPETEER_DOCKER=1
|
ENV PUPPETEER_DOCKER=1
|
||||||
|
|
@ -20,7 +19,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
|
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
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-install imagick zip
|
RUN docker-php-ext-install imagick
|
||||||
|
|
||||||
# Composer uses its php binary, but we want it to use the container's one
|
# Composer uses its php binary, but we want it to use the container's one
|
||||||
RUN rm -f /usr/bin/php84
|
RUN rm -f /usr/bin/php84
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@ RUN apk add --no-cache \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
imagemagick-dev \
|
imagemagick-dev \
|
||||||
chromium \
|
chromium
|
||||||
libzip-dev
|
|
||||||
|
|
||||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
ENV PUPPETEER_DOCKER=1
|
ENV PUPPETEER_DOCKER=1
|
||||||
|
|
@ -25,7 +24,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
|
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
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-install imagick zip
|
RUN docker-php-ext-install imagick
|
||||||
|
|
||||||
RUN rm -f /usr/bin/php84
|
RUN rm -f /usr/bin/php84
|
||||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||||
|
|
|
||||||
5
.gitignore
vendored
|
|
@ -29,8 +29,3 @@ yarn-error.log
|
||||||
/.junie/guidelines.md
|
/.junie/guidelines.md
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/.mcp.json
|
/.mcp.json
|
||||||
/.ai
|
|
||||||
.DS_Store
|
|
||||||
/boost.json
|
|
||||||
/.gemini
|
|
||||||
/GEMINI.md
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
########################
|
########################
|
||||||
# Base Image
|
# Base Image
|
||||||
########################
|
########################
|
||||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
|
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
|
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
|
||||||
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
[](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.
|
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** (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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,6 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
||||||
'offset_x' => $modelData['offset_x'] ?? 0,
|
'offset_x' => $modelData['offset_x'] ?? 0,
|
||||||
'offset_y' => $modelData['offset_y'] ?? 0,
|
'offset_y' => $modelData['offset_y'] ?? 0,
|
||||||
'published_at' => $modelData['published_at'] ?? null,
|
'published_at' => $modelData['published_at'] ?? null,
|
||||||
'kind' => $modelData['kind'] ?? null,
|
|
||||||
'source' => 'api',
|
'source' => 'api',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,6 @@ class Data extends FiltersProvider
|
||||||
*/
|
*/
|
||||||
public function map_to_i(array $input): array
|
public function map_to_i(array $input): array
|
||||||
{
|
{
|
||||||
return array_map(intval(...), $input);
|
return array_map('intval', $input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,6 @@ class Device extends Model
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
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 = [
|
protected $casts = [
|
||||||
'battery_notification_sent' => 'boolean',
|
'battery_notification_sent' => 'boolean',
|
||||||
'proxy_cloud' => 'boolean',
|
'proxy_cloud' => 'boolean',
|
||||||
|
|
|
||||||
|
|
@ -37,32 +37,21 @@ class Playlist extends Model
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user's timezone or fall back to app timezone
|
// Check weekday
|
||||||
$timezone = $this->device->user->timezone ?? config('app.timezone');
|
if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) {
|
||||||
$now = now($timezone);
|
|
||||||
|
|
||||||
// Check weekday (using timezone-aware time)
|
|
||||||
if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->active_from !== null && $this->active_until !== null) {
|
if ($this->active_from !== null && $this->active_until !== null) {
|
||||||
// Create timezone-aware datetime objects for active_from and active_until
|
$now = now();
|
||||||
$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
|
// Handle time ranges that span across midnight
|
||||||
if ($activeFrom > $activeUntil) {
|
if ($this->active_from > $this->active_until) {
|
||||||
// Time range spans midnight (e.g., 09:01 to 03:58)
|
// Time range spans midnight (e.g., 09:01 to 03:58)
|
||||||
if ($now >= $activeFrom || $now <= $activeUntil) {
|
if ($now >= $this->active_from || $now <= $this->active_until) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} elseif ($now >= $activeFrom && $now <= $activeUntil) {
|
} elseif ($now >= $this->active_from && $now <= $this->active_until) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ use App\Liquid\Filters\StandardFilters;
|
||||||
use App\Liquid\Filters\StringMarkup;
|
use App\Liquid\Filters\StringMarkup;
|
||||||
use App\Liquid\Filters\Uniqueness;
|
use App\Liquid\Filters\Uniqueness;
|
||||||
use App\Liquid\Tags\TemplateTag;
|
use App\Liquid\Tags\TemplateTag;
|
||||||
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -24,10 +23,10 @@ use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Process;
|
use Illuminate\Support\Facades\Process;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use InvalidArgumentException;
|
|
||||||
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
||||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
use Keepsuit\Liquid\Extensions\StandardExtension;
|
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||||
|
use SimpleXMLElement;
|
||||||
|
|
||||||
class Plugin extends Model
|
class Plugin extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -45,8 +44,6 @@ class Plugin extends Model
|
||||||
'no_bleed' => 'boolean',
|
'no_bleed' => 'boolean',
|
||||||
'dark_mode' => 'boolean',
|
'dark_mode' => 'boolean',
|
||||||
'preferred_renderer' => 'string',
|
'preferred_renderer' => 'string',
|
||||||
'plugin_type' => 'string',
|
|
||||||
'alias' => 'boolean',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
|
|
@ -58,18 +55,6 @@ class Plugin extends Model
|
||||||
$model->uuid = Str::uuid();
|
$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()
|
public function user()
|
||||||
|
|
@ -77,25 +62,6 @@ class Plugin extends Model
|
||||||
return $this->belongsTo(User::class);
|
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
|
public function hasMissingRequiredConfigurationFields(): bool
|
||||||
{
|
{
|
||||||
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
||||||
|
|
@ -136,11 +102,6 @@ class Plugin extends Model
|
||||||
|
|
||||||
public function isDataStale(): bool
|
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') {
|
if ($this->data_strategy === 'webhook') {
|
||||||
// Treat as stale if any webhook event has occurred in the past hour
|
// 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());
|
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
||||||
|
|
@ -154,13 +115,12 @@ class Plugin extends Model
|
||||||
|
|
||||||
public function updateDataPayload(): void
|
public function updateDataPayload(): void
|
||||||
{
|
{
|
||||||
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
if ($this->data_strategy === 'polling' && $this->polling_url) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||||
|
|
||||||
// resolve headers
|
|
||||||
if ($this->polling_header) {
|
if ($this->polling_header) {
|
||||||
|
// Resolve Liquid variables in the polling header
|
||||||
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
||||||
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
||||||
foreach ($headerLines as $line) {
|
foreach ($headerLines as $line) {
|
||||||
|
|
@ -171,71 +131,145 @@ class Plugin extends Model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolve and clean URLs
|
// Resolve Liquid variables in the entire polling_url field first, then split by newline
|
||||||
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
||||||
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
|
$urls = array_filter(
|
||||||
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
||||||
fn ($url): bool => filled($url)
|
fn ($url): bool => ! empty($url)
|
||||||
));
|
);
|
||||||
|
|
||||||
$combinedResponse = [];
|
// If only one URL, use the original logic without nesting
|
||||||
|
if (count($urls) === 1) {
|
||||||
// Loop through all URLs (Handles 1 or many)
|
$url = reset($urls);
|
||||||
foreach ($urls as $index => $url) {
|
|
||||||
$httpRequest = Http::withHeaders($headers);
|
$httpRequest = Http::withHeaders($headers);
|
||||||
|
|
||||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||||
|
// Resolve Liquid variables in the polling body
|
||||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// URL is already resolved, use it directly
|
||||||
|
$resolvedUrl = $url;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$httpResponse = ($this->polling_verb === 'post')
|
// Make the request based on the verb
|
||||||
? $httpRequest->post($url)
|
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
||||||
: $httpRequest->get($url);
|
|
||||||
|
|
||||||
$response = $this->parseResponse($httpResponse);
|
$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([
|
$this->update([
|
||||||
'data_payload' => $finalPayload,
|
'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(),
|
'data_payload_updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseResponse(Response $httpResponse): array
|
return;
|
||||||
{
|
}
|
||||||
$parsers = app(ResponseParserRegistry::class)->getParsers();
|
|
||||||
|
|
||||||
foreach ($parsers as $parser) {
|
// Multiple URLs - use nested response logic
|
||||||
$parserName = class_basename($parser);
|
$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 {
|
try {
|
||||||
$result = $parser->parse($httpResponse);
|
// Make the request based on the verb
|
||||||
|
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
||||||
|
|
||||||
if ($result !== null) {
|
$response = $this->parseResponse($httpResponse);
|
||||||
return $result;
|
|
||||||
|
// 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) {
|
} catch (Exception $e) {
|
||||||
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
// 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'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['error' => 'Failed to parse response'];
|
$this->update([
|
||||||
|
'data_payload' => $combinedResponse,
|
||||||
|
'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')) {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -417,10 +451,6 @@ class Plugin extends Model
|
||||||
*/
|
*/
|
||||||
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
|
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) {
|
if ($this->render_markup) {
|
||||||
$renderedContent = '';
|
$renderedContent = '';
|
||||||
|
|
||||||
|
|
@ -528,30 +558,17 @@ class Plugin extends Model
|
||||||
|
|
||||||
if ($this->render_markup_view) {
|
if ($this->render_markup_view) {
|
||||||
if ($standalone) {
|
if ($standalone) {
|
||||||
$renderedView = view($this->render_markup_view, [
|
|
||||||
'size' => $size,
|
|
||||||
'data' => $this->data_payload,
|
|
||||||
'config' => $this->configuration ?? [],
|
|
||||||
])->render();
|
|
||||||
|
|
||||||
if ($size === 'full') {
|
|
||||||
return view('trmnl-layouts.single', [
|
return view('trmnl-layouts.single', [
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'noBleed' => $this->no_bleed,
|
'noBleed' => $this->no_bleed,
|
||||||
'darkMode' => $this->dark_mode,
|
'darkMode' => $this->dark_mode,
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'slot' => $renderedView,
|
'slot' => view($this->render_markup_view, [
|
||||||
])->render();
|
'size' => $size,
|
||||||
}
|
'data' => $this->data_payload,
|
||||||
|
'config' => $this->configuration ?? [],
|
||||||
return view('trmnl-layouts.mashup', [
|
])->render(),
|
||||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
|
||||||
'colorDepth' => $device?->colorDepth(),
|
|
||||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
|
||||||
'darkMode' => $this->dark_mode,
|
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
|
||||||
'slot' => $renderedView,
|
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -582,61 +599,4 @@ class Plugin extends Model
|
||||||
default => '1Tx1B',
|
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,44 +26,11 @@ class ImageGenerationService
|
||||||
public static function generateImage(string $markup, $deviceId): string
|
public static function generateImage(string $markup, $deviceId): string
|
||||||
{
|
{
|
||||||
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
|
$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();
|
$uuid = Uuid::uuid4()->toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get image generation settings from DeviceModel or Device (for legacy devices)
|
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||||
$imageSettings = $deviceModel
|
$imageSettings = self::getImageSettings($device);
|
||||||
? self::getImageSettingsFromModel($deviceModel)
|
|
||||||
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
|
|
||||||
|
|
||||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||||
|
|
@ -78,7 +45,7 @@ class ImageGenerationService
|
||||||
$browserStage->html($markup);
|
$browserStage->html($markup);
|
||||||
|
|
||||||
// Set timezone from user or fall back to app timezone
|
// Set timezone from user or fall back to app timezone
|
||||||
$timezone = $user?->timezone ?? config('app.timezone');
|
$timezone = $device->user->timezone ?? config('app.timezone');
|
||||||
$browserStage->timezone($timezone);
|
$browserStage->timezone($timezone);
|
||||||
|
|
||||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||||
|
|
@ -98,12 +65,12 @@ class ImageGenerationService
|
||||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get palette from parameter or fallback to device model's default palette
|
// Get palette from device or fallback to device model's default palette
|
||||||
|
$palette = $device->palette ?? $device->deviceModel?->palette;
|
||||||
$colorPalette = null;
|
$colorPalette = null;
|
||||||
|
|
||||||
if ($palette && $palette->colors) {
|
if ($palette && $palette->colors) {
|
||||||
$colorPalette = $palette->colors;
|
$colorPalette = $palette->colors;
|
||||||
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
|
|
||||||
$colorPalette = $deviceModel->palette->colors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$imageStage = new ImageStage();
|
$imageStage = new ImageStage();
|
||||||
|
|
@ -140,7 +107,8 @@ class ImageGenerationService
|
||||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info("Generated image: $uuid");
|
$device->update(['current_screen_image' => $uuid]);
|
||||||
|
Log::info("Device $device->id: updated with new image: $uuid");
|
||||||
|
|
||||||
return $uuid;
|
return $uuid;
|
||||||
|
|
||||||
|
|
@ -157,7 +125,22 @@ class ImageGenerationService
|
||||||
{
|
{
|
||||||
// If device has a DeviceModel, use its settings
|
// If device has a DeviceModel, use its settings
|
||||||
if ($device->deviceModel) {
|
if ($device->deviceModel) {
|
||||||
return self::getImageSettingsFromModel($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,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to device settings
|
// Fallback to device settings
|
||||||
|
|
@ -181,43 +164,6 @@ 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
|
* Determine the appropriate ImageFormat based on DeviceModel settings
|
||||||
*/
|
*/
|
||||||
|
|
@ -334,10 +280,6 @@ class ImageGenerationService
|
||||||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||||
{
|
{
|
||||||
if ($plugin?->id) {
|
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
|
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
||||||
$hasCustomDimensions = Device::query()
|
$hasCustomDimensions = Device::query()
|
||||||
->where(function ($query): void {
|
->where(function ($query): void {
|
||||||
|
|
@ -369,7 +311,7 @@ class ImageGenerationService
|
||||||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -403,10 +345,10 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate a default screen image from Blade template
|
* Generate a default screen image from Blade template
|
||||||
*/
|
*/
|
||||||
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
public static function generateDefaultScreenImage(Device $device, string $imageType): string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
||||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -423,7 +365,7 @@ class ImageGenerationService
|
||||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||||
|
|
||||||
// Generate HTML from Blade template
|
// Generate HTML from Blade template
|
||||||
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
$html = self::generateDefaultScreenHtml($device, $imageType);
|
||||||
|
|
||||||
// Create custom Browsershot instance if using AWS Lambda
|
// Create custom Browsershot instance if using AWS Lambda
|
||||||
$browsershotInstance = null;
|
$browsershotInstance = null;
|
||||||
|
|
@ -503,13 +445,12 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate HTML from Blade template for default screens
|
* Generate HTML from Blade template for default screens
|
||||||
*/
|
*/
|
||||||
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
private static function generateDefaultScreenHtml(Device $device, string $imageType): string
|
||||||
{
|
{
|
||||||
// Map image type to template name
|
// Map image type to template name
|
||||||
$templateName = match ($imageType) {
|
$templateName = match ($imageType) {
|
||||||
'setup-logo' => 'default-screens.setup',
|
'setup-logo' => 'default-screens.setup',
|
||||||
'sleep' => 'default-screens.sleep',
|
'sleep' => 'default-screens.sleep',
|
||||||
'error' => 'default-screens.error',
|
|
||||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -520,22 +461,14 @@ class ImageGenerationService
|
||||||
$scaleLevel = $device->scaleLevel();
|
$scaleLevel = $device->scaleLevel();
|
||||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||||
|
|
||||||
// Build view data
|
// Render the Blade template
|
||||||
$viewData = [
|
return view($templateName, [
|
||||||
'noBleed' => false,
|
'noBleed' => false,
|
||||||
'darkMode' => $darkMode,
|
'darkMode' => $darkMode,
|
||||||
'deviceVariant' => $deviceVariant,
|
'deviceVariant' => $deviceVariant,
|
||||||
'deviceOrientation' => $deviceOrientation,
|
'deviceOrientation' => $deviceOrientation,
|
||||||
'colorDepth' => $colorDepth,
|
'colorDepth' => $colorDepth,
|
||||||
'scaleLevel' => $scaleLevel,
|
'scaleLevel' => $scaleLevel,
|
||||||
];
|
])->render();
|
||||||
|
|
||||||
// Add plugin name for error screens
|
|
||||||
if ($imageType === 'error' && $pluginName !== null) {
|
|
||||||
$viewData['pluginName'] = $pluginName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the Blade template
|
|
||||||
return view($templateName, $viewData)->render();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
<?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'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
<?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,34 +17,6 @@ use ZipArchive;
|
||||||
|
|
||||||
class PluginImportService
|
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
|
* Import a plugin from a ZIP file
|
||||||
*
|
*
|
||||||
|
|
@ -75,55 +47,32 @@ class PluginImportService
|
||||||
$zip->extractTo($tempDir);
|
$zip->extractTo($tempDir);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
||||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||||
|
|
||||||
// Validate that we found the required files
|
// Validate that we found the required files
|
||||||
if (! $filePaths['settingsYamlPath']) {
|
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
||||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Parse settings.yml
|
||||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
$this->validateYAML($settings);
|
|
||||||
|
|
||||||
// Determine which template file to use and read its content
|
// Read full.liquid content
|
||||||
$templatePath = null;
|
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
||||||
$markupLanguage = 'blade';
|
|
||||||
|
|
||||||
if ($filePaths['fullLiquidPath']) {
|
// Prepend shared.liquid content if available
|
||||||
$templatePath = $filePaths['fullLiquidPath'];
|
|
||||||
$fullLiquid = File::get($templatePath);
|
|
||||||
|
|
||||||
// Prepend shared.liquid or shared.blade.php content if available
|
|
||||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
$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
|
// 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';
|
$markupLanguage = 'blade';
|
||||||
|
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
|
||||||
|
$markupLanguage = 'liquid';
|
||||||
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure custom_fields is properly formatted
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -195,12 +144,11 @@ class PluginImportService
|
||||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
* @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 $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
|
||||||
* @param string|null $iconUrl Optional icon URL to set on the plugin
|
* @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
|
* @return Plugin The created plugin instance
|
||||||
*
|
*
|
||||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
* @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, bool $allowDuplicate = false): Plugin
|
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin
|
||||||
{
|
{
|
||||||
// Download the ZIP file
|
// Download the ZIP file
|
||||||
$response = Http::timeout(60)->get($zipUrl);
|
$response = Http::timeout(60)->get($zipUrl);
|
||||||
|
|
@ -228,55 +176,32 @@ class PluginImportService
|
||||||
$zip->extractTo($tempDir);
|
$zip->extractTo($tempDir);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
||||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||||
|
|
||||||
// Validate that we found the required files
|
// Validate that we found the required files
|
||||||
if (! $filePaths['settingsYamlPath']) {
|
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
||||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are 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
|
// Parse settings.yml
|
||||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
$this->validateYAML($settings);
|
|
||||||
|
|
||||||
// Determine which template file to use and read its content
|
// Read full.liquid content
|
||||||
$templatePath = null;
|
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
||||||
$markupLanguage = 'blade';
|
|
||||||
|
|
||||||
if ($filePaths['fullLiquidPath']) {
|
// Prepend shared.liquid content if available
|
||||||
$templatePath = $filePaths['fullLiquidPath'];
|
|
||||||
$fullLiquid = File::get($templatePath);
|
|
||||||
|
|
||||||
// Prepend shared.liquid or shared.blade.php content if available
|
|
||||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
$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
|
// 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';
|
$markupLanguage = 'blade';
|
||||||
|
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
|
||||||
|
$markupLanguage = 'liquid';
|
||||||
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure custom_fields is properly formatted
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -292,26 +217,17 @@ class PluginImportService
|
||||||
'custom_fields' => $settings['custom_fields'],
|
'custom_fields' => $settings['custom_fields'],
|
||||||
];
|
];
|
||||||
|
|
||||||
// Determine the trmnlp_id to use
|
$plugin_updated = isset($settings['id'])
|
||||||
$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();
|
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||||
|
|
||||||
// Create a new plugin
|
// Create a new plugin
|
||||||
$plugin = Plugin::updateOrCreate(
|
$plugin = Plugin::updateOrCreate(
|
||||||
[
|
[
|
||||||
'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
|
'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||||
'trmnlp_id' => $trmnlpId,
|
'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||||
'polling_url' => $settings['polling_url'] ?? null,
|
'polling_url' => $settings['polling_url'] ?? null,
|
||||||
|
|
@ -356,7 +272,6 @@ class PluginImportService
|
||||||
$settingsYamlPath = null;
|
$settingsYamlPath = null;
|
||||||
$fullLiquidPath = null;
|
$fullLiquidPath = null;
|
||||||
$sharedLiquidPath = null;
|
$sharedLiquidPath = null;
|
||||||
$sharedBladePath = null;
|
|
||||||
|
|
||||||
// If zipEntryPath is specified, look for files in that specific directory first
|
// If zipEntryPath is specified, look for files in that specific directory first
|
||||||
if ($zipEntryPath) {
|
if ($zipEntryPath) {
|
||||||
|
|
@ -374,8 +289,6 @@ class PluginImportService
|
||||||
|
|
||||||
if (File::exists($targetDir.'/shared.liquid')) {
|
if (File::exists($targetDir.'/shared.liquid')) {
|
||||||
$sharedLiquidPath = $targetDir.'/shared.liquid';
|
$sharedLiquidPath = $targetDir.'/shared.liquid';
|
||||||
} elseif (File::exists($targetDir.'/shared.blade.php')) {
|
|
||||||
$sharedBladePath = $targetDir.'/shared.blade.php';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -391,18 +304,15 @@ class PluginImportService
|
||||||
|
|
||||||
if (File::exists($targetDir.'/src/shared.liquid')) {
|
if (File::exists($targetDir.'/src/shared.liquid')) {
|
||||||
$sharedLiquidPath = $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 we found the required files in the target directory, return them
|
||||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
if ($settingsYamlPath && $fullLiquidPath) {
|
||||||
return [
|
return [
|
||||||
'settingsYamlPath' => $settingsYamlPath,
|
'settingsYamlPath' => $settingsYamlPath,
|
||||||
'fullLiquidPath' => $fullLiquidPath,
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
'sharedLiquidPath' => $sharedLiquidPath,
|
'sharedLiquidPath' => $sharedLiquidPath,
|
||||||
'sharedBladePath' => $sharedBladePath,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -419,11 +329,9 @@ class PluginImportService
|
||||||
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for shared.liquid or shared.blade.php in the same directory
|
// Check for shared.liquid in the same directory
|
||||||
if (File::exists($tempDir.'/src/shared.liquid')) {
|
if (File::exists($tempDir.'/src/shared.liquid')) {
|
||||||
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
||||||
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
|
||||||
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Search for the files in the extracted directory structure
|
// Search for the files in the extracted directory structure
|
||||||
|
|
@ -440,24 +348,20 @@ class PluginImportService
|
||||||
$fullLiquidPath = $filepath;
|
$fullLiquidPath = $filepath;
|
||||||
} elseif ($filename === 'shared.liquid') {
|
} elseif ($filename === 'shared.liquid') {
|
||||||
$sharedLiquidPath = $filepath;
|
$sharedLiquidPath = $filepath;
|
||||||
} elseif ($filename === 'shared.blade.php') {
|
|
||||||
$sharedBladePath = $filepath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
|
// Check if shared.liquid exists in the same directory as full.liquid
|
||||||
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
|
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) {
|
||||||
$fullLiquidDir = dirname((string) $fullLiquidPath);
|
$fullLiquidDir = dirname((string) $fullLiquidPath);
|
||||||
if (File::exists($fullLiquidDir.'/shared.liquid')) {
|
if (File::exists($fullLiquidDir.'/shared.liquid')) {
|
||||||
$sharedLiquidPath = $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,
|
// 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
|
// check if they're in the root of the ZIP or in a subfolder
|
||||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
if ($settingsYamlPath && $fullLiquidPath) {
|
||||||
// If the files are in the root of the ZIP, create a src folder and move them there
|
// If the files are in the root of the ZIP, create a src folder and move them there
|
||||||
$srcDir = dirname((string) $settingsYamlPath);
|
$srcDir = dirname((string) $settingsYamlPath);
|
||||||
|
|
||||||
|
|
@ -468,25 +372,17 @@ class PluginImportService
|
||||||
|
|
||||||
// Copy the files to the src directory
|
// Copy the files to the src directory
|
||||||
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
||||||
|
File::copy($fullLiquidPath, $newSrcDir.'/full.liquid');
|
||||||
|
|
||||||
// Copy full.liquid or full.blade.php if it exists
|
// Copy shared.liquid 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) {
|
if ($sharedLiquidPath) {
|
||||||
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
||||||
$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
|
// Update the paths
|
||||||
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||||
|
$fullLiquidPath = $newSrcDir.'/full.liquid';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -495,7 +391,6 @@ class PluginImportService
|
||||||
'settingsYamlPath' => $settingsYamlPath,
|
'settingsYamlPath' => $settingsYamlPath,
|
||||||
'fullLiquidPath' => $fullLiquidPath,
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
'sharedLiquidPath' => $sharedLiquidPath,
|
'sharedLiquidPath' => $sharedLiquidPath,
|
||||||
'sharedBladePath' => $sharedBladePath,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
15
boost.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
"claude_code",
|
||||||
|
"copilot",
|
||||||
|
"cursor",
|
||||||
|
"phpstorm"
|
||||||
|
],
|
||||||
|
"editors": [
|
||||||
|
"claude_code",
|
||||||
|
"cursor",
|
||||||
|
"phpstorm",
|
||||||
|
"vscode"
|
||||||
|
],
|
||||||
|
"guidelines": []
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"trmnl",
|
"trmnl",
|
||||||
"trmnl-server",
|
"trmnl-server",
|
||||||
"trmnl-byos",
|
|
||||||
"laravel"
|
"laravel"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -15,7 +14,7 @@
|
||||||
"ext-imagick": "*",
|
"ext-imagick": "*",
|
||||||
"ext-simplexml": "*",
|
"ext-simplexml": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"bnussbau/laravel-trmnl-blade": "2.1.*",
|
"bnussbau/laravel-trmnl-blade": "2.0.*",
|
||||||
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
||||||
"keepsuit/laravel-liquid": "^0.5.2",
|
"keepsuit/laravel-liquid": "^0.5.2",
|
||||||
"laravel/framework": "^12.1",
|
"laravel/framework": "^12.1",
|
||||||
|
|
@ -24,9 +23,7 @@
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"livewire/flux": "^2.0",
|
"livewire/flux": "^2.0",
|
||||||
"livewire/volt": "^1.7",
|
"livewire/volt": "^1.7",
|
||||||
"om/icalparser": "^3.2",
|
|
||||||
"spatie/browsershot": "^5.0",
|
"spatie/browsershot": "^5.0",
|
||||||
"stevebauman/purify": "^6.3",
|
|
||||||
"symfony/yaml": "^7.3",
|
"symfony/yaml": "^7.3",
|
||||||
"wnx/sidecar-browsershot": "^2.6"
|
"wnx/sidecar-browsershot": "^2.6"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
1055
composer.lock
generated
|
|
@ -1,6 +0,0 @@
|
||||||
<?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
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => 'test-'.$this->faker->unique()->slug(),
|
'id' => 'test-' . $this->faker->unique()->slug(),
|
||||||
'name' => $this->faker->words(3, true),
|
'name' => $this->faker->words(3, true),
|
||||||
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
||||||
'colors' => $this->faker->optional()->passthrough([
|
'colors' => $this->faker->optional()->passthrough([
|
||||||
|
|
|
||||||
|
|
@ -29,24 +29,8 @@ class PluginFactory extends Factory
|
||||||
'icon_url' => null,
|
'icon_url' => null,
|
||||||
'flux_icon_name' => null,
|
'flux_icon_name' => null,
|
||||||
'author_name' => $this->faker->name(),
|
'author_name' => $this->faker->name(),
|
||||||
'plugin_type' => 'recipe',
|
|
||||||
'created_at' => Carbon::now(),
|
'created_at' => Carbon::now(),
|
||||||
'updated_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']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Plugin;
|
|
||||||
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
|
|
||||||
{
|
|
||||||
// Find and handle duplicate (user_id, trmnlp_id) combinations
|
|
||||||
$duplicates = Plugin::query()
|
|
||||||
->selectRaw('user_id, trmnlp_id, COUNT(*) as duplicate_count')
|
|
||||||
->whereNotNull('trmnlp_id')
|
|
||||||
->groupBy('user_id', 'trmnlp_id')
|
|
||||||
->havingRaw('COUNT(*) > ?', [1])
|
|
||||||
->get();
|
|
||||||
|
|
||||||
// For each duplicate combination, keep the first one (by id) and set others to null
|
|
||||||
foreach ($duplicates as $duplicate) {
|
|
||||||
$plugins = Plugin::query()
|
|
||||||
->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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$plugin->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']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -13,8 +13,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
public function run($user_id = 1): void
|
public function run($user_id = 1): void
|
||||||
{
|
{
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
['uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec'],
|
|
||||||
[
|
[
|
||||||
|
'uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec',
|
||||||
'name' => 'ÖBB Departures',
|
'name' => 'ÖBB Departures',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
@ -32,8 +32,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
);
|
);
|
||||||
|
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
['uuid' => '3b046eda-34e9-4232-b935-c33b989a284b'],
|
|
||||||
[
|
[
|
||||||
|
'uuid' => '3b046eda-34e9-4232-b935-c33b989a284b',
|
||||||
'name' => 'Weather',
|
'name' => 'Weather',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
@ -51,8 +51,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
);
|
);
|
||||||
|
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
['uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54'],
|
|
||||||
[
|
[
|
||||||
|
'uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54',
|
||||||
'name' => 'Zen Quotes',
|
'name' => 'Zen Quotes',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
@ -70,8 +70,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
);
|
);
|
||||||
|
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
['uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f'],
|
|
||||||
[
|
[
|
||||||
|
'uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f',
|
||||||
'name' => 'This Day in History',
|
'name' => 'This Day in History',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
@ -89,8 +89,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
);
|
);
|
||||||
|
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
['uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d'],
|
|
||||||
[
|
[
|
||||||
|
'uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d',
|
||||||
'name' => 'Home Assistant',
|
'name' => 'Home Assistant',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
@ -108,8 +108,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
);
|
);
|
||||||
|
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
['uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80'],
|
|
||||||
[
|
[
|
||||||
|
'uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80',
|
||||||
'name' => 'Sunrise/Sunset',
|
'name' => 'Sunrise/Sunset',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
@ -127,8 +127,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
);
|
);
|
||||||
|
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
['uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe'],
|
|
||||||
[
|
[
|
||||||
|
'uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe',
|
||||||
'name' => 'Pollen Forecast',
|
'name' => 'Pollen Forecast',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
@ -144,42 +144,5 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
'flux_icon_name' => 'flower',
|
'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",
|
"codemirror": "^6.0.2",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0",
|
"laravel-vite-plugin": "^2.0",
|
||||||
"puppeteer": "24.30.0",
|
"puppeteer": "24.17.0",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 401 B |
|
Before Width: | Height: | Size: 518 B |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
|
@ -1,521 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"name": "TRMNL BYOS Laravel Mirror",
|
|
||||||
"short_name": "TRMNL BYOS",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"theme_color": "#ffffff"
|
|
||||||
}
|
|
||||||
|
|
@ -59,10 +59,6 @@
|
||||||
@apply !mb-0 !leading-tight;
|
@apply !mb-0 !leading-tight;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-flux-description] a {
|
|
||||||
@apply text-accent underline hover:opacity-80;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus[data-flux-control],
|
input:focus[data-flux-control],
|
||||||
textarea:focus[data-flux-control],
|
textarea:focus[data-flux-control],
|
||||||
select:focus[data-flux-control] {
|
select:focus[data-flux-control] {
|
||||||
|
|
@ -72,39 +68,3 @@ select:focus[data-flux-control] {
|
||||||
/* \[:where(&)\]:size-4 {
|
/* \[:where(&)\]:size-4 {
|
||||||
@apply 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,9 +1,8 @@
|
||||||
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
||||||
import { ViewPlugin } from '@codemirror/view';
|
import { ViewPlugin } from '@codemirror/view';
|
||||||
import { indentWithTab, selectAll } from '@codemirror/commands';
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
import { foldGutter, foldKeymap } from '@codemirror/language';
|
import { foldGutter, foldKeymap } from '@codemirror/language';
|
||||||
import { history, historyKeymap } from '@codemirror/commands';
|
import { history, historyKeymap } from '@codemirror/commands';
|
||||||
import { searchKeymap } from '@codemirror/search';
|
|
||||||
import { html } from '@codemirror/lang-html';
|
import { html } from '@codemirror/lang-html';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
|
|
@ -155,16 +154,7 @@ export function createCodeMirror(element, options = {}) {
|
||||||
createResizePlugin(),
|
createResizePlugin(),
|
||||||
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
||||||
...themeSupport,
|
...themeSupport,
|
||||||
keymap.of([
|
keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]),
|
||||||
indentWithTab,
|
|
||||||
...foldKeymap,
|
|
||||||
...historyKeymap,
|
|
||||||
...searchKeymap,
|
|
||||||
{
|
|
||||||
key: 'Mod-a',
|
|
||||||
run: selectAll,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="styled-container">
|
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||||
<div class="px-10 py-8">{{ $slot }}</div>
|
<div class="px-10 py-8">{{ $slot }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
@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,26 +1,20 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Livewire\Attributes\Lazy;
|
|
||||||
use Livewire\Volt\Component;
|
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Lazy]
|
#[Lazy]
|
||||||
class extends Component
|
class extends Component {
|
||||||
{
|
|
||||||
public array $catalogPlugins = [];
|
public array $catalogPlugins = [];
|
||||||
|
|
||||||
public string $installingPlugin = '';
|
public string $installingPlugin = '';
|
||||||
|
|
||||||
public string $previewingPlugin = '';
|
|
||||||
|
|
||||||
public array $previewData = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->loadCatalogPlugins();
|
$this->loadCatalogPlugins();
|
||||||
|
|
@ -55,7 +49,7 @@ class extends Component
|
||||||
return collect($catalog)
|
return collect($catalog)
|
||||||
->filter(function ($plugin) use ($currentVersion) {
|
->filter(function ($plugin) use ($currentVersion) {
|
||||||
// Check if Laravel compatibility is true
|
// Check if Laravel compatibility is true
|
||||||
if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,9 +79,8 @@ class extends Component
|
||||||
})
|
})
|
||||||
->sortBy('name')
|
->sortBy('name')
|
||||||
->toArray();
|
->toArray();
|
||||||
} catch (Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Failed to load catalog from URL: '.$e->getMessage());
|
Log::error('Failed to load catalog from URL: ' . $e->getMessage());
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -99,9 +92,8 @@ class extends Component
|
||||||
|
|
||||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||||
|
|
||||||
if (! $plugin || ! $plugin['zip_url']) {
|
if (!$plugin || !$plugin['zip_url']) {
|
||||||
$this->addError('installation', 'Plugin not found or no download URL available.');
|
$this->addError('installation', 'Plugin not found or no download URL available.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,45 +105,24 @@ class extends Component
|
||||||
auth()->user(),
|
auth()->user(),
|
||||||
$plugin['zip_entry_path'] ?? null,
|
$plugin['zip_entry_path'] ?? null,
|
||||||
null,
|
null,
|
||||||
$plugin['logo_url'] ?? null,
|
$plugin['logo_url'] ?? null
|
||||||
allowDuplicate: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->dispatch('plugin-installed');
|
$this->dispatch('plugin-installed');
|
||||||
Flux::modal('import-from-catalog')->close();
|
Flux::modal('import-from-catalog')->close();
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
$this->installingPlugin = '';
|
$this->installingPlugin = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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">
|
<div class="space-y-4">
|
||||||
@if(empty($catalogPlugins))
|
@if(empty($catalogPlugins))
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<flux:heading class="mt-2">No plugins available</flux:heading>
|
<flux:heading class="mt-2">No plugins available</flux:heading>
|
||||||
<flux:subheading>Catalog is empty</flux:subheading>
|
<flux:subheading>Catalog is empty</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -162,30 +133,30 @@ class extends Component
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
@foreach($catalogPlugins as $plugin)
|
@foreach($catalogPlugins as $plugin)
|
||||||
<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="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@if($plugin['logo_url'])
|
@if($plugin['logo_url'])
|
||||||
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||||
@else
|
@else
|
||||||
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
<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-zinc-400" />
|
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
|
||||||
@if ($plugin['github'])
|
@if ($plugin['github'])
|
||||||
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
|
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@if($plugin['license'])
|
@if($plugin['license'])
|
||||||
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
|
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||||
@endif
|
@endif
|
||||||
@if($plugin['repo_url'])
|
@if($plugin['repo_url'])
|
||||||
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
<flux:icon name="github" class="w-5 h-5" />
|
<flux:icon name="github" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -193,7 +164,7 @@ class extends Component
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($plugin['description'])
|
@if($plugin['description'])
|
||||||
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center space-x-3">
|
<div class="mt-4 flex items-center space-x-3">
|
||||||
|
|
@ -203,19 +174,6 @@ class extends Component
|
||||||
Install
|
Install
|
||||||
</flux:button>
|
</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'])
|
@if($plugin['learn_more_url'])
|
||||||
<flux:button
|
<flux:button
|
||||||
href="{{ $plugin['learn_more_url'] }}"
|
href="{{ $plugin['learn_more_url'] }}"
|
||||||
|
|
@ -231,38 +189,4 @@ class extends Component
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,20 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Services\PluginImportService;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Livewire\Attributes\Lazy;
|
use Livewire\Attributes\Lazy;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use App\Services\PluginImportService;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Lazy]
|
#[Lazy]
|
||||||
class extends Component
|
class extends Component {
|
||||||
{
|
|
||||||
public array $recipes = [];
|
public array $recipes = [];
|
||||||
|
|
||||||
public int $page = 1;
|
|
||||||
|
|
||||||
public bool $hasMore = false;
|
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
public bool $isSearching = false;
|
public bool $isSearching = false;
|
||||||
|
|
||||||
public string $previewingRecipe = '';
|
|
||||||
|
|
||||||
public array $previewData = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->loadNewest();
|
$this->loadNewest();
|
||||||
|
|
@ -47,102 +37,61 @@ class extends Component
|
||||||
private function loadNewest(): void
|
private function loadNewest(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
|
$this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () {
|
||||||
$response = Cache::remember($cacheKey, 43200, function () {
|
|
||||||
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
'page' => $this->page,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (!$response->successful()) {
|
||||||
throw new RuntimeException('Failed to fetch TRMNL recipes');
|
throw new \RuntimeException('Failed to fetch TRMNL recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response->json();
|
$json = $response->json();
|
||||||
|
$data = $json['data'] ?? [];
|
||||||
|
return $this->mapRecipes($data);
|
||||||
});
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
$data = $response['data'] ?? [];
|
Log::error('TRMNL catalog load error: ' . $e->getMessage());
|
||||||
$mapped = $this->mapRecipes($data);
|
|
||||||
|
|
||||||
if ($this->page === 1) {
|
|
||||||
$this->recipes = $mapped;
|
|
||||||
} else {
|
|
||||||
$this->recipes = array_merge($this->recipes, $mapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->hasMore = ! empty($response['next_page_url']);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('TRMNL catalog load error: '.$e->getMessage());
|
|
||||||
if ($this->page === 1) {
|
|
||||||
$this->recipes = [];
|
$this->recipes = [];
|
||||||
}
|
}
|
||||||
$this->hasMore = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function searchRecipes(string $term): void
|
private function searchRecipes(string $term): void
|
||||||
{
|
{
|
||||||
$this->isSearching = true;
|
$this->isSearching = true;
|
||||||
try {
|
try {
|
||||||
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
|
$cacheKey = 'trmnl_recipes_search_' . md5($term);
|
||||||
$response = Cache::remember($cacheKey, 300, function () use ($term) {
|
$this->recipes = Cache::remember($cacheKey, 300, function () use ($term) {
|
||||||
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
||||||
'search' => $term,
|
'search' => $term,
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
'page' => $this->page,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (!$response->successful()) {
|
||||||
throw new RuntimeException('Failed to search TRMNL recipes');
|
throw new \RuntimeException('Failed to search TRMNL recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $response->json();
|
$json = $response->json();
|
||||||
|
$data = $json['data'] ?? [];
|
||||||
|
return $this->mapRecipes($data);
|
||||||
});
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
$data = $response['data'] ?? [];
|
Log::error('TRMNL catalog search error: ' . $e->getMessage());
|
||||||
$mapped = $this->mapRecipes($data);
|
|
||||||
|
|
||||||
if ($this->page === 1) {
|
|
||||||
$this->recipes = $mapped;
|
|
||||||
} else {
|
|
||||||
$this->recipes = array_merge($this->recipes, $mapped);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->hasMore = ! empty($response['next_page_url']);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('TRMNL catalog search error: '.$e->getMessage());
|
|
||||||
if ($this->page === 1) {
|
|
||||||
$this->recipes = [];
|
$this->recipes = [];
|
||||||
}
|
|
||||||
$this->hasMore = false;
|
|
||||||
} finally {
|
} finally {
|
||||||
$this->isSearching = false;
|
$this->isSearching = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadMore(): void
|
|
||||||
{
|
|
||||||
$this->page++;
|
|
||||||
|
|
||||||
$term = mb_trim($this->search);
|
|
||||||
if ($term === '' || mb_strlen($term) < 2) {
|
|
||||||
$this->loadNewest();
|
|
||||||
} else {
|
|
||||||
$this->searchRecipes($term);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSearch(): void
|
public function updatedSearch(): void
|
||||||
{
|
{
|
||||||
$this->page = 1;
|
$term = trim($this->search);
|
||||||
$term = mb_trim($this->search);
|
|
||||||
if ($term === '') {
|
if ($term === '') {
|
||||||
$this->loadNewest();
|
$this->loadNewest();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mb_strlen($term) < 2) {
|
if (strlen($term) < 2) {
|
||||||
// Require at least 2 chars to avoid noisy calls
|
// Require at least 2 chars to avoid noisy calls
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -164,50 +113,15 @@ class extends Component
|
||||||
auth()->user(),
|
auth()->user(),
|
||||||
null,
|
null,
|
||||||
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
|
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
|
||||||
$recipe['icon_url'] ?? null,
|
$recipe['icon_url'] ?? null
|
||||||
allowDuplicate: true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->dispatch('plugin-installed');
|
$this->dispatch('plugin-installed');
|
||||||
Flux::modal('import-from-trmnl-catalog')->close();
|
Flux::modal('import-from-trmnl-catalog')->close();
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Plugin installation failed: '.$e->getMessage());
|
Log::error('Plugin installation failed: ' . $e->getMessage());
|
||||||
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function previewRecipe(string $recipeId): void
|
|
||||||
{
|
|
||||||
$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) ?? [];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,16 +132,7 @@ class extends Component
|
||||||
private function mapRecipes(array $items): array
|
private function mapRecipes(array $items): array
|
||||||
{
|
{
|
||||||
return collect($items)
|
return collect($items)
|
||||||
->map(fn (array $item) => $this->mapRecipe($item))
|
->map(function (array $item) {
|
||||||
->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $item
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function mapRecipe(array $item): array
|
|
||||||
{
|
|
||||||
return [
|
return [
|
||||||
'id' => $item['id'] ?? null,
|
'id' => $item['id'] ?? null,
|
||||||
'name' => $item['name'] ?? 'Untitled',
|
'name' => $item['name'] ?? 'Untitled',
|
||||||
|
|
@ -240,8 +145,10 @@ class extends Component
|
||||||
'installs' => data_get($item, 'stats.installs'),
|
'installs' => data_get($item, 'stats.installs'),
|
||||||
'forks' => data_get($item, 'stats.forks'),
|
'forks' => data_get($item, 'stats.forks'),
|
||||||
],
|
],
|
||||||
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null,
|
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null,
|
||||||
];
|
];
|
||||||
|
})
|
||||||
|
->toArray();
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
|
|
@ -254,7 +161,7 @@ class extends Component
|
||||||
icon="magnifying-glass"
|
icon="magnifying-glass"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<flux:badge color="zinc">Newest</flux:badge>
|
<flux:badge color="gray">Newest</flux:badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@error('installation')
|
@error('installation')
|
||||||
|
|
@ -263,36 +170,35 @@ class extends Component
|
||||||
|
|
||||||
@if(empty($recipes))
|
@if(empty($recipes))
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
||||||
<flux:heading class="mt-2">No recipes found</flux:heading>
|
<flux:heading class="mt-2">No recipes found</flux:heading>
|
||||||
<flux:subheading>Try a different search term</flux:subheading>
|
<flux:subheading>Try a different search term</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
@foreach($recipes as $recipe)
|
@foreach($recipes as $recipe)
|
||||||
<div wire:key="recipe-{{ $recipe['id'] }}" class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
<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="px-10 py-8 space-y-6">
|
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
|
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
|
||||||
@if($thumb)
|
@if($thumb)
|
||||||
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||||
@else
|
@else
|
||||||
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
<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-zinc-400" />
|
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $recipe['name'] }}</h3>
|
||||||
@if(data_get($recipe, 'stats.installs'))
|
@if(data_get($recipe, 'stats.installs'))
|
||||||
<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>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@if($recipe['detail_url'])
|
@if($recipe['detail_url'])
|
||||||
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
|
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -300,7 +206,7 @@ class extends Component
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($recipe['author_bio'])
|
@if($recipe['author_bio'])
|
||||||
<flux:text class="mt-2" size="sm">{{ $recipe['author_bio'] }}</flux:text>
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $recipe['author_bio'] }}</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center space-x-3">
|
<div class="mt-4 flex items-center space-x-3">
|
||||||
|
|
@ -312,96 +218,19 @@ class extends Component
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($recipe['id'] && ($recipe['screenshot_url'] ?? null))
|
@if($recipe['detail_url'])
|
||||||
<flux:modal.trigger name="trmnl-catalog-preview">
|
|
||||||
<flux:button
|
<flux:button
|
||||||
wire:click="previewRecipe('{{ $recipe['id'] }}')"
|
href="{{ $recipe['detail_url'] }}"
|
||||||
variant="subtle"
|
target="_blank"
|
||||||
icon="eye">
|
variant="subtle">
|
||||||
Preview
|
View on TRMNL
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</flux:modal.trigger>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($hasMore)
|
|
||||||
<div class="flex justify-center mt-6">
|
|
||||||
<flux:button wire:click="loadMore" variant="subtle" wire:loading.attr="disabled">
|
|
||||||
<span wire:loading.remove wire:target="loadMore">Load next page</span>
|
|
||||||
<span wire:loading wire:target="loadMore">Loading...</span>
|
|
||||||
</flux:button>
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
@endif
|
|
||||||
|
|
||||||
<!-- Preview Modal -->
|
|
||||||
<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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ new class extends Component {
|
||||||
@if($devices->isEmpty())
|
@if($devices->isEmpty())
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div
|
<div
|
||||||
class="styled-container">
|
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||||
<div class="px-10 py-8">
|
<div class="px-10 py-8">
|
||||||
<h1 class="text-xl font-medium dark:text-zinc-200">Add your first device</h1>
|
<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"
|
<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)
|
@foreach($devices as $device)
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div
|
<div
|
||||||
class="styled-container">
|
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||||
<div class="px-10 py-8">
|
<div class="px-10 py-8">
|
||||||
@php
|
@php
|
||||||
$current_image_uuid =$device->current_screen_image;
|
$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="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="flex flex-col gap-6">
|
||||||
<div
|
<div
|
||||||
class="styled-container">
|
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||||
<div class="px-10 py-8">
|
<div class="px-10 py-8">
|
||||||
@php
|
@php
|
||||||
$current_image_uuid =$device->current_screen_image;
|
$current_image_uuid =$device->current_screen_image;
|
||||||
|
|
@ -368,10 +368,6 @@ new class extends Component {
|
||||||
<flux:menu.item icon="arrow-up-circle">Update Firmware</flux:menu.item>
|
<flux:menu.item icon="arrow-up-circle">Update Firmware</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<flux:menu.item icon="bars-3" href="{{ route('devices.logs', $device) }}" wire:navigate>Show Logs</flux:menu.item>
|
<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:modal.trigger name="delete-device">
|
||||||
<flux:menu.item icon="trash" variant="danger">Delete Device</flux:menu.item>
|
<flux:menu.item icon="trash" variant="danger">Delete Device</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
@ -502,26 +498,6 @@ new class extends Component {
|
||||||
</flux:modal>
|
</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(!$device->mirror_device_id)
|
||||||
@if($current_image_path)
|
@if($current_image_path)
|
||||||
<flux:separator class="mt-6 mb-6" text="Screen"/>
|
<flux:separator class="mt-6 mb-6" text="Screen"/>
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ new class extends Component {
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
@if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty()))
|
@if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty()))
|
||||||
<div class="styled-container">
|
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||||
<div class="px-10 py-8">
|
<div class="px-10 py-8">
|
||||||
<h1 class="text-xl font-medium dark:text-zinc-200">No playlists found</h1>
|
<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>
|
<p class="text-sm dark:text-zinc-400 mt-2">Add playlists to your devices to see them here.</p>
|
||||||
|
|
|
||||||
|
|
@ -1,516 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,298 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
<?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,8 +26,6 @@ new class extends Component {
|
||||||
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
|
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
|
||||||
'api' =>
|
'api' =>
|
||||||
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.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 = [
|
protected $rules = [
|
||||||
|
|
@ -42,12 +40,7 @@ new class extends Component {
|
||||||
|
|
||||||
public function refreshPlugins(): void
|
public function refreshPlugins(): void
|
||||||
{
|
{
|
||||||
// Only show recipe plugins in the main list (image_webhook has its own management page)
|
$userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray();
|
||||||
$userPlugins = auth()->user()?->plugins()
|
|
||||||
->where('plugin_type', 'recipe')
|
|
||||||
->get()
|
|
||||||
->makeHidden(['render_markup', 'data_payload'])
|
|
||||||
->toArray();
|
|
||||||
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
|
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
|
||||||
$allPlugins = array_values($allPlugins);
|
$allPlugins = array_values($allPlugins);
|
||||||
$allPlugins = $this->sortPlugins($allPlugins);
|
$allPlugins = $this->sortPlugins($allPlugins);
|
||||||
|
|
@ -395,7 +388,7 @@ new class extends Component {
|
||||||
wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}"
|
wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}"
|
||||||
x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }"
|
x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }"
|
||||||
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
|
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
|
||||||
class="styled-container">
|
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||||
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
|
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
|
||||||
class="block h-full">
|
class="block h-full">
|
||||||
<div class="flex items-center space-x-4 px-10 py-8 h-full">
|
<div class="flex items-center space-x-4 px-10 py-8 h-full">
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,12 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Device;
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use App\Models\DeviceModel;
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Livewire\Attributes\On;
|
|
||||||
use Livewire\Attributes\Computed;
|
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
public Plugin $plugin;
|
public Plugin $plugin;
|
||||||
|
|
@ -38,15 +34,16 @@ new class extends Component {
|
||||||
public string $mashup_layout = 'full';
|
public string $mashup_layout = 'full';
|
||||||
public array $mashup_plugins = [];
|
public array $mashup_plugins = [];
|
||||||
public array $configuration_template = [];
|
public array $configuration_template = [];
|
||||||
public ?int $preview_device_model_id = null;
|
public array $configuration = [];
|
||||||
public string $preview_size = 'full';
|
public array $xhrSelectOptions = [];
|
||||||
|
public array $searchQueries = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
$this->blade_code = $this->plugin->render_markup;
|
$this->blade_code = $this->plugin->render_markup;
|
||||||
// required to render some stuff
|
|
||||||
$this->configuration_template = $this->plugin->configuration_template ?? [];
|
$this->configuration_template = $this->plugin->configuration_template ?? [];
|
||||||
|
$this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
|
||||||
|
|
||||||
if ($this->plugin->render_markup_view) {
|
if ($this->plugin->render_markup_view) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -77,12 +74,6 @@ new class extends Component {
|
||||||
|
|
||||||
$this->fillformFields();
|
$this->fillformFields();
|
||||||
$this->data_payload_updated_at = $this->plugin->data_payload_updated_at;
|
$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
|
public function fillFormFields(): void
|
||||||
|
|
@ -138,19 +129,6 @@ new class extends Component {
|
||||||
$validated = $this->validate();
|
$validated = $this->validate();
|
||||||
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
|
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
|
||||||
$this->plugin->update($validated);
|
$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
|
protected function validatePollingUrl(): void
|
||||||
|
|
@ -276,6 +254,39 @@ new class extends Component {
|
||||||
Flux::modal('add-to-playlist')->close();
|
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)
|
public function getDevicePlaylists($deviceId)
|
||||||
{
|
{
|
||||||
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
||||||
|
|
@ -296,6 +307,8 @@ new class extends Component {
|
||||||
return $this->configuration[$key] ?? $default;
|
return $this->configuration[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function renderExample(string $example)
|
public function renderExample(string $example)
|
||||||
{
|
{
|
||||||
switch ($example) {
|
switch ($example) {
|
||||||
|
|
@ -364,17 +377,13 @@ HTML;
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
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 data strategy is polling and data_payload is null, fetch the data first
|
||||||
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
|
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
|
||||||
$this->updateData();
|
$this->updateData();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a device object with og_plus model and the selected bitdepth
|
$previewMarkup = $this->plugin->render($size);
|
||||||
$device = $this->createPreviewDevice();
|
|
||||||
$previewMarkup = $this->plugin->render($size, true, $device);
|
|
||||||
$this->dispatch('preview-updated', preview: $previewMarkup);
|
$this->dispatch('preview-updated', preview: $previewMarkup);
|
||||||
} catch (LiquidException $e) {
|
} catch (LiquidException $e) {
|
||||||
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
|
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
|
||||||
|
|
@ -383,38 +392,6 @@ 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
|
public function deletePlugin(): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
@ -422,31 +399,42 @@ HTML;
|
||||||
$this->redirect(route('plugins.index'));
|
$this->redirect(route('plugins.index'));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[On('config-updated')]
|
public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
|
||||||
public function refreshPlugin()
|
|
||||||
{
|
{
|
||||||
// This pulls the fresh 'configuration' from the DB
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
// and re-triggers the @if check in the Blade template
|
|
||||||
$this->plugin = $this->plugin->fresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Laravel Livewire computed property: access with $this->parsed_urls
|
|
||||||
#[Computed]
|
|
||||||
private function parsedUrls()
|
|
||||||
{
|
|
||||||
if (!isset($this->polling_url)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return $this->plugin->resolveLiquidVariables($this->polling_url);
|
$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) {
|
} catch (\Exception $e) {
|
||||||
return 'PARSE_ERROR: ' . $e->getMessage();
|
$this->xhrSelectOptions[$fieldKey] = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function searchXhrSelect(string $fieldKey, string $endpoint): void
|
||||||
|
{
|
||||||
|
$query = $this->searchQueries[$fieldKey] ?? '';
|
||||||
|
if (!empty($query)) {
|
||||||
|
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
|
|
@ -478,6 +466,7 @@ HTML;
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
</flux:menu>
|
</flux:menu>
|
||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
|
|
||||||
</flux:button.group>
|
</flux:button.group>
|
||||||
<flux:button.group>
|
<flux:button.group>
|
||||||
<flux:modal.trigger name="add-to-playlist">
|
<flux:modal.trigger name="add-to-playlist">
|
||||||
|
|
@ -487,11 +476,6 @@ HTML;
|
||||||
<flux:dropdown>
|
<flux:dropdown>
|
||||||
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
||||||
<flux:menu>
|
<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:modal.trigger name="delete-plugin">
|
||||||
<flux:menu.item icon="trash" variant="danger">Delete Plugin</flux:menu.item>
|
<flux:menu.item icon="trash" variant="danger">Delete Plugin</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
@ -633,15 +617,8 @@ HTML;
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
<flux:modal name="preview-plugin" class="min-w-[850px] min-h-[480px] space-y-6">
|
<flux:modal name="preview-plugin" class="min-w-[850px] min-h-[480px] space-y-6">
|
||||||
<div class="flex items-center gap-4">
|
<div>
|
||||||
<flux:heading size="lg">Preview {{ $plugin->name }}</flux:heading>
|
<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>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
|
|
@ -649,9 +626,269 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
<livewire:plugins.recipes.settings :plugin="$plugin" />
|
<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.config-modal :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>
|
||||||
|
|
||||||
<div class="mt-5 mb-5">
|
<div class="mt-5 mb-5">
|
||||||
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
|
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
|
||||||
|
|
@ -739,7 +976,7 @@ HTML;
|
||||||
@endif
|
@endif
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:modal.trigger name="configuration-modal">
|
<flux:modal.trigger name="configuration-modal">
|
||||||
<flux:button icon="variable" class="block mt-1 w-full">Configuration Fields</flux:button>
|
<flux:button icon="cog" class="block mt-1 w-full">Configuration</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -752,62 +989,15 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($data_strategy === 'polling')
|
@if($data_strategy === 'polling')
|
||||||
<flux:label>Polling URL</flux:label>
|
<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"
|
||||||
<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"
|
placeholder="https://example.com/api"
|
||||||
rows="5"
|
class="block w-full" type="text" name="polling_url" autofocus>
|
||||||
/>
|
</flux:input>
|
||||||
<flux:description>
|
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full">
|
||||||
{!! '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
|
Fetch data now
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
|
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
|
||||||
|
|
@ -950,7 +1140,7 @@ HTML;
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
x-data="codeEditorFormComponent({
|
x-data="codeEditorFormComponent({
|
||||||
isDisabled: @js((bool)$plugin->render_markup_view),
|
isDisabled: false,
|
||||||
language: 'liquid',
|
language: 'liquid',
|
||||||
state: $wire.entangle('markup_code'),
|
state: $wire.entangle('markup_code'),
|
||||||
textareaId: @js($textareaId)
|
textareaId: @js($textareaId)
|
||||||
|
|
@ -971,6 +1161,9 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="flex items-center gap-6 mb-4 mt-4">
|
<div class="flex items-center gap-6 mb-4 mt-4">
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
@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::view size="{{ $size }}">
|
||||||
<x-trmnl::layout>
|
<x-trmnl::layout>
|
||||||
<x-trmnl::layout class="layout--col">
|
<x-trmnl::layout class="layout--col">
|
||||||
<div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div>
|
<div class="b-h-gray-1">{{$data[0]['a']}}</div>
|
||||||
@if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
|
@if (strlen($data[0]['q']) < 300 && $size != 'quadrant')
|
||||||
<p class="value">{{ $data['data'][0]['q'] ?? '' }}</p>
|
<p class="value">{{ $data[0]['q'] }}</p>
|
||||||
@else
|
@else
|
||||||
<p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p>
|
<p class="value--small">{{ $data[0]['q'] }}</p>
|
||||||
@endif
|
@endif
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
|
|
|
||||||
204
routes/api.php
|
|
@ -18,16 +18,18 @@ use Illuminate\Support\Str;
|
||||||
Route::get('/display', function (Request $request) {
|
Route::get('/display', function (Request $request) {
|
||||||
$mac_address = $request->header('id');
|
$mac_address = $request->header('id');
|
||||||
$access_token = $request->header('access-token');
|
$access_token = $request->header('access-token');
|
||||||
$device = Device::where('api_key', $access_token)->first();
|
$device = Device::where('mac_address', $mac_address)
|
||||||
|
->where('api_key', $access_token)
|
||||||
|
->first();
|
||||||
|
|
||||||
if (! $device) {
|
if (! $device) {
|
||||||
// Check if there's a user with assign_new_devices enabled
|
// Check if there's a user with assign_new_devices enabled
|
||||||
$auto_assign_user = User::where('assign_new_devices', true)->first();
|
$auto_assign_user = User::where('assign_new_devices', true)->first();
|
||||||
|
|
||||||
if ($auto_assign_user && $mac_address) {
|
if ($auto_assign_user) {
|
||||||
// Create a new device and assign it to this user
|
// Create a new device and assign it to this user
|
||||||
$device = Device::create([
|
$device = Device::create([
|
||||||
'mac_address' => mb_strtoupper($mac_address ?? ''),
|
'mac_address' => $mac_address,
|
||||||
'api_key' => $access_token,
|
'api_key' => $access_token,
|
||||||
'user_id' => $auto_assign_user->id,
|
'user_id' => $auto_assign_user->id,
|
||||||
'name' => "{$auto_assign_user->name}'s TRMNL",
|
'name' => "{$auto_assign_user->name}'s TRMNL",
|
||||||
|
|
@ -37,7 +39,7 @@ Route::get('/display', function (Request $request) {
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'MAC Address not registered (or not set), or invalid access token',
|
'message' => 'MAC Address not registered or invalid access token',
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -93,16 +95,9 @@ Route::get('/display', function (Request $request) {
|
||||||
// Check and update stale data if needed
|
// Check and update stale data if needed
|
||||||
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
||||||
$plugin->updateDataPayload();
|
$plugin->updateDataPayload();
|
||||||
try {
|
|
||||||
$markup = $plugin->render(device: $device);
|
$markup = $plugin->render(device: $device);
|
||||||
|
|
||||||
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
|
|
||||||
// Generate error display
|
|
||||||
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name);
|
|
||||||
$device->update(['current_screen_image' => $errorImageUuid]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$plugin->refresh();
|
$plugin->refresh();
|
||||||
|
|
@ -125,17 +120,8 @@ Route::get('/display', function (Request $request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
$markup = $playlistItem->render(device: $device);
|
$markup = $playlistItem->render(device: $device);
|
||||||
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage());
|
|
||||||
// For mashups, show error for the first plugin or a generic error
|
|
||||||
$firstPlugin = $plugins->first();
|
|
||||||
$pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe';
|
|
||||||
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName);
|
|
||||||
$device->update(['current_screen_image' => $errorImageUuid]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$device->refresh();
|
$device->refresh();
|
||||||
|
|
||||||
|
|
@ -218,7 +204,7 @@ Route::get('/setup', function (Request $request) {
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$device = Device::where('mac_address', mb_strtoupper($mac_address))->first();
|
$device = Device::where('mac_address', $mac_address)->first();
|
||||||
|
|
||||||
if (! $device) {
|
if (! $device) {
|
||||||
// Check if there's a user with assign_new_devices enabled
|
// Check if there's a user with assign_new_devices enabled
|
||||||
|
|
@ -233,7 +219,7 @@ Route::get('/setup', function (Request $request) {
|
||||||
|
|
||||||
// Create a new device and assign it to this user
|
// Create a new device and assign it to this user
|
||||||
$device = Device::create([
|
$device = Device::create([
|
||||||
'mac_address' => mb_strtoupper($mac_address),
|
'mac_address' => $mac_address,
|
||||||
'api_key' => Str::random(22),
|
'api_key' => Str::random(22),
|
||||||
'user_id' => $auto_assign_user->id,
|
'user_id' => $auto_assign_user->id,
|
||||||
'name' => "{$auto_assign_user->name}'s TRMNL",
|
'name' => "{$auto_assign_user->name}'s TRMNL",
|
||||||
|
|
@ -359,7 +345,7 @@ Route::post('/display/update', function (Request $request) {
|
||||||
Route::post('/screens', function (Request $request) {
|
Route::post('/screens', function (Request $request) {
|
||||||
$mac_address = $request->header('id');
|
$mac_address = $request->header('id');
|
||||||
$access_token = $request->header('access-token');
|
$access_token = $request->header('access-token');
|
||||||
$device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
|
$device = Device::where('mac_address', $mac_address)
|
||||||
->where('api_key', $access_token)
|
->where('api_key', $access_token)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|
@ -547,91 +533,6 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) {
|
||||||
return response()->json(['message' => 'Data updated successfully']);
|
return response()->json(['message' => 'Data updated successfully']);
|
||||||
})->name('api.custom_plugins.webhook');
|
})->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) {
|
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
|
||||||
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
|
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -676,90 +577,3 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
})->middleware('auth:sanctum');
|
})->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,8 +31,6 @@ Route::middleware(['auth'])->group(function () {
|
||||||
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
|
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
|
||||||
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
|
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
|
||||||
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
|
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');
|
Volt::route('playlists', 'playlists.index')->name('playlists.index');
|
||||||
|
|
||||||
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
|
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ use App\Models\Playlist;
|
||||||
use App\Models\PlaylistItem;
|
use App\Models\PlaylistItem;
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ImageGenerationService;
|
|
||||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -263,7 +262,7 @@ test('invalid device credentials return error', function (): void {
|
||||||
])->get('/api/display');
|
])->get('/api/display');
|
||||||
|
|
||||||
$response->assertNotFound()
|
$response->assertNotFound()
|
||||||
->assertJson(['message' => 'MAC Address not registered (or not set), or invalid access token']);
|
->assertJson(['message' => 'MAC Address not registered or invalid access token']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('log endpoint requires valid device credentials', function (): void {
|
test('log endpoint requires valid device credentials', function (): void {
|
||||||
|
|
@ -955,232 +954,3 @@ test('setup endpoint handles non-existent device model gracefully', function ():
|
||||||
expect($device)->not->toBeNull()
|
expect($device)->not->toBeNull()
|
||||||
->and($device->device_model_id)->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();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
<?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,30 +324,6 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
|
||||||
expect($plugin->current_image)->toBe('test-uuid');
|
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 {
|
it('determines correct image format from device model', function (): void {
|
||||||
// Test BMP format detection
|
// Test BMP format detection
|
||||||
$bmpModel = DeviceModel::factory()->create([
|
$bmpModel = DeviceModel::factory()->create([
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ test('fetch device models job handles successful api response', function (): voi
|
||||||
'mime_type' => 'image/png',
|
'mime_type' => 'image/png',
|
||||||
'offset_x' => 0,
|
'offset_x' => 0,
|
||||||
'offset_y' => 0,
|
'offset_y' => 0,
|
||||||
'kind' => 'trmnl',
|
|
||||||
'published_at' => '2023-01-01T00:00:00Z',
|
'published_at' => '2023-01-01T00:00:00Z',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -75,7 +74,6 @@ test('fetch device models job handles successful api response', function (): voi
|
||||||
expect($deviceModel->mime_type)->toBe('image/png');
|
expect($deviceModel->mime_type)->toBe('image/png');
|
||||||
expect($deviceModel->offset_x)->toBe(0);
|
expect($deviceModel->offset_x)->toBe(0);
|
||||||
expect($deviceModel->offset_y)->toBe(0);
|
expect($deviceModel->offset_y)->toBe(0);
|
||||||
// expect($deviceModel->kind)->toBe('trmnl');
|
|
||||||
expect($deviceModel->source)->toBe('api');
|
expect($deviceModel->source)->toBe('api');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -314,7 +312,6 @@ test('fetch device models job handles device model with partial data', function
|
||||||
expect($deviceModel->mime_type)->toBe('');
|
expect($deviceModel->mime_type)->toBe('');
|
||||||
expect($deviceModel->offset_x)->toBe(0);
|
expect($deviceModel->offset_x)->toBe(0);
|
||||||
expect($deviceModel->offset_y)->toBe(0);
|
expect($deviceModel->offset_y)->toBe(0);
|
||||||
expect($deviceModel->kind)->toBeNull();
|
|
||||||
expect($deviceModel->source)->toBe('api');
|
expect($deviceModel->source)->toBe('api');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,46 +65,6 @@ it('loads plugins from catalog URL', function (): void {
|
||||||
$component->assertSee('testuser');
|
$component->assertSee('testuser');
|
||||||
$component->assertSee('A test plugin');
|
$component->assertSee('A test plugin');
|
||||||
$component->assertSee('MIT');
|
$component->assertSee('MIT');
|
||||||
$component->assertSee('Preview');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides preview button when screenshot_url is missing', function (): void {
|
|
||||||
// Clear cache first to ensure fresh data
|
|
||||||
Cache::forget('catalog_plugins');
|
|
||||||
|
|
||||||
// Mock the HTTP response for the catalog URL without screenshot_url
|
|
||||||
$catalogData = [
|
|
||||||
'test-plugin' => [
|
|
||||||
'name' => 'Test Plugin Without Screenshot',
|
|
||||||
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
|
||||||
'author_bio' => [
|
|
||||||
'description' => 'A test plugin',
|
|
||||||
],
|
|
||||||
'license' => 'MIT',
|
|
||||||
'trmnlp' => [
|
|
||||||
'zip_url' => 'https://example.com/plugin.zip',
|
|
||||||
],
|
|
||||||
'byos' => [
|
|
||||||
'byos_laravel' => [
|
|
||||||
'compatibility' => true,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'logo_url' => 'https://example.com/logo.png',
|
|
||||||
'screenshot_url' => null,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$yamlContent = Yaml::dump($catalogData);
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
config('app.catalog_url') => Http::response($yamlContent, 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withoutLazyLoading();
|
|
||||||
|
|
||||||
Volt::test('catalog.index')
|
|
||||||
->assertSee('Test Plugin Without Screenshot')
|
|
||||||
->assertDontSeeHtml('variant="subtle" icon="eye"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when plugin not found', function (): void {
|
it('shows error when plugin not found', function (): void {
|
||||||
|
|
@ -154,46 +114,3 @@ it('shows error when zip_url is missing', function (): void {
|
||||||
$component->assertHasErrors();
|
$component->assertHasErrors();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can preview a plugin', function (): void {
|
|
||||||
// Clear cache first to ensure fresh data
|
|
||||||
Cache::forget('catalog_plugins');
|
|
||||||
|
|
||||||
// Mock the HTTP response for the catalog URL
|
|
||||||
$catalogData = [
|
|
||||||
'test-plugin' => [
|
|
||||||
'name' => 'Test Plugin',
|
|
||||||
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
|
||||||
'author_bio' => [
|
|
||||||
'description' => 'A test plugin description',
|
|
||||||
],
|
|
||||||
'license' => 'MIT',
|
|
||||||
'trmnlp' => [
|
|
||||||
'zip_url' => 'https://example.com/plugin.zip',
|
|
||||||
],
|
|
||||||
'byos' => [
|
|
||||||
'byos_laravel' => [
|
|
||||||
'compatibility' => true,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'logo_url' => 'https://example.com/logo.png',
|
|
||||||
'screenshot_url' => 'https://example.com/screenshot.png',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$yamlContent = Yaml::dump($catalogData);
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
config('app.catalog_url') => Http::response($yamlContent, 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withoutLazyLoading();
|
|
||||||
|
|
||||||
Volt::test('catalog.index')
|
|
||||||
->assertSee('Test Plugin')
|
|
||||||
->call('previewPlugin', 'test-plugin')
|
|
||||||
->assertSet('previewingPlugin', 'test-plugin')
|
|
||||||
->assertSet('previewData.name', 'Test Plugin')
|
|
||||||
->assertSee('Preview Test Plugin')
|
|
||||||
->assertSee('A test plugin description');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
<?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');
|
|
||||||
});
|
|
||||||
|
|
@ -1,112 +0,0 @@
|
||||||
<?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,48 +130,3 @@ test('playlist isActiveNow handles normal time ranges correctly', function (): v
|
||||||
Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0));
|
Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0));
|
||||||
expect($playlist->isActiveNow())->toBeFalse();
|
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,34 +83,19 @@ it('throws exception for invalid zip file', function (): void {
|
||||||
->toThrow(Exception::class, 'Could not open the ZIP file.');
|
->toThrow(Exception::class, 'Could not open the ZIP file.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws exception for missing settings.yml', function (): void {
|
it('throws exception for missing required files', function (): void {
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$zipContent = createMockZipFile([
|
|
||||||
'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 file settings.yml is missing.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws exception for missing template files', function (): void {
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$zipContent = createMockZipFile([
|
$zipContent = createMockZipFile([
|
||||||
'src/settings.yml' => getValidSettingsYaml(),
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
// Missing all template files
|
// Missing full.liquid
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
$pluginImportService = new PluginImportService();
|
$pluginImportService = new PluginImportService();
|
||||||
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
|
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.');
|
->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets default values when settings are missing', function (): void {
|
it('sets default values when settings are missing', function (): void {
|
||||||
|
|
@ -442,103 +427,6 @@ YAML;
|
||||||
->and($displayIncidentField['default'])->toBe('true');
|
->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
|
// Helper methods
|
||||||
function createMockZipFile(array $files): string
|
function createMockZipFile(array $files): string
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
test('plugin parses JSON responses correctly', function (): void {
|
test('plugin parses JSON responses correctly', function (): void {
|
||||||
|
|
@ -192,96 +191,3 @@ test('plugin handles POST requests with XML responses', function (): void {
|
||||||
expect($plugin->data_payload['rss']['status'])->toBe('success');
|
expect($plugin->data_payload['rss']['status'])->toBe('success');
|
||||||
expect($plugin->data_payload['rss']['data'])->toBe('test');
|
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,33 +28,9 @@ it('loads newest TRMNL recipes on mount', function (): void {
|
||||||
Volt::test('catalog.trmnl')
|
Volt::test('catalog.trmnl')
|
||||||
->assertSee('Weather Chum')
|
->assertSee('Weather Chum')
|
||||||
->assertSee('Install')
|
->assertSee('Install')
|
||||||
->assertDontSeeHtml('variant="subtle" icon="eye"')
|
|
||||||
->assertSee('Installs: 10');
|
->assertSee('Installs: 10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows preview button when screenshot_url is provided', function (): void {
|
|
||||||
Http::fake([
|
|
||||||
'usetrmnl.com/recipes.json*' => Http::response([
|
|
||||||
'data' => [
|
|
||||||
[
|
|
||||||
'id' => 123,
|
|
||||||
'name' => 'Weather Chum',
|
|
||||||
'icon_url' => 'https://example.com/icon.png',
|
|
||||||
'screenshot_url' => 'https://example.com/screenshot.png',
|
|
||||||
'author_bio' => null,
|
|
||||||
'stats' => ['installs' => 10, 'forks' => 2],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withoutLazyLoading();
|
|
||||||
|
|
||||||
Volt::test('catalog.trmnl')
|
|
||||||
->assertSee('Weather Chum')
|
|
||||||
->assertSee('Preview');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('searches TRMNL recipes when search term is provided', function (): void {
|
it('searches TRMNL recipes when search term is provided', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
// First call (mount -> newest)
|
// First call (mount -> newest)
|
||||||
|
|
@ -176,111 +152,3 @@ it('shows error when plugin installation fails', function (): void {
|
||||||
->call('installPlugin', '123')
|
->call('installPlugin', '123')
|
||||||
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
|
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
|
||||||
});
|
});
|
||||||
|
|
||||||
it('previews a recipe with async fetch', function (): void {
|
|
||||||
Http::fake([
|
|
||||||
'usetrmnl.com/recipes.json*' => Http::response([
|
|
||||||
'data' => [
|
|
||||||
[
|
|
||||||
'id' => 123,
|
|
||||||
'name' => 'Weather Chum',
|
|
||||||
'icon_url' => 'https://example.com/icon.png',
|
|
||||||
'screenshot_url' => 'https://example.com/old.png',
|
|
||||||
'author_bio' => null,
|
|
||||||
'stats' => ['installs' => 10, 'forks' => 2],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
], 200),
|
|
||||||
'usetrmnl.com/recipes/123.json' => Http::response([
|
|
||||||
'data' => [
|
|
||||||
'id' => 123,
|
|
||||||
'name' => 'Weather Chum Updated',
|
|
||||||
'icon_url' => 'https://example.com/icon.png',
|
|
||||||
'screenshot_url' => 'https://example.com/new.png',
|
|
||||||
'author_bio' => ['description' => 'New bio'],
|
|
||||||
'stats' => ['installs' => 11, 'forks' => 3],
|
|
||||||
],
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withoutLazyLoading();
|
|
||||||
|
|
||||||
Volt::test('catalog.trmnl')
|
|
||||||
->assertSee('Weather Chum')
|
|
||||||
->call('previewRecipe', '123')
|
|
||||||
->assertSet('previewingRecipe', '123')
|
|
||||||
->assertSet('previewData.name', 'Weather Chum Updated')
|
|
||||||
->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
|
|
||||||
->assertSee('Preview Weather Chum Updated')
|
|
||||||
->assertSee('New bio');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports pagination and loading more recipes', function (): void {
|
|
||||||
Http::fake([
|
|
||||||
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
|
|
||||||
'data' => [
|
|
||||||
[
|
|
||||||
'id' => 1,
|
|
||||||
'name' => 'Recipe Page 1',
|
|
||||||
'icon_url' => null,
|
|
||||||
'screenshot_url' => null,
|
|
||||||
'author_bio' => null,
|
|
||||||
'stats' => ['installs' => 1, 'forks' => 0],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'next_page_url' => '/recipes.json?page=2',
|
|
||||||
], 200),
|
|
||||||
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
|
|
||||||
'data' => [
|
|
||||||
[
|
|
||||||
'id' => 2,
|
|
||||||
'name' => 'Recipe Page 2',
|
|
||||||
'icon_url' => null,
|
|
||||||
'screenshot_url' => null,
|
|
||||||
'author_bio' => null,
|
|
||||||
'stats' => ['installs' => 2, 'forks' => 0],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'next_page_url' => null,
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withoutLazyLoading();
|
|
||||||
|
|
||||||
Volt::test('catalog.trmnl')
|
|
||||||
->assertSee('Recipe Page 1')
|
|
||||||
->assertDontSee('Recipe Page 2')
|
|
||||||
->assertSee('Load next page')
|
|
||||||
->call('loadMore')
|
|
||||||
->assertSee('Recipe Page 1')
|
|
||||||
->assertSee('Recipe Page 2')
|
|
||||||
->assertDontSee('Load next page');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets pagination when search term changes', function (): void {
|
|
||||||
Http::fake([
|
|
||||||
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
|
|
||||||
->push([
|
|
||||||
'data' => [['id' => 1, 'name' => 'Initial 1']],
|
|
||||||
'next_page_url' => '/recipes.json?page=2',
|
|
||||||
])
|
|
||||||
->push([
|
|
||||||
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
|
|
||||||
'next_page_url' => null,
|
|
||||||
]),
|
|
||||||
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
|
|
||||||
'data' => [['id' => 2, 'name' => 'Weather Result']],
|
|
||||||
'next_page_url' => null,
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::withoutLazyLoading();
|
|
||||||
|
|
||||||
Volt::test('catalog.trmnl')
|
|
||||||
->assertSee('Initial 1')
|
|
||||||
->call('loadMore')
|
|
||||||
->set('search', 'weather')
|
|
||||||
->assertSee('Weather Result')
|
|
||||||
->assertDontSee('Initial 1')
|
|
||||||
->assertSet('page', 1);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -99,35 +99,6 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function ():
|
||||||
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
|
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 {
|
test('updateDataPayload handles single URL without nesting', function (): void {
|
||||||
$plugin = Plugin::factory()->create([
|
$plugin = Plugin::factory()->create([
|
||||||
'data_strategy' => 'polling',
|
'data_strategy' => 'polling',
|
||||||
|
|
@ -708,233 +679,3 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context
|
||||||
->toContain('America/Chicago')
|
->toContain('America/Chicago')
|
||||||
->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
|
->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);
|
|
||||||
});
|
|
||||||
|
|
|
||||||