Compare commits

...

82 commits

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

fix idx bug and add tests

fix light mode styling and remove transitions

add global styling class
2026-01-10 17:44:51 +01:00
Benjamin Nussbaum
043f683db7 feat(#138): add device model dropdown in preview model
Some checks are pending
tests / ci (push) Waiting to run
2026-01-09 22:37:40 +01:00
Benjamin Nussbaum
36e1ad8441 feat: add Mirror URL modal for device configuration
Some checks are pending
tests / ci (push) Waiting to run
2026-01-09 21:11:28 +01:00
Benjamin Nussbaum
a06a0879ff chore: gitignore 2026-01-09 20:23:24 +01:00
Gabriele Lauricella
ddce3947c6 feat: enhanced web mirror trmnl client
Some checks are pending
tests / ci (push) Waiting to run
2026-01-09 11:14:31 +01:00
Gabriele Lauricella
4bc42cc1d2 feat: add web mirror trmnl client 2026-01-09 11:14:31 +01:00
Benjamin Nussbaum
94d5fca879 fix: half and quadrant layout for recipes with render_markup_view
Some checks failed
tests / ci (push) Has been cancelled
2026-01-06 20:23:14 +01:00
Benjamin Nussbaum
dc676327c2 fix(#121): allow multiple instances of the same plugin 2026-01-06 20:23:14 +01:00
Benjamin Nussbaum
e3bb9ad4e2 feat: implement Plugin duplicate action 2026-01-06 20:23:14 +01:00
jerremyng
e176f2828e add checks for comma when importing recipies 2026-01-06 19:38:12 +01:00
jerremyng
164a990dfe add validation for config_modal
Commas are now not allowed in multistring inputs. config_modal was also refactored and extracted as its own file (code was getting messy)
some basic tests were also created
2026-01-06 19:38:12 +01:00
Benjamin Nussbaum
6d02415b7d fix(#146): add validation to multi_string recipe configuration field
Some checks are pending
tests / ci (push) Waiting to run
2026-01-05 21:20:02 +01:00
Benjamin Nussbaum
3def60ae3e feat: add Image Webhook plugin 2026-01-05 21:07:13 +01:00
Benjamin Nussbaum
809965e81c
Revise statistics in README.md
Some checks are pending
tests / ci (push) Waiting to run
Updated download and star counts for TRMNL BYOS Laravel.
2026-01-05 19:12:41 +01:00
Benjamin Nussbaum
b855ccffcb chore: update dependencies 2026-01-05 14:44:51 +01:00
Benjamin Nussbaum
32dd4c3d08 fix: codemirror enable searchKeymap, selectAll 2026-01-05 14:43:30 +01:00
jerremyng
a3f792944c change tests to test model/plugin logic directly
Some checks are pending
tests / ci (push) Waiting to run
Previously it was testing the rendered frontend, now it ensures no malicious xss is saved
2026-01-04 17:18:46 +01:00
jerremyng
3e670d37c0 add support for multi_string 2026-01-04 17:18:46 +01:00
jerremyng
46e792bc6d add HTML rendering on config modal with tests
Models/Plugin will now sanitize "description" and "help text" before loading. This allows HTML from these fields to be rendered safely.
Sanitization is done using Purify library for completeness (new dependency).

A test suite of simple xss attacks is also added.
2026-01-04 17:18:46 +01:00
jerremyng
9019561bb3 add zip dependencies to dev-container dockerfiles 2026-01-04 17:18:46 +01:00
Benjamin Nussbaum
838b4fd33b feat: bump to Design Framework 2.1
Some checks failed
tests / ci (push) Has been cancelled
2026-01-02 22:20:42 +01:00
Benjamin Nussbaum
4451361f15 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2026-01-02 14:53:45 +01:00
Benjamin Nussbaum
265972ac24 fix(#130): server error on faulty recipes
Some checks are pending
tests / ci (push) Waiting to run
2025-12-30 14:09:31 +01:00
Benjamin Nussbaum
7f97114f6e feat: add trmnl catalog paginator 2025-12-30 10:52:54 +01:00
Benjamin Nussbaum
3250bb0402 fix: install loading spinner not shown after catalog search 2025-12-30 10:28:41 +01:00
Benjamin Nussbaum
50853728bc refactor(#120): remove unnecessary js, improve cache handling 2025-12-30 10:22:46 +01:00
Benjamin Nussbaum
3cdc267809 chore: pint
Some checks are pending
tests / ci (push) Waiting to run
2025-12-29 23:08:52 +01:00
Benjamin Nussbaum
1298814521 fix(#136): mac address matching is case senstive 2025-12-29 23:07:21 +01:00
Benjamin Nussbaum
a5cb38421e fix(#131): invalidate cache when updating recipe markup 2025-12-29 22:24:32 +01:00
Benjamin Nussbaum
e6d66af298 fix(#135): use user configured timezone in Playlists 2025-12-29 22:16:29 +01:00
Benjamin Nussbaum
d4b5cf99d5 chore: update dependencies 2025-12-29 22:05:20 +01:00
Benjamin Nussbaum
d81c1b99f1
Update download and star counts in README
Some checks are pending
tests / ci (push) Waiting to run
2025-12-29 11:39:21 +01:00
dowjames
0b2b5bf25f Update holidays-ical.blade.php
Some checks failed
tests / ci (push) Has been cancelled
*Past events are removed.
*Events that started earlier but are still ongoing today remain visible.
*Anything from today onward displays.
2025-12-27 23:25:20 +01:00
Benjamin Nussbaum
f1a9103f0d chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-12-23 12:50:24 +01:00
Benjamin Nussbaum
d49a2d4f6c fix: styling in line with project standards
Some checks failed
tests / ci (push) Has been cancelled
2025-12-13 14:01:10 +01:00
andrzejskowron
be2bb637c9 styling in line with project standards
Some checks failed
tests / ci (push) Has been cancelled
2025-12-12 23:06:33 +01:00
andrzejskowron
f3538048d4 use flux design 2025-12-12 23:06:33 +01:00
andrzejskowron
a7963947f8 use flux design 2025-12-12 23:06:33 +01:00
andrzejskowron
b1467204f8 add preview import list 2025-12-12 23:06:33 +01:00
Benjamin Nussbaum
fb9469d9cd chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-12-10 16:43:27 +01:00
Benjamin Nussbaum
b6faa2f232 chore: update puppeteer 24.30.0
Some checks are pending
tests / ci (push) Waiting to run
2025-12-09 21:29:46 +01:00
Benjamin Nussbaum
60f2a38169 feat(#129): add iCal response parser 2025-12-09 21:07:48 +01:00
Benjamin Nussbaum
838db288e7 feat: update Docker image to include php ext: intl
Some checks failed
tests / ci (push) Has been cancelled
2025-12-07 09:49:01 +01:00
Benjamin Nussbaum
8776c668b4 chore: update node dependencies 2025-12-05 17:54:07 +01:00
Benjamin Nussbaum
1096118e03 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-12-05 15:39:40 +01:00
Benjamin Nussbaum
b10bbca774 fix(#124): improve timezone support
Some checks failed
tests / ci (push) Has been cancelled
2025-12-02 16:54:57 +01:00
Benjamin Nussbaum
0322ec899e fix(#123): shared layout not prepended when installing recipe 2025-12-02 15:14:15 +01:00
Benjamin Nussbaum
7c8e55588a fix(#123): normalizes non-named select config options for recipes 2025-12-02 14:58:27 +01:00
Benjamin Nussbaum
dac8064938 fix(#112): error when config field of a recipe expects json 2025-12-02 14:34:46 +01:00
Benjamin Nussbaum
fd41e77e7d chore: update dependencies 2025-12-02 14:29:22 +01:00
Benjamin Nussbaum
568bd69fea feat(#91): add multi color and palette support 2025-11-25 18:56:41 +01:00
Benjamin Nussbaum
61b9ff56e0 pin trmnl-pipeline-php color palette branch 2025-11-25 18:56:41 +01:00
Benjamin Nussbaum
73f0fd26c2 fix: typo 2025-11-25 18:56:41 +01:00
Benjamin Nussbaum
7014250ac5 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-11-21 11:48:39 +01:00
Benjamin Nussbaum
c157dcf3b6 chore: node audit
Some checks failed
tests / ci (push) Has been cancelled
2025-11-15 21:16:26 +01:00
Benjamin Nussbaum
742fd86c77
Revise README with updated downloads and recipe links
Some checks are pending
tests / ci (push) Waiting to run
Updated statistics and links in the README.
2025-11-15 08:48:42 +01:00
Benjamin Nussbaum
7489d85592 fix: tests
Some checks are pending
tests / ci (push) Waiting to run
2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
22a24383b2 feat: catalog add loading spinner 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
468e8a130d chore: set PUPPETEER_WAIT_FOR_NETWORK_IDLE to true by default 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
346f04a7af test: add coverage for ext renderer 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
31a73ccc6e ci(docker): optimize multi-stage build 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
042654993a feat: improve polling url rendering with liquid loops. support external liquid renderer 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
6c438ff4d4 chore: add limitation hint 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
b7ce0b6152 fix: lazy load plugin images
fix: lazy load catalog
2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
cdf477e2ed chore: OSS catalog, archive import are now beta 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
e63953dc13 feat: reposition filter button 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
a8f3232ccc feat: add TRMNL recipe catalog 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
41baff51a6 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-11-13 16:07:46 +01:00
Benjamin Nussbaum
f0f6b28107 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2025-11-12 18:26:01 +01:00
Benjamin Nussbaum
e53c584eed ci: metadata-action change to semver tag type
Some checks failed
tests / ci (push) Has been cancelled
2025-11-06 21:53:41 +01:00
Benjamin Nussbaum
1ccaa8382b
Update recipe count in README.md 2025-11-06 15:38:09 +01:00
Benjamin Nussbaum
36f783ac60 chore: update dependencies 2025-11-06 15:36:27 +01:00
92 changed files with 9334 additions and 2155 deletions

View file

@ -9,7 +9,8 @@ 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
@ -19,7 +20,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 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 RUN docker-php-ext-install imagick zip
# 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

View file

@ -14,7 +14,8 @@ 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
@ -24,7 +25,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 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 RUN docker-php-ext-install imagick zip
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

View file

@ -42,7 +42,7 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=ref,event=tag type=semver,pattern={{version}}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

5
.gitignore vendored
View file

@ -29,3 +29,8 @@ 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

View file

@ -1,7 +1,7 @@
######################## ########################
# Base Image # Base Image
######################## ########################
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium AS base FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel" LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
@ -12,9 +12,14 @@ ENV APP_VERSION=${APP_VERSION}
ENV AUTORUN_ENABLED="true" ENV AUTORUN_ENABLED="true"
# Mark trmnl-liquid-cli as installed
ENV TRMNL_LIQUID_ENABLED=1
# Switch to the root user so we can do root things # Switch to the root user so we can do root things
USER root USER root
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
# Set the working directory # Set the working directory
WORKDIR /var/www/html WORKDIR /var/www/html
@ -48,6 +53,5 @@ FROM base AS production
# Copy the assets from the assets image # Copy the assets from the assets image
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build
COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules
# Drop back to the www-data user # Drop back to the www-data user
USER www-data USER www-data

View file

@ -3,7 +3,7 @@
[![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml) [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](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, recipes (55+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 20k downloads and 100+ stars, its the most popular community-driven BYOS. It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, its the most popular community-driven BYOS.
![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot.png)
![Screenshot](README_byos-screenshot-dark.png) ![Screenshot](README_byos-screenshot-dark.png)
@ -16,7 +16,8 @@ It allows you to manage TRMNL devices, generate screens using native plugins, re
* 🔍 Auto-Join Automatically detects and adds devices from your local network. * 🔍 Auto-Join Automatically detects and adds devices from your local network.
* 🖥️ Screen Generation Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code. * 🖥️ Screen Generation Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
* Support for TRMNL [Design Framework](https://usetrmnl.com/framework) * Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
* Over 45 compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/) * Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
* Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes)
* Supported Devices * Supported Devices
* TRMNL OG (1-bit & 2-bit) * TRMNL OG (1-bit & 2-bit)
* SeeedStudio TRMNL 7,5" (OG) DIY Kit * SeeedStudio TRMNL 7,5" (OG) DIY Kit
@ -24,6 +25,7 @@ It allows you to manage TRMNL devices, generate screens using native plugins, re
* reTerminal E1001 Monochrome ePaper Display * reTerminal E1001 Monochrome ePaper Display
* Custom ESP32 with TRMNL firmware * Custom ESP32 with TRMNL firmware
* E-Reader Devices * E-Reader Devices
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27)) * Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook)) * Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo)) * Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))

View file

@ -121,6 +121,10 @@ class GenerateDefaultImagesCommand extends Command
$browserStage = new BrowserStage($browsershotInstance); $browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($html); $browserStage->html($html);
// Set timezone from app config (no user context in this command)
$browserStage->timezone(config('app.timezone'));
$browserStage $browserStage
->width($deviceModel->width) ->width($deviceModel->width)
->height($deviceModel->height); ->height($deviceModel->height);

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Jobs; namespace App\Jobs;
use App\Models\DeviceModel; use App\Models\DeviceModel;
use App\Models\DevicePalette;
use Exception; use Exception;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -20,6 +21,8 @@ final class FetchDeviceModelsJob implements ShouldQueue
private const API_URL = 'https://usetrmnl.com/api/models'; private const API_URL = 'https://usetrmnl.com/api/models';
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
/** /**
* Create a new job instance. * Create a new job instance.
*/ */
@ -34,6 +37,8 @@ final class FetchDeviceModelsJob implements ShouldQueue
public function handle(): void public function handle(): void
{ {
try { try {
$this->processPalettes();
$response = Http::timeout(30)->get(self::API_URL); $response = Http::timeout(30)->get(self::API_URL);
if (! $response->successful()) { if (! $response->successful()) {
@ -69,6 +74,86 @@ final class FetchDeviceModelsJob implements ShouldQueue
} }
} }
/**
* Process palettes from API and update/create records.
*/
private function processPalettes(): void
{
try {
$response = Http::timeout(30)->get(self::PALETTES_API_URL);
if (! $response->successful()) {
Log::error('Failed to fetch palettes from API', [
'status' => $response->status(),
'body' => $response->body(),
]);
return;
}
$data = $response->json('data', []);
if (! is_array($data)) {
Log::error('Invalid response format from palettes API', [
'response' => $response->json(),
]);
return;
}
foreach ($data as $paletteData) {
try {
$this->updateOrCreatePalette($paletteData);
} catch (Exception $e) {
Log::error('Failed to process palette', [
'palette_data' => $paletteData,
'error' => $e->getMessage(),
]);
}
}
Log::info('Successfully fetched and updated palettes', [
'count' => count($data),
]);
} catch (Exception $e) {
Log::error('Exception occurred while fetching palettes', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* Update or create a palette record.
*/
private function updateOrCreatePalette(array $paletteData): void
{
$name = $paletteData['id'] ?? null;
if (! $name) {
Log::warning('Palette data missing id field', [
'palette_data' => $paletteData,
]);
return;
}
$attributes = [
'name' => $name,
'description' => $paletteData['name'] ?? '',
'grays' => $paletteData['grays'] ?? 2,
'colors' => $paletteData['colors'] ?? null,
'framework_class' => $paletteData['framework_class'] ?? '',
'source' => 'api',
];
DevicePalette::updateOrCreate(
['name' => $name],
$attributes
);
}
/** /**
* Process the device models data and update/create records. * Process the device models data and update/create records.
*/ */
@ -114,12 +199,49 @@ 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',
]; ];
// Set palette_id to the first palette from the model's palettes array
$firstPaletteId = $this->getFirstPaletteId($modelData);
if ($firstPaletteId) {
$attributes['palette_id'] = $firstPaletteId;
}
DeviceModel::updateOrCreate( DeviceModel::updateOrCreate(
['name' => $name], ['name' => $name],
$attributes $attributes
); );
} }
/**
* Get the first palette ID from model data.
*/
private function getFirstPaletteId(array $modelData): ?int
{
$paletteName = null;
// Check for palette_ids array
if (isset($modelData['palette_ids']) && is_array($modelData['palette_ids']) && $modelData['palette_ids'] !== []) {
$paletteName = $modelData['palette_ids'][0];
}
// Check for palettes array (array of objects with id)
if (! $paletteName && isset($modelData['palettes']) && is_array($modelData['palettes']) && $modelData['palettes'] !== []) {
$firstPalette = $modelData['palettes'][0];
if (is_array($firstPalette) && isset($firstPalette['id'])) {
$paletteName = $firstPalette['id'];
}
}
if (! $paletteName) {
return null;
}
// Look up palette by name to get the integer ID
$palette = DevicePalette::where('name', $paletteName)->first();
return $palette?->id;
}
} }

View file

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

View file

@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage;
/** /**
* @property-read DeviceModel|null $deviceModel * @property-read DeviceModel|null $deviceModel
* @property-read DevicePalette|null $palette
*/ */
class Device extends Model class Device extends Model
{ {
@ -19,6 +20,14 @@ 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',
@ -187,6 +196,11 @@ class Device extends Model
return $this->belongsTo(DeviceModel::class); return $this->belongsTo(DeviceModel::class);
} }
public function palette(): BelongsTo
{
return $this->belongsTo(DevicePalette::class, 'palette_id');
}
/** /**
* Get the color depth string (e.g., "4bit") for the associated device model. * Get the color depth string (e.g., "4bit") for the associated device model.
*/ */

View file

@ -6,7 +6,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property-read DevicePalette|null $palette
*/
final class DeviceModel extends Model final class DeviceModel extends Model
{ {
use HasFactory; use HasFactory;
@ -35,7 +39,7 @@ final class DeviceModel extends Model
return '2bit'; return '2bit';
} }
// if higher then 4 return 4bit // if higher than 4 return 4bit
if ($this->bit_depth > 4) { if ($this->bit_depth > 4) {
return '4bit'; return '4bit';
} }
@ -66,4 +70,9 @@ final class DeviceModel extends Model
return null; return null;
} }
public function palette(): BelongsTo
{
return $this->belongsTo(DevicePalette::class, 'palette_id');
}
} }

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property array|null $colors
*/
final class DevicePalette extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'grays' => 'integer',
'colors' => 'array',
];
}

View file

@ -37,21 +37,32 @@ class Playlist extends Model
return false; return false;
} }
// Check weekday // Get user's timezone or fall back to app timezone
if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) { $timezone = $this->device->user->timezone ?? config('app.timezone');
$now = now($timezone);
// Check weekday (using timezone-aware time)
if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) {
return false; return false;
} }
if ($this->active_from !== null && $this->active_until !== null) { if ($this->active_from !== null && $this->active_until !== null) {
$now = now(); // Create timezone-aware datetime objects for active_from and active_until
$activeFrom = $now->copy()
->setTimeFrom($this->active_from)
->timezone($timezone);
$activeUntil = $now->copy()
->setTimeFrom($this->active_until)
->timezone($timezone);
// Handle time ranges that span across midnight // Handle time ranges that span across midnight
if ($this->active_from > $this->active_until) { if ($activeFrom > $activeUntil) {
// Time range spans midnight (e.g., 09:01 to 03:58) // Time range spans midnight (e.g., 09:01 to 03:58)
if ($now >= $this->active_from || $now <= $this->active_until) { if ($now >= $activeFrom || $now <= $activeUntil) {
return true; return true;
} }
} elseif ($now >= $this->active_from && $now <= $this->active_until) { } elseif ($now >= $activeFrom && $now <= $activeUntil) {
return true; return true;
} }

View file

@ -11,6 +11,9 @@ 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 Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -19,11 +22,12 @@ use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
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
{ {
@ -40,6 +44,9 @@ class Plugin extends Model
'configuration_template' => 'json', 'configuration_template' => 'json',
'no_bleed' => 'boolean', 'no_bleed' => 'boolean',
'dark_mode' => 'boolean', 'dark_mode' => 'boolean',
'preferred_renderer' => 'string',
'plugin_type' => 'string',
'alias' => 'boolean',
]; ];
protected static function boot() protected static function boot()
@ -51,6 +58,18 @@ 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()
@ -58,6 +77,25 @@ 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'])) {
@ -98,6 +136,11 @@ 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());
@ -111,160 +154,88 @@ 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'];
if ($this->polling_header) {
// Resolve Liquid variables in the polling header
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", mb_trim($resolvedHeader));
foreach ($headerLines as $line) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
}
}
}
// Split URLs by newline and filter out empty lines
$urls = array_filter(
array_map('trim', explode("\n", $this->polling_url)),
fn ($url): bool => ! empty($url)
);
// If only one URL, use the original logic without nesting
if (count($urls) === 1) {
$url = reset($urls);
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try {
// Make the request based on the verb
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
$response = $this->parseResponse($httpResponse);
$this->update([
'data_payload' => $response,
'data_payload_updated_at' => now(),
]);
} catch (Exception $e) {
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$this->update([
'data_payload' => ['error' => 'Failed to fetch data'],
'data_payload_updated_at' => now(),
]);
}
return;
}
// Multiple URLs - use nested response logic
$combinedResponse = [];
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try {
// Make the request based on the verb
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
$response = $this->parseResponse($httpResponse);
// Check if response is an array at root level
if (array_keys($response) === range(0, count($response) - 1)) {
// Response is a sequential array, nest under .data
$combinedResponse["IDX_{$index}"] = ['data' => $response];
} else {
// Response is an object or associative array, keep as is
$combinedResponse["IDX_{$index}"] = $response;
}
} catch (Exception $e) {
// Log error and continue with other URLs
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
}
}
$this->update([
'data_payload' => $combinedResponse,
'data_payload_updated_at' => now(),
]);
} }
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
// resolve headers
if ($this->polling_header) {
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", mb_trim($resolvedHeader));
foreach ($headerLines as $line) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
}
}
}
// resolve and clean URLs
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
array_map('trim', explode("\n", $resolvedPollingUrls)),
fn ($url): bool => filled($url)
));
$combinedResponse = [];
// Loop through all URLs (Handles 1 or many)
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
try {
$httpResponse = ($this->polling_verb === 'post')
? $httpRequest->post($url)
: $httpRequest->get($url);
$response = $this->parseResponse($httpResponse);
// Nest if it's a sequential array
if (array_keys($response) === range(0, count($response) - 1)) {
$combinedResponse["IDX_{$index}"] = ['data' => $response];
} else {
$combinedResponse["IDX_{$index}"] = $response;
}
} catch (Exception $e) {
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
}
}
// unwrap IDX_0 if only one URL
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
$this->update([
'data_payload' => $finalPayload,
'data_payload_updated_at' => now(),
]);
} }
/**
* Parse HTTP response, handling both JSON and XML content types
*/
private function parseResponse(Response $httpResponse): array private function parseResponse(Response $httpResponse): array
{ {
if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) { $parsers = app(ResponseParserRegistry::class)->getParsers();
foreach ($parsers as $parser) {
$parserName = class_basename($parser);
try { try {
// Convert XML to array and wrap under 'rss' key $result = $parser->parse($httpResponse);
$xml = simplexml_load_string($httpResponse->body());
if ($xml === false) { if ($result !== null) {
throw new Exception('Invalid XML content'); return $result;
} }
// Convert SimpleXML directly to array
$xmlArray = $this->xmlToArray($xml);
return ['rss' => $xmlArray];
} catch (Exception $e) { } catch (Exception $e) {
Log::warning('Failed to parse XML response: '.$e->getMessage()); Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
return ['error' => 'Failed to parse XML response'];
} }
} }
try { return ['error' => 'Failed to parse response'];
// 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;
} }
/** /**
@ -341,19 +312,48 @@ class Plugin extends Model
return $template; return $template;
} }
/**
* Check if a template contains a Liquid for loop pattern
*
* @param string $template The template string to check
* @return bool True if the template contains a for loop pattern
*/
private function containsLiquidForLoop(string $template): bool
{
return preg_match('/{%-?\s*for\s+/i', $template) === 1;
}
/** /**
* Resolve Liquid variables in a template string using the Liquid template engine * Resolve Liquid variables in a template string using the Liquid template engine
* *
* Uses the external trmnl-liquid renderer when:
* - preferred_renderer is 'trmnl-liquid'
* - External renderer is enabled in config
* - Template contains a Liquid for loop pattern
*
* Otherwise uses the internal PHP-based Liquid renderer.
*
* @param string $template The template string containing Liquid variables * @param string $template The template string containing Liquid variables
* @return string The resolved template with variables replaced with their values * @return string The resolved template with variables replaced with their values
* *
* @throws LiquidException * @throws LiquidException
* @throws Exception
*/ */
public function resolveLiquidVariables(string $template): string public function resolveLiquidVariables(string $template): string
{ {
// Get configuration variables - make them available at root level // Get configuration variables - make them available at root level
$variables = $this->configuration ?? []; $variables = $this->configuration ?? [];
// Check if external renderer should be used
$useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid'
&& config('services.trmnl.liquid_enabled')
&& $this->containsLiquidForLoop($template);
if ($useExternalRenderer) {
// Use external Ruby liquid renderer
return $this->renderWithExternalLiquidRenderer($template, $variables);
}
// Use the Liquid template engine to resolve variables // Use the Liquid template engine to resolve variables
$environment = App::make('liquid.environment'); $environment = App::make('liquid.environment');
$environment->filterRegistry->register(StandardFilters::class); $environment->filterRegistry->register(StandardFilters::class);
@ -363,6 +363,53 @@ class Plugin extends Model
return $liquidTemplate->render($context); return $liquidTemplate->render($context);
} }
/**
* Render template using external Ruby liquid renderer
*
* @param string $template The liquid template string
* @param array $context The render context data
* @return string The rendered HTML
*
* @throws Exception
*/
private function renderWithExternalLiquidRenderer(string $template, array $context): string
{
$liquidPath = config('services.trmnl.liquid_path');
if (empty($liquidPath)) {
throw new Exception('External liquid renderer path is not configured');
}
// HTML encode the template
$encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
// Encode context as JSON
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($jsonContext === false) {
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg());
}
// Validate argument sizes
app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath);
// Execute the external renderer
$process = Process::run([
$liquidPath,
'--template',
$encodedTemplate,
'--context',
$jsonContext,
]);
if (! $process->successful()) {
$errorOutput = $process->errorOutput() ?: $process->output();
throw new Exception('External liquid renderer failed: '.$errorOutput);
}
return $process->output();
}
/** /**
* Render the plugin's markup * Render the plugin's markup
* *
@ -370,63 +417,81 @@ 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 = '';
if ($this->markup_language === 'liquid') { if ($this->markup_language === 'liquid') {
// Create a custom environment with inline templates support // Get timezone from user or fall back to app timezone
$inlineFileSystem = new InlineTemplatesFileSystem(); $timezone = $this->user->timezone ?? config('app.timezone');
$environment = new \Keepsuit\Liquid\Environment(
fileSystem: $inlineFileSystem,
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
);
// Register all custom filters // Calculate UTC offset in seconds
$environment->filterRegistry->register(Data::class); $utcOffset = (string) Carbon::now($timezone)->getOffset();
$environment->filterRegistry->register(Date::class);
$environment->filterRegistry->register(Localization::class);
$environment->filterRegistry->register(Numbers::class);
$environment->filterRegistry->register(StringMarkup::class);
$environment->filterRegistry->register(Uniqueness::class);
// Register the template tag for inline templates // Build render context
$environment->tagRegistry->register(TemplateTag::class); $context = [
'size' => $size,
// Apply Liquid replacements (including 'with' syntax conversion) 'data' => $this->data_payload,
$processedMarkup = $this->applyLiquidReplacements($this->render_markup); 'config' => $this->configuration ?? [],
...(is_array($this->data_payload) ? $this->data_payload : []),
$template = $environment->parseString($processedMarkup); 'trmnl' => [
$context = $environment->newRenderContext( 'system' => [
data: [ 'timestamp_utc' => now()->utc()->timestamp,
'size' => $size, ],
'data' => $this->data_payload, 'user' => [
'config' => $this->configuration ?? [], 'utc_offset' => $utcOffset,
...(is_array($this->data_payload) ? $this->data_payload : []), 'name' => $this->user->name ?? 'Unknown User',
'trmnl' => [ 'locale' => 'en',
'system' => [ 'time_zone_iana' => $timezone,
'timestamp_utc' => now()->utc()->timestamp, ],
], 'plugin_settings' => [
'user' => [ 'instance_name' => $this->name,
'utc_offset' => '0', 'strategy' => $this->data_strategy,
'name' => $this->user->name ?? 'Unknown User', 'dark_mode' => $this->dark_mode ? 'yes' : 'no',
'locale' => 'en', 'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
'time_zone_iana' => config('app.timezone'), 'polling_headers' => $this->polling_header,
], 'polling_url' => $this->polling_url,
'plugin_settings' => [ 'custom_fields_values' => [
'instance_name' => $this->name, ...(is_array($this->configuration) ? $this->configuration : []),
'strategy' => $this->data_strategy,
'dark_mode' => $this->dark_mode ? 'yes' : 'no',
'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
'polling_headers' => $this->polling_header,
'polling_url' => $this->polling_url,
'custom_fields_values' => [
...(is_array($this->configuration) ? $this->configuration : []),
],
], ],
], ],
] ],
); ];
$renderedContent = $template->render($context);
// Check if external renderer should be used
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
// Use external Ruby renderer - pass raw template without preprocessing
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
} else {
// Use PHP keepsuit/liquid renderer
// Create a custom environment with inline templates support
$inlineFileSystem = new InlineTemplatesFileSystem();
$environment = new \Keepsuit\Liquid\Environment(
fileSystem: $inlineFileSystem,
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
);
// Register all custom filters
$environment->filterRegistry->register(Data::class);
$environment->filterRegistry->register(Date::class);
$environment->filterRegistry->register(Localization::class);
$environment->filterRegistry->register(Numbers::class);
$environment->filterRegistry->register(StringMarkup::class);
$environment->filterRegistry->register(Uniqueness::class);
// Register the template tag for inline templates
$environment->tagRegistry->register(TemplateTag::class);
// Apply Liquid replacements (including 'with' syntax conversion)
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
$template = $environment->parseString($processedMarkup);
$liquidContext = $environment->newRenderContext(data: $context);
$renderedContent = $template->render($liquidContext);
}
} else { } else {
$renderedContent = Blade::render($this->render_markup, [ $renderedContent = Blade::render($this->render_markup, [
'size' => $size, 'size' => $size,
@ -463,17 +528,30 @@ class Plugin extends Model
if ($this->render_markup_view) { if ($this->render_markup_view) {
if ($standalone) { if ($standalone) {
return view('trmnl-layouts.single', [ $renderedView = view($this->render_markup_view, [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
])->render();
if ($size === 'full') {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'slot' => $renderedView,
])->render();
}
return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode, 'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'slot' => view($this->render_markup_view, [ 'slot' => $renderedView,
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
])->render(),
])->render(); ])->render();
} }
@ -504,4 +582,61 @@ 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);
}
} }

View file

@ -27,6 +27,7 @@ class User extends Authenticatable // implements MustVerifyEmail
'assign_new_devices', 'assign_new_devices',
'assign_new_device_id', 'assign_new_device_id',
'oidc_sub', 'oidc_sub',
'timezone',
]; ];
/** /**

View file

@ -25,12 +25,45 @@ class ImageGenerationService
{ {
public static function generateImage(string $markup, $deviceId): string public static function generateImage(string $markup, $deviceId): string
{ {
$device = Device::with('deviceModel')->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 if available, otherwise use device settings // Get image generation settings from DeviceModel or Device (for legacy devices)
$imageSettings = self::getImageSettings($device); $imageSettings = $deviceModel
? 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);
@ -44,6 +77,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance); $browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($markup); $browserStage->html($markup);
// Set timezone from user or fall back to app timezone
$timezone = $user?->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') { if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage $browserStage
->width($imageSettings['width']) ->width($imageSettings['width'])
@ -61,6 +98,14 @@ 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
$colorPalette = null;
if ($palette && $palette->colors) {
$colorPalette = $palette->colors;
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
$colorPalette = $deviceModel->palette->colors;
}
$imageStage = new ImageStage(); $imageStage = new ImageStage();
$imageStage->format($fileExtension) $imageStage->format($fileExtension)
->width($imageSettings['width']) ->width($imageSettings['width'])
@ -72,6 +117,11 @@ class ImageGenerationService
->offsetY($imageSettings['offset_y']) ->offsetY($imageSettings['offset_y'])
->outputPath($outputPath); ->outputPath($outputPath);
// Apply color palette if available
if ($colorPalette) {
$imageStage->colormap($colorPalette);
}
// Apply dithering if requested by markup // Apply dithering if requested by markup
$shouldDither = self::markupContainsDitherImage($markup); $shouldDither = self::markupContainsDitherImage($markup);
if ($shouldDither) { if ($shouldDither) {
@ -90,8 +140,7 @@ class ImageGenerationService
throw new RuntimeException('Image file is empty: '.$outputPath); throw new RuntimeException('Image file is empty: '.$outputPath);
} }
$device->update(['current_screen_image' => $uuid]); Log::info("Generated image: $uuid");
Log::info("Device $device->id: updated with new image: $uuid");
return $uuid; return $uuid;
@ -108,22 +157,7 @@ class ImageGenerationService
{ {
// If device has a DeviceModel, use its settings // If device has a DeviceModel, use its settings
if ($device->deviceModel) { if ($device->deviceModel) {
/** @var DeviceModel $model */ return self::getImageSettingsFromModel($device->deviceModel);
$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
@ -147,6 +181,43 @@ class ImageGenerationService
]; ];
} }
/**
* Get image generation settings from a DeviceModel
*/
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
{
if ($deviceModel) {
return [
'width' => $deviceModel->width,
'height' => $deviceModel->height,
'colors' => $deviceModel->colors,
'bit_depth' => $deviceModel->bit_depth,
'scale_factor' => $deviceModel->scale_factor,
'rotation' => $deviceModel->rotation,
'mime_type' => $deviceModel->mime_type,
'offset_x' => $deviceModel->offset_x,
'offset_y' => $deviceModel->offset_y,
'image_format' => self::determineImageFormatFromModel($deviceModel),
'use_model_settings' => true,
];
}
// Default settings if no device model provided
return [
'width' => 800,
'height' => 480,
'colors' => 2,
'bit_depth' => 1,
'scale_factor' => 1.0,
'rotation' => 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
'image_format' => ImageFormat::AUTO->value,
'use_model_settings' => false,
];
}
/** /**
* Determine the appropriate ImageFormat based on DeviceModel settings * Determine the appropriate ImageFormat based on DeviceModel settings
*/ */
@ -263,6 +334,10 @@ 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 {
@ -294,7 +369,7 @@ class ImageGenerationService
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
{ {
// Validate image type // Validate image type
if (! in_array($imageType, ['setup-logo', 'sleep'])) { if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
return null; return null;
} }
@ -328,16 +403,19 @@ class ImageGenerationService
/** /**
* Generate a default screen image from Blade template * Generate a default screen image from Blade template
*/ */
public static function generateDefaultScreenImage(Device $device, string $imageType): string public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
{ {
// Validate image type // Validate image type
if (! in_array($imageType, ['setup-logo', 'sleep'])) { if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
throw new InvalidArgumentException("Invalid image type: {$imageType}"); throw new InvalidArgumentException("Invalid image type: {$imageType}");
} }
$uuid = Uuid::uuid4()->toString(); $uuid = Uuid::uuid4()->toString();
try { try {
// Load device with relationships
$device->load(['palette', 'deviceModel.palette', 'user']);
// Get image generation settings from DeviceModel if available, otherwise use device settings // Get image generation settings from DeviceModel if available, otherwise use device settings
$imageSettings = self::getImageSettings($device); $imageSettings = self::getImageSettings($device);
@ -345,7 +423,7 @@ class ImageGenerationService
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
// Generate HTML from Blade template // Generate HTML from Blade template
$html = self::generateDefaultScreenHtml($device, $imageType); $html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
// Create custom Browsershot instance if using AWS Lambda // Create custom Browsershot instance if using AWS Lambda
$browsershotInstance = null; $browsershotInstance = null;
@ -356,6 +434,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance); $browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($html); $browserStage->html($html);
// Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') { if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage $browserStage
->width($imageSettings['width']) ->width($imageSettings['width'])
@ -372,6 +454,14 @@ 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 device or fallback to device model's default palette
$palette = $device->palette ?? $device->deviceModel?->palette;
$colorPalette = null;
if ($palette && $palette->colors) {
$colorPalette = $palette->colors;
}
$imageStage = new ImageStage(); $imageStage = new ImageStage();
$imageStage->format($fileExtension) $imageStage->format($fileExtension)
->width($imageSettings['width']) ->width($imageSettings['width'])
@ -383,6 +473,11 @@ class ImageGenerationService
->offsetY($imageSettings['offset_y']) ->offsetY($imageSettings['offset_y'])
->outputPath($outputPath); ->outputPath($outputPath);
// Apply color palette if available
if ($colorPalette) {
$imageStage->colormap($colorPalette);
}
(new TrmnlPipeline())->pipe($browserStage) (new TrmnlPipeline())->pipe($browserStage)
->pipe($imageStage) ->pipe($imageStage)
->process(); ->process();
@ -408,12 +503,13 @@ class ImageGenerationService
/** /**
* Generate HTML from Blade template for default screens * Generate HTML from Blade template for default screens
*/ */
private static function generateDefaultScreenHtml(Device $device, string $imageType): string private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
{ {
// Map image type to template name // Map image type to template name
$templateName = match ($imageType) { $templateName = match ($imageType) {
'setup-logo' => 'default-screens.setup', 'setup-logo' => 'default-screens.setup',
'sleep' => 'default-screens.sleep', 'sleep' => 'default-screens.sleep',
'error' => 'default-screens.error',
default => throw new InvalidArgumentException("Invalid image type: {$imageType}") default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
}; };
@ -424,14 +520,22 @@ class ImageGenerationService
$scaleLevel = $device->scaleLevel(); $scaleLevel = $device->scaleLevel();
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
// Render the Blade template // Build view data
return view($templateName, [ $viewData = [
'noBleed' => false, 'noBleed' => false,
'darkMode' => $darkMode, 'darkMode' => $darkMode,
'deviceVariant' => $deviceVariant, 'deviceVariant' => $deviceVariant,
'deviceOrientation' => $deviceOrientation, 'deviceOrientation' => $deviceOrientation,
'colorDepth' => $colorDepth, 'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel, 'scaleLevel' => $scaleLevel,
])->render(); ];
// Add plugin name for error screens
if ($imageType === 'error' && $pluginName !== null) {
$viewData['pluginName'] = $pluginName;
}
// Render the Blade template
return view($templateName, $viewData)->render();
} }
} }

View file

@ -0,0 +1,111 @@
<?php
namespace App\Services\Plugin\Parsers;
use Carbon\Carbon;
use DateTimeInterface;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
use om\IcalParser;
class IcalResponseParser implements ResponseParser
{
public function __construct(
private readonly IcalParser $parser = new IcalParser(),
) {}
public function parse(Response $response): ?array
{
$contentType = $response->header('Content-Type');
$body = $response->body();
if (! $this->isIcalResponse($contentType, $body)) {
return null;
}
try {
$this->parser->parseString($body);
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
$windowStart = now()->subDays(7);
$windowEnd = now()->addDays(30);
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
if (! $startDate instanceof Carbon) {
return false;
}
return $startDate->between($windowStart, $windowEnd, true);
}));
$normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents);
return ['ical' => $normalizedEvents];
} catch (Exception $exception) {
Log::warning('Failed to parse iCal response: '.$exception->getMessage());
return ['error' => 'Failed to parse iCal response'];
}
}
private function isIcalResponse(?string $contentType, string $body): bool
{
$normalizedContentType = $contentType ? mb_strtolower($contentType) : '';
if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) {
return true;
}
return str_contains($body, 'BEGIN:VCALENDAR');
}
private function asCarbon(DateTimeInterface|string|null $value): ?Carbon
{
if ($value instanceof Carbon) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return Carbon::instance($value);
}
if (is_string($value) && $value !== '') {
try {
return Carbon::parse($value);
} catch (Exception $exception) {
Log::warning('Failed to parse date value: '.$exception->getMessage());
return null;
}
}
return null;
}
private function normalizeIcalEvent(array $event): array
{
$normalized = [];
foreach ($event as $key => $value) {
$normalized[$key] = $this->normalizeIcalValue($value);
}
return $normalized;
}
private function normalizeIcalValue(mixed $value): mixed
{
if ($value instanceof DateTimeInterface) {
return Carbon::instance($value)->toAtomString();
}
if (is_array($value)) {
return array_map($this->normalizeIcalValue(...), $value);
}
return $value;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Services\Plugin\Parsers;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
class JsonOrTextResponseParser implements ResponseParser
{
public function parse(Response $response): array
{
try {
$json = $response->json();
if ($json !== null) {
return $json;
}
return ['data' => $response->body()];
} catch (Exception $e) {
Log::warning('Failed to parse JSON response: '.$e->getMessage());
return ['error' => 'Failed to parse JSON response'];
}
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Services\Plugin\Parsers;
use Illuminate\Http\Client\Response;
interface ResponseParser
{
/**
* Attempt to parse the given response.
*
* Return null when the parser is not applicable so other parsers can run.
*/
public function parse(Response $response): ?array;
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Services\Plugin\Parsers;
class ResponseParserRegistry
{
/**
* @var array<int, ResponseParser>
*/
private readonly array $parsers;
/**
* @param array<int, ResponseParser> $parsers
*/
public function __construct(array $parsers = [])
{
$this->parsers = $parsers ?: [
new XmlResponseParser(),
new IcalResponseParser(),
new JsonOrTextResponseParser(),
];
}
/**
* @return array<int, ResponseParser>
*/
public function getParsers(): array
{
return $this->parsers;
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Services\Plugin\Parsers;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
use SimpleXMLElement;
class XmlResponseParser implements ResponseParser
{
public function parse(Response $response): ?array
{
$contentType = $response->header('Content-Type');
if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) {
return null;
}
try {
$xml = simplexml_load_string($response->body());
if ($xml === false) {
throw new Exception('Invalid XML content');
}
return ['rss' => $this->xmlToArray($xml)];
} catch (Exception $exception) {
Log::warning('Failed to parse XML response: '.$exception->getMessage());
return ['error' => 'Failed to parse XML response'];
}
}
private function xmlToArray(SimpleXMLElement $xml): array
{
$array = (array) $xml;
foreach ($array as $key => $value) {
if ($value instanceof SimpleXMLElement) {
$array[$key] = $this->xmlToArray($value);
}
}
return $array;
}
}

View file

@ -17,6 +17,34 @@ 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
* *
@ -47,32 +75,55 @@ class PluginImportService
$zip->extractTo($tempDir); $zip->extractTo($tempDir);
$zip->close(); $zip->close();
// Find the required files (settings.yml and full.liquid/full.blade.php) // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files // Validate that we found the required files
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { if (! $filePaths['settingsYamlPath']) {
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
}
// Validate that we have at least one template file
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
} }
// Parse settings.yml // Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']); $settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings);
// Read full.liquid content // Determine which template file to use and read its content
$fullLiquid = File::get($filePaths['fullLiquidPath']); $templatePath = null;
// Prepend shared.liquid content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
$markupLanguage = 'blade'; $markupLanguage = 'blade';
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
if ($filePaths['fullLiquidPath']) {
$templatePath = $filePaths['fullLiquidPath'];
$fullLiquid = File::get($templatePath);
// Prepend shared.liquid or shared.blade.php content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$sharedBlade = File::get($filePaths['sharedBladePath']);
$fullLiquid = $sharedBlade."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid'; $markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade';
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -80,6 +131,9 @@ class PluginImportService
$settings['custom_fields'] = []; $settings['custom_fields'] = [];
} }
// Normalize options in custom_fields (convert non-named values to named values)
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
// Create configuration template with the custom fields // Create configuration template with the custom fields
$configurationTemplate = [ $configurationTemplate = [
'custom_fields' => $settings['custom_fields'], 'custom_fields' => $settings['custom_fields'],
@ -139,11 +193,14 @@ class PluginImportService
* @param string $zipUrl The URL to the ZIP file * @param string $zipUrl The URL to the ZIP file
* @param User $user The user importing the plugin * @param User $user The user importing the plugin
* @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 $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): Plugin public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
{ {
// Download the ZIP file // Download the ZIP file
$response = Http::timeout(60)->get($zipUrl); $response = Http::timeout(60)->get($zipUrl);
@ -171,32 +228,55 @@ class PluginImportService
$zip->extractTo($tempDir); $zip->extractTo($tempDir);
$zip->close(); $zip->close();
// Find the required files (settings.yml and full.liquid/full.blade.php) // Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath); $filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
// Validate that we found the required files // Validate that we found the required files
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) { if (! $filePaths['settingsYamlPath']) {
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.'); throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
}
// Validate that we have at least one template file
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
} }
// Parse settings.yml // Parse settings.yml
$settingsYaml = File::get($filePaths['settingsYamlPath']); $settingsYaml = File::get($filePaths['settingsYamlPath']);
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings);
// Read full.liquid content // Determine which template file to use and read its content
$fullLiquid = File::get($filePaths['fullLiquidPath']); $templatePath = null;
// Prepend shared.liquid content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
$markupLanguage = 'blade'; $markupLanguage = 'blade';
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
if ($filePaths['fullLiquidPath']) {
$templatePath = $filePaths['fullLiquidPath'];
$fullLiquid = File::get($templatePath);
// Prepend shared.liquid or shared.blade.php content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$sharedBlade = File::get($filePaths['sharedBladePath']);
$fullLiquid = $sharedBlade."\n".$fullLiquid;
}
// Check if the file ends with .liquid to set markup language
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid'; $markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade';
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -204,22 +284,34 @@ class PluginImportService
$settings['custom_fields'] = []; $settings['custom_fields'] = [];
} }
// Normalize options in custom_fields (convert non-named values to named values)
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
// Create configuration template with the custom fields // Create configuration template with the custom fields
$configurationTemplate = [ $configurationTemplate = [
'custom_fields' => $settings['custom_fields'], 'custom_fields' => $settings['custom_fields'],
]; ];
$plugin_updated = isset($settings['id']) // Determine the trmnlp_id to use
$trmnlpId = $settings['id'] ?? Uuid::v7();
// If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID
if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) {
$trmnlpId = Uuid::v7();
}
$plugin_updated = ! $allowDuplicate && isset($settings['id'])
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists(); && 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' => $settings['id'] ?? Uuid::v7(), 'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
], ],
[ [
'user_id' => $user->id, 'user_id' => $user->id,
'name' => $settings['name'] ?? 'Imported Plugin', 'name' => $settings['name'] ?? 'Imported Plugin',
'trmnlp_id' => $settings['id'] ?? Uuid::v7(), 'trmnlp_id' => $trmnlpId,
'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,
@ -232,6 +324,8 @@ class PluginImportService
'render_markup' => $fullLiquid, 'render_markup' => $fullLiquid,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
'preferred_renderer' => $preferredRenderer,
'icon_url' => $iconUrl,
]); ]);
if (! $plugin_updated) { if (! $plugin_updated) {
@ -262,6 +356,7 @@ 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) {
@ -279,6 +374,8 @@ 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';
} }
} }
@ -294,15 +391,18 @@ 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) { if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
return [ return [
'settingsYamlPath' => $settingsYamlPath, 'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath, 'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath,
]; ];
} }
} }
@ -319,9 +419,11 @@ class PluginImportService
$fullLiquidPath = $tempDir.'/src/full.blade.php'; $fullLiquidPath = $tempDir.'/src/full.blade.php';
} }
// Check for shared.liquid in the same directory // Check for shared.liquid or shared.blade.php in the same directory
if (File::exists($tempDir.'/src/shared.liquid')) { 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
@ -338,17 +440,24 @@ 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;
} }
}
// If we found both required files, break the loop // Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
if ($settingsYamlPath && $fullLiquidPath) { if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
break; $fullLiquidDir = dirname((string) $fullLiquidPath);
if (File::exists($fullLiquidDir.'/shared.liquid')) {
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
} elseif (File::exists($fullLiquidDir.'/shared.blade.php')) {
$sharedBladePath = $fullLiquidDir.'/shared.blade.php';
} }
} }
// If we found the files but they're not in the src folder, // 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) { if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
// 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);
@ -359,17 +468,25 @@ 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 shared.liquid if it exists // Copy full.liquid or full.blade.php if it exists
if ($fullLiquidPath) {
$extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION);
File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension);
$fullLiquidPath = $newSrcDir.'/full.'.$extension;
}
// Copy shared.liquid or shared.blade.php if it exists
if ($sharedLiquidPath) { 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';
} }
} }
} }
@ -378,6 +495,104 @@ class PluginImportService
'settingsYamlPath' => $settingsYamlPath, 'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath, 'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath,
]; ];
} }
/**
* Normalize options in custom_fields by converting non-named values to named values
* This ensures that options like ["true", "false"] become [["true" => "true"], ["false" => "false"]]
*
* @param array $customFields The custom_fields array from settings
* @return array The normalized custom_fields array
*/
private function normalizeCustomFieldsOptions(array $customFields): array
{
foreach ($customFields as &$field) {
// Only process select fields with options
if (isset($field['field_type']) && $field['field_type'] === 'select' && isset($field['options']) && is_array($field['options'])) {
$normalizedOptions = [];
foreach ($field['options'] as $option) {
// If option is already a named value (array with key-value pair), keep it as is
if (is_array($option)) {
$normalizedOptions[] = $option;
} else {
// Convert non-named value to named value
// Convert boolean to string, use lowercase for label
$value = is_bool($option) ? ($option ? 'true' : 'false') : (string) $option;
$normalizedOptions[] = [$value => $value];
}
}
$field['options'] = $normalizedOptions;
// Normalize default value to match normalized option values
if (isset($field['default'])) {
$default = $field['default'];
// If default is boolean, convert to string to match normalized options
if (is_bool($default)) {
$field['default'] = $default ? 'true' : 'false';
} else {
// Convert to string to ensure consistency
$field['default'] = (string) $default;
}
}
}
}
return $customFields;
}
/**
* Validate that template and context are within command-line argument limits
*
* @param string $template The liquid template string
* @param string $jsonContext The JSON-encoded context
* @param string $liquidPath The path to the liquid renderer executable
*
* @throws Exception If the template or context exceeds argument limits
*/
public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void
{
// MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments
// ARG_MAX is the total size of all arguments (typically 2MB on modern systems)
$maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit
$maxTotalArgLength = $this->getMaxArgumentLength();
// Check individual argument sizes (template and context are the largest)
if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) {
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
}
// Calculate total size of all arguments (path + flags + template + context)
// Add overhead for path, flags, and separators (conservative estimate: ~200 bytes)
$totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template)
+ mb_strlen('--context') + mb_strlen($jsonContext) + 200;
if ($totalArgSize > $maxTotalArgLength) {
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
}
}
/**
* Get the maximum argument length for command-line arguments
*
* @return int Maximum argument length in bytes
*/
private function getMaxArgumentLength(): int
{
// Try to get ARG_MAX from system using getconf
$argMax = null;
if (function_exists('shell_exec')) {
$result = @shell_exec('getconf ARG_MAX 2>/dev/null');
if ($result !== null && is_numeric(mb_trim($result))) {
$argMax = (int) mb_trim($result);
}
}
// Use conservative fallback if ARG_MAX cannot be determined
// ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB)
// We use 200KB as a conservative limit that works on both systems
// Note: ARG_MAX includes environment variables, so we leave headroom
return $argMax !== null ? min($argMax, 204800) : 204800;
}
} }

View file

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

View file

@ -6,6 +6,7 @@
"keywords": [ "keywords": [
"trmnl", "trmnl",
"trmnl-server", "trmnl-server",
"trmnl-byos",
"laravel" "laravel"
], ],
"license": "MIT", "license": "MIT",
@ -14,8 +15,8 @@
"ext-imagick": "*", "ext-imagick": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"ext-zip": "*", "ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "2.0.*", "bnussbau/laravel-trmnl-blade": "2.1.*",
"bnussbau/trmnl-pipeline-php": "^0.4.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",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
@ -23,7 +24,9 @@
"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"
}, },

1754
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,7 @@ return [
'force_https' => env('FORCE_HTTPS', false), 'force_https' => env('FORCE_HTTPS', false),
'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false), 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true),
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null), 'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
'notifications' => [ 'notifications' => [

View file

@ -41,6 +41,8 @@ return [
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'), 'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false), 'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false),
'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices 'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices
'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false),
'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'),
], ],
'webhook' => [ 'webhook' => [

6
config/trustedproxy.php Normal file
View file

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

View file

@ -0,0 +1,38 @@
<?php
namespace Database\Factories;
use App\Models\DevicePalette;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DevicePalette>
*/
class DevicePaletteFactory extends Factory
{
protected $model = DevicePalette::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'id' => 'test-'.$this->faker->unique()->slug(),
'name' => $this->faker->words(3, true),
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
'colors' => $this->faker->optional()->passthrough([
'#FF0000',
'#00FF00',
'#0000FF',
'#FFFF00',
'#000000',
'#FFFFFF',
]),
'framework_class' => null,
'source' => 'api',
];
}
}

View file

@ -29,8 +29,24 @@ 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']),
]);
}
} }

View file

@ -22,6 +22,7 @@ return new class extends Migration
public function down(): void public function down(): void
{ {
Schema::table('users', function (Blueprint $table) { Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['oidc_sub']);
$table->dropColumn('oidc_sub'); $table->dropColumn('oidc_sub');
}); });
} }

View file

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

View file

@ -0,0 +1,33 @@
<?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::create('device_palettes', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('description')->nullable();
$table->integer('grays');
$table->json('colors')->nullable();
$table->string('framework_class')->default('');
$table->string('source')->default('api');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('device_palettes');
}
};

View file

@ -0,0 +1,29 @@
<?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('device_models', function (Blueprint $table) {
$table->foreignId('palette_id')->nullable()->after('source')->constrained('device_palettes')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->dropForeign(['palette_id']);
$table->dropColumn('palette_id');
});
}
};

View file

@ -0,0 +1,29 @@
<?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('devices', function (Blueprint $table) {
$table->foreignId('palette_id')->nullable()->constrained('device_palettes')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropForeign(['palette_id']);
$table->dropColumn('palette_id');
});
}
};

View file

@ -0,0 +1,124 @@
<?php
use App\Models\DeviceModel;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Seed palettes from hardcoded data
// name = identifier, description = human-readable name
$palettes = [
[
'name' => 'bw',
'description' => 'Black & White',
'grays' => 2,
'colors' => null,
'framework_class' => 'screen--1bit',
'source' => 'api',
],
[
'name' => 'gray-4',
'description' => '4 Grays',
'grays' => 4,
'colors' => null,
'framework_class' => 'screen--2bit',
'source' => 'api',
],
[
'name' => 'gray-16',
'description' => '16 Grays',
'grays' => 16,
'colors' => null,
'framework_class' => 'screen--4bit',
'source' => 'api',
],
[
'name' => 'gray-256',
'description' => '256 Grays',
'grays' => 256,
'colors' => null,
'framework_class' => 'screen--4bit',
'source' => 'api',
],
[
'name' => 'color-6a',
'description' => '6 Colors',
'grays' => 2,
'colors' => json_encode(['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#000000', '#FFFFFF']),
'framework_class' => '',
'source' => 'api',
],
[
'name' => 'color-7a',
'description' => '7 Colors',
'grays' => 2,
'colors' => json_encode(['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FFA500']),
'framework_class' => '',
'source' => 'api',
],
];
$now = now();
$paletteIdMap = [];
foreach ($palettes as $paletteData) {
$paletteName = $paletteData['name'];
$paletteData['created_at'] = $now;
$paletteData['updated_at'] = $now;
DB::table('device_palettes')->updateOrInsert(
['name' => $paletteName],
$paletteData
);
// Get the ID of the palette (either newly created or existing)
$paletteRecord = DB::table('device_palettes')->where('name', $paletteName)->first();
$paletteIdMap[$paletteName] = $paletteRecord->id;
}
// Set default palette_id on DeviceModel based on first palette_ids entry
$models = [
['name' => 'og_png', 'palette_name' => 'bw'],
['name' => 'og_plus', 'palette_name' => 'gray-4'],
['name' => 'amazon_kindle_2024', 'palette_name' => 'gray-256'],
['name' => 'amazon_kindle_paperwhite_6th_gen', 'palette_name' => 'gray-256'],
['name' => 'amazon_kindle_paperwhite_7th_gen', 'palette_name' => 'gray-256'],
['name' => 'inkplate_10', 'palette_name' => 'gray-4'],
['name' => 'amazon_kindle_7', 'palette_name' => 'gray-256'],
['name' => 'inky_impression_7_3', 'palette_name' => 'color-7a'],
['name' => 'kobo_libra_2', 'palette_name' => 'gray-16'],
['name' => 'amazon_kindle_oasis_2', 'palette_name' => 'gray-256'],
['name' => 'kobo_aura_one', 'palette_name' => 'gray-16'],
['name' => 'kobo_aura_hd', 'palette_name' => 'gray-16'],
['name' => 'inky_impression_13_3', 'palette_name' => 'color-6a'],
['name' => 'm5_paper_s3', 'palette_name' => 'gray-16'],
['name' => 'amazon_kindle_scribe', 'palette_name' => 'gray-256'],
['name' => 'seeed_e1001', 'palette_name' => 'gray-4'],
['name' => 'seeed_e1002', 'palette_name' => 'gray-4'],
['name' => 'waveshare_4_26', 'palette_name' => 'gray-4'],
['name' => 'waveshare_7_5_bw', 'palette_name' => 'bw'],
];
foreach ($models as $modelData) {
$deviceModel = DeviceModel::where('name', $modelData['name'])->first();
if ($deviceModel && ! $deviceModel->palette_id && isset($paletteIdMap[$modelData['palette_name']])) {
$deviceModel->update(['palette_id' => $paletteIdMap[$modelData['palette_name']]]);
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Remove palette_id from device models but keep palettes
DeviceModel::query()->update(['palette_id' => null]);
}
};

View file

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

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plugins', function (Blueprint $table): 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');
});
}
};

View file

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

View file

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

View file

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

View file

@ -144,5 +144,42 @@ 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',
]
);
} }
} }

1009
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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.17.0", "puppeteer": "24.30.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"vite": "^7.0.4" "vite": "^7.0.4"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

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

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

View file

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

View file

@ -59,6 +59,10 @@
@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] {
@ -68,3 +72,39 @@ 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;
}
}

View file

@ -1,8 +1,9 @@
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 } from '@codemirror/commands'; import { indentWithTab, selectAll } 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';
@ -154,7 +155,16 @@ export function createCodeMirror(element, options = {}) {
createResizePlugin(), createResizePlugin(),
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]), ...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
...themeSupport, ...themeSupport,
keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]), keymap.of([
indentWithTab,
...foldKeymap,
...historyKeymap,
...searchKeymap,
{
key: 'Mod-a',
run: selectAll,
},
]),
EditorView.theme({ EditorView.theme({
'&': { '&': {
fontSize: '14px', fontSize: '14px',

View file

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

View file

@ -0,0 +1,23 @@
@props([
'noBleed' => false,
'darkMode' => false,
'deviceVariant' => 'og',
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'pluginName' => 'Recipe',
])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}">
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">
<x-trmnl::title>Error on {{ $pluginName }}</x-trmnl::title>
<x-trmnl::content>Unable to render content. Please check server logs.</x-trmnl::content>
</x-trmnl::richtext>
</x-trmnl::layout>
<x-trmnl::title-bar/>
</x-trmnl::view>
</x-trmnl::screen>

View file

@ -1,29 +1,52 @@
<?php <?php
use App\Services\PluginImportService; use App\Services\PluginImportService;
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 class extends Component { new
#[Lazy]
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();
} }
public function placeholder()
{
return <<<'HTML'
<div class="space-y-4">
<div class="flex items-center justify-center py-12">
<div class="flex items-center space-x-2">
<flux:icon.loading />
<flux:text>Loading recipes...</flux:text>
</div>
</div>
</div>
HTML;
}
private function loadCatalogPlugins(): void private function loadCatalogPlugins(): void
{ {
$catalogUrl = config('app.catalog_url'); $catalogUrl = config('app.catalog_url');
$this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) { $this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) {
try { try {
$response = Http::get($catalogUrl); $response = Http::timeout(10)->get($catalogUrl);
$catalogContent = $response->body(); $catalogContent = $response->body();
$catalog = Yaml::parse($catalogContent); $catalog = Yaml::parse($catalogContent);
@ -32,7 +55,7 @@ new 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;
} }
@ -62,8 +85,9 @@ new 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 [];
} }
}); });
@ -75,31 +99,59 @@ new 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;
} }
$this->installingPlugin = $pluginId; $this->installingPlugin = $pluginId;
try { try {
$importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null); $importedPlugin = $pluginImportService->importFromUrl(
$plugin['zip_url'],
auth()->user(),
$plugin['zip_entry_path'] ?? null,
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-gray-400" /> <flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
<flux:heading class="mt-2">No plugins available</flux:heading> <flux:heading class="mt-2">No plugins available</flux:heading>
<flux:subheading>Catalog is empty</flux:subheading> <flux:subheading>Catalog is empty</flux:subheading>
</div> </div>
@ -110,30 +162,30 @@ new class extends Component {
@enderror @enderror
@foreach($catalogPlugins as $plugin) @foreach($catalogPlugins as $plugin)
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6"> <div wire:key="plugin-{{ $plugin['id'] }}" class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
<div class="flex items-start space-x-4"> <div class="flex items-start space-x-4">
@if($plugin['logo_url']) @if($plugin['logo_url'])
<img src="{{ $plugin['logo_url'] }}" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover"> <img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else @else
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" /> <flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
</div> </div>
@endif @endif
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3> <flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
@if ($plugin['github']) @if ($plugin['github'])
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p> <flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
@endif @endif
</div> </div>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@if($plugin['license']) @if($plugin['license'])
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge> <flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
@endif @endif
@if($plugin['repo_url']) @if($plugin['repo_url'])
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"> <a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
<flux:icon name="github" class="w-5 h-5" /> <flux:icon name="github" class="w-5 h-5" />
</a> </a>
@endif @endif
@ -141,7 +193,7 @@ new class extends Component {
</div> </div>
@if($plugin['description']) @if($plugin['description'])
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p> <flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
@endif @endif
<div class="mt-4 flex items-center space-x-3"> <div class="mt-4 flex items-center space-x-3">
@ -151,6 +203,19 @@ new 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'] }}"
@ -166,4 +231,38 @@ new 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>

View file

@ -0,0 +1,407 @@
<?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\Volt\Component;
new
#[Lazy]
class extends Component
{
public array $recipes = [];
public int $page = 1;
public bool $hasMore = false;
public string $search = '';
public bool $isSearching = false;
public string $previewingRecipe = '';
public array $previewData = [];
public function mount(): void
{
$this->loadNewest();
}
public function placeholder()
{
return <<<'HTML'
<div class="space-y-4">
<div class="flex items-center justify-center py-12">
<div class="flex items-center space-x-2">
<flux:icon.loading />
<flux:text>Loading recipes...</flux:text>
</div>
</div>
</div>
HTML;
}
private function loadNewest(): void
{
try {
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
$response = Cache::remember($cacheKey, 43200, function () {
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
'sort-by' => 'newest',
'page' => $this->page,
]);
if (! $response->successful()) {
throw new RuntimeException('Failed to fetch TRMNL recipes');
}
return $response->json();
});
$data = $response['data'] ?? [];
$mapped = $this->mapRecipes($data);
if ($this->page === 1) {
$this->recipes = $mapped;
} else {
$this->recipes = array_merge($this->recipes, $mapped);
}
$this->hasMore = ! empty($response['next_page_url']);
} catch (Throwable $e) {
Log::error('TRMNL catalog load error: '.$e->getMessage());
if ($this->page === 1) {
$this->recipes = [];
}
$this->hasMore = false;
}
}
private function searchRecipes(string $term): void
{
$this->isSearching = true;
try {
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
$response = Cache::remember($cacheKey, 300, function () use ($term) {
$response = Http::get('https://usetrmnl.com/recipes.json', [
'search' => $term,
'sort-by' => 'newest',
'page' => $this->page,
]);
if (! $response->successful()) {
throw new RuntimeException('Failed to search TRMNL recipes');
}
return $response->json();
});
$data = $response['data'] ?? [];
$mapped = $this->mapRecipes($data);
if ($this->page === 1) {
$this->recipes = $mapped;
} else {
$this->recipes = array_merge($this->recipes, $mapped);
}
$this->hasMore = ! empty($response['next_page_url']);
} catch (Throwable $e) {
Log::error('TRMNL catalog search error: '.$e->getMessage());
if ($this->page === 1) {
$this->recipes = [];
}
$this->hasMore = false;
} finally {
$this->isSearching = false;
}
}
public function loadMore(): void
{
$this->page++;
$term = mb_trim($this->search);
if ($term === '' || mb_strlen($term) < 2) {
$this->loadNewest();
} else {
$this->searchRecipes($term);
}
}
public function updatedSearch(): void
{
$this->page = 1;
$term = mb_trim($this->search);
if ($term === '') {
$this->loadNewest();
return;
}
if (mb_strlen($term) < 2) {
// Require at least 2 chars to avoid noisy calls
return;
}
$this->searchRecipes($term);
}
public function installPlugin(string $recipeId, PluginImportService $pluginImportService): void
{
abort_unless(auth()->user() !== null, 403);
try {
$zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive";
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
$plugin = $pluginImportService->importFromUrl(
$zipUrl,
auth()->user(),
null,
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
$recipe['icon_url'] ?? null,
allowDuplicate: true
);
$this->dispatch('plugin-installed');
Flux::modal('import-from-trmnl-catalog')->close();
} catch (Exception $e) {
Log::error('Plugin installation failed: '.$e->getMessage());
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
}
}
public function previewRecipe(string $recipeId): void
{
$this->previewingRecipe = $recipeId;
$this->previewData = [];
try {
$response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json");
if ($response->successful()) {
$item = $response->json()['data'] ?? [];
$this->previewData = $this->mapRecipe($item);
} else {
// Fallback to searching for the specific recipe if single endpoint doesn't exist
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
'search' => $recipeId,
]);
if ($response->successful()) {
$data = $response->json()['data'] ?? [];
$item = collect($data)->firstWhere('id', $recipeId);
if ($item) {
$this->previewData = $this->mapRecipe($item);
}
}
}
} catch (Throwable $e) {
Log::error('TRMNL catalog preview fetch error: '.$e->getMessage());
}
if (empty($this->previewData)) {
$this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? [];
}
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
private function mapRecipes(array $items): array
{
return collect($items)
->map(fn (array $item) => $this->mapRecipe($item))
->toArray();
}
/**
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
private function mapRecipe(array $item): array
{
return [
'id' => $item['id'] ?? null,
'name' => $item['name'] ?? 'Untitled',
'icon_url' => $item['icon_url'] ?? null,
'screenshot_url' => $item['screenshot_url'] ?? null,
'author_bio' => is_array($item['author_bio'] ?? null)
? strip_tags($item['author_bio']['description'] ?? null)
: null,
'stats' => [
'installs' => data_get($item, 'stats.installs'),
'forks' => data_get($item, 'stats.forks'),
],
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null,
];
}
}; ?>
<div class="space-y-4">
<div class="flex items-center gap-3">
<div class="flex-1">
<flux:input
wire:model.live.debounce.400ms="search"
placeholder="Search TRMNL recipes (min 2 chars)..."
icon="magnifying-glass"
/>
</div>
<flux:badge color="zinc">Newest</flux:badge>
</div>
@error('installation')
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}" />
@enderror
@if(empty($recipes))
<div class="text-center py-8">
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
<flux:heading class="mt-2">No recipes found</flux:heading>
<flux:subheading>Try a different search term</flux:subheading>
</div>
@else
<div class="grid grid-cols-1 gap-4">
@foreach($recipes as $recipe)
<div wire:key="recipe-{{ $recipe['id'] }}" class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
<div class="px-10 py-8 space-y-6">
<div class="flex items-start space-x-4">
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
@if($thumb)
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
</div>
@endif
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div>
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
@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>
@endif
</div>
<div class="flex items-center space-x-2">
@if($recipe['detail_url'])
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
</a>
@endif
</div>
</div>
@if($recipe['author_bio'])
<flux:text class="mt-2" size="sm">{{ $recipe['author_bio'] }}</flux:text>
@endif
<div class="mt-4 flex items-center space-x-3">
@if($recipe['id'])
<flux:button
wire:click="installPlugin('{{ $recipe['id'] }}')"
variant="primary">
Install
</flux:button>
@endif
@if($recipe['id'] && ($recipe['screenshot_url'] ?? null))
<flux:modal.trigger name="trmnl-catalog-preview">
<flux:button
wire:click="previewRecipe('{{ $recipe['id'] }}')"
variant="subtle"
icon="eye">
Preview
</flux:button>
</flux:modal.trigger>
@endif
</div>
</div>
</div>
</div>
</div>
@endforeach
</div>
@if($hasMore)
<div class="flex justify-center mt-6">
<flux:button wire:click="loadMore" variant="subtle" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="loadMore">Load next page</span>
<span wire:loading wire:target="loadMore">Loading...</span>
</flux:button>
</div>
@endif
@endif
<!-- Preview Modal -->
<flux:modal name="trmnl-catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
<div wire:loading wire:target="previewRecipe" class="flex items-center justify-center py-12">
<div class="flex items-center space-x-2">
<flux:icon.loading />
<flux:text>Fetching recipe details...</flux:text>
</div>
</div>
<div wire:loading.remove wire:target="previewRecipe">
@if($previewingRecipe && !empty($previewData))
<div>
<flux:heading size="lg" class="mb-2">Preview {{ $previewData['name'] ?? 'Recipe' }}</flux:heading>
</div>
<div class="space-y-4">
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<img src="{{ $previewData['screenshot_url'] }}"
alt="Preview of {{ $previewData['name'] }}"
class="w-full h-auto max-h-[480px] object-contain">
</div>
@if($previewData['author_bio'])
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
<div class="px-10 py-8">
<flux:heading size="sm" class="mb-2">Description</flux:heading>
<flux:text size="sm">{{ $previewData['author_bio'] }}</flux:text>
</div>
</div>
@endif
@if(data_get($previewData, 'stats.installs'))
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
<div class="px-10 py-8">
<flux:heading size="sm" class="mb-2">Statistics</flux:heading>
<flux:text size="sm">
Installs: {{ data_get($previewData, 'stats.installs') }} ·
Forks: {{ data_get($previewData, 'stats.forks') }}
</flux:text>
</div>
</div>
@endif
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
@if($previewData['detail_url'])
<flux:button
href="{{ $previewData['detail_url'] }}"
target="_blank"
variant="subtle">
View on TRMNL
</flux:button>
@endif
<flux:modal.close>
<flux:button
wire:click="installPlugin('{{ $previewingRecipe }}')"
variant="primary">
Install Recipe
</flux:button>
</flux:modal.close>
</div>
</div>
@endif
</div>
</flux:modal>
</div>

View file

@ -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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> class="styled-container">
<div class="px-10 py-8"> <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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> class="styled-container">
<div class="px-10 py-8"> <div class="px-10 py-8">
@php @php
$current_image_uuid =$device->current_screen_image; $current_image_uuid =$device->current_screen_image;

View file

@ -1,26 +1,43 @@
<?php <?php
use App\Models\DeviceModel; use App\Models\DeviceModel;
use App\Models\DevicePalette;
use Livewire\Volt\Component; use Livewire\Volt\Component;
new class extends Component { new class extends Component
{
public $deviceModels; public $deviceModels;
public $devicePalettes;
public $name; public $name;
public $label; public $label;
public $description; public $description;
public $width; public $width;
public $height; public $height;
public $colors; public $colors;
public $bit_depth; public $bit_depth;
public $scale_factor = 1.0; public $scale_factor = 1.0;
public $rotation = 0; public $rotation = 0;
public $mime_type = 'image/png'; public $mime_type = 'image/png';
public $offset_x = 0; public $offset_x = 0;
public $offset_y = 0; public $offset_y = 0;
public $published_at; public $published_at;
public $palette_id;
protected $rules = [ protected $rules = [
'name' => 'required|string|max:255|unique:device_models,name', 'name' => 'required|string|max:255|unique:device_models,name',
'label' => 'required|string|max:255', 'label' => 'required|string|max:255',
@ -40,62 +57,58 @@ new class extends Component {
public function mount() public function mount()
{ {
$this->deviceModels = DeviceModel::all(); $this->deviceModels = DeviceModel::all();
$this->devicePalettes = DevicePalette::all();
return view('livewire.device-models.index'); return view('livewire.device-models.index');
} }
public function createDeviceModel(): void
{
$this->validate();
DeviceModel::create([
'name' => $this->name,
'label' => $this->label,
'description' => $this->description,
'width' => $this->width,
'height' => $this->height,
'colors' => $this->colors,
'bit_depth' => $this->bit_depth,
'scale_factor' => $this->scale_factor,
'rotation' => $this->rotation,
'mime_type' => $this->mime_type,
'offset_x' => $this->offset_x,
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
]);
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at']);
\Flux::modal('create-device-model')->close();
$this->deviceModels = DeviceModel::all();
session()->flash('message', 'Device model created successfully.');
}
public $editingDeviceModelId; public $editingDeviceModelId;
public function editDeviceModel(DeviceModel $deviceModel): void public $viewingDeviceModelId;
public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void
{ {
$this->editingDeviceModelId = $deviceModel->id; if ($deviceModelId) {
$this->name = $deviceModel->name; $deviceModel = DeviceModel::findOrFail($deviceModelId);
$this->label = $deviceModel->label;
$this->description = $deviceModel->description; if ($viewOnly) {
$this->width = $deviceModel->width; $this->viewingDeviceModelId = $deviceModel->id;
$this->height = $deviceModel->height; $this->editingDeviceModelId = null;
$this->colors = $deviceModel->colors; } else {
$this->bit_depth = $deviceModel->bit_depth; $this->editingDeviceModelId = $deviceModel->id;
$this->scale_factor = $deviceModel->scale_factor; $this->viewingDeviceModelId = null;
$this->rotation = $deviceModel->rotation; }
$this->mime_type = $deviceModel->mime_type;
$this->offset_x = $deviceModel->offset_x; $this->name = $deviceModel->name;
$this->offset_y = $deviceModel->offset_y; $this->label = $deviceModel->label;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); $this->description = $deviceModel->description;
$this->width = $deviceModel->width;
$this->height = $deviceModel->height;
$this->colors = $deviceModel->colors;
$this->bit_depth = $deviceModel->bit_depth;
$this->scale_factor = $deviceModel->scale_factor;
$this->rotation = $deviceModel->rotation;
$this->mime_type = $deviceModel->mime_type;
$this->offset_x = $deviceModel->offset_x;
$this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id;
} else {
$this->editingDeviceModelId = null;
$this->viewingDeviceModelId = null;
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id']);
$this->mime_type = 'image/png';
$this->scale_factor = 1.0;
$this->rotation = 0;
$this->offset_x = 0;
$this->offset_y = 0;
}
} }
public function updateDeviceModel(): void public function saveDeviceModel(): void
{ {
$deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId); $rules = [
'name' => 'required|string|max:255',
$this->validate([
'name' => 'required|string|max:255|unique:device_models,name,' . $deviceModel->id,
'label' => 'required|string|max:255', 'label' => 'required|string|max:255',
'description' => 'required|string', 'description' => 'required|string',
'width' => 'required|integer|min:1', 'width' => 'required|integer|min:1',
@ -108,38 +121,96 @@ new class extends Component {
'offset_x' => 'required|integer', 'offset_x' => 'required|integer',
'offset_y' => 'required|integer', 'offset_y' => 'required|integer',
'published_at' => 'nullable|date', 'published_at' => 'nullable|date',
]); 'palette_id' => 'nullable|exists:device_palettes,id',
];
$deviceModel->update([ if ($this->editingDeviceModelId) {
'name' => $this->name, $rules['name'] = 'required|string|max:255|unique:device_models,name,'.$this->editingDeviceModelId;
'label' => $this->label, } else {
'description' => $this->description, $rules['name'] = 'required|string|max:255|unique:device_models,name';
'width' => $this->width, }
'height' => $this->height,
'colors' => $this->colors,
'bit_depth' => $this->bit_depth,
'scale_factor' => $this->scale_factor,
'rotation' => $this->rotation,
'mime_type' => $this->mime_type,
'offset_x' => $this->offset_x,
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
]);
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'editingDeviceModelId']); $this->validate($rules);
\Flux::modal('edit-device-model-' . $deviceModel->id)->close();
if ($this->editingDeviceModelId) {
$deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId);
$deviceModel->update([
'name' => $this->name,
'label' => $this->label,
'description' => $this->description,
'width' => $this->width,
'height' => $this->height,
'colors' => $this->colors,
'bit_depth' => $this->bit_depth,
'scale_factor' => $this->scale_factor,
'rotation' => $this->rotation,
'mime_type' => $this->mime_type,
'offset_x' => $this->offset_x,
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null,
]);
$message = 'Device model updated successfully.';
} else {
DeviceModel::create([
'name' => $this->name,
'label' => $this->label,
'description' => $this->description,
'width' => $this->width,
'height' => $this->height,
'colors' => $this->colors,
'bit_depth' => $this->bit_depth,
'scale_factor' => $this->scale_factor,
'rotation' => $this->rotation,
'mime_type' => $this->mime_type,
'offset_x' => $this->offset_x,
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null,
'source' => 'manual',
]);
$message = 'Device model created successfully.';
}
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'editingDeviceModelId', 'viewingDeviceModelId']);
Flux::modal('device-model-modal')->close();
$this->deviceModels = DeviceModel::all(); $this->deviceModels = DeviceModel::all();
session()->flash('message', 'Device model updated successfully.'); session()->flash('message', $message);
} }
public function deleteDeviceModel(DeviceModel $deviceModel): void public function deleteDeviceModel(string $deviceModelId): void
{ {
$deviceModel = DeviceModel::findOrFail($deviceModelId);
$deviceModel->delete(); $deviceModel->delete();
$this->deviceModels = DeviceModel::all(); $this->deviceModels = DeviceModel::all();
session()->flash('message', 'Device model deleted successfully.'); session()->flash('message', 'Device model deleted successfully.');
} }
public function duplicateDeviceModel(string $deviceModelId): void
{
$deviceModel = DeviceModel::findOrFail($deviceModelId);
$this->editingDeviceModelId = null;
$this->viewingDeviceModelId = null;
$this->name = $deviceModel->name.' (Copy)';
$this->label = $deviceModel->label;
$this->description = $deviceModel->description;
$this->width = $deviceModel->width;
$this->height = $deviceModel->height;
$this->colors = $deviceModel->colors;
$this->bit_depth = $deviceModel->bit_depth;
$this->scale_factor = $deviceModel->scale_factor;
$this->rotation = $deviceModel->rotation;
$this->mime_type = $deviceModel->mime_type;
$this->offset_x = $deviceModel->offset_x;
$this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id;
$this->js('Flux.modal("device-model-modal").show()');
}
} }
?> ?>
@ -148,10 +219,19 @@ new class extends Component {
<div class="py-12"> <div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Models</h2> <div class="flex items-center space-x-2">
{{-- <flux:modal.trigger name="create-device-model">--}} <h2 class="text-2xl font-semibold dark:text-gray-100">Device Models</h2>
{{-- <flux:button icon="plus" variant="primary">Add Device Model</flux:button>--}} <flux:dropdown>
{{-- </flux:modal.trigger>--}} <flux:button icon="chevron-down" variant="ghost"></flux:button>
<flux:menu>
<flux:menu.item href="{{ route('devices') }}">Devices</flux:menu.item>
<flux:menu.item href="{{ route('device-palettes.index') }}">Device Palettes</flux:menu.item>
</flux:menu>
</flux:dropdown>
</div>
<flux:modal.trigger name="device-model-modal">
<flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button>
</flux:modal.trigger>
</div> </div>
@if (session()->has('message')) @if (session()->has('message'))
<div class="mb-4"> <div class="mb-4">
@ -164,157 +244,104 @@ new class extends Component {
</div> </div>
@endif @endif
<flux:modal name="create-device-model" class="md:w-96"> <flux:modal name="device-model-modal" class="md:w-96">
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<flux:heading size="lg">Add Device Model</flux:heading> <flux:heading size="lg">
@if ($viewingDeviceModelId)
View Device Model
@elseif ($editingDeviceModelId)
Edit Device Model
@else
Add Device Model
@endif
</flux:heading>
</div> </div>
<form wire:submit="createDeviceModel"> <form wire:submit="saveDeviceModel">
<div class="mb-4"> <div class="mb-4">
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text" <flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
name="name" autofocus/> name="name" autofocus :disabled="(bool) $viewingDeviceModelId"/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<flux:input label="Label" wire:model="label" id="label" class="block mt-1 w-full" <flux:input label="Label" wire:model="label" id="label" class="block mt-1 w-full"
type="text" type="text"
name="label"/> name="label" :disabled="(bool) $viewingDeviceModelId"/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<flux:input label="Description" wire:model="description" id="description" <flux:input label="Description" wire:model="description" id="description"
class="block mt-1 w-full" name="description"/> class="block mt-1 w-full" name="description" :disabled="(bool) $viewingDeviceModelId"/>
</div> </div>
<div class="grid grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Width" wire:model="width" id="width" class="block mt-1 w-full" <flux:input label="Width" wire:model="width" id="width" class="block mt-1 w-full"
type="number" type="number"
name="width"/> name="width" :disabled="(bool) $viewingDeviceModelId"/>
<flux:input label="Height" wire:model="height" id="height" class="block mt-1 w-full" <flux:input label="Height" wire:model="height" id="height" class="block mt-1 w-full"
type="number" type="number"
name="height"/> name="height" :disabled="(bool) $viewingDeviceModelId"/>
</div> </div>
<div class="grid grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Colors" wire:model="colors" id="colors" class="block mt-1 w-full" <flux:input label="Colors" wire:model="colors" id="colors" class="block mt-1 w-full"
type="number" type="number"
name="colors"/> name="colors" :disabled="(bool) $viewingDeviceModelId"/>
<flux:input label="Bit Depth" wire:model="bit_depth" id="bit_depth" <flux:input label="Bit Depth" wire:model="bit_depth" id="bit_depth"
class="block mt-1 w-full" type="number" class="block mt-1 w-full" type="number"
name="bit_depth"/> name="bit_depth" :disabled="(bool) $viewingDeviceModelId"/>
</div> </div>
<div class="grid grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Scale Factor" wire:model="scale_factor" id="scale_factor" <flux:input label="Scale Factor" wire:model="scale_factor" id="scale_factor"
class="block mt-1 w-full" type="number" class="block mt-1 w-full" type="number"
name="scale_factor" step="0.1"/> name="scale_factor" step="0.1" :disabled="(bool) $viewingDeviceModelId"/>
<flux:input label="Rotation" wire:model="rotation" id="rotation" class="block mt-1 w-full" <flux:input label="Rotation" wire:model="rotation" id="rotation" class="block mt-1 w-full"
type="number" type="number"
name="rotation"/> name="rotation" :disabled="(bool) $viewingDeviceModelId"/>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<flux:input label="MIME Type" wire:model="mime_type" id="mime_type" <flux:select label="MIME Type" wire:model="mime_type" id="mime_type" name="mime_type" :disabled="(bool) $viewingDeviceModelId">
class="block mt-1 w-full" type="text" <flux:select.option>image/png</flux:select.option>
name="mime_type"/> <flux:select.option>image/bmp</flux:select.option>
</flux:select>
</div> </div>
<div class="grid grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Offset X" wire:model="offset_x" id="offset_x" class="block mt-1 w-full" <flux:input label="Offset X" wire:model="offset_x" id="offset_x" class="block mt-1 w-full"
type="number" type="number"
name="offset_x"/> name="offset_x" :disabled="(bool) $viewingDeviceModelId"/>
<flux:input label="Offset Y" wire:model="offset_y" id="offset_y" class="block mt-1 w-full" <flux:input label="Offset Y" wire:model="offset_y" id="offset_y" class="block mt-1 w-full"
type="number" type="number"
name="offset_y"/> name="offset_y" :disabled="(bool) $viewingDeviceModelId"/>
</div> </div>
<div class="flex"> <div class="mb-4">
<flux:spacer/> <flux:select label="Color Palette" wire:model="palette_id" id="palette_id" name="palette_id" :disabled="(bool) $viewingDeviceModelId">
<flux:button type="submit" variant="primary">Create Device Model</flux:button> <flux:select.option value="">None</flux:select.option>
@foreach ($devicePalettes as $palette)
<flux:select.option value="{{ $palette->id }}">{{ $palette->description ?? $palette->name }} ({{ $palette->name }})</flux:select.option>
@endforeach
</flux:select>
</div> </div>
@if (!$viewingDeviceModelId)
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">{{ $editingDeviceModelId ? 'Update' : 'Create' }} Device Model</flux:button>
</div>
@else
<div class="flex">
<flux:spacer/>
<flux:button type="button" wire:click="duplicateDeviceModel({{ $viewingDeviceModelId }})" variant="primary">Duplicate</flux:button>
</div>
@endif
</form> </form>
</div> </div>
</flux:modal> </flux:modal>
@foreach ($deviceModels as $deviceModel)
<flux:modal name="edit-device-model-{{ $deviceModel->id }}" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Edit Device Model</flux:heading>
</div>
<form wire:submit="updateDeviceModel">
<div class="mb-4">
<flux:input label="Name" wire:model="name" id="edit_name" class="block mt-1 w-full"
type="text"
name="edit_name"/>
</div>
<div class="mb-4">
<flux:input label="Label" wire:model="label" id="edit_label" class="block mt-1 w-full"
type="text"
name="edit_label"/>
</div>
<div class="mb-4">
<flux:input label="Description" wire:model="description" id="edit_description"
class="block mt-1 w-full" name="edit_description"/>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Width" wire:model="width" id="edit_width" class="block mt-1 w-full"
type="number"
name="edit_width"/>
<flux:input label="Height" wire:model="height" id="edit_height"
class="block mt-1 w-full" type="number"
name="edit_height"/>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Colors" wire:model="colors" id="edit_colors"
class="block mt-1 w-full" type="number"
name="edit_colors"/>
<flux:input label="Bit Depth" wire:model="bit_depth" id="edit_bit_depth"
class="block mt-1 w-full" type="number"
name="edit_bit_depth"/>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Scale Factor" wire:model="scale_factor" id="edit_scale_factor"
class="block mt-1 w-full" type="number"
name="edit_scale_factor" step="0.1"/>
<flux:input label="Rotation" wire:model="rotation" id="edit_rotation"
class="block mt-1 w-full" type="number"
name="edit_rotation"/>
</div>
<div class="mb-4">
<flux:select label="MIME Type" wire:model="mime_type" id="edit_mime_type" name="edit_mime_type">
<flux:select.option>image/png</flux:select.option>
<flux:select.option>image/bmp</flux:select.option>
</flux:select>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<flux:input label="Offset X" wire:model="offset_x" id="edit_offset_x"
class="block mt-1 w-full" type="number"
name="edit_offset_x"/>
<flux:input label="Offset Y" wire:model="offset_y" id="edit_offset_y"
class="block mt-1 w-full" type="number"
name="edit_offset_y"/>
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Update Device Model</flux:button>
</div>
</form>
</div>
</flux:modal>
@endforeach
<table <table
class="min-w-full table-fixed text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20 text-zinc-800" class="min-w-full table-fixed text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20 text-zinc-800"
data-flux-table> data-flux-table>
@ -369,14 +396,25 @@ new class extends Component {
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<flux:button.group> <flux:button.group>
<flux:modal.trigger name="edit-device-model-{{ $deviceModel->id }}"> @if ($deviceModel->source === 'api')
<flux:button wire:click="editDeviceModel({{ $deviceModel->id }})" icon="pencil" <flux:modal.trigger name="device-model-modal">
<flux:button wire:click="openDeviceModelModal('{{ $deviceModel->id }}', true)" icon="eye"
iconVariant="outline">
</flux:button>
</flux:modal.trigger>
<flux:button wire:click="duplicateDeviceModel('{{ $deviceModel->id }}')" icon="document-duplicate"
iconVariant="outline"> iconVariant="outline">
</flux:button> </flux:button>
</flux:modal.trigger> @else
<flux:button wire:click="deleteDeviceModel({{ $deviceModel->id }})" icon="trash" <flux:modal.trigger name="device-model-modal">
iconVariant="outline"> <flux:button wire:click="openDeviceModelModal('{{ $deviceModel->id }}')" icon="pencil"
</flux:button> iconVariant="outline">
</flux:button>
</flux:modal.trigger>
<flux:button wire:click="deleteDeviceModel('{{ $deviceModel->id }}')" icon="trash"
iconVariant="outline">
</flux:button>
@endif
</flux:button.group> </flux:button.group>
</div> </div>
</td> </td>

View file

@ -0,0 +1,384 @@
<?php
use App\Models\DevicePalette;
use Livewire\Volt\Component;
new class extends Component
{
public $devicePalettes;
public $name;
public $description;
public $grays = 2;
public $colors = [];
public $framework_class = '';
public $colorInput = '';
protected $rules = [
'name' => 'required|string|max:255|unique:device_palettes,name',
'description' => 'nullable|string|max:255',
'grays' => 'required|integer|min:1|max:256',
'colors' => 'nullable|array',
'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/',
'framework_class' => 'nullable|string|max:255',
];
public function mount()
{
$this->devicePalettes = DevicePalette::all();
return view('livewire.device-palettes.index');
}
public function addColor(): void
{
$this->validate(['colorInput' => 'required|string|regex:/^#[0-9A-Fa-f]{6}$/'], [
'colorInput.regex' => 'Color must be a valid hex color (e.g., #FF0000)',
]);
if (! in_array($this->colorInput, $this->colors)) {
$this->colors[] = $this->colorInput;
}
$this->colorInput = '';
}
public function removeColor(int $index): void
{
unset($this->colors[$index]);
$this->colors = array_values($this->colors);
}
public $editingDevicePaletteId;
public $viewingDevicePaletteId;
public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void
{
if ($devicePaletteId) {
$devicePalette = DevicePalette::findOrFail($devicePaletteId);
if ($viewOnly) {
$this->viewingDevicePaletteId = $devicePalette->id;
$this->editingDevicePaletteId = null;
} else {
$this->editingDevicePaletteId = $devicePalette->id;
$this->viewingDevicePaletteId = null;
}
$this->name = $devicePalette->name;
$this->description = $devicePalette->description;
$this->grays = $devicePalette->grays;
// Ensure colors is always an array and properly decoded
// The model cast should handle JSON decoding, but we'll be explicit
$colors = $devicePalette->getAttribute('colors');
if ($colors === null) {
$this->colors = [];
} elseif (is_string($colors)) {
$decoded = json_decode($colors, true);
$this->colors = is_array($decoded) ? array_values($decoded) : [];
} elseif (is_array($colors)) {
$this->colors = array_values($colors); // Re-index array
} else {
$this->colors = [];
}
$this->framework_class = $devicePalette->framework_class;
} else {
$this->editingDevicePaletteId = null;
$this->viewingDevicePaletteId = null;
$this->reset(['name', 'description', 'grays', 'colors', 'framework_class']);
}
$this->colorInput = '';
}
public function saveDevicePalette(): void
{
$rules = [
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:255',
'grays' => 'required|integer|min:1|max:256',
'colors' => 'nullable|array',
'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/',
'framework_class' => 'nullable|string|max:255',
];
if ($this->editingDevicePaletteId) {
$rules['name'] = 'required|string|max:255|unique:device_palettes,name,'.$this->editingDevicePaletteId;
} else {
$rules['name'] = 'required|string|max:255|unique:device_palettes,name';
}
$this->validate($rules);
if ($this->editingDevicePaletteId) {
$devicePalette = DevicePalette::findOrFail($this->editingDevicePaletteId);
$devicePalette->update([
'name' => $this->name,
'description' => $this->description,
'grays' => $this->grays,
'colors' => ! empty($this->colors) ? $this->colors : null,
'framework_class' => $this->framework_class,
]);
$message = 'Device palette updated successfully.';
} else {
DevicePalette::create([
'name' => $this->name,
'description' => $this->description,
'grays' => $this->grays,
'colors' => ! empty($this->colors) ? $this->colors : null,
'framework_class' => $this->framework_class,
'source' => 'manual',
]);
$message = 'Device palette created successfully.';
}
$this->reset(['name', 'description', 'grays', 'colors', 'framework_class', 'colorInput', 'editingDevicePaletteId', 'viewingDevicePaletteId']);
Flux::modal('device-palette-modal')->close();
$this->devicePalettes = DevicePalette::all();
session()->flash('message', $message);
}
public function deleteDevicePalette(string $devicePaletteId): void
{
$devicePalette = DevicePalette::findOrFail($devicePaletteId);
$devicePalette->delete();
$this->devicePalettes = DevicePalette::all();
session()->flash('message', 'Device palette deleted successfully.');
}
public function duplicateDevicePalette(string $devicePaletteId): void
{
$devicePalette = DevicePalette::findOrFail($devicePaletteId);
$this->editingDevicePaletteId = null;
$this->viewingDevicePaletteId = null;
$this->name = $devicePalette->name.' (Copy)';
$this->description = $devicePalette->description;
$this->grays = $devicePalette->grays;
$colors = $devicePalette->getAttribute('colors');
if ($colors === null) {
$this->colors = [];
} elseif (is_string($colors)) {
$decoded = json_decode($colors, true);
$this->colors = is_array($decoded) ? array_values($decoded) : [];
} elseif (is_array($colors)) {
$this->colors = array_values($colors);
} else {
$this->colors = [];
}
$this->framework_class = $devicePalette->framework_class;
$this->colorInput = '';
$this->js('Flux.modal("device-palette-modal").show()');
}
}
?>
<div>
<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">
<div class="flex items-center space-x-2">
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Palettes</h2>
<flux:dropdown>
<flux:button icon="chevron-down" variant="ghost"></flux:button>
<flux:menu>
<flux:menu.item href="{{ route('devices') }}">Devices</flux:menu.item>
<flux:menu.item href="{{ route('device-models.index') }}">Device Models</flux:menu.item>
</flux:menu>
</flux:dropdown>
</div>
<flux:modal.trigger name="device-palette-modal">
<flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button>
</flux:modal.trigger>
</div>
@if (session()->has('message'))
<div class="mb-4">
<flux:callout variant="success" icon="check-circle" heading=" {{ session('message') }}">
<x-slot name="controls">
<flux:button icon="x-mark" variant="ghost"
x-on:click="$el.closest('[data-flux-callout]').remove()"/>
</x-slot>
</flux:callout>
</div>
@endif
<flux:modal name="device-palette-modal" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">
@if ($viewingDevicePaletteId)
View Device Palette
@elseif ($editingDevicePaletteId)
Edit Device Palette
@else
Add Device Palette
@endif
</flux:heading>
</div>
<form wire:submit="saveDevicePalette">
<div class="mb-4">
<flux:input label="Name (Identifier)" wire:model="name" id="name" class="block mt-1 w-full" type="text"
name="name" autofocus :disabled="$viewingDevicePaletteId"/>
</div>
<div class="mb-4">
<flux:input label="Description" wire:model="description" id="description" class="block mt-1 w-full" type="text"
name="description" :disabled="$viewingDevicePaletteId"/>
</div>
<div class="mb-4">
<flux:input label="Grays" wire:model="grays" id="grays" class="block mt-1 w-full"
type="number"
name="grays" min="1" max="256" :disabled="$viewingDevicePaletteId"/>
</div>
<div class="mb-4">
<flux:input label="Framework Class" wire:model="framework_class" id="framework_class"
class="block mt-1 w-full" type="text"
name="framework_class" :disabled="$viewingDevicePaletteId"/>
</div>
<div class="mb-4">
<flux:label>Colors</flux:label>
@if (!$viewingDevicePaletteId)
<div class="flex gap-2 mb-2">
<flux:input wire:model="colorInput" placeholder="#FF0000" class="flex-1"/>
<flux:button type="button" wire:click="addColor" variant="ghost">Add</flux:button>
</div>
@endif
<div class="flex flex-wrap gap-2">
@if (!empty($colors) && is_array($colors) && count($colors) > 0)
@foreach ($colors as $index => $color)
@if (!empty($color))
<div wire:key="color-{{ $editingDevicePaletteId ?? $viewingDevicePaletteId ?? 'new' }}-{{ $index }}-{{ $color }}" class="flex items-center gap-2 px-3 py-1 bg-zinc-100 dark:bg-zinc-800 rounded">
<div class="w-4 h-4 rounded border border-zinc-300 dark:border-zinc-600" style="background-color: {{ $color }}"></div>
<span class="text-sm">{{ $color }}</span>
@if (!$viewingDevicePaletteId)
<flux:button type="button" wire:click="removeColor({{ $index }})" icon="x-mark" variant="ghost" size="sm"></flux:button>
@endif
</div>
@endif
@endforeach
@endif
</div>
@if (!$viewingDevicePaletteId)
<p class="mt-1 text-xs text-zinc-500">Leave empty for grayscale-only palette</p>
@endif
</div>
@if (!$viewingDevicePaletteId)
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">{{ $editingDevicePaletteId ? 'Update' : 'Create' }} Device Palette</flux:button>
</div>
@else
<div class="flex">
<flux:spacer/>
<flux:button type="button" wire:click="duplicateDevicePalette('{{ $viewingDevicePaletteId }}')" variant="primary">Duplicate</flux:button>
</div>
@endif
</form>
</div>
</flux:modal>
<table
class="min-w-full table-fixed text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20 text-zinc-800"
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 group-[]/right-align:justify-end">Description</div>
</th>
<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 group-[]/right-align:justify-end">Grays</div>
</th>
<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 group-[]/right-align:justify-end">Colors</div>
</th>
<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 group-[]/right-align:justify-end">Actions</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800/10 dark:divide-white/20" data-flux-rows>
@foreach ($devicePalettes as $devicePalette)
<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"
>
<div>
<div class="font-medium text-zinc-800 dark:text-white">{{ $devicePalette->description ?? $devicePalette->name }}</div>
<div class="text-xs text-zinc-500">{{ $devicePalette->name }}</div>
</div>
</td>
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
>
{{ $devicePalette->grays }}
</td>
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
>
@if ($devicePalette->colors)
<div class="flex gap-1">
@foreach ($devicePalette->colors as $color)
<div class="w-4 h-4 rounded border border-zinc-300 dark:border-zinc-600" style="background-color: {{ $color }}"></div>
@endforeach
<span class="ml-2">({{ count($devicePalette->colors) }})</span>
</div>
@else
<span class="text-zinc-400">Grayscale only</span>
@endif
</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"
>
<div class="flex items-center gap-4">
<flux:button.group>
@if ($devicePalette->source === 'api')
<flux:modal.trigger name="device-palette-modal">
<flux:button wire:click="openDevicePaletteModal('{{ $devicePalette->id }}', true)" icon="eye"
iconVariant="outline">
</flux:button>
</flux:modal.trigger>
<flux:button wire:click="duplicateDevicePalette('{{ $devicePalette->id }}')" icon="document-duplicate"
iconVariant="outline">
</flux:button>
@else
<flux:modal.trigger name="device-palette-modal">
<flux:button wire:click="openDevicePaletteModal('{{ $devicePalette->id }}')" icon="pencil"
iconVariant="outline">
</flux:button>
</flux:modal.trigger>
<flux:button wire:click="deleteDevicePalette('{{ $devicePalette->id }}')" icon="trash"
iconVariant="outline">
</flux:button>
@endif
</flux:button.group>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>

View file

@ -309,7 +309,7 @@ new class extends Component {
<div class="bg-muted flex flex-col items-center justify-center gap-6 p-6 md:p-10"> <div class="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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> class="styled-container">
<div class="px-10 py-8"> <div class="px-10 py-8">
@php @php
$current_image_uuid =$device->current_screen_image; $current_image_uuid =$device->current_screen_image;
@ -368,6 +368,10 @@ new class extends Component {
<flux:menu.item icon="arrow-up-circle">Update Firmware</flux:menu.item> <flux: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>
@ -498,6 +502,26 @@ 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"/>

View file

@ -121,7 +121,16 @@ new class extends Component {
{{--@dump($devices)--}} {{--@dump($devices)--}}
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Devices</h2> <div class="flex items-center space-x-2">
<h2 class="text-2xl font-semibold dark:text-gray-100">Devices</h2>
<flux:dropdown>
<flux:button icon="chevron-down" variant="ghost"></flux:button>
<flux:menu>
<flux:menu.item href="{{ route('device-models.index') }}">Device Models</flux:menu.item>
<flux:menu.item href="{{ route('device-palettes.index') }}">Device Palettes</flux:menu.item>
</flux:menu>
</flux:dropdown>
</div>
<flux:modal.trigger name="create-device"> <flux:modal.trigger name="create-device">
<flux:button icon="plus" variant="primary">Add Device</flux:button> <flux:button icon="plus" variant="primary">Add Device</flux:button>
</flux:modal.trigger> </flux:modal.trigger>

View file

@ -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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> <div class="styled-container">
<div class="px-10 py-8"> <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>

View file

@ -0,0 +1,516 @@
<?php
use App\Models\Plugin;
use Livewire\Volt\Component;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
/*
* This component contains the configuation modal
*/
new class extends Component {
public Plugin $plugin;
public array $configuration_template = [];
public array $configuration = []; // holds config data
public array $multiValues = []; // UI boxes for multi_string
public array $xhrSelectOptions = [];
public array $searchQueries = [];
// ------------------------------------This section contains one-off functions for the form------------------------------------------------
public function mount(): void
{
$this -> loadData();
}
public function loadData(): void
{
$this->resetErrorBag();
// Reload data
$this->plugin = $this->plugin->fresh();
$this->configuration_template = $this->plugin->configuration_template ?? [];
$this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
// Initialize multiValues by exploding the CSV strings from the DB
foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
if (($field['field_type'] ?? null) === 'multi_string') {
$fieldKey = $field['keyname'];
$rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? '');
$currentValue = is_array($rawValue) ? '' : (string)$rawValue;
$this->multiValues[$fieldKey] = $currentValue !== ''
? array_values(array_filter(explode(',', $currentValue)))
: [''];
}
}
}
/**
* Triggered by @close on the modal to discard any typed but unsaved changes
*/
public int $resetIndex = 0; // Add this property
public function resetForm(): void
{
$this->loadData();
$this->resetIndex++; // Increment to force DOM refresh
}
public function saveConfiguration()
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
// final validation layer
$this->validate([
'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
], [
'multiValues.*.*.regex' => 'Items cannot contain commas.',
]);
// Prepare config copy to send to db
$finalValues = $this->configuration;
foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
$fieldKey = $field['keyname'];
// Handle multi_string: Join array back to CSV string
if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) {
$finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
}
// Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior)
if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) {
$decoded = json_decode($finalValues[$fieldKey], true);
if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) {
$finalValues[$fieldKey] = $decoded;
}
}
}
// send to db
$this->plugin->update(['configuration' => $finalValues]);
$this->configuration = $finalValues; // update local state
$this->dispatch('config-updated'); // notifies listeners
Flux::modal('configuration-modal')->close();
}
// ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------
public function addMultiItem(string $fieldKey): void
{
$this->multiValues[$fieldKey][] = '';
}
public function removeMultiItem(string $fieldKey, int $index): void
{
unset($this->multiValues[$fieldKey][$index]);
$this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]);
if (empty($this->multiValues[$fieldKey])) {
$this->multiValues[$fieldKey][] = '';
}
}
// Livewire magic method to validate MultiValue input boxes
// Runs on every debounce
public function updatedMultiValues($value, $key)
{
$this->validate([
'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
], [
'multiValues.*.*.regex' => 'Items cannot contain commas.',
]);
}
public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
try {
$requestData = [];
if ($query !== null) {
$requestData = [
'function' => $fieldKey,
'query' => $query
];
}
$response = $query !== null
? Http::post($endpoint, $requestData)
: Http::post($endpoint);
if ($response->successful()) {
$this->xhrSelectOptions[$fieldKey] = $response->json();
} else {
$this->xhrSelectOptions[$fieldKey] = [];
}
} catch (\Exception $e) {
$this->xhrSelectOptions[$fieldKey] = [];
}
}
public function searchXhrSelect(string $fieldKey, string $endpoint): void
{
$query = $this->searchQueries[$fieldKey] ?? '';
if (!empty($query)) {
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
}
}
};?>
<flux:modal name="configuration-modal" @close="resetForm" class="md:w-96">
<div wire:key="config-form-{{ $resetIndex }}" class="space-y-6">
<div class="space-y-6">
<div>
<flux:heading size="lg">Configuration</flux:heading>
<flux:subheading>Configure your plugin settings</flux:subheading>
</div>
<form wire:submit="saveConfiguration">
@if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields']))
@foreach($configuration_template['custom_fields'] as $field)
@php
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
$rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? '');
# These are sanitized at Model/Plugin level, safe to render HTML
$safeDescription = $field['description'] ?? '';
$safeHelp = $field['help_text'] ?? '';
// For code fields, if the value is an array, JSON encode it
if ($field['field_type'] === 'code' && is_array($rawValue)) {
$currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} else {
$currentValue = is_array($rawValue) ? '' : (string) $rawValue;
}
@endphp
<div class="mb-4">
@if($field['field_type'] === 'author_bio')
@continue
@endif
@if($field['field_type'] === 'copyable_webhook_url')
@continue
@endif
@if($field['field_type'] === 'string' || $field['field_type'] === 'url')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'text')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:textarea
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'code')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:textarea
rows="{{ $field['rows'] ?? 3 }}"
placeholder="{{ $field['placeholder'] ?? null }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
class="font-mono"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'password')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="password"
wire:model="local_configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
viewable
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'copyable')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
value="{{ $field['value'] }}"
copyable
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'time_zone')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select
wire:model="configuration.{{ $fieldKey }}"
value="{{ $field['value'] }}"
>
<option value="">Select timezone...</option>
@foreach(timezone_identifiers_list() as $timezone)
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
@endforeach
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'number')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="number"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'boolean')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:checkbox
wire:model="configuration.{{ $fieldKey }}"
:checked="$currentValue"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'date')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="date"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'time')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input
type="time"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'select')
@if(isset($field['multiple']) && $field['multiple'] === true)
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:checkbox.group wire:model="configuration.{{ $fieldKey }}">
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<flux:checkbox label="{{ $option }}" value="{{ $key }}"/>
@endif
@endforeach
@endif
</flux:checkbox.group>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@else
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select wire:model="configuration.{{ $fieldKey }}">
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@endif
@elseif($field['field_type'] === 'xhrSelect')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:select
wire:model="configuration.{{ $fieldKey }}"
wire:init="loadXhrSelectOptions('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
>
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
@foreach($xhrSelectOptions[$fieldKey] as $option)
@if(is_array($option))
@if(isset($option['id']) && isset($option['name']))
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@elseif($field['field_type'] === 'xhrSelectSearch')
<div class="space-y-2">
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<flux:input.group>
<flux:input
wire:model="searchQueries.{{ $fieldKey }}"
placeholder="Enter search query..."
/>
<flux:button
wire:click="searchXhrSelect('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
icon="magnifying-glass"/>
</flux:input.group>
<flux:description>{!! $safeHelp !!}</flux:description>
@if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue))
<flux:select
wire:model="configuration.{{ $fieldKey }}"
>
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
@foreach($xhrSelectOptions[$fieldKey] as $option)
@if(is_array($option))
@if(isset($option['id']) && isset($option['name']))
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
@if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey])))
{{-- Show current value even if no options are loaded --}}
<option value="{{ $currentValue }}" selected>{{ $currentValue }}</option>
@endif
</flux:select>
@endif
</div>
@elseif($field['field_type'] === 'multi_string')
<flux:field>
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{!! $safeDescription !!}</flux:description>
<div class="space-y-2 mt-2">
@foreach($multiValues[$fieldKey] as $index => $item)
<div class="flex gap-2 items-center"
wire:key="multi-{{ $fieldKey }}-{{ $index }}">
<flux:input
wire:model.live.debounce="multiValues.{{ $fieldKey }}.{{ $index }}"
:placeholder="$field['placeholder'] ?? 'Value...'"
:invalid="$errors->has('multiValues.'.$fieldKey.'.'.$index)"
class="flex-1"
/>
@if(count($multiValues[$fieldKey]) > 1)
<flux:button
variant="ghost"
icon="trash"
size="sm"
wire:click="removeMultiItem('{{ $fieldKey }}', {{ $index }})"
/>
@endif
</div>
@error("multiValues.{$fieldKey}.{$index}")
<div class="flex items-center gap-2 mt-1 text-amber-600">
<flux:icon name="exclamation-triangle" variant="micro" />
{{-- $message comes from thrown error --}}
<span class="text-xs font-medium">{{ $message }}</span>
</div>
@enderror
@endforeach
<flux:button
variant="ghost"
size="sm"
icon="plus"
wire:click="addMultiItem('{{ $fieldKey }}')"
>
Add Item
</flux:button>
</div>
<flux:description>{!! $safeHelp !!}</flux:description>
</flux:field>
@else
<flux:callout variant="warning">Field type "{{ $field['field_type'] }}" not yet supported</flux:callout>
@endif
</div>
@endforeach
@endif
<div class="flex-col space-y-2 items-end w-full">
<flux:spacer/>
<flux:button
type="submit"
variant="primary"
:disabled="$errors->any()"
class="disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale"
>
Save Configuration
</flux:button>
@if($errors->any())
<div class="flex items-center gap-2 text-amber-600">
<flux:icon name="exclamation-circle" variant="micro" />
<span class="text-sm font-medium">
Fix errors before saving.
</span>
</div>
@endif
</div>
</form>
</div>
</div>
</flux:modal>

View file

@ -0,0 +1,298 @@
<?php
use App\Models\Plugin;
use Livewire\Volt\Component;
new class extends Component {
public Plugin $plugin;
public string $name;
public array $checked_devices = [];
public array $device_playlists = [];
public array $device_playlist_names = [];
public array $device_weekdays = [];
public array $device_active_from = [];
public array $device_active_until = [];
public function mount(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
abort_unless($this->plugin->plugin_type === 'image_webhook', 404);
$this->name = $this->plugin->name;
}
protected array $rules = [
'name' => 'required|string|max:255',
'checked_devices' => 'array',
'device_playlist_names' => 'array',
'device_playlists' => 'array',
'device_weekdays' => 'array',
'device_active_from' => 'array',
'device_active_until' => 'array',
];
public function updateName(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->validate(['name' => 'required|string|max:255']);
$this->plugin->update(['name' => $this->name]);
}
public function addToPlaylist()
{
$this->validate([
'checked_devices' => 'required|array|min:1',
]);
foreach ($this->checked_devices as $deviceId) {
if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) {
$this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.');
return;
}
if ($this->device_playlists[$deviceId] === 'new') {
if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) {
$this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.');
return;
}
}
}
foreach ($this->checked_devices as $deviceId) {
$playlist = null;
if ($this->device_playlists[$deviceId] === 'new') {
$playlist = \App\Models\Playlist::create([
'device_id' => $deviceId,
'name' => $this->device_playlist_names[$deviceId],
'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null,
'active_from' => $this->device_active_from[$deviceId] ?? null,
'active_until' => $this->device_active_until[$deviceId] ?? null,
]);
} else {
$playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]);
}
$maxOrder = $playlist->items()->max('order') ?? 0;
// Image webhook plugins only support full layout
$playlist->items()->create([
'plugin_id' => $this->plugin->id,
'order' => $maxOrder + 1,
]);
}
$this->reset([
'checked_devices',
'device_playlists',
'device_playlist_names',
'device_weekdays',
'device_active_from',
'device_active_until',
]);
Flux::modal('add-to-playlist')->close();
}
public function getDevicePlaylists($deviceId)
{
return \App\Models\Playlist::where('device_id', $deviceId)->get();
}
public function hasAnyPlaylistSelected(): bool
{
foreach ($this->checked_devices as $deviceId) {
if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) {
return true;
}
}
return false;
}
public function deletePlugin(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->plugin->delete();
$this->redirect(route('plugins.image-webhook'));
}
public function getImagePath(): ?string
{
if (!$this->plugin->current_image) {
return null;
}
$extensions = ['png', 'bmp'];
foreach ($extensions as $ext) {
$path = 'images/generated/'.$this->plugin->current_image.'.'.$ext;
if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) {
return $path;
}
}
return null;
}
};
?>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Image Webhook {{$plugin->name}}</h2>
<flux:button.group>
<flux:modal.trigger name="add-to-playlist">
<flux:button icon="play" variant="primary">Add to Playlist</flux:button>
</flux:modal.trigger>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:modal.trigger name="delete-plugin">
<flux:menu.item icon="trash" variant="danger">Delete Instance</flux:menu.item>
</flux:modal.trigger>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div>
<flux:modal name="add-to-playlist" class="min-w-2xl">
<div class="space-y-6">
<div>
<flux:heading size="lg">Add to Playlist</flux:heading>
</div>
<form wire:submit="addToPlaylist">
<flux:separator text="Device(s)" />
<div class="mt-4 mb-4">
<flux:checkbox.group wire:model.live="checked_devices">
@foreach(auth()->user()->devices as $device)
<flux:checkbox label="{{ $device->name }}" value="{{ $device->id }}"/>
@endforeach
</flux:checkbox.group>
</div>
@if(count($checked_devices) > 0)
<flux:separator text="Playlist Selection" />
<div class="mt-4 mb-4 space-y-6">
@foreach($checked_devices as $deviceId)
@php
$device = auth()->user()->devices->find($deviceId);
@endphp
<div class="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4">
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
{{ $device->name }}
</div>
<div class="mb-4">
<flux:select wire:model.live.debounce="device_playlists.{{ $deviceId }}">
<option value="">Select Playlist or Create New</option>
@foreach($this->getDevicePlaylists($deviceId) as $playlist)
<option value="{{ $playlist->id }}">{{ $playlist->name }}</option>
@endforeach
<option value="new">Create New Playlist</option>
</flux:select>
</div>
@if(isset($device_playlists[$deviceId]) && $device_playlists[$deviceId] === 'new')
<div class="space-y-4">
<div>
<flux:input label="Playlist Name" wire:model="device_playlist_names.{{ $deviceId }}"/>
</div>
<div>
<flux:checkbox.group wire:model="device_weekdays.{{ $deviceId }}" label="Active Days (optional)">
<flux:checkbox label="Monday" value="1"/>
<flux:checkbox label="Tuesday" value="2"/>
<flux:checkbox label="Wednesday" value="3"/>
<flux:checkbox label="Thursday" value="4"/>
<flux:checkbox label="Friday" value="5"/>
<flux:checkbox label="Saturday" value="6"/>
<flux:checkbox label="Sunday" value="0"/>
</flux:checkbox.group>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<flux:input type="time" label="Active From (optional)" wire:model="device_active_from.{{ $deviceId }}"/>
</div>
<div>
<flux:input type="time" label="Active Until (optional)" wire:model="device_active_until.{{ $deviceId }}"/>
</div>
</div>
</div>
@endif
</div>
@endforeach
</div>
@endif
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Add to Playlist</flux:button>
</div>
</form>
</div>
</flux:modal>
<flux:modal name="delete-plugin" class="min-w-[22rem] space-y-6">
<div>
<flux:heading size="lg">Delete {{ $plugin->name }}?</flux:heading>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
</div>
<div class="flex gap-2">
<flux:spacer/>
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button wire:click="deletePlugin" variant="danger">Delete instance</flux:button>
</div>
</flux:modal>
<div class="grid lg:grid-cols-2 lg:gap-8">
<div>
<form wire:submit="updateName" class="mb-6">
<div class="mb-4">
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
name="name" autofocus/>
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary" class="w-full">Save</flux:button>
</div>
</form>
<div class="mb-6">
<flux:label>Webhook URL</flux:label>
<flux:input
:value="route('api.plugin_settings.image', ['uuid' => $plugin->uuid])"
class="font-mono text-sm"
readonly
copyable
/>
<flux:description class="mt-2">POST an image (PNG or BMP) to this URL to update the displayed image.</flux:description>
<flux:callout variant="warning" icon="exclamation-circle" class="mt-4">
<flux:callout.text>Images must be posted in a format that can directly be read by the device. You need to take care of image format, dithering, and bit-depth. Check device logs if the image is not shown.</flux:callout.text>
</flux:callout>
</div>
</div>
<div>
<div class="mb-4">
<flux:label>Current Image</flux:label>
@if($this->getImagePath())
<img src="{{ url('storage/'.$this->getImagePath()) }}" alt="{{ $plugin->name }}" class="w-full h-auto rounded-lg border border-zinc-200 dark:border-zinc-700 mt-2" />
@else
<flux:callout variant="warning" class="mt-2">
<flux:text>No image uploaded yet. POST an image to the webhook URL to get started.</flux:text>
</flux:callout>
@endif
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,163 @@
<?php
use App\Models\Plugin;
use Livewire\Volt\Component;
use Illuminate\Support\Str;
new class extends Component {
public string $name = '';
public array $instances = [];
protected $rules = [
'name' => 'required|string|max:255',
];
public function mount(): void
{
$this->refreshInstances();
}
public function refreshInstances(): void
{
$this->instances = auth()->user()
->plugins()
->where('plugin_type', 'image_webhook')
->orderBy('created_at', 'desc')
->get()
->toArray();
}
public function createInstance(): void
{
abort_unless(auth()->user() !== null, 403);
$this->validate();
Plugin::create([
'uuid' => Str::uuid(),
'user_id' => auth()->id(),
'name' => $this->name,
'plugin_type' => 'image_webhook',
'data_strategy' => 'static', // Not used for image_webhook, but required
'data_stale_minutes' => 60, // Not used for image_webhook, but required
]);
$this->reset(['name']);
$this->refreshInstances();
Flux::modal('create-instance')->close();
}
public function deleteInstance(int $pluginId): void
{
abort_unless(auth()->user() !== null, 403);
$plugin = Plugin::where('id', $pluginId)
->where('user_id', auth()->id())
->where('plugin_type', 'image_webhook')
->firstOrFail();
$plugin->delete();
$this->refreshInstances();
}
};
?>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Image Webhook
<flux:badge size="sm" class="ml-2">Plugin</flux:badge>
</h2>
<flux:modal.trigger name="create-instance">
<flux:button icon="plus" variant="primary">Create Instance</flux:button>
</flux:modal.trigger>
</div>
<flux:modal name="create-instance" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Create Image Webhook Instance</flux:heading>
<flux:subheading>Create a new instance that accepts images via webhook</flux:subheading>
</div>
<form wire:submit="createInstance">
<div class="mb-4">
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
name="name" autofocus/>
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Create Instance</flux:button>
</div>
</form>
</div>
</flux:modal>
@if(empty($instances))
<div class="text-center py-12">
<flux:callout>
<flux:heading size="sm">No instances yet</flux:heading>
<flux:text>Create your first Image Webhook instance to get started.</flux:text>
</flux:callout>
</div>
@else
<table
class="min-w-full table-auto text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20"
data-flux-table="">
<thead data-flux-columns="">
<tr>
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
data-flux-column="">
<div class="whitespace-nowrap flex">Name</div>
</th>
<th class="py-3 px-3 first:pl-0 last:pr-0 text-right text-sm font-medium text-zinc-800 dark:text-white"
data-flux-column="">
<div class="whitespace-nowrap flex justify-end">Actions</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800/10 dark:divide-white/20" data-flux-rows="">
@foreach($instances as $instance)
<tr data-flux-row="">
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300">
{{ $instance['name'] }}
</td>
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white text-right">
<div class="flex items-center justify-end">
<flux:button.group>
<flux:button href="{{ route('plugins.image-webhook-instance', ['plugin' => $instance['id']]) }}" wire:navigate icon="pencil" iconVariant="outline">
</flux:button>
<flux:modal.trigger name="delete-instance-{{ $instance['id'] }}">
<flux:button icon="trash" iconVariant="outline">
</flux:button>
</flux:modal.trigger>
</flux:button.group>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
@foreach($instances as $instance)
<flux:modal name="delete-instance-{{ $instance['id'] }}" class="min-w-88 space-y-6">
<div>
<flux:heading size="lg">Delete {{ $instance['name'] }}?</flux:heading>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
</div>
<div class="flex gap-2">
<flux:spacer/>
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button wire:click="deleteInstance({{ $instance['id'] }})" variant="danger">Delete instance</flux:button>
</div>
</flux:modal>
@endforeach
</div>
</div>

View file

@ -26,6 +26,8 @@ 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 = [
@ -40,7 +42,12 @@ new class extends Component {
public function refreshPlugins(): void public function refreshPlugins(): void
{ {
$userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray(); // Only show recipe plugins in the main list (image_webhook has its own management page)
$userPlugins = auth()->user()?->plugins()
->where('plugin_type', 'recipe')
->get()
->makeHidden(['render_markup', 'data_payload'])
->toArray();
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []); $allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
$allPlugins = array_values($allPlugins); $allPlugins = array_values($allPlugins);
$allPlugins = $this->sortPlugins($allPlugins); $allPlugins = $this->sortPlugins($allPlugins);
@ -156,6 +163,7 @@ new class extends Component {
<div class="py-12" x-data="{ <div class="py-12" x-data="{
searchTerm: '', searchTerm: '',
showFilters: false,
filterPlugins(plugins) { filterPlugins(plugins) {
if (this.searchTerm.length <= 1) return plugins; if (this.searchTerm.length <= 1) return plugins;
const search = this.searchTerm.toLowerCase(); const search = this.searchTerm.toLowerCase();
@ -165,28 +173,37 @@ new class extends Component {
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Plugins &amp; Recipes</h2> <h2 class="text-2xl font-semibold dark:text-gray-100">Plugins &amp; Recipes</h2>
<div class="flex items-center space-x-2">
<flux:button icon="funnel" variant="ghost" @click="showFilters = !showFilters"></flux:button>
<flux:button.group>
<flux:modal.trigger name="add-plugin">
<flux:button icon="plus" variant="primary">Add Recipe</flux:button>
</flux:modal.trigger>
<flux:button.group> <flux:dropdown>
<flux:modal.trigger name="add-plugin"> <flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:button icon="plus" variant="primary">Add Recipe</flux:button> <flux:menu>
</flux:modal.trigger> <flux:modal.trigger name="import-from-catalog">
<flux:menu.item icon="book-open">Import from OSS Catalog</flux:menu.item>
<flux:dropdown> </flux:modal.trigger>
<flux:button icon="chevron-down" variant="primary"></flux:button> @if(config('services.trmnl.liquid_enabled'))
<flux:menu> <flux:modal.trigger name="import-from-trmnl-catalog">
<flux:modal.trigger name="import-zip"> <flux:menu.item icon="book-open">Import from TRMNL Catalog</flux:menu.item>
<flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item> </flux:modal.trigger>
</flux:modal.trigger> @endif
<flux:modal.trigger name="import-from-catalog"> <flux:separator />
<flux:menu.item icon="book-open">Import from Catalog</flux:menu.item> <flux:modal.trigger name="import-zip">
</flux:modal.trigger> <flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item>
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item> </flux:modal.trigger>
</flux:menu> <flux:separator />
</flux:dropdown> <flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
</flux:button.group> </flux:menu>
</flux:dropdown>
</flux:button.group>
</div>
</div> </div>
<div class="mb-6 flex flex-col sm:flex-row gap-4"> <div x-show="showFilters" class="mb-6 flex flex-col sm:flex-row gap-4" style="display: none;">
<div class="flex-1"> <div class="flex-1">
<flux:input <flux:input
x-model="searchTerm" x-model="searchTerm"
@ -214,7 +231,7 @@ new class extends Component {
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<flux:heading size="lg">Import Recipe <flux:heading size="lg">Import Recipe
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge> <flux:badge color="blue" class="ml-2">Beta</flux:badge>
</flux:heading> </flux:heading>
<flux:subheading>Upload a ZIP archive containing a TRMNL recipe either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading> <flux:subheading>Upload a ZIP archive containing a TRMNL recipe either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading>
</div> </div>
@ -272,7 +289,7 @@ new class extends Component {
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<flux:heading size="lg">Import from Catalog <flux:heading size="lg">Import from Catalog
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge> <flux:badge color="blue" class="ml-2">Beta</flux:badge>
</flux:heading> </flux:heading>
<flux:subheading>Browse and install Recipes from the community. Add yours <a href="https://github.com/bnussbau/trmnl-recipe-catalog" class="underline" target="_blank">here</a>.</flux:subheading> <flux:subheading>Browse and install Recipes from the community. Add yours <a href="https://github.com/bnussbau/trmnl-recipe-catalog" class="underline" target="_blank">here</a>.</flux:subheading>
</div> </div>
@ -280,6 +297,27 @@ new class extends Component {
</div> </div>
</flux:modal> </flux:modal>
<flux:modal name="import-from-trmnl-catalog">
<div class="space-y-6">
<div>
<flux:heading size="lg">Import from TRMNL Recipe Catalog
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
</flux:heading>
<flux:callout class="mb-4 mt-4" color="yellow">
<flux:heading size="sm">Limitations</flux:heading>
<ul class="list-disc pl-5 mt-2">
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
<li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span> executable.</flux:text></li>
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
</ul>
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
</flux:callout>
</div>
<livewire:catalog.trmnl />
</div>
</flux:modal>
<flux:modal name="add-plugin" class="md:w-96"> <flux:modal name="add-plugin" class="md:w-96">
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
@ -357,12 +395,16 @@ 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="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> class="styled-container">
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}" <a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
class="block"> class="block h-full">
<div class="flex items-center space-x-4 px-10 py-8"> <div class="flex items-center space-x-4 px-10 py-8 h-full">
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}" @isset($plugin['icon_url'])
<img src="{{ $plugin['icon_url'] }}" class="h-6"/>
@else
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}"
class="text-4xl text-accent"/> class="text-4xl text-accent"/>
@endif
<h3 class="text-lg font-medium dark:text-zinc-200">{{$plugin['name']}}</h3> <h3 class="text-lg font-medium dark:text-zinc-200">{{$plugin['name']}}</h3>
</div> </div>
</a> </a>

View file

@ -1,12 +1,16 @@
<?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;
@ -34,16 +38,15 @@ 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 array $configuration = []; public ?int $preview_device_model_id = null;
public array $xhrSelectOptions = []; public string $preview_size = 'full';
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 {
@ -74,6 +77,12 @@ 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
@ -129,6 +138,19 @@ 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
@ -254,27 +276,6 @@ 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])) {
$configurationValues[$fieldKey] = $this->configuration[$fieldKey];
}
}
}
$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();
@ -295,8 +296,6 @@ 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) {
@ -365,13 +364,17 @@ 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 {
$previewMarkup = $this->plugin->render($size); // Create a device object with og_plus model and the selected bitdepth
$device = $this->createPreviewDevice();
$previewMarkup = $this->plugin->render($size, true, $device);
$this->dispatch('preview-updated', preview: $previewMarkup); $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());
@ -380,6 +383,38 @@ HTML;
} }
} }
private function createPreviewDevice(): \App\Models\Device
{
$deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id)
?? DeviceModel::with(['palette'])->first();
$device = new Device();
$device->setRelation('deviceModel', $deviceModel);
return $device;
}
public function getDeviceModels()
{
return DeviceModel::whereKind('trmnl')->orderBy('label')->get();
}
public function updatedPreviewDeviceModelId(): void
{
$this->renderPreview($this->preview_size);
}
public function duplicatePlugin(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
// Use the model's duplicate method
$newPlugin = $this->plugin->duplicate(auth()->id());
// Redirect to the new plugin's detail page
$this->redirect(route('plugins.recipe', ['plugin' => $newPlugin]));
}
public function deletePlugin(): void public function deletePlugin(): void
{ {
abort_unless(auth()->user()->plugins->contains($this->plugin), 403); abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
@ -387,42 +422,31 @@ HTML;
$this->redirect(route('plugins.index')); $this->redirect(route('plugins.index'));
} }
public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void #[On('config-updated')]
{ public function refreshPlugin()
abort_unless(auth()->user()->plugins->contains($this->plugin), 403); {
// This pulls the fresh 'configuration' from the DB
// and re-triggers the @if check in the Blade template
$this->plugin = $this->plugin->fresh();
}
try { // Laravel Livewire computed property: access with $this->parsed_urls
$requestData = []; #[Computed]
if ($query !== null) { private function parsedUrls()
$requestData = [ {
'function' => $fieldKey, if (!isset($this->polling_url)) {
'query' => $query return null;
];
}
$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 try {
{ return $this->plugin->resolveLiquidVariables($this->polling_url);
$query = $this->searchQueries[$fieldKey] ?? '';
if (!empty($query)) { } catch (\Exception $e) {
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query); return 'PARSE_ERROR: ' . $e->getMessage();
}
} }
}
} }
?> ?>
<div class="py-12"> <div class="py-12">
@ -454,7 +478,6 @@ 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">
@ -464,6 +487,11 @@ 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>
@ -605,8 +633,15 @@ 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> <div class="flex items-center gap-4">
<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">
@ -614,262 +649,9 @@ HTML;
</div> </div>
</flux:modal> </flux:modal>
<flux:modal name="configuration-modal" class="md:w-96"> <livewire:plugins.recipes.settings :plugin="$plugin" />
<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"> <livewire:plugins.config-modal :plugin="$plugin" />
@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'];
$currentValue = $configuration[$fieldKey] ?? '';
@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>
@ -957,7 +739,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="cog" class="block mt-1 w-full">Configuration</flux:button> <flux:button icon="variable" class="block mt-1 w-full">Configuration Fields</flux:button>
</flux:modal.trigger> </flux:modal.trigger>
</div> </div>
@endif @endif
@ -970,15 +752,62 @@ HTML;
</div> </div>
@if($data_strategy === 'polling') @if($data_strategy === 'polling')
<div class="mb-4"> <flux:label>Polling URL</flux:label>
<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"
class="block w-full" type="text" name="polling_url" autofocus> rows="5"
</flux:input> />
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full"> <flux:description>
{!! 'Hint: Supports multiple requests via line break separation. You can also use configuration variables with <a href="https://help.usetrmnl.com/en/articles/12689499-dynamic-polling-urls">Liquid syntax</a>. ' !!}
</flux:description>
</flux:field>
</div>
<div x-show="subTab === 'preview'" x-cloak>
<flux:field>
<flux:description>Preview computed URLs here (readonly):</flux:description>
<flux:textarea
readonly
placeholder="Nothing to show..."
rows="5"
>
{{ $this->parsed_urls }}
</flux:textarea>
</flux:field>
</div>
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="w-full mt-4">
Fetch data now 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">
@ -1082,7 +911,7 @@ HTML;
})" })"
wire:ignore wire:ignore
wire:key="cm-{{ $textareaId }}" wire:key="cm-{{ $textareaId }}"
class="max-w-2xl min-h-[300px] h-[500px] overflow-hidden resize-y" class="max-w-2xl min-h-[300px] h-[565px] overflow-hidden resize-y"
> >
<!-- Loading state --> <!-- Loading state -->
<div x-show="isLoading" class="flex items-center justify-center h-full"> <div x-show="isLoading" class="flex items-center justify-center h-full">
@ -1142,9 +971,6 @@ 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">

View file

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

View file

@ -11,9 +11,12 @@ use Livewire\Volt\Component;
new class extends Component { new class extends Component {
public ?int $assign_new_device_id = null; public ?int $assign_new_device_id = null;
public ?string $timezone = null;
public function mount(): void public function mount(): void
{ {
$this->assign_new_device_id = Auth::user()->assign_new_device_id; $this->assign_new_device_id = Auth::user()->assign_new_device_id;
$this->timezone = Auth::user()->timezone ?? config('app.timezone');
} }
public function updatePreferences(): void public function updatePreferences(): void
@ -26,6 +29,11 @@ new class extends Component {
->whereNull('mirror_device_id'); ->whereNull('mirror_device_id');
}), }),
], ],
'timezone' => [
'nullable',
'string',
Rule::in(timezone_identifiers_list()),
],
]); ]);
Auth::user()->update($validated); Auth::user()->update($validated);
@ -39,6 +47,14 @@ new class extends Component {
<x-settings.layout heading="Preferences" subheading="Update your preferences"> <x-settings.layout heading="Preferences" subheading="Update your preferences">
<form wire:submit="updatePreferences" class="my-6 w-full space-y-6"> <form wire:submit="updatePreferences" class="my-6 w-full space-y-6">
<flux:select wire:model="timezone" label="Timezone">
<flux:select.option value="" disabled>Select timezone...</flux:select.option>
@foreach(timezone_identifiers_list() as $tz)
<flux:select.option value="{{ $tz }}">{{ $tz }}</flux:select.option>
@endforeach
</flux:select>
<flux:select wire:model="assign_new_device_id" label="Auto-Joined Devices should mirror"> <flux:select wire:model="assign_new_device_id" label="Auto-Joined Devices should mirror">
<flux:select.option value="">None</flux:select.option> <flux:select.option value="">None</flux:select.option>
@foreach(auth()->user()->devices->where('mirror_device_id', null) as $device) @foreach(auth()->user()->devices->where('mirror_device_id', null) as $device)

View file

@ -0,0 +1,97 @@
@props(['size' => 'full'])
@php
use Carbon\Carbon;
$today = Carbon::today(config('app.timezone'));
$events = collect($data['ical'] ?? [])
->map(function (array $event): array {
try {
$start = isset($event['DTSTART'])
? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone'))
: null;
} catch (Exception $e) {
$start = null;
}
try {
$end = isset($event['DTEND'])
? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone'))
: null;
} catch (Exception $e) {
$end = null;
}
return [
'summary' => $event['SUMMARY'] ?? 'Untitled event',
'location' => $event['LOCATION'] ?? '—',
'start' => $start,
'end' => $end,
];
})
->filter(fn ($event) =>
$event['start'] &&
(
$event['start']->greaterThanOrEqualTo($today) ||
($event['end'] && $event['end']->greaterThanOrEqualTo($today))
)
)
->sortBy('start')
->take($size === 'quadrant' ? 5 : 8)
->values();
@endphp
<x-trmnl::view size="{{$size}}">
<x-trmnl::layout class="layout--col gap--small">
<x-trmnl::table>
<thead>
<tr>
<th>
<x-trmnl::title>Date</x-trmnl::title>
</th>
<th>
<x-trmnl::title>Time</x-trmnl::title>
</th>
<th>
<x-trmnl::title>Event</x-trmnl::title>
</th>
<th>
<x-trmnl::title>Location</x-trmnl::title>
</th>
</tr>
</thead>
<tbody>
@forelse($events as $event)
<tr>
<td>
<x-trmnl::label>{{ $event['start']?->format('D, M j') }}</x-trmnl::label>
</td>
<td>
<x-trmnl::label>
{{ $event['start']?->format('H:i') }}
@if($event['end'])
{{ $event['end']->format('H:i') }}
@endif
</x-trmnl::label>
</td>
<td>
<x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label>
</td>
<td>
<x-trmnl::label variant="inverted">{{ $event['location'] ?? '—' }}</x-trmnl::label>
</td>
</tr>
@empty
<tr>
<td colspan="4">
<x-trmnl::label>No events available</x-trmnl::label>
</td>
</tr>
@endforelse
</tbody>
</x-trmnl::table>
</x-trmnl::layout>
<x-trmnl::title-bar title="Public Holidays" instance="updated: {{ now()->format('M j, H:i') }}"/>
</x-trmnl::view>

View file

@ -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[0]['a']}}</div> <div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div>
@if (strlen($data[0]['q']) < 300 && $size != 'quadrant') @if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
<p class="value">{{ $data[0]['q'] }}</p> <p class="value">{{ $data['data'][0]['q'] ?? '' }}</p>
@else @else
<p class="value--small">{{ $data[0]['q'] }}</p> <p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p>
@endif @endif
</x-trmnl::layout> </x-trmnl::layout>
</x-trmnl::layout> </x-trmnl::layout>

View file

@ -18,7 +18,7 @@ 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('mac_address', $mac_address) $device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
->where('api_key', $access_token) ->where('api_key', $access_token)
->first(); ->first();
@ -29,7 +29,7 @@ Route::get('/display', function (Request $request) {
if ($auto_assign_user) { 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' => $mac_address, 'mac_address' => mb_strtoupper($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",
@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) {
// Check and update stale data if needed // Check and update stale data if needed
if ($plugin->isDataStale() || $plugin->current_image === null) { if ($plugin->isDataStale() || $plugin->current_image === null) {
$plugin->updateDataPayload(); $plugin->updateDataPayload();
$markup = $plugin->render(device: $device); try {
$markup = $plugin->render(device: $device);
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
} catch (Exception $e) {
Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
// Generate error display
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name);
$device->update(['current_screen_image' => $errorImageUuid]);
}
} }
$plugin->refresh(); $plugin->refresh();
@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) {
} }
} }
$markup = $playlistItem->render(device: $device); try {
GenerateScreenJob::dispatchSync($device->id, null, $markup); $markup = $playlistItem->render(device: $device);
GenerateScreenJob::dispatchSync($device->id, null, $markup);
} catch (Exception $e) {
Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage());
// For mashups, show error for the first plugin or a generic error
$firstPlugin = $plugins->first();
$pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe';
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName);
$device->update(['current_screen_image' => $errorImageUuid]);
}
$device->refresh(); $device->refresh();
@ -204,7 +220,7 @@ Route::get('/setup', function (Request $request) {
], 404); ], 404);
} }
$device = Device::where('mac_address', $mac_address)->first(); $device = Device::where('mac_address', mb_strtoupper($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
@ -219,7 +235,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' => $mac_address, 'mac_address' => mb_strtoupper($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",
@ -345,7 +361,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', $mac_address) $device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
->where('api_key', $access_token) ->where('api_key', $access_token)
->first(); ->first();
@ -533,6 +549,91 @@ 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([
@ -577,3 +678,90 @@ 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');

View file

@ -24,12 +24,15 @@ Route::middleware(['auth'])->group(function () {
Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs'); Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs');
Volt::route('/device-models', 'device-models.index')->name('device-models.index'); Volt::route('/device-models', 'device-models.index')->name('device-models.index');
Volt::route('/device-palettes', 'device-palettes.index')->name('device-palettes.index');
Volt::route('plugins', 'plugins.index')->name('plugins.index'); Volt::route('plugins', 'plugins.index')->name('plugins.index');
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) {

View file

@ -7,6 +7,7 @@ use App\Models\Playlist;
use App\Models\PlaylistItem; use App\Models\PlaylistItem;
use App\Models\Plugin; use App\Models\Plugin;
use App\Models\User; use App\Models\User;
use App\Services\ImageGenerationService;
use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -954,3 +955,232 @@ 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();
});

View file

@ -0,0 +1,196 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/generated');
});
test('can upload image to image webhook plugin via multipart form', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
$image = UploadedFile::fake()->image('test.png', 800, 480);
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
'image' => $image,
]);
$response->assertOk()
->assertJsonStructure([
'message',
'image_url',
]);
$plugin->refresh();
expect($plugin->current_image)
->not->toBeNull()
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
// File should exist with the new UUID
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
// Image URL should contain the new UUID
expect($response->json('image_url'))
->toContain($plugin->current_image);
});
test('can upload image to image webhook plugin via raw binary', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a simple PNG image binary
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
'CONTENT_TYPE' => 'image/png',
], $pngData);
$response->assertOk()
->assertJsonStructure([
'message',
'image_url',
]);
$plugin->refresh();
expect($plugin->current_image)
->not->toBeNull()
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
// File should exist with the new UUID
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
// Image URL should contain the new UUID
expect($response->json('image_url'))
->toContain($plugin->current_image);
});
test('can upload image to image webhook plugin via base64 data URI', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a simple PNG image as base64 data URI
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", [
'image' => $base64Image,
]);
$response->assertOk()
->assertJsonStructure([
'message',
'image_url',
]);
$plugin->refresh();
expect($plugin->current_image)
->not->toBeNull()
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
// File should exist with the new UUID
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
// Image URL should contain the new UUID
expect($response->json('image_url'))
->toContain($plugin->current_image);
});
test('returns 400 for non-image-webhook plugin', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'plugin_type' => 'recipe',
]);
$image = UploadedFile::fake()->image('test.png', 800, 480);
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
'image' => $image,
]);
$response->assertStatus(400)
->assertJson(['error' => 'Plugin is not an image webhook plugin']);
});
test('returns 404 for non-existent plugin', function (): void {
$image = UploadedFile::fake()->image('test.png', 800, 480);
$response = $this->post('/api/plugin_settings/'.Str::uuid().'/image', [
'image' => $image,
]);
$response->assertNotFound();
});
test('returns 400 for unsupported image format', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a fake GIF file (not supported)
$gifData = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
'CONTENT_TYPE' => 'image/gif',
], $gifData);
$response->assertStatus(400)
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
});
test('returns 400 for JPG image format', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a fake JPG file (not supported)
$jpgData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A');
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
'CONTENT_TYPE' => 'image/jpeg',
], $jpgData);
$response->assertStatus(400)
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
});
test('returns 400 when no image data provided', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", []);
$response->assertStatus(400)
->assertJson(['error' => 'No image data provided']);
});
test('image webhook plugin isDataStale returns false', function (): void {
$plugin = Plugin::factory()->imageWebhook()->create();
expect($plugin->isDataStale())->toBeFalse();
});
test('image webhook plugin factory creates correct plugin type', function (): void {
$plugin = Plugin::factory()->imageWebhook()->create();
expect($plugin)
->plugin_type->toBe('image_webhook')
->data_strategy->toBe('static');
});

View file

@ -324,6 +324,30 @@ 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([

View file

@ -12,6 +12,13 @@ uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
DeviceModel::truncate(); DeviceModel::truncate();
// Mock palettes API to return empty array by default
Http::fake([
'usetrmnl.com/api/palettes' => Http::response([
'data' => [],
], 200),
]);
}); });
test('fetch device models job can be dispatched', function (): void { test('fetch device models job can be dispatched', function (): void {
@ -21,6 +28,7 @@ test('fetch device models job can be dispatched', function (): void {
test('fetch device models job handles successful api response', function (): void { test('fetch device models job handles successful api response', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => [ 'data' => [
[ [
@ -36,12 +44,17 @@ 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',
], ],
], ],
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info') Log::shouldReceive('info')
->once() ->once()
->with('Successfully fetched and updated device models', ['count' => 1]); ->with('Successfully fetched and updated device models', ['count' => 1]);
@ -62,11 +75,13 @@ 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');
}); });
test('fetch device models job handles multiple device models', function (): void { test('fetch device models job handles multiple device models', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => [ 'data' => [
[ [
@ -103,6 +118,10 @@ test('fetch device models job handles multiple device models', function (): void
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info') Log::shouldReceive('info')
->once() ->once()
->with('Successfully fetched and updated device models', ['count' => 2]); ->with('Successfully fetched and updated device models', ['count' => 2]);
@ -116,11 +135,16 @@ test('fetch device models job handles multiple device models', function (): void
test('fetch device models job handles empty data array', function (): void { test('fetch device models job handles empty data array', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => [], 'data' => [],
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info') Log::shouldReceive('info')
->once() ->once()
->with('Successfully fetched and updated device models', ['count' => 0]); ->with('Successfully fetched and updated device models', ['count' => 0]);
@ -133,11 +157,16 @@ test('fetch device models job handles empty data array', function (): void {
test('fetch device models job handles missing data field', function (): void { test('fetch device models job handles missing data field', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'message' => 'No data available', 'message' => 'No data available',
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info') Log::shouldReceive('info')
->once() ->once()
->with('Successfully fetched and updated device models', ['count' => 0]); ->with('Successfully fetched and updated device models', ['count' => 0]);
@ -150,11 +179,16 @@ test('fetch device models job handles missing data field', function (): void {
test('fetch device models job handles non-array data', function (): void { test('fetch device models job handles non-array data', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => 'invalid-data', 'data' => 'invalid-data',
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('error') Log::shouldReceive('error')
->once() ->once()
->with('Invalid response format from device models API', Mockery::type('array')); ->with('Invalid response format from device models API', Mockery::type('array'));
@ -167,11 +201,16 @@ test('fetch device models job handles non-array data', function (): void {
test('fetch device models job handles api failure', function (): void { test('fetch device models job handles api failure', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'error' => 'Internal Server Error', 'error' => 'Internal Server Error',
], 500), ], 500),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('error') Log::shouldReceive('error')
->once() ->once()
->with('Failed to fetch device models from API', [ ->with('Failed to fetch device models from API', [
@ -187,11 +226,16 @@ test('fetch device models job handles api failure', function (): void {
test('fetch device models job handles network exception', function (): void { test('fetch device models job handles network exception', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => function (): void { 'usetrmnl.com/api/models' => function (): void {
throw new Exception('Network connection failed'); throw new Exception('Network connection failed');
}, },
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('error') Log::shouldReceive('error')
->once() ->once()
->with('Exception occurred while fetching device models', Mockery::type('array')); ->with('Exception occurred while fetching device models', Mockery::type('array'));
@ -204,6 +248,7 @@ test('fetch device models job handles network exception', function (): void {
test('fetch device models job handles device model with missing name', function (): void { test('fetch device models job handles device model with missing name', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => [ 'data' => [
[ [
@ -214,6 +259,10 @@ test('fetch device models job handles device model with missing name', function
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('warning') Log::shouldReceive('warning')
->once() ->once()
->with('Device model data missing name field', Mockery::type('array')); ->with('Device model data missing name field', Mockery::type('array'));
@ -230,6 +279,7 @@ test('fetch device models job handles device model with missing name', function
test('fetch device models job handles device model with partial data', function (): void { test('fetch device models job handles device model with partial data', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => [ 'data' => [
[ [
@ -240,6 +290,10 @@ test('fetch device models job handles device model with partial data', function
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info') Log::shouldReceive('info')
->once() ->once()
->with('Successfully fetched and updated device models', ['count' => 1]); ->with('Successfully fetched and updated device models', ['count' => 1]);
@ -260,6 +314,7 @@ 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');
}); });
@ -273,6 +328,7 @@ test('fetch device models job updates existing device model', function (): void
]); ]);
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => [ 'data' => [
[ [
@ -294,6 +350,10 @@ test('fetch device models job updates existing device model', function (): void
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('info') Log::shouldReceive('info')
->once() ->once()
->with('Successfully fetched and updated device models', ['count' => 1]); ->with('Successfully fetched and updated device models', ['count' => 1]);
@ -311,6 +371,7 @@ test('fetch device models job updates existing device model', function (): void
test('fetch device models job handles processing exception for individual model', function (): void { test('fetch device models job handles processing exception for individual model', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ 'usetrmnl.com/api/models' => Http::response([
'data' => [ 'data' => [
[ [
@ -327,6 +388,10 @@ test('fetch device models job handles processing exception for individual model'
], 200), ], 200),
]); ]);
Log::shouldReceive('info')
->once()
->with('Successfully fetched and updated palettes', ['count' => 0]);
Log::shouldReceive('warning') Log::shouldReceive('warning')
->once() ->once()
->with('Device model data missing name field', Mockery::type('array')); ->with('Device model data missing name field', Mockery::type('array'));

View file

@ -3,6 +3,7 @@
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Livewire\Volt\Volt; use Livewire\Volt\Volt;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@ -16,6 +17,8 @@ it('can render catalog component', function (): void {
config('app.catalog_url') => Http::response('', 200), config('app.catalog_url') => Http::response('', 200),
]); ]);
Livewire::withoutLazyLoading();
$component = Volt::test('catalog.index'); $component = Volt::test('catalog.index');
$component->assertSee('No plugins available'); $component->assertSee('No plugins available');
@ -54,12 +57,54 @@ it('loads plugins from catalog URL', function (): void {
config('app.catalog_url') => Http::response($yamlContent, 200), config('app.catalog_url') => Http::response($yamlContent, 200),
]); ]);
Livewire::withoutLazyLoading();
$component = Volt::test('catalog.index'); $component = Volt::test('catalog.index');
$component->assertSee('Test Plugin'); $component->assertSee('Test Plugin');
$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 {
@ -67,6 +112,8 @@ it('shows error when plugin not found', function (): void {
$this->actingAs($user); $this->actingAs($user);
Livewire::withoutLazyLoading();
$component = Volt::test('catalog.index'); $component = Volt::test('catalog.index');
$component->call('installPlugin', 'non-existent-plugin'); $component->call('installPlugin', 'non-existent-plugin');
@ -97,6 +144,8 @@ it('shows error when zip_url is missing', function (): void {
$this->actingAs($user); $this->actingAs($user);
Livewire::withoutLazyLoading();
$component = Volt::test('catalog.index'); $component = Volt::test('catalog.index');
$component->call('installPlugin', 'test-plugin'); $component->call('installPlugin', 'test-plugin');
@ -105,3 +154,46 @@ it('shows error when zip_url is missing', function (): void {
$component->assertHasErrors(); $component->assertHasErrors();
}); });
it('can preview a plugin', function (): void {
// Clear cache first to ensure fresh data
Cache::forget('catalog_plugins');
// Mock the HTTP response for the catalog URL
$catalogData = [
'test-plugin' => [
'name' => 'Test Plugin',
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
'author_bio' => [
'description' => 'A test plugin description',
],
'license' => 'MIT',
'trmnlp' => [
'zip_url' => 'https://example.com/plugin.zip',
],
'byos' => [
'byos_laravel' => [
'compatibility' => true,
],
],
'logo_url' => 'https://example.com/logo.png',
'screenshot_url' => 'https://example.com/screenshot.png',
],
];
$yamlContent = Yaml::dump($catalogData);
Http::fake([
config('app.catalog_url') => Http::response($yamlContent, 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.index')
->assertSee('Test Plugin')
->call('previewPlugin', 'test-plugin')
->assertSet('previewingPlugin', 'test-plugin')
->assertSet('previewData.name', 'Test Plugin')
->assertSee('Preview Test Plugin')
->assertSee('A test plugin description');
});

View file

@ -0,0 +1,124 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Volt\Volt;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
test('config modal correctly loads multi_string defaults into UI boxes', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::create([
'uuid' => Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin',
'data_strategy' => 'static',
'configuration_template' => [
'custom_fields' => [[
'keyname' => 'tags',
'field_type' => 'multi_string',
'name' => 'Reading Days',
'default' => 'alpha,beta',
]]
],
'configuration' => ['tags' => 'alpha,beta']
]);
Volt::test('plugins.config-modal', ['plugin' => $plugin])
->assertSet('multiValues.tags', ['alpha', 'beta']);
});
test('config modal validates against commas in multi_string boxes', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::create([
'uuid' => Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin',
'data_strategy' => 'static',
'configuration_template' => [
'custom_fields' => [[
'keyname' => 'tags',
'field_type' => 'multi_string',
'name' => 'Reading Days',
]]
]
]);
Volt::test('plugins.config-modal', ['plugin' => $plugin])
->set('multiValues.tags.0', 'no,commas,allowed')
->call('saveConfiguration')
->assertHasErrors(['multiValues.tags.0' => 'regex']);
// Assert DB remains unchanged
expect($plugin->fresh()->configuration['tags'] ?? '')->not->toBe('no,commas,allowed');
});
test('config modal merges multi_string boxes into a single CSV string on save', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::create([
'uuid' => Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin',
'data_strategy' => 'static',
'configuration_template' => [
'custom_fields' => [[
'keyname' => 'items',
'field_type' => 'multi_string',
'name' => 'Reading Days',
]]
],
'configuration' => []
]);
Volt::test('plugins.config-modal', ['plugin' => $plugin])
->set('multiValues.items.0', 'First')
->call('addMultiItem', 'items')
->set('multiValues.items.1', 'Second')
->call('saveConfiguration')
->assertHasNoErrors();
expect($plugin->fresh()->configuration['items'])->toBe('First,Second');
});
test('config modal resetForm clears dirty state and increments resetIndex', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::create([
'uuid' => Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin',
'data_strategy' => 'static',
'configuration' => ['simple_key' => 'original_value']
]);
Volt::test('plugins.config-modal', ['plugin' => $plugin])
->set('configuration.simple_key', 'dirty_value')
->call('resetForm')
->assertSet('configuration.simple_key', 'original_value')
->assertSet('resetIndex', 1);
});
test('config modal dispatches update event for parent warning refresh', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::create([
'uuid' => Str::uuid(),
'user_id' => $user->id,
'name' => 'Test Plugin',
'data_strategy' => 'static'
]);
Volt::test('plugins.config-modal', ['plugin' => $plugin])
->call('saveConfiguration')
->assertDispatched('config-updated');
});

View file

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

View file

@ -130,3 +130,48 @@ 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();
});

View file

@ -83,19 +83,34 @@ 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 required files', function (): void { it('throws exception for missing settings.yml', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$zipContent = createMockZipFile([ $zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(), 'src/full.liquid' => getValidFullLiquid(),
// Missing full.liquid // Missing settings.yml
]); ]);
$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. Required files settings.yml and full.liquid are missing.'); ->toThrow(Exception::class, 'Invalid ZIP structure. Required file settings.yml is missing.');
});
it('throws exception for missing template files', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
// Missing all template files
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
->toThrow(Exception::class, 'Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
}); });
it('sets default values when settings are missing', function (): void { it('sets default values when settings are missing', function (): void {
@ -341,6 +356,189 @@ it('imports specific plugin from monorepo zip with zip_entry_path parameter', fu
->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>'); ->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>');
}); });
it('sets icon_url when importing from URL with iconUrl parameter', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
]);
Http::fake([
'https://example.com/plugin.zip' => Http::response($zipContent, 200),
]);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromUrl(
'https://example.com/plugin.zip',
$user,
null,
null,
'https://example.com/icon.png'
);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->icon_url)->toBe('https://example.com/icon.png');
});
it('does not set icon_url when importing from URL without iconUrl parameter', function (): void {
$user = User::factory()->create();
$zipContent = createMockZipFile([
'src/settings.yml' => getValidSettingsYaml(),
'src/full.liquid' => getValidFullLiquid(),
]);
Http::fake([
'https://example.com/plugin.zip' => Http::response($zipContent, 200),
]);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromUrl(
'https://example.com/plugin.zip',
$user
);
expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->icon_url)->toBeNull();
});
it('normalizes non-named select options to named values', function (): void {
$user = User::factory()->create();
$settingsYaml = <<<'YAML'
name: Test Plugin
refresh_interval: 30
strategy: static
polling_verb: get
static_data: '{}'
custom_fields:
- keyname: display_incident
field_type: select
options:
- true
- false
default: true
YAML;
$zipContent = createMockZipFile([
'src/settings.yml' => $settingsYaml,
'src/full.liquid' => getValidFullLiquid(),
]);
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
$pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user);
$customFields = $plugin->configuration_template['custom_fields'];
$displayIncidentField = collect($customFields)->firstWhere('keyname', 'display_incident');
expect($displayIncidentField)->not->toBeNull()
->and($displayIncidentField['options'])->toBe([
['true' => 'true'],
['false' => 'false'],
])
->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
{ {

View file

@ -3,6 +3,7 @@
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 {
@ -191,3 +192,96 @@ test('plugin handles POST requests with XML responses', function (): void {
expect($plugin->data_payload['rss']['status'])->toBe('success'); expect($plugin->data_payload['rss']['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();
});

View file

@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use Livewire\Volt\Volt;
it('loads newest TRMNL recipes on mount', 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' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->assertSee('Install')
->assertDontSeeHtml('variant="subtle" icon="eye"')
->assertSee('Installs: 10');
});
it('shows preview button when screenshot_url is provided', function (): void {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => 'https://example.com/screenshot.png',
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->assertSee('Preview');
});
it('searches TRMNL recipes when search term is provided', function (): void {
Http::fake([
// First call (mount -> newest)
'usetrmnl.com/recipes.json?*' => Http::sequence()
->push([
'data' => [
[
'id' => 1,
'name' => 'Initial Recipe',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 1, 'forks' => 0],
],
],
], 200)
// Second call (search)
->push([
'data' => [
[
'id' => 2,
'name' => 'Weather Search Result',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 3, 'forks' => 1],
],
],
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Initial Recipe')
->set('search', 'weather')
->assertSee('Weather Search Result')
->assertSee('Install');
});
it('installs plugin successfully when user is authenticated', function (): void {
$user = User::factory()->create();
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
]);
$this->actingAs($user);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file
});
it('shows error when user is not authenticated', 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' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertStatus(403); // This will return 403 because user is not authenticated
});
it('shows error when plugin installation fails', function (): void {
$user = User::factory()->create();
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
]);
$this->actingAs($user);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('installPlugin', '123')
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
});
it('previews a recipe with async fetch', function (): void {
Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([
'data' => [
[
'id' => 123,
'name' => 'Weather Chum',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => 'https://example.com/old.png',
'author_bio' => null,
'stats' => ['installs' => 10, 'forks' => 2],
],
],
], 200),
'usetrmnl.com/recipes/123.json' => Http::response([
'data' => [
'id' => 123,
'name' => 'Weather Chum Updated',
'icon_url' => 'https://example.com/icon.png',
'screenshot_url' => 'https://example.com/new.png',
'author_bio' => ['description' => 'New bio'],
'stats' => ['installs' => 11, 'forks' => 3],
],
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Weather Chum')
->call('previewRecipe', '123')
->assertSet('previewingRecipe', '123')
->assertSet('previewData.name', 'Weather Chum Updated')
->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
->assertSee('Preview Weather Chum Updated')
->assertSee('New bio');
});
it('supports pagination and loading more recipes', function (): void {
Http::fake([
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
'data' => [
[
'id' => 1,
'name' => 'Recipe Page 1',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 1, 'forks' => 0],
],
],
'next_page_url' => '/recipes.json?page=2',
], 200),
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
'data' => [
[
'id' => 2,
'name' => 'Recipe Page 2',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 2, 'forks' => 0],
],
],
'next_page_url' => null,
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Recipe Page 1')
->assertDontSee('Recipe Page 2')
->assertSee('Load next page')
->call('loadMore')
->assertSee('Recipe Page 1')
->assertSee('Recipe Page 2')
->assertDontSee('Load next page');
});
it('resets pagination when search term changes', function (): void {
Http::fake([
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
->push([
'data' => [['id' => 1, 'name' => 'Initial 1']],
'next_page_url' => '/recipes.json?page=2',
])
->push([
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
'next_page_url' => null,
]),
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
'data' => [['id' => 2, 'name' => 'Weather Result']],
'next_page_url' => null,
]),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Initial 1')
->call('loadMore')
->set('search', 'weather')
->assertSee('Weather Result')
->assertDontSee('Initial 1')
->assertSet('page', 1);
});

View file

@ -0,0 +1,575 @@
<?php
declare(strict_types=1);
use App\Models\DevicePalette;
use App\Models\User;
use Livewire\Volt\Volt;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('device palettes page can be rendered', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('device-palettes.index'))->assertOk();
});
test('component loads all device palettes on mount', function (): void {
$user = User::factory()->create();
$initialCount = DevicePalette::count();
DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
DevicePalette::create(['name' => 'palette-3', 'grays' => 16, 'framework_class' => '']);
$this->actingAs($user);
$component = Volt::test('device-palettes.index');
$palettes = $component->get('devicePalettes');
expect($palettes)->toHaveCount($initialCount + 3);
});
test('can open modal to create new device palette', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('openDevicePaletteModal');
$component
->assertSet('editingDevicePaletteId', null)
->assertSet('viewingDevicePaletteId', null)
->assertSet('name', null)
->assertSet('grays', 2);
});
test('can create a new device palette', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('description', 'Test Palette Description')
->set('grays', 16)
->set('colors', ['#FF0000', '#00FF00'])
->set('framework_class', 'TestFramework')
->call('saveDevicePalette');
$component->assertHasNoErrors();
expect(DevicePalette::where('name', 'test-palette')->exists())->toBeTrue();
$palette = DevicePalette::where('name', 'test-palette')->first();
expect($palette->description)->toBe('Test Palette Description');
expect($palette->grays)->toBe(16);
expect($palette->colors)->toBe(['#FF0000', '#00FF00']);
expect($palette->framework_class)->toBe('TestFramework');
expect($palette->source)->toBe('manual');
});
test('can create a grayscale-only palette without colors', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'grayscale-palette')
->set('grays', 256)
->set('colors', [])
->call('saveDevicePalette');
$component->assertHasNoErrors();
$palette = DevicePalette::where('name', 'grayscale-palette')->first();
expect($palette->colors)->toBeNull();
expect($palette->grays)->toBe(256);
});
test('can open modal to edit existing device palette', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'existing-palette',
'description' => 'Existing Description',
'grays' => 4,
'colors' => ['#FF0000', '#00FF00'],
'framework_class' => 'Framework',
'source' => 'manual',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('openDevicePaletteModal', $palette->id);
$component
->assertSet('editingDevicePaletteId', $palette->id)
->assertSet('name', 'existing-palette')
->assertSet('description', 'Existing Description')
->assertSet('grays', 4)
->assertSet('colors', ['#FF0000', '#00FF00'])
->assertSet('framework_class', 'Framework');
});
test('can update an existing device palette', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'original-palette',
'grays' => 2,
'framework_class' => '',
'source' => 'manual',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('openDevicePaletteModal', $palette->id)
->set('name', 'updated-palette')
->set('description', 'Updated Description')
->set('grays', 16)
->set('colors', ['#0000FF'])
->call('saveDevicePalette');
$component->assertHasNoErrors();
$palette->refresh();
expect($palette->name)->toBe('updated-palette');
expect($palette->description)->toBe('Updated Description');
expect($palette->grays)->toBe(16);
expect($palette->colors)->toBe(['#0000FF']);
});
test('can delete a device palette', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'to-delete',
'grays' => 2,
'framework_class' => '',
'source' => 'manual',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('deleteDevicePalette', $palette->id);
expect(DevicePalette::find($palette->id))->toBeNull();
$component->assertSet('devicePalettes', function ($palettes) use ($palette) {
return $palettes->where('id', $palette->id)->isEmpty();
});
});
test('can duplicate a device palette', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'original-palette',
'description' => 'Original Description',
'grays' => 4,
'colors' => ['#FF0000', '#00FF00'],
'framework_class' => 'Framework',
'source' => 'manual',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('duplicateDevicePalette', $palette->id);
$component
->assertSet('editingDevicePaletteId', null)
->assertSet('name', 'original-palette (Copy)')
->assertSet('description', 'Original Description')
->assertSet('grays', 4)
->assertSet('colors', ['#FF0000', '#00FF00'])
->assertSet('framework_class', 'Framework');
});
test('can add a color to the colors array', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colorInput', '#FF0000')
->call('addColor');
$component
->assertHasNoErrors()
->assertSet('colors', ['#FF0000'])
->assertSet('colorInput', '');
});
test('cannot add duplicate colors', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colors', ['#FF0000'])
->set('colorInput', '#FF0000')
->call('addColor');
$component
->assertHasNoErrors()
->assertSet('colors', ['#FF0000']);
});
test('can add multiple colors', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colorInput', '#FF0000')
->call('addColor')
->set('colorInput', '#00FF00')
->call('addColor')
->set('colorInput', '#0000FF')
->call('addColor');
$component
->assertSet('colors', ['#FF0000', '#00FF00', '#0000FF']);
});
test('can remove a color from the colors array', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
->call('removeColor', 1);
$component->assertSet('colors', ['#FF0000', '#0000FF']);
});
test('removing color reindexes array', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
->call('removeColor', 0);
$colors = $component->get('colors');
expect($colors)->toBe(['#00FF00', '#0000FF']);
expect(array_keys($colors))->toBe([0, 1]);
});
test('can open modal in view-only mode for api-sourced palette', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'api-palette',
'grays' => 2,
'framework_class' => '',
'source' => 'api',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('openDevicePaletteModal', $palette->id, true);
$component
->assertSet('viewingDevicePaletteId', $palette->id)
->assertSet('editingDevicePaletteId', null);
});
test('name is required when creating device palette', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('grays', 16)
->call('saveDevicePalette');
$component->assertHasErrors(['name']);
});
test('name must be unique when creating device palette', function (): void {
$user = User::factory()->create();
DevicePalette::create([
'name' => 'existing-name',
'grays' => 2,
'framework_class' => '',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'existing-name')
->set('grays', 16)
->call('saveDevicePalette');
$component->assertHasErrors(['name']);
});
test('name can be same when updating device palette', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'original-name',
'grays' => 2,
'framework_class' => '',
'source' => 'manual',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('openDevicePaletteModal', $palette->id)
->set('grays', 16)
->call('saveDevicePalette');
$component->assertHasNoErrors();
});
test('grays is required when creating device palette', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('grays', null)
->call('saveDevicePalette');
$component->assertHasErrors(['grays']);
});
test('grays must be at least 1', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('grays', 0)
->call('saveDevicePalette');
$component->assertHasErrors(['grays']);
});
test('grays must be at most 256', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('grays', 257)
->call('saveDevicePalette');
$component->assertHasErrors(['grays']);
});
test('colors must be valid hex format', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('grays', 16)
->set('colors', ['invalid-color', '#FF0000'])
->call('saveDevicePalette');
$component->assertHasErrors(['colors.0']);
});
test('color input must be valid hex format when adding color', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colorInput', 'invalid-color')
->call('addColor');
$component->assertHasErrors(['colorInput']);
});
test('color input accepts valid hex format', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colorInput', '#FF0000')
->call('addColor');
$component->assertHasNoErrors();
});
test('color input accepts lowercase hex format', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('colorInput', '#ff0000')
->call('addColor');
$component->assertHasNoErrors();
});
test('description can be null', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('grays', 16)
->set('description', null)
->call('saveDevicePalette');
$component->assertHasNoErrors();
$palette = DevicePalette::where('name', 'test-palette')->first();
expect($palette->description)->toBeNull();
});
test('framework class can be empty string', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('grays', 16)
->set('framework_class', '')
->call('saveDevicePalette');
$component->assertHasNoErrors();
$palette = DevicePalette::where('name', 'test-palette')->first();
expect($palette->framework_class)->toBe('');
});
test('empty colors array is saved as null', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('grays', 16)
->set('colors', [])
->call('saveDevicePalette');
$component->assertHasNoErrors();
$palette = DevicePalette::where('name', 'test-palette')->first();
expect($palette->colors)->toBeNull();
});
test('component resets form after saving', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'test-palette')
->set('description', 'Test Description')
->set('grays', 16)
->set('colors', ['#FF0000'])
->set('framework_class', 'TestFramework')
->call('saveDevicePalette');
$component
->assertSet('name', null)
->assertSet('description', null)
->assertSet('grays', 2)
->assertSet('colors', [])
->assertSet('framework_class', '')
->assertSet('colorInput', '')
->assertSet('editingDevicePaletteId', null)
->assertSet('viewingDevicePaletteId', null);
});
test('component handles palette with null colors when editing', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'grayscale-palette',
'grays' => 2,
'colors' => null,
'framework_class' => '',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('openDevicePaletteModal', $palette->id);
$component->assertSet('colors', []);
});
test('component handles palette with string colors when editing', function (): void {
$user = User::factory()->create();
$palette = DevicePalette::create([
'name' => 'string-colors-palette',
'grays' => 2,
'framework_class' => '',
]);
// Manually set colors as JSON string to simulate edge case
$palette->setRawAttributes(array_merge($palette->getAttributes(), [
'colors' => json_encode(['#FF0000', '#00FF00']),
]));
$palette->save();
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('openDevicePaletteModal', $palette->id);
$component->assertSet('colors', ['#FF0000', '#00FF00']);
});
test('component refreshes palette list after creating', function (): void {
$user = User::factory()->create();
$initialCount = DevicePalette::count();
DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->set('name', 'new-palette')
->set('grays', 16)
->call('saveDevicePalette');
$palettes = $component->get('devicePalettes');
expect($palettes)->toHaveCount($initialCount + 3);
expect(DevicePalette::count())->toBe($initialCount + 3);
});
test('component refreshes palette list after deleting', function (): void {
$user = User::factory()->create();
$initialCount = DevicePalette::count();
$palette1 = DevicePalette::create([
'name' => 'palette-1',
'grays' => 2,
'framework_class' => '',
'source' => 'manual',
]);
$palette2 = DevicePalette::create([
'name' => 'palette-2',
'grays' => 2,
'framework_class' => '',
'source' => 'manual',
]);
$this->actingAs($user);
$component = Volt::test('device-palettes.index')
->call('deleteDevicePalette', $palette1->id);
$palettes = $component->get('devicePalettes');
expect($palettes)->toHaveCount($initialCount + 1);
expect(DevicePalette::count())->toBe($initialCount + 1);
});

View file

@ -77,7 +77,7 @@ test('l_date handles null locale parameter', function (): void {
$filter = new Localization(); $filter = new Localization();
$date = '2025-01-11'; $date = '2025-01-11';
$result = $filter->l_date($date, 'Y-m-d', null); $result = $filter->l_date($date, 'Y-m-d');
// Should work the same as default // Should work the same as default
expect($result)->toContain('2025'); expect($result)->toContain('2025');

View file

@ -1,6 +1,8 @@
<?php <?php
use App\Models\Plugin; use App\Models\Plugin;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -97,6 +99,35 @@ 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',
@ -357,3 +388,553 @@ test('resolveLiquidVariables handles empty configuration', function (): void {
expect($plugin->resolveLiquidVariables($template))->toBe($expected); expect($plugin->resolveLiquidVariables($template))->toBe($expected);
}); });
test('resolveLiquidVariables uses external renderer when preferred_renderer is trmnl-liquid and template contains for loop', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: 'https://api1.example.com/data\nhttps://api2.example.com/data',
exitCode: 0
),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$template = <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID;
$result = $plugin->resolveLiquidVariables($template);
// Trim trailing newlines that may be added by the process
expect(mb_trim($result))->toBe('https://api1.example.com/data\nhttps://api2.example.com/data');
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return str_contains($command, 'trmnl-liquid-cli') &&
str_contains($command, '--template') &&
str_contains($command, '--context');
});
});
test('resolveLiquidVariables uses internal renderer when preferred_renderer is not trmnl-liquid', function (): void {
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'php',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$template = <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID;
// Should use internal renderer even with for loop
$result = $plugin->resolveLiquidVariables($template);
// Internal renderer should process the template
expect($result)->toBeString();
});
test('resolveLiquidVariables uses internal renderer when external renderer is disabled', function (): void {
config(['services.trmnl.liquid_enabled' => false]);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$template = <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID;
// Should use internal renderer when external is disabled
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBeString();
});
test('resolveLiquidVariables uses internal renderer when template does not contain for loop', function (): void {
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [
'api_key' => 'test123',
],
]);
$template = 'https://api.example.com/data?key={{ api_key }}';
// Should use internal renderer when no for loop
$result = $plugin->resolveLiquidVariables($template);
expect($result)->toBe('https://api.example.com/data?key=test123');
Illuminate\Support\Facades\Process::assertNothingRan();
});
test('resolveLiquidVariables detects for loop with standard opening tag', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: 'resolved',
exitCode: 0
),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [],
]);
// Test {% for pattern
$template = '{% for item in items %}test{% endfor %}';
$plugin->resolveLiquidVariables($template);
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($command, 'trmnl-liquid-cli');
});
});
test('resolveLiquidVariables detects for loop with whitespace stripping tag', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: 'resolved',
exitCode: 0
),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'preferred_renderer' => 'trmnl-liquid',
'configuration' => [],
]);
// Test {%- for pattern (with whitespace stripping)
$template = '{%- for item in items %}test{% endfor %}';
$plugin->resolveLiquidVariables($template);
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($command, 'trmnl-liquid-cli');
});
});
test('updateDataPayload resolves entire polling_url field first then splits by newline', function (): void {
Http::fake([
'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => "https://api1.example.com/data\nhttps://api2.example.com/data",
'polling_verb' => 'get',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$plugin->updateDataPayload();
// Should have split the multi-line URL and generated two requests
expect($plugin->data_payload)->toHaveKey('IDX_0');
expect($plugin->data_payload)->toHaveKey('IDX_1');
expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
});
test('updateDataPayload handles multi-line polling_url with for loop using external renderer', function (): void {
Illuminate\Support\Facades\Process::fake([
'*' => Illuminate\Support\Facades\Process::result(
output: "https://api1.example.com/data\nhttps://api2.example.com/data",
exitCode: 0
),
]);
Http::fake([
'https://api1.example.com/data' => Http::response(['data' => 'test1'], 200),
'https://api2.example.com/data' => Http::response(['data' => 'test2'], 200),
]);
config(['services.trmnl.liquid_enabled' => true]);
config(['services.trmnl.liquid_path' => '/usr/local/bin/trmnl-liquid-cli']);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'preferred_renderer' => 'trmnl-liquid',
'polling_url' => <<<'LIQUID'
{% assign ids = recipe_ids | split: "," %}
{% for id in ids %}
https://api{{ id }}.example.com/data
{% endfor %}
LIQUID
,
'polling_verb' => 'get',
'configuration' => [
'recipe_ids' => '1,2',
],
]);
$plugin->updateDataPayload();
// Should have used external renderer and generated two URLs
expect($plugin->data_payload)->toHaveKey('IDX_0');
expect($plugin->data_payload)->toHaveKey('IDX_1');
expect($plugin->data_payload['IDX_0'])->toBe(['data' => 'test1']);
expect($plugin->data_payload['IDX_1'])->toBe(['data' => 'test2']);
Illuminate\Support\Facades\Process::assertRan(function ($process): bool {
$command = is_array($process->command) ? implode(' ', $process->command) : (string) $process->command;
return str_contains($command, 'trmnl-liquid-cli');
});
});
test('plugin render uses user timezone when set', function (): void {
$user = User::factory()->create([
'timezone' => 'America/New_York',
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
]);
$rendered = $plugin->render();
expect($rendered)->toContain('America/New_York');
});
test('plugin render falls back to app timezone when user timezone is not set', function (): void {
$user = User::factory()->create([
'timezone' => null,
]);
config(['app.timezone' => 'Europe/London']);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
]);
$rendered = $plugin->render();
expect($rendered)->toContain('Europe/London');
});
test('plugin render calculates correct UTC offset from user timezone', function (): void {
$user = User::factory()->create([
'timezone' => 'America/New_York', // UTC-5 (EST) or UTC-4 (EDT)
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
// America/New_York offset should be -18000 (EST) or -14400 (EDT) in seconds
$expectedOffset = (string) Carbon::now('America/New_York')->getOffset();
expect($rendered)->toContain($expectedOffset);
});
test('plugin render calculates correct UTC offset from app timezone when user timezone is null', function (): void {
$user = User::factory()->create([
'timezone' => null,
]);
config(['app.timezone' => 'Europe/London']);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
// Europe/London offset should be 0 (GMT) or 3600 (BST) in seconds
$expectedOffset = (string) Carbon::now('Europe/London')->getOffset();
expect($rendered)->toContain($expectedOffset);
});
test('plugin render includes utc_offset and time_zone_iana in trmnl.user context', function (): void {
$user = User::factory()->create([
'timezone' => 'America/Chicago', // UTC-6 (CST) or UTC-5 (CDT)
]);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'render_markup' => '{{ trmnl.user.time_zone_iana }}|{{ trmnl.user.utc_offset }}',
]);
$rendered = $plugin->render();
expect($rendered)
->toContain('America/Chicago')
->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
});
/**
* Plugin security: XSS Payload Dataset
* [Input, Expected Result, Forbidden String]
*/
dataset('xss_vectors', [
'standard_script' => ['Safe <script>alert(1)</script>', 'Safe ', '<script>'],
'attribute_event_handlers' => ['<a onmouseover="alert(1)">Link</a>', '<a>Link</a>', 'onmouseover'],
'javascript_protocol' => ['<a href="javascript:alert(1)">Click</a>', '<a>Click</a>', 'javascript:'],
'iframe_injection' => ['Watch <iframe src="https://x.com"></iframe>', 'Watch ', '<iframe>'],
'img_onerror_fallback' => ['Photo <img src=x onerror=alert(1)>', 'Photo <img src="x" alt="x">', 'onerror'],
]);
test('plugin model sanitizes template fields on save', function (string $input, string $expected, string $forbidden): void {
$user = User::factory()->create();
// We test the Model logic directly. This triggers the static::saving hook.
$plugin = Plugin::create([
'user_id' => $user->id,
'name' => 'Security Test',
'data_stale_minutes' => 15,
'data_strategy' => 'static',
'polling_verb' => 'get',
'configuration_template' => [
'custom_fields' => [
[
'keyname' => 'test_field',
'description' => $input,
'help_text' => $input,
],
],
],
]);
$field = $plugin->fresh()->configuration_template['custom_fields'][0];
// Assert the saved data is clean
expect($field['description'])->toBe($expected)
->and($field['help_text'])->toBe($expected)
->and($field['description'])->not->toContain($forbidden);
})->with('xss_vectors');
test('plugin model preserves multi_string csv format', function (): void {
$user = User::factory()->create();
$plugin = Plugin::create([
'user_id' => $user->id,
'name' => 'Multi-string Test',
'data_stale_minutes' => 15,
'data_strategy' => 'static',
'polling_verb' => 'get',
'configuration' => [
'tags' => 'laravel,pest,security',
],
]);
expect($plugin->fresh()->configuration['tags'])->toBe('laravel,pest,security');
});
test('plugin duplicate copies all attributes except id and uuid', function (): void {
$user = User::factory()->create();
$original = Plugin::factory()->create([
'user_id' => $user->id,
'name' => 'Original Plugin',
'data_stale_minutes' => 30,
'data_strategy' => 'polling',
'polling_url' => 'https://api.example.com/data',
'polling_verb' => 'get',
'polling_header' => 'Authorization: Bearer token123',
'polling_body' => '{"query": "test"}',
'render_markup' => '<div>Test markup</div>',
'markup_language' => 'blade',
'configuration' => ['api_key' => 'secret123'],
'configuration_template' => [
'custom_fields' => [
[
'keyname' => 'api_key',
'field_type' => 'string',
],
],
],
'no_bleed' => true,
'dark_mode' => true,
'data_payload' => ['test' => 'data'],
]);
$duplicate = $original->duplicate();
// Refresh to ensure casts are applied
$original->refresh();
$duplicate->refresh();
expect($duplicate->id)->not->toBe($original->id)
->and($duplicate->uuid)->not->toBe($original->uuid)
->and($duplicate->name)->toBe('Original Plugin (Copy)')
->and($duplicate->user_id)->toBe($original->user_id)
->and($duplicate->data_stale_minutes)->toBe($original->data_stale_minutes)
->and($duplicate->data_strategy)->toBe($original->data_strategy)
->and($duplicate->polling_url)->toBe($original->polling_url)
->and($duplicate->polling_verb)->toBe($original->polling_verb)
->and($duplicate->polling_header)->toBe($original->polling_header)
->and($duplicate->polling_body)->toBe($original->polling_body)
->and($duplicate->render_markup)->toBe($original->render_markup)
->and($duplicate->markup_language)->toBe($original->markup_language)
->and($duplicate->configuration)->toBe($original->configuration)
->and($duplicate->configuration_template)->toBe($original->configuration_template)
->and($duplicate->no_bleed)->toBe($original->no_bleed)
->and($duplicate->dark_mode)->toBe($original->dark_mode)
->and($duplicate->data_payload)->toBe($original->data_payload)
->and($duplicate->render_markup_view)->toBeNull();
});
test('plugin duplicate copies render_markup_view file content to render_markup', function (): void {
$user = User::factory()->create();
// Create a test blade file
$testViewPath = resource_path('views/recipes/test-duplicate.blade.php');
$testContent = '<div class="test-view">Test Content</div>';
// Ensure directory exists
if (! is_dir(dirname($testViewPath))) {
mkdir(dirname($testViewPath), 0755, true);
}
file_put_contents($testViewPath, $testContent);
try {
$original = Plugin::factory()->create([
'user_id' => $user->id,
'name' => 'View Plugin',
'render_markup' => null,
'render_markup_view' => 'recipes.test-duplicate',
'markup_language' => null,
]);
$duplicate = $original->duplicate();
expect($duplicate->render_markup)->toBe($testContent)
->and($duplicate->markup_language)->toBe('blade')
->and($duplicate->render_markup_view)->toBeNull()
->and($duplicate->name)->toBe('View Plugin (Copy)');
} finally {
// Clean up test file
if (file_exists($testViewPath)) {
unlink($testViewPath);
}
}
});
test('plugin duplicate handles liquid file extension', function (): void {
$user = User::factory()->create();
// Create a test liquid file
$testViewPath = resource_path('views/recipes/test-duplicate-liquid.liquid');
$testContent = '<div class="test-view">{{ data.message }}</div>';
// Ensure directory exists
if (! is_dir(dirname($testViewPath))) {
mkdir(dirname($testViewPath), 0755, true);
}
file_put_contents($testViewPath, $testContent);
try {
$original = Plugin::factory()->create([
'user_id' => $user->id,
'name' => 'Liquid Plugin',
'render_markup' => null,
'render_markup_view' => 'recipes.test-duplicate-liquid',
'markup_language' => null,
]);
$duplicate = $original->duplicate();
expect($duplicate->render_markup)->toBe($testContent)
->and($duplicate->markup_language)->toBe('liquid')
->and($duplicate->render_markup_view)->toBeNull();
} finally {
// Clean up test file
if (file_exists($testViewPath)) {
unlink($testViewPath);
}
}
});
test('plugin duplicate handles missing view file gracefully', function (): void {
$user = User::factory()->create();
$original = Plugin::factory()->create([
'user_id' => $user->id,
'name' => 'Missing View Plugin',
'render_markup' => null,
'render_markup_view' => 'recipes.nonexistent-view',
'markup_language' => null,
]);
$duplicate = $original->duplicate();
expect($duplicate->render_markup_view)->toBeNull()
->and($duplicate->name)->toBe('Missing View Plugin (Copy)');
});
test('plugin duplicate uses provided user_id', function (): void {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$original = Plugin::factory()->create([
'user_id' => $user1->id,
'name' => 'Original Plugin',
]);
$duplicate = $original->duplicate($user2->id);
expect($duplicate->user_id)->toBe($user2->id)
->and($duplicate->user_id)->not->toBe($original->user_id);
});
test('plugin duplicate falls back to original user_id when no user_id provided', function (): void {
$user = User::factory()->create();
$original = Plugin::factory()->create([
'user_id' => $user->id,
'name' => 'Original Plugin',
]);
$duplicate = $original->duplicate();
expect($duplicate->user_id)->toBe($original->user_id);
});