Compare commits

...

63 commits
0.26.0 ... main

Author SHA1 Message Date
Benjamin Nussbaum
7301cac8ca fix(#203): add iCal workaround where if ORGANIZER has no parameters
Some checks failed
tests / ci (push) Has been cancelled
2026-03-11 11:58:04 +01:00
Benjamin Nussbaum
5ca028b885 chore(#155): update om/icalparser; require PHP 8.4
Some checks are pending
tests / ci (push) Waiting to run
2026-03-11 06:46:14 +01:00
Benjamin Nussbaum
7864c8b7ab chore: update dependencies 2026-03-11 06:41:17 +01:00
christoph
433bda9639 add trmnl property to Balde renderContext to follow up liquid renderContex changes
Some checks failed
tests / ci (push) Has been cancelled
2026-03-08 19:33:05 +01:00
Benjamin Nussbaum
3abc67ff67 feat: rebranding to LaraPaper
Some checks are pending
tests / ci (push) Waiting to run
2026-03-08 17:55:15 +01:00
Benjamin Nussbaum
9df538de16 feat: add logo 2026-03-08 17:40:45 +01:00
Benjamin Nussbaum
ba541f62f1 feat(dev): seed default playlist with device 2026-03-08 17:40:36 +01:00
Benjamin Nussbaum
26b5f3ceb1 feat(#194): refactor cache to be device specific 2026-03-08 13:16:48 +01:00
Benjamin Nussbaum
c194ab5db1 feat(dev): seed default playlist with device 2026-03-08 12:57:11 +01:00
Benjamin Nussbaum
d246ac2c59 chore(docker): add instructions on how to generate an APP_KEY 2026-03-08 11:53:04 +01:00
Benjamin Nussbaum
d96fb297bc chore: update dependencies 2026-03-08 11:53:04 +01:00
Benjamin Nussbaum
d17fa1eedb chore: update npm packages
Some checks failed
tests / ci (push) Has been cancelled
2026-02-26 08:19:30 +01:00
Benjamin Nussbaum
00d242dac1 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2026-02-24 18:10:04 +01:00
Benjamin Nussbaum
b8b6caba12 fix: enable backwards compatibility v1 rendering strategy
Some checks failed
tests / ci (push) Has been cancelled
2026-02-22 14:31:30 +01:00
Benjamin Nussbaum
5eb442d9d6 chore: phpstan fixes
Some checks failed
tests / ci (push) Has been cancelled
2026-02-20 20:16:40 +01:00
Benjamin Nussbaum
c7abc14e26 chore: generate screens for new models 2026-02-20 20:00:57 +01:00
Benjamin Nussbaum
0797f17ebb feat: inject device dimensions into framework 2026-02-20 20:00:23 +01:00
Benjamin Nussbaum
64e2b6cdbd chore(docker): update base image
Some checks are pending
tests / ci (push) Waiting to run
* #179: adds chinese fonts
* update dependencies
  * PHP 8.4.18
  * Chromium 144.0.7559.132
  * ImageMagick 7.1.2-13
2026-02-20 14:50:49 +01:00
Benjamin Nussbaum
f23fbf3a3d chore(#179): move chinese fonts into base image
* see: 3d68bab9a9
2026-02-20 12:18:28 +01:00
Benjamin Nussbaum
83b9ca89ee chore: update composer & npm dependencies
* update to puppeteer: v24.37.0
2026-02-20 12:11:13 +01:00
Benjamin Nussbaum
84c340be4b
docs: README edit env variable descriptions
Some checks are pending
tests / ci (push) Waiting to run
Updated descriptions for FORCE_HTTPS and APP_TIMEZONE variables, and added experimental environment variables section.
2026-02-19 12:57:28 +01:00
Benjamin Nussbaum
1be02bb510 feat(#149): add UIfor css_variables to DeviceModel
Some checks are pending
tests / ci (push) Waiting to run
2026-02-18 13:14:19 +01:00
Benjamin Nussbaum
cc2cb070da feat: set PUPPETEER_WINDOW_SIZE_STRATEGY=v2 as default
Some checks are pending
tests / ci (push) Waiting to run
2026-02-17 22:30:00 +01:00
Benjamin Nussbaum
d884ac0a58 feat(#149): add css_name and css_variables to DeviceModel and update related views 2026-02-17 22:30:00 +01:00
Benjamin Nussbaum
89a2edfcbb feat: show version number on welcome page 2026-02-17 22:30:00 +01:00
Benjamin Nussbaum
d83a4095cb chore: update dependencies 2026-02-17 22:29:59 +01:00
dowjames
3419085325 Update Dockerfile to install extra fonts
My solution to:
https://github.com/usetrmnl/byos_laravel/issues/179
2026-02-17 21:59:22 +01:00
Benjamin Nussbaum
e595a37a4d fix(#190): mashups rendering blank
Some checks failed
tests / ci (push) Has been cancelled
2026-02-15 13:48:35 +01:00
Gabriele Lauricella
fbc3b1196f feat: web mirror client - add fullscreen and lock screen on
Some checks failed
tests / ci (push) Has been cancelled
2026-02-14 02:05:48 +01:00
Benjamin Nussbaum
226afa2ed3 feat(pipeline): enhance color palette support
Some checks failed
tests / ci (push) Has been cancelled
2026-02-12 22:15:34 +01:00
Benjamin Nussbaum
cb46636452 fix: remove duplicate layout in zen recipe 2026-02-12 22:02:16 +01:00
Benjamin Nussbaum
8400c1eaf1 feat(#188): support battery-percent header 2026-02-12 21:48:03 +01:00
Benjamin Nussbaum
d9ab052cbc chore: update dependencies 2026-02-12 18:06:54 +01:00
Benjamin Nussbaum
4e345c493d feat: added UI for configuration template in recipe settings
Some checks are pending
tests / ci (push) Waiting to run
2026-02-12 12:46:16 +01:00
Benjamin Nussbaum
49222838c4 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2026-02-11 21:53:26 +01:00
Benjamin Nussbaum
b267923595 feat: bump to Design Framework v2.3.0
Some checks failed
tests / ci (push) Has been cancelled
2026-02-09 13:24:04 +01:00
Jamie Shiell
9c5b5b33f5 Use root element name for root of array rather than "rss" 2026-02-09 13:20:54 +01:00
Jamie Shiell
afc29e15d5 Strip namespaces from namespaced XML plugin response, so we get usuable output 2026-02-09 13:20:54 +01:00
Jamie Shiell
0aa38428f6 Correctly set content type when specified in pollin headers
Some checks are pending
tests / ci (push) Waiting to run
2026-02-09 12:53:56 +01:00
Gabriele Lauricella
344286a5d3 chore: remove mac_address param web mirror trmnl client 2026-02-09 12:51:06 +01:00
Benjamin Nussbaum
b96a96155d chore: format fixes
Some checks failed
tests / ci (push) Has been cancelled
2026-02-07 14:44:20 +01:00
Benjamin Nussbaum
a37a9cfe96 fix: shared template context injection 2026-02-07 14:44:20 +01:00
Benjamin Nussbaum
35ca55a90b feat(#169): add mirroring section to device configuration
Some checks are pending
tests / ci (push) Waiting to run
2026-02-07 00:02:48 +01:00
Benjamin Nussbaum
e3ac975321 chore: revert bnussbau/laravel-trmnl-blade upgrade 2026-02-06 23:48:19 +01:00
Benjamin Nussbaum
8beeff754f feat: add update functionality for device palettes in UI 2026-02-06 23:42:14 +01:00
Benjamin Nussbaum
e71d79190a feat: add update functionality for device models in UI 2026-02-06 23:39:29 +01:00
Benjamin Nussbaum
06e6fb0e84 feat: add support for trmnl-liquid renderer in recipe settings 2026-02-06 23:24:07 +01:00
Benjamin Nussbaum
d586ecb1f2 chore: update dependencies 2026-02-06 22:55:08 +01:00
Benjamin Nussbaum
7ebfa586c1 feat: support additional markup layouts 2026-02-06 22:55:08 +01:00
Benjamin Nussbaum
a57feabe95 chore: bump trmnl-liquid-cli to 0.2.0 2026-02-06 22:09:03 +01:00
Benjamin Nussbaum
98c4d9f1bf docs: add trusted proxies 2026-02-06 18:02:44 +01:00
Benjamin Nussbaum
06e684227e chore: bump laravel-trmnl-blade to 2.2.1
Some checks failed
tests / ci (push) Has been cancelled
2026-02-05 19:17:51 +01:00
Benjamin Nussbaum
0f61861c5e chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2026-02-05 16:38:14 +01:00
Benjamin Nussbaum
2b919a193d fix(#176): inject device variables into recipes
Some checks failed
tests / ci (push) Has been cancelled
2026-02-03 22:21:35 +01:00
Benjamin Nussbaum
7adbcc104e chore: update maximum compatibility wording 2026-02-03 22:04:09 +01:00
Benjamin Nussbaum
bb0f2a4555 chore: update dependencies 2026-02-03 21:40:26 +01:00
Benjamin Nussbaum
1afd8935af
Revise download and star statistics in README
Some checks failed
tests / ci (push) Has been cancelled
Updated download and star counts for TRMNL BYOS Laravel.
2026-02-01 14:26:56 +01:00
Jamie Shiell
9578251238 Add maximum_compatibility boolean to devices to address redraw issues with certain hardware (#178) 2026-02-01 14:20:50 +01:00
Benjamin Nussbaum
bcfc62c782 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2026-01-31 17:10:38 +01:00
Benjamin Nussbaum
82d53ae304 fix: recipes
Some checks failed
tests / ci (push) Has been cancelled
2026-01-29 07:58:47 +01:00
Benjamin Nussbaum
3a5becc951 chore: add file to .gitignore
Some checks are pending
tests / ci (push) Waiting to run
2026-01-28 20:27:09 +01:00
Benjamin Nussbaum
c59f3deed9 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2026-01-28 12:23:19 +01:00
Benjamin Nussbaum
1e43aded77 chore: update trmnl base url 2026-01-28 12:10:29 +01:00
105 changed files with 4098 additions and 1524 deletions

2
.github/FUNDING.yml vendored
View file

@ -1 +1 @@
custom: ["https://usetrmnl.com/?ref=laravel-trmnl"] custom: ["https://trmnl.com/?ref=laravel-trmnl"]

View file

@ -6,7 +6,6 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build-and-push: build-and-push:
@ -40,7 +39,9 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: |
${{ env.REGISTRY }}/usetrmnl/byos_laravel
${{ env.REGISTRY }}/usetrmnl/larapaper
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}

5
.gitignore vendored
View file

@ -37,3 +37,8 @@ yarn-error.log
/.claude /.claude
/AGENTS.md /AGENTS.md
/opencode.json /opencode.json
/.cursor
/.opencode
/build.sh
/.junie
/.agents

View file

@ -1,10 +1,10 @@
######################## ########################
# Base Image # Base Image
######################## ########################
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:ed705a4060d50143ddc538c1288afff217eaf76ad5791f7556a97943854cf745 AS base
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel LABEL org.opencontainers.image.source=https://github.com/usetrmnl/larapaper
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel" LABEL org.opencontainers.image.description="LaraPaper"
LABEL org.opencontainers.image.licenses=MIT LABEL org.opencontainers.image.licenses=MIT
ARG APP_VERSION ARG APP_VERSION
@ -18,7 +18,7 @@ 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/ COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.2.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

View file

@ -1,9 +1,9 @@
## TRMNL BYOS (PHP/Laravel) ## LaraPaper (PHP/Laravel)
[![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/larapaper/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/larapaper/actions/workflows/test.yml)
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel. LaraPaper is a self-hostable implementation of a TRMNL server (BYOS), built with Laravel.
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, its the most popular community-driven BYOS. It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (130+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 700+ from the [TRMNL catalog](https://trmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 50k downloads and 200+ 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)
@ -15,9 +15,9 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
* 📡 Device Information Display battery status, WiFi strength, firmware version, and more. * 📡 Device Information Display battery status, WiFi strength, firmware version, and more.
* 🔍 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://trmnl.com/framework)
* 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) * Import from the [TRMNL community recipe catalog](https://trmnl.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
@ -26,7 +26,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
* 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)) * 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/larapaper/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))
* Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android) * Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
@ -43,7 +43,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
### Support ❤️ ### Support ❤️
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau). This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
Support the development of this package by purchasing a TRMNL device through the referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase. Support the development of this package by purchasing a TRMNL device through the referral link: https://trmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
or or
@ -61,7 +61,7 @@ Docker Compose file located at: [docker/prod/docker-compose.yml](docker/prod/doc
##### Backup Database ##### Backup Database
```sh ```sh
docker ps #find container id of byos_laravel container docker ps #find container id of larapaper container
docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite
``` ```
@ -73,11 +73,11 @@ docker compose up -d
``` ```
#### VPS #### VPS
If youre using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel). If youre using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy LaraPaper using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
Its a quick way to get started without having to manually manage Docker setup. Its a quick way to get started without having to manually manage Docker setup.
#### PikaPods #### PikaPods
You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel) You can vote for LaraPaper to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
#### Umbrel #### Umbrel
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store). Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
@ -121,10 +121,16 @@ php artisan db:seed --class=ExampleRecipesSeeder
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 | | `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 | | `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` | | `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 | | `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. Alternative: `TRUSTED_PROXIES`. | 0 |
| `TRUSTED_PROXIES` | If your server handles SSL termination, allow mixed mode. e.g. `"172.0.0.0/8"` or `*` | null |
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 | | `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 | | `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC | | `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions. UTC is recommended. | UTC |
##### Experimental Environment Variables
| Environment Variable | Description | Default |
|----------------------------------|--------------------------------------------------------------------------------|---------|
| `PUPPETEER_WINDOW_SIZE_STRATEGY` | Set to `v2` to size the browser window to match the devices screen dimensions | `null` |
#### Login #### Login
@ -167,13 +173,13 @@ See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://ww
### ☁️ Activate fresh TRMNL Device with Cloud Proxy ### ☁️ Activate fresh TRMNL Device with Cloud Proxy
1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works) 1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works)
2) Setup Laravel BYOS, create a user and login 2) Setup LaraPaper, create a user and login
3) In Laravel BYOS in the header bar, activate the toggle "Permit Auto-Join" 3) In LaraPaper in the header bar, activate the toggle "Permit Auto-Join"
4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash). 4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash).
5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of your Laravel BYOS 5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of LaraPaper.
6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again. 6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again.
7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.) 7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.)
8) As long as no Laravel BYOS plugin is scheduled, the device will show your cloud plugins. 8) As long as no LaraPaper plugin is scheduled, the device will show your cloud plugins.
###### Troubleshooting ###### Troubleshooting

View file

@ -184,7 +184,7 @@ class GenerateDefaultImagesCommand extends Command
}; };
// Determine device properties from DeviceModel // Determine device properties from DeviceModel
$deviceVariant = $deviceModel->name ?? 'og'; $deviceVariant = $deviceModel->css_name ?? $deviceModel->name ?? 'og';
$colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method $colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
$scaleLevel = $deviceModel->scale_level; // Use the accessor method $scaleLevel = $deviceModel->scale_level; // Use the accessor method
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
@ -196,6 +196,7 @@ class GenerateDefaultImagesCommand extends Command
'deviceVariant' => $deviceVariant, 'deviceVariant' => $deviceVariant,
'colorDepth' => $colorDepth, 'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel, 'scaleLevel' => $scaleLevel,
'cssVariables' => $deviceModel->css_variables,
])->render(); ])->render();
} }
} }

View file

@ -156,7 +156,7 @@ class CheckVersionUpdateJob
private function extractLatestVersion(array $response, bool $enablePrereleases): array private function extractLatestVersion(array $response, bool $enablePrereleases): array
{ {
if (! $enablePrereleases || ! is_array($response) || ! isset($response[0])) { if (! $enablePrereleases || ! isset($response[0])) {
return [ return [
Arr::get($response, 'tag_name'), Arr::get($response, 'tag_name'),
$response, $response,

View file

@ -12,14 +12,16 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class FetchDeviceModelsJob implements ShouldQueue final class FetchDeviceModelsJob implements ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private const API_URL = 'https://usetrmnl.com/api/models'; private const API_URL = '/api/models';
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes'; private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
@ -39,7 +41,7 @@ final class FetchDeviceModelsJob implements ShouldQueue
try { try {
$this->processPalettes(); $this->processPalettes();
$response = Http::timeout(30)->get(self::API_URL); $response = Http::timeout(30)->get(config('services.trmnl.base_url').self::API_URL);
if (! $response->successful()) { if (! $response->successful()) {
Log::error('Failed to fetch device models from API', [ Log::error('Failed to fetch device models from API', [
@ -209,12 +211,41 @@ final class FetchDeviceModelsJob implements ShouldQueue
$attributes['palette_id'] = $firstPaletteId; $attributes['palette_id'] = $firstPaletteId;
} }
$attributes['css_name'] = $this->parseCssNameFromApi($modelData['css'] ?? null);
$attributes['css_variables'] = $this->parseCssVariablesFromApi($modelData['css'] ?? null);
DeviceModel::updateOrCreate( DeviceModel::updateOrCreate(
['name' => $name], ['name' => $name],
$attributes $attributes
); );
} }
/**
* Extract css_name from API css payload (strip "screen--" prefix from classes.device).
*/
private function parseCssNameFromApi(mixed $css): ?string
{
$deviceClass = is_array($css) ? Arr::get($css, 'classes.device') : null;
return (is_string($deviceClass) ? Str::after($deviceClass, 'screen--') : null) ?: null;
}
/**
* Extract css_variables from API css payload (convert [[key, value], ...] to associative array).
*/
private function parseCssVariablesFromApi(mixed $css): ?array
{
$pairs = is_array($css) ? Arr::get($css, 'variables', []) : [];
if (! is_array($pairs)) {
return null;
}
$validPairs = Arr::where($pairs, fn (mixed $pair): bool => is_array($pair) && isset($pair[0], $pair[1]));
$variables = Arr::pluck($validPairs, 1, 0);
return $variables !== [] ? $variables : null;
}
/** /**
* Get the first palette ID from model data. * Get the first palette ID from model data.
*/ */

View file

@ -22,7 +22,9 @@ class FirmwarePollJob implements ShouldQueue
public function handle(): void public function handle(): void
{ {
try { try {
$response = Http::get('https://usetrmnl.com/api/firmware/latest')->json(); $firmwareEndpoint = config('services.trmnl.base_url').'/api/firmware/latest';
$response = Http::get($firmwareEndpoint)->json();
if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) { if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) {
Log::error('Invalid firmware response format received'); Log::error('Invalid firmware response format received');

View file

@ -34,8 +34,13 @@ class GenerateScreenJob implements ShouldQueue
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]); Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
if ($this->pluginId) { if ($this->pluginId) {
// cache current image $plugin = Plugin::find($this->pluginId);
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]); $update = ['current_image' => $newImageUuid];
if ($plugin->plugin_type === 'recipe') {
$device = Device::with(['deviceModel', 'deviceModel.palette'])->find($this->deviceId);
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDevice($device);
}
$plugin->update($update);
} }
ImageGenerationService::cleanupFolder(); ImageGenerationService::cleanupFolder();

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Liquid\Tags;
use Keepsuit\Liquid\Render\RenderContext;
use Keepsuit\Liquid\Support\MissingValue;
use Keepsuit\Liquid\Tags\RenderTag;
/**
* Render tag that injects plugin context (trmnl, size, data, config) into partials
* so shared templates can use variables like trmnl.user.name without passing them explicitly.
*/
class PluginRenderTag extends RenderTag
{
/**
* Root-level keys from the plugin render context that should be available in partials.
*
* @var list<string>
*/
private const PARENT_CONTEXT_KEYS = ['trmnl', 'size', 'data', 'config'];
protected function buildPartialContext(RenderContext $rootContext, string $templateName, array $variables = []): RenderContext
{
$partialContext = $rootContext->newIsolatedSubContext($templateName);
foreach (self::PARENT_CONTEXT_KEYS as $key) {
$value = $rootContext->get($key);
if ($value !== null && ! $value instanceof MissingValue) {
$partialContext->set($key, $value);
}
}
foreach ($variables as $key => $value) {
$partialContext->set($key, $value);
}
foreach ($this->attributes as $key => $value) {
$partialContext->set($key, $rootContext->evaluate($value));
}
return $partialContext;
}
}

View file

@ -42,6 +42,7 @@ class Device extends Model
'sleep_mode_to' => 'datetime:H:i', 'sleep_mode_to' => 'datetime:H:i',
'special_function' => 'string', 'special_function' => 'string',
'pause_until' => 'datetime', 'pause_until' => 'datetime',
'maximum_compatibility' => 'boolean',
]; ];
public function getBatteryPercentAttribute(): int|float public function getBatteryPercentAttribute(): int|float

View file

@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
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; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* @property-read array<string, string> $css_variables
* @property-read string|null $css_name
* @property-read DevicePalette|null $palette * @property-read DevicePalette|null $palette
*/ */
final class DeviceModel extends Model final class DeviceModel extends Model
@ -27,6 +30,7 @@ final class DeviceModel extends Model
'offset_x' => 'integer', 'offset_x' => 'integer',
'offset_y' => 'integer', 'offset_y' => 'integer',
'published_at' => 'datetime', 'published_at' => 'datetime',
'css_variables' => 'array',
]; ];
public function getColorDepthAttribute(): ?string public function getColorDepthAttribute(): ?string
@ -71,8 +75,54 @@ final class DeviceModel extends Model
return null; return null;
} }
/**
* Returns css_name for v2 (per-device sizing); for v1 returns 'og' to preserve legacy single-variant behaviour.
*
* @return Attribute<string|null, string|null>
*/
protected function cssName(): Attribute
{
/** @var Attribute<string|null, string|null> */
return Attribute::get(
fn (mixed $value): ?string => config('app.puppeteer_window_size_strategy') === 'v2' ? ($value !== null ? (string) $value : null) : 'og'
);
}
public function palette(): BelongsTo public function palette(): BelongsTo
{ {
return $this->belongsTo(DevicePalette::class, 'palette_id'); return $this->belongsTo(DevicePalette::class, 'palette_id');
} }
/**
* Returns css_variables with --screen-w and --screen-h filled from width/height
* when puppeteer_window_size_strategy is v2 and they are not set.
*
* @return Attribute<array<string, string>, array<string, string>>
*/
protected function cssVariables(): Attribute
{
/** @var Attribute<array<string, string>, array<string, string>> */
return Attribute::get(
/** @return array<string, string> */
function (mixed $value, array $attributes): array {
$vars = is_array($value) ? $value : (is_string($value) ? (json_decode($value, true) ?? []) : []);
if (config('app.puppeteer_window_size_strategy') !== 'v2') {
return $vars;
}
$width = $attributes['width'] ?? null;
$height = $attributes['height'] ?? null;
if (empty($vars['--screen-w']) && $width !== null && $width !== '') {
$vars['--screen-w'] = is_numeric($width) ? (int) $width.'px' : (string) $width;
}
if (empty($vars['--screen-h']) && $height !== null && $height !== '') {
$vars['--screen-h'] = is_numeric($height) ? (int) $height.'px' : (string) $height;
}
/** @var array<string, string> $vars */
return $vars;
});
}
} }

View file

@ -140,10 +140,11 @@ class PlaylistItem extends Model
if (! $this->isMashup()) { if (! $this->isMashup()) {
return view('trmnl-layouts.single', [ return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $this->plugin instanceof Plugin 'slot' => $this->plugin instanceof Plugin
? $this->plugin->render('full', false) ? $this->plugin->render('full', false, $device)
: throw new Exception('Invalid plugin instance'), : throw new Exception('Invalid plugin instance'),
])->render(); ])->render();
} }
@ -157,13 +158,14 @@ class PlaylistItem extends Model
foreach ($plugins as $index => $plugin) { foreach ($plugins as $index => $plugin) {
$size = $this->getLayoutSize($index); $size = $this->getLayoutSize($index);
$pluginMarkups[] = $plugin->render($size, false); $pluginMarkups[] = $plugin->render($size, false, $device);
} }
return view('trmnl-layouts.mashup', [ return view('trmnl-layouts.mashup', [
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'mashupLayout' => $this->getMashupLayoutType(), 'mashupLayout' => $this->getMashupLayoutType(),
'slot' => implode('', $pluginMarkups), 'slot' => implode('', $pluginMarkups),
])->render(); ])->render();

View file

@ -10,6 +10,7 @@ use App\Liquid\Filters\Numbers;
use App\Liquid\Filters\StandardFilters; 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\PluginRenderTag;
use App\Liquid\Tags\TemplateTag; use App\Liquid\Tags\TemplateTag;
use App\Services\Plugin\Parsers\ResponseParserRegistry; use App\Services\Plugin\Parsers\ResponseParserRegistry;
use App\Services\PluginImportService; use App\Services\PluginImportService;
@ -28,6 +29,7 @@ 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 Symfony\Component\Yaml\Yaml;
class Plugin extends Model class Plugin extends Model
{ {
@ -47,6 +49,7 @@ class Plugin extends Model
'preferred_renderer' => 'string', 'preferred_renderer' => 'string',
'plugin_type' => 'string', 'plugin_type' => 'string',
'alias' => 'boolean', 'alias' => 'boolean',
'current_image_metadata' => 'array',
]; ];
protected static function boot() protected static function boot()
@ -60,9 +63,16 @@ class Plugin extends Model
}); });
static::updating(function ($model): void { static::updating(function ($model): void {
// Reset image cache when markup changes // Reset image cache when any markup changes
if ($model->isDirty('render_markup')) { if ($model->isDirty([
'render_markup',
'render_markup_half_horizontal',
'render_markup_half_vertical',
'render_markup_quadrant',
'render_markup_shared',
])) {
$model->current_image = null; $model->current_image = null;
$model->current_image_metadata = null;
} }
}); });
@ -72,11 +82,68 @@ class Plugin extends Model
}); });
} }
public const CUSTOM_FIELDS_KEY = 'custom_fields';
public function user() public function user()
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
/**
* YAML for the custom_fields editor
*/
public function getCustomFieldsEditorYaml(): string
{
$template = $this->configuration_template;
$list = $template[self::CUSTOM_FIELDS_KEY] ?? null;
if ($list === null || $list === []) {
return '';
}
return Yaml::dump($list, 4, 2);
}
/**
* Parse editor YAML and return configuration_template for DB (custom_fields key). Returns null when empty.
*/
public static function configurationTemplateFromCustomFieldsYaml(string $yaml, ?array $existingTemplate): ?array
{
$list = $yaml !== '' ? Yaml::parse($yaml) : [];
if ($list === null || (is_array($list) && $list === [])) {
return null;
}
$template = $existingTemplate ?? [];
$template[self::CUSTOM_FIELDS_KEY] = is_array($list) ? $list : [];
return $template;
}
/**
* Validate that each custom field entry has field_type and name. For use with parsed editor YAML.
*
* @param array<int, array<string, mixed>> $list
*
* @throws \Illuminate\Validation\ValidationException
*/
public static function validateCustomFieldsList(array $list): void
{
$validator = \Illuminate\Support\Facades\Validator::make(
['custom_fields' => $list],
[
'custom_fields' => ['required', 'array'],
'custom_fields.*.field_type' => ['required', 'string'],
'custom_fields.*.name' => ['required', 'string'],
],
[
'custom_fields.*.field_type.required' => 'Each custom field must have a field_type.',
'custom_fields.*.name.required' => 'Each custom field must have a name.',
]
);
$validator->validate();
}
// sanitize configuration template descriptions and help texts (since they allow HTML rendering) // sanitize configuration template descriptions and help texts (since they allow HTML rendering)
protected function sanitizeTemplate(): void protected function sanitizeTemplate(): void
{ {
@ -157,7 +224,7 @@ class Plugin extends Model
if ($this->data_strategy !== 'polling' || ! $this->polling_url) { if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
return; return;
} }
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; $headers = ['User-Agent' => 'usetrmnl/larapaper', 'Accept' => 'application/json'];
// resolve headers // resolve headers
if ($this->polling_header) { if ($this->polling_header) {
@ -185,8 +252,12 @@ class Plugin extends Model
$httpRequest = Http::withHeaders($headers); $httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) { if ($this->polling_verb === 'post' && $this->polling_body) {
$contentType = (array_key_exists('Content-Type', $headers))
? $headers['Content-Type']
: 'application/json';
$resolvedBody = $this->resolveLiquidVariables($this->polling_body); $resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody); $httpRequest = $httpRequest->withBody($resolvedBody, $contentType);
} }
try { try {
@ -421,7 +492,9 @@ class Plugin extends Model
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.'); throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
} }
if ($this->render_markup) { $markup = $this->getMarkupForSize($size);
if ($markup) {
$renderedContent = ''; $renderedContent = '';
if ($this->markup_language === 'liquid') { if ($this->markup_language === 'liquid') {
@ -447,6 +520,13 @@ class Plugin extends Model
'locale' => 'en', 'locale' => 'en',
'time_zone_iana' => $timezone, 'time_zone_iana' => $timezone,
], ],
'device' => [
'friendly_id' => $device?->friendly_id,
'percent_charged' => $device?->battery_percent,
'wifi_strength' => $device?->wifi_strength,
'height' => $device?->height,
'width' => $device?->width,
],
'plugin_settings' => [ 'plugin_settings' => [
'instance_name' => $this->name, 'instance_name' => $this->name,
'strategy' => $this->data_strategy, 'strategy' => $this->data_strategy,
@ -464,7 +544,7 @@ class Plugin extends Model
// Check if external renderer should be used // Check if external renderer should be used
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) { if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
// Use external Ruby renderer - pass raw template without preprocessing // Use external Ruby renderer - pass raw template without preprocessing
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context); $renderedContent = $this->renderWithExternalLiquidRenderer($markup, $context);
} else { } else {
// Use PHP keepsuit/liquid renderer // Use PHP keepsuit/liquid renderer
// Create a custom environment with inline templates support // Create a custom environment with inline templates support
@ -484,19 +564,56 @@ class Plugin extends Model
// Register the template tag for inline templates // Register the template tag for inline templates
$environment->tagRegistry->register(TemplateTag::class); $environment->tagRegistry->register(TemplateTag::class);
// Use plugin render tag so partials receive trmnl, size, data, config
$environment->tagRegistry->register(PluginRenderTag::class);
// Apply Liquid replacements (including 'with' syntax conversion) // Apply Liquid replacements (including 'with' syntax conversion)
$processedMarkup = $this->applyLiquidReplacements($this->render_markup); $processedMarkup = $this->applyLiquidReplacements($markup);
$template = $environment->parseString($processedMarkup); $template = $environment->parseString($processedMarkup);
$liquidContext = $environment->newRenderContext(data: $context); $liquidContext = $environment->newRenderContext(data: $context);
$renderedContent = $template->render($liquidContext); $renderedContent = $template->render($liquidContext);
} }
} else { } else {
$renderedContent = Blade::render($this->render_markup, [ // Get timezone from user or fall back to app timezone
$timezone = $this->user->timezone ?? config('app.timezone');
// Calculate UTC offset in seconds
$utcOffset = (string) Carbon::now($timezone)->getOffset();
$renderedContent = Blade::render($markup, [
'size' => $size, 'size' => $size,
'data' => $this->data_payload, 'data' => $this->data_payload,
'config' => $this->configuration ?? [], 'config' => $this->configuration ?? [],
'trmnl' => [
'system' => [
'timestamp_utc' => now()->utc()->timestamp,
],
'user' => [
'utc_offset' => $utcOffset,
'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en',
'time_zone_iana' => $timezone,
],
'device' => [
'friendly_id' => $device?->friendly_id,
'percent_charged' => $device?->battery_percent,
'wifi_strength' => $device?->wifi_strength,
'height' => $device?->height,
'width' => $device?->width,
],
'plugin_settings' => [
'instance_name' => $this->name,
'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 : []),
],
],
],
]); ]);
} }
@ -504,10 +621,11 @@ class Plugin extends Model
if ($size === 'full') { if ($size === 'full') {
return view('trmnl-layouts.single', [ return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed, 'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode, 'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedContent, 'slot' => $renderedContent,
])->render(); ])->render();
} }
@ -515,9 +633,10 @@ class Plugin extends Model
return view('trmnl-layouts.mashup', [ return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode, 'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedContent, 'slot' => $renderedContent,
])->render(); ])->render();
@ -537,10 +656,11 @@ class Plugin extends Model
if ($size === 'full') { if ($size === 'full') {
return view('trmnl-layouts.single', [ return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed, 'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode, 'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedView, 'slot' => $renderedView,
])->render(); ])->render();
} }
@ -548,9 +668,10 @@ class Plugin extends Model
return view('trmnl-layouts.mashup', [ return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size), 'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode, 'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedView, 'slot' => $renderedView,
])->render(); ])->render();
} }
@ -574,6 +695,30 @@ class Plugin extends Model
return $this->configuration[$key] ?? $default; return $this->configuration[$key] ?? $default;
} }
/**
* Get the appropriate markup for a given size, including shared prepending logic
*
* @param string $size The layout size (full, half_horizontal, half_vertical, quadrant)
* @return string|null The markup code for the given size, with shared prepended if available
*/
public function getMarkupForSize(string $size): ?string
{
$markup = match ($size) {
'full' => $this->render_markup,
'half_horizontal' => $this->render_markup_half_horizontal ?? $this->render_markup,
'half_vertical' => $this->render_markup_half_vertical ?? $this->render_markup,
'quadrant' => $this->render_markup_quadrant ?? $this->render_markup,
default => $this->render_markup,
};
// Prepend shared markup if it exists
if ($markup && $this->render_markup_shared) {
$markup = $this->render_markup_shared."\n".$markup;
}
return $markup;
}
public function getPreviewMashupLayoutForSize(string $size): string public function getPreviewMashupLayoutForSize(string $size): string
{ {
return match ($size) { return match ($size) {
@ -630,8 +775,8 @@ class Plugin extends Model
} }
} }
// Append " (Copy)" to the name // Append "_copy" to the name
$attributes['name'] = $this->name.' (Copy)'; $attributes['name'] = $this->name.'_copy';
// Set user_id - use provided userId or fall back to original plugin's user_id // Set user_id - use provided userId or fall back to original plugin's user_id
$attributes['user_id'] = $userId ?? $this->user_id; $attributes['user_id'] = $userId ?? $this->user_id;

View file

@ -331,36 +331,88 @@ class ImageGenerationService
} }
} }
public static function resetIfNotCacheable(?Plugin $plugin): void /**
* Ensure plugin image cache is valid for the current context. No-op for image_webhook.
* When deviceOrModel is provided (recipe only), clears cache if stored metadata does not match.
*/
public static function resetIfNotCacheable(?Plugin $plugin, Device|DeviceModel|null $deviceOrModel = null): void
{ {
if ($plugin?->id) { if (! $plugin?->id || $plugin->plugin_type === 'image_webhook') {
// Image webhook plugins have finalized images that shouldn't be reset
if ($plugin->plugin_type === 'image_webhook') {
return; return;
} }
// Check if any devices have custom dimensions or use non-standard DeviceModels if ($deviceOrModel === null || $plugin->plugin_type !== 'recipe') {
$hasCustomDimensions = Device::query() return;
->where(function ($query): void { }
$query->where('width', '!=', 800) if ($plugin->current_image === null) {
->orWhere('height', '!=', 480) return;
->orWhere('rotate', '!=', 0); }
}) if (self::imageMetadataMatches($plugin->current_image_metadata, $deviceOrModel)) {
->orWhereHas('deviceModel', function ($query): void { return;
// Only allow caching if all device models have standard dimensions (800x480, rotation=0) }
$query->where(function ($subQuery): void { $plugin->update([
$subQuery->where('width', '!=', 800) 'current_image' => null,
->orWhere('height', '!=', 480) 'current_image_metadata' => null,
->orWhere('rotation', '!=', 0); ]);
}); Log::debug("Plugin {$plugin->id}: cleared image cache due to metadata mismatch");
}) }
->exists();
if ($hasCustomDimensions) { /**
// TODO cache image per device * Build canonical image metadata from a Device for cache comparison.
$plugin->update(['current_image' => null]); *
Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist'); * @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string}
*/
public static function buildImageMetadataFromDevice(Device $device): array
{
$device->loadMissing(['deviceModel', 'deviceModel.palette']);
$settings = self::getImageSettings($device);
$paletteId = $device->palette_id ?? $device->deviceModel?->palette_id;
return [
'width' => $settings['width'],
'height' => $settings['height'],
'rotation' => $settings['rotation'] ?? 0,
'palette_id' => $paletteId,
'mime_type' => $settings['mime_type'],
];
}
/**
* Build canonical image metadata from a DeviceModel for cache comparison.
*
* @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string}
*/
public static function buildImageMetadataFromDeviceModel(DeviceModel $model): array
{
return [
'width' => $model->width,
'height' => $model->height,
'rotation' => $model->rotation ?? 0,
'palette_id' => $model->palette_id,
'mime_type' => $model->mime_type,
];
}
/**
* Check if stored metadata matches the current device or device model.
* Returns false if stored is null or empty so cache is regenerated and metadata is stored.
*/
public static function imageMetadataMatches(?array $stored, Device|DeviceModel $deviceOrModel): bool
{
if ($stored === null || $stored === []) {
return false;
}
$current = $deviceOrModel instanceof Device
? self::buildImageMetadataFromDevice($deviceOrModel)
: self::buildImageMetadataFromDeviceModel($deviceOrModel);
foreach (['width', 'height', 'rotation', 'palette_id', 'mime_type'] as $key) {
if (($stored[$key] ?? null) !== ($current[$key] ?? null)) {
return false;
} }
} }
return true;
} }
/** /**
@ -514,7 +566,7 @@ class ImageGenerationService
}; };
// Determine device properties from DeviceModel or device settings // Determine device properties from DeviceModel or device settings
$deviceVariant = $device->deviceVariant(); $deviceVariant = $device->deviceModel?->css_name ?? $device->deviceVariant();
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape'; $deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
$colorDepth = $device->colorDepth() ?? '1bit'; $colorDepth = $device->colorDepth() ?? '1bit';
$scaleLevel = $device->scaleLevel(); $scaleLevel = $device->scaleLevel();
@ -528,6 +580,7 @@ class ImageGenerationService
'deviceOrientation' => $deviceOrientation, 'deviceOrientation' => $deviceOrientation,
'colorDepth' => $colorDepth, 'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel, 'scaleLevel' => $scaleLevel,
'cssVariables' => $device->deviceModel?->css_variables ?? [],
]; ];
// Add plugin name for error screens // Add plugin name for error screens

View file

@ -25,7 +25,12 @@ class IcalResponseParser implements ResponseParser
} }
try { try {
$this->parser->parseString($body); // Workaround for om/icalparser v4.0.0 bug where it fails if ORGANIZER or ATTENDEE has no parameters.
// When ORGANIZER or ATTENDEE has no parameters (no semicolon after the key),
// IcalParser::parseRow returns an empty string for $middle instead of an array,
// which causes a type error in a foreach loop in IcalParser::parseString.
$normalizedBody = preg_replace('/^(ORGANIZER|ATTENDEE):/m', '$1;CN=Unknown:', $body);
$this->parser->parseString($normalizedBody);
$events = $this->parser->getEvents()->sorted()->getArrayCopy(); $events = $this->parser->getEvents()->sorted()->getArrayCopy();
$windowStart = now()->subDays(7); $windowStart = now()->subDays(7);

View file

@ -18,12 +18,12 @@ class XmlResponseParser implements ResponseParser
} }
try { try {
$xml = simplexml_load_string($response->body()); $xml = $this->simplexml_load_string_strip_namespaces($response->body());
if ($xml === false) { if ($xml === false) {
throw new Exception('Invalid XML content'); throw new Exception('Invalid XML content');
} }
return ['rss' => $this->xmlToArray($xml)]; return [$xml->getName() => $this->xmlToArray($xml)];
} catch (Exception $exception) { } catch (Exception $exception) {
Log::warning('Failed to parse XML response: '.$exception->getMessage()); Log::warning('Failed to parse XML response: '.$exception->getMessage());
@ -43,4 +43,25 @@ class XmlResponseParser implements ResponseParser
return $array; return $array;
} }
function simplexml_load_string_strip_namespaces($xml_response) {
$xml = simplexml_load_string($xml_response);
if ($xml === false) {
return false;
}
$namespaces = array_keys($xml->getDocNamespaces(true));
$namespaces = array_filter($namespaces, function($name) { return !empty($name); });
if (count($namespaces) == 0) {
return $xml;
}
$namespaces = array_map(function($ns) { return "$ns:"; }, $namespaces);
$xml_no_namespaces = str_replace(
array_merge(["xmlns="], $namespaces),
array_merge(["ns="], array_fill(0, count($namespaces), '')),
$xml_response
);
return simplexml_load_string($xml_no_namespaces);
}
} }

View file

@ -51,17 +51,35 @@ class PluginExportService
$settings = $this->generateSettingsYaml($plugin); $settings = $this->generateSettingsYaml($plugin);
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); $settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
File::put($tempDir.'/settings.yml', $settingsYaml); File::put($tempDir.'/settings.yml', $settingsYaml);
// Generate full template content
$fullTemplate = $this->generateFullTemplate($plugin);
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php'; $extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
// Export full template if it exists
if ($plugin->render_markup) {
$fullTemplate = $this->generateLayoutTemplate($plugin->render_markup);
File::put($tempDir.'/full.'.$extension, $fullTemplate); File::put($tempDir.'/full.'.$extension, $fullTemplate);
// Generate shared.liquid if needed (for liquid templates)
if ($plugin->markup_language === 'liquid') {
$sharedTemplate = $this->generateSharedTemplate();
/** @phpstan-ignore-next-line */
if ($sharedTemplate) {
File::put($tempDir.'/shared.liquid', $sharedTemplate);
} }
// Export layout-specific templates if they exist
if ($plugin->render_markup_half_horizontal) {
$halfHorizontalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_horizontal);
File::put($tempDir.'/half_horizontal.'.$extension, $halfHorizontalTemplate);
}
if ($plugin->render_markup_half_vertical) {
$halfVerticalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_vertical);
File::put($tempDir.'/half_vertical.'.$extension, $halfVerticalTemplate);
}
if ($plugin->render_markup_quadrant) {
$quadrantTemplate = $this->generateLayoutTemplate($plugin->render_markup_quadrant);
File::put($tempDir.'/quadrant.'.$extension, $quadrantTemplate);
}
// Export shared template if it exists
if ($plugin->render_markup_shared) {
$sharedTemplate = $this->generateLayoutTemplate($plugin->render_markup_shared);
File::put($tempDir.'/shared.'.$extension, $sharedTemplate);
} }
// Create ZIP file // Create ZIP file
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip'; $zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
@ -124,29 +142,21 @@ class PluginExportService
} }
/** /**
* Generate the full template content * Generate template content from markup, removing wrapper divs if present
*/ */
private function generateFullTemplate(Plugin $plugin): string private function generateLayoutTemplate(?string $markup): string
{ {
$markup = $plugin->render_markup; if (! $markup) {
return '';
}
// Remove the wrapper div if it exists (it will be added during import) // Remove the wrapper div if it exists (it will be added during import for liquid)
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup); $markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup); $markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
return mb_trim($markup); return mb_trim($markup);
} }
/**
* Generate the shared template content (for liquid templates)
*/
private function generateSharedTemplate(): null
{
// For now, we don't have a way to store shared templates separately
// TODO - add support for shared templates
return null;
}
/** /**
* Add a directory and its contents to a ZIP file * Add a directory and its contents to a ZIP file
*/ */

View file

@ -93,37 +93,59 @@ class PluginImportService
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings); $this->validateYAML($settings);
// Determine which template file to use and read its content // Determine markup language from the first available file
$templatePath = null;
$markupLanguage = 'blade'; $markupLanguage = 'blade';
$firstTemplatePath = $filePaths['fullLiquidPath']
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
?? ($filePaths['quadrantLiquidPath'] ?? null)
?? ($filePaths['sharedLiquidPath'] ?? null)
?? ($filePaths['sharedBladePath'] ?? null);
if ($filePaths['fullLiquidPath']) { if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
$templatePath = $filePaths['fullLiquidPath']; $markupLanguage = 'liquid';
$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 // Read full markup (don't prepend shared - it will be prepended at render time)
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { $fullLiquid = null;
$markupLanguage = 'liquid'; if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
$fullLiquid = File::get($filePaths['fullLiquidPath']);
if ($markupLanguage === 'liquid') {
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} }
} elseif ($filePaths['sharedLiquidPath']) { }
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath); // Read shared markup separately
$markupLanguage = 'liquid'; $sharedMarkup = null;
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
} elseif ($filePaths['sharedBladePath']) { $sharedMarkup = File::get($filePaths['sharedLiquidPath']);
$templatePath = $filePaths['sharedBladePath']; } elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$fullLiquid = File::get($templatePath); $sharedMarkup = File::get($filePaths['sharedBladePath']);
$markupLanguage = 'blade'; }
// Read layout-specific markups
$halfHorizontalMarkup = null;
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
}
}
$halfVerticalMarkup = null;
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
}
}
$quadrantMarkup = null;
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
if ($markupLanguage === 'liquid') {
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
}
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -160,6 +182,10 @@ class PluginImportService
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid ?? null,
'render_markup_half_horizontal' => $halfHorizontalMarkup,
'render_markup_half_vertical' => $halfVerticalMarkup,
'render_markup_quadrant' => $quadrantMarkup,
'render_markup_shared' => $sharedMarkup,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
]); ]);
@ -246,37 +272,59 @@ class PluginImportService
$settings = Yaml::parse($settingsYaml); $settings = Yaml::parse($settingsYaml);
$this->validateYAML($settings); $this->validateYAML($settings);
// Determine which template file to use and read its content // Determine markup language from the first available file
$templatePath = null;
$markupLanguage = 'blade'; $markupLanguage = 'blade';
$firstTemplatePath = $filePaths['fullLiquidPath']
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
?? ($filePaths['quadrantLiquidPath'] ?? null)
?? ($filePaths['sharedLiquidPath'] ?? null)
?? ($filePaths['sharedBladePath'] ?? null);
if ($filePaths['fullLiquidPath']) { if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
$templatePath = $filePaths['fullLiquidPath']; $markupLanguage = 'liquid';
$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 // Read full markup (don't prepend shared - it will be prepended at render time)
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') { $fullLiquid = null;
$markupLanguage = 'liquid'; if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
$fullLiquid = File::get($filePaths['fullLiquidPath']);
if ($markupLanguage === 'liquid') {
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; $fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} }
} elseif ($filePaths['sharedLiquidPath']) { }
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath); // Read shared markup separately
$markupLanguage = 'liquid'; $sharedMarkup = null;
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>'; if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
} elseif ($filePaths['sharedBladePath']) { $sharedMarkup = File::get($filePaths['sharedLiquidPath']);
$templatePath = $filePaths['sharedBladePath']; } elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
$fullLiquid = File::get($templatePath); $sharedMarkup = File::get($filePaths['sharedBladePath']);
$markupLanguage = 'blade'; }
// Read layout-specific markups
$halfHorizontalMarkup = null;
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
}
}
$halfVerticalMarkup = null;
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
if ($markupLanguage === 'liquid') {
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
}
}
$quadrantMarkup = null;
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
if ($markupLanguage === 'liquid') {
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
}
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -322,6 +370,10 @@ class PluginImportService
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid ?? null,
'render_markup_half_horizontal' => $halfHorizontalMarkup,
'render_markup_half_vertical' => $halfVerticalMarkup,
'render_markup_quadrant' => $quadrantMarkup,
'render_markup_shared' => $sharedMarkup,
'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, 'preferred_renderer' => $preferredRenderer,
@ -357,6 +409,9 @@ class PluginImportService
$fullLiquidPath = null; $fullLiquidPath = null;
$sharedLiquidPath = null; $sharedLiquidPath = null;
$sharedBladePath = null; $sharedBladePath = null;
$halfHorizontalLiquidPath = null;
$halfVerticalLiquidPath = null;
$quadrantLiquidPath = 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) {
@ -377,6 +432,25 @@ class PluginImportService
} elseif (File::exists($targetDir.'/shared.blade.php')) { } elseif (File::exists($targetDir.'/shared.blade.php')) {
$sharedBladePath = $targetDir.'/shared.blade.php'; $sharedBladePath = $targetDir.'/shared.blade.php';
} }
// Check for layout-specific files
if (File::exists($targetDir.'/half_horizontal.liquid')) {
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.liquid';
} elseif (File::exists($targetDir.'/half_horizontal.blade.php')) {
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.blade.php';
}
if (File::exists($targetDir.'/half_vertical.liquid')) {
$halfVerticalLiquidPath = $targetDir.'/half_vertical.liquid';
} elseif (File::exists($targetDir.'/half_vertical.blade.php')) {
$halfVerticalLiquidPath = $targetDir.'/half_vertical.blade.php';
}
if (File::exists($targetDir.'/quadrant.liquid')) {
$quadrantLiquidPath = $targetDir.'/quadrant.liquid';
} elseif (File::exists($targetDir.'/quadrant.blade.php')) {
$quadrantLiquidPath = $targetDir.'/quadrant.blade.php';
}
} }
// Check if files are in src subdirectory of target directory // Check if files are in src subdirectory of target directory
@ -394,6 +468,25 @@ class PluginImportService
} elseif (File::exists($targetDir.'/src/shared.blade.php')) { } elseif (File::exists($targetDir.'/src/shared.blade.php')) {
$sharedBladePath = $targetDir.'/src/shared.blade.php'; $sharedBladePath = $targetDir.'/src/shared.blade.php';
} }
// Check for layout-specific files in src
if (File::exists($targetDir.'/src/half_horizontal.liquid')) {
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.liquid';
} elseif (File::exists($targetDir.'/src/half_horizontal.blade.php')) {
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.blade.php';
}
if (File::exists($targetDir.'/src/half_vertical.liquid')) {
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.liquid';
} elseif (File::exists($targetDir.'/src/half_vertical.blade.php')) {
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.blade.php';
}
if (File::exists($targetDir.'/src/quadrant.liquid')) {
$quadrantLiquidPath = $targetDir.'/src/quadrant.liquid';
} elseif (File::exists($targetDir.'/src/quadrant.blade.php')) {
$quadrantLiquidPath = $targetDir.'/src/quadrant.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
@ -425,6 +518,25 @@ class PluginImportService
} elseif (File::exists($tempDir.'/src/shared.blade.php')) { } elseif (File::exists($tempDir.'/src/shared.blade.php')) {
$sharedBladePath = $tempDir.'/src/shared.blade.php'; $sharedBladePath = $tempDir.'/src/shared.blade.php';
} }
// Check for layout-specific files
if (File::exists($tempDir.'/src/half_horizontal.liquid')) {
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.liquid';
} elseif (File::exists($tempDir.'/src/half_horizontal.blade.php')) {
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.blade.php';
}
if (File::exists($tempDir.'/src/half_vertical.liquid')) {
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.liquid';
} elseif (File::exists($tempDir.'/src/half_vertical.blade.php')) {
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.blade.php';
}
if (File::exists($tempDir.'/src/quadrant.liquid')) {
$quadrantLiquidPath = $tempDir.'/src/quadrant.liquid';
} elseif (File::exists($tempDir.'/src/quadrant.blade.php')) {
$quadrantLiquidPath = $tempDir.'/src/quadrant.blade.php';
}
} else { } else {
// Search for the files in the extracted directory structure // Search for the files in the extracted directory structure
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS); $directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
@ -442,6 +554,12 @@ class PluginImportService
$sharedLiquidPath = $filepath; $sharedLiquidPath = $filepath;
} elseif ($filename === 'shared.blade.php') { } elseif ($filename === 'shared.blade.php') {
$sharedBladePath = $filepath; $sharedBladePath = $filepath;
} elseif ($filename === 'half_horizontal.liquid' || $filename === 'half_horizontal.blade.php') {
$halfHorizontalLiquidPath = $filepath;
} elseif ($filename === 'half_vertical.liquid' || $filename === 'half_vertical.blade.php') {
$halfVerticalLiquidPath = $filepath;
} elseif ($filename === 'quadrant.liquid' || $filename === 'quadrant.blade.php') {
$quadrantLiquidPath = $filepath;
} }
} }
@ -485,6 +603,25 @@ class PluginImportService
$sharedBladePath = $newSrcDir.'/shared.blade.php'; $sharedBladePath = $newSrcDir.'/shared.blade.php';
} }
// Copy layout-specific files if they exist
if ($halfHorizontalLiquidPath) {
$extension = pathinfo((string) $halfHorizontalLiquidPath, PATHINFO_EXTENSION);
File::copy($halfHorizontalLiquidPath, $newSrcDir.'/half_horizontal.'.$extension);
$halfHorizontalLiquidPath = $newSrcDir.'/half_horizontal.'.$extension;
}
if ($halfVerticalLiquidPath) {
$extension = pathinfo((string) $halfVerticalLiquidPath, PATHINFO_EXTENSION);
File::copy($halfVerticalLiquidPath, $newSrcDir.'/half_vertical.'.$extension);
$halfVerticalLiquidPath = $newSrcDir.'/half_vertical.'.$extension;
}
if ($quadrantLiquidPath) {
$extension = pathinfo((string) $quadrantLiquidPath, PATHINFO_EXTENSION);
File::copy($quadrantLiquidPath, $newSrcDir.'/quadrant.'.$extension);
$quadrantLiquidPath = $newSrcDir.'/quadrant.'.$extension;
}
// Update the paths // Update the paths
$settingsYamlPath = $newSrcDir.'/settings.yml'; $settingsYamlPath = $newSrcDir.'/settings.yml';
} }
@ -496,6 +633,9 @@ class PluginImportService
'fullLiquidPath' => $fullLiquidPath, 'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath, 'sharedBladePath' => $sharedBladePath,
'halfHorizontalLiquidPath' => $halfHorizontalLiquidPath,
'halfVerticalLiquidPath' => $halfVerticalLiquidPath,
'quadrantLiquidPath' => $quadrantLiquidPath,
]; ];
} }

View file

@ -11,12 +11,12 @@
], ],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.4",
"ext-imagick": "*", "ext-imagick": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"ext-zip": "*", "ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "2.1.*", "bnussbau/laravel-trmnl-blade": "^2.3",
"bnussbau/trmnl-pipeline-php": "^0.6.0", "bnussbau/trmnl-pipeline-php": "^0.8",
"keepsuit/laravel-liquid": "^0.5.2", "keepsuit/laravel-liquid": "^0.5.2",
"laravel/fortify": "^1.30", "laravel/fortify": "^1.30",
"laravel/framework": "^12.1", "laravel/framework": "^12.1",
@ -25,7 +25,7 @@
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0", "livewire/flux": "^2.0",
"livewire/livewire": "^4.0", "livewire/livewire": "^4.0",
"om/icalparser": "^3.2", "om/icalparser": "^4.0",
"spatie/browsershot": "^5.0", "spatie/browsershot": "^5.0",
"spatie/laravel-settings": "^3.6", "spatie/laravel-settings": "^3.6",
"stevebauman/purify": "^6.3", "stevebauman/purify": "^6.3",
@ -35,7 +35,7 @@
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^3.0", "larastan/larastan": "^3.0",
"laravel/boost": "^1.0", "laravel/boost": "^2.0",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.18", "laravel/pint": "^1.18",
"laravel/sail": "^1.41", "laravel/sail": "^1.41",

1198
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ return [
| |
*/ */
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'LaraPaper'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -127,11 +127,13 @@ return [
'enabled' => env('REGISTRATION_ENABLED', true), 'enabled' => env('REGISTRATION_ENABLED', true),
], ],
'pixel_logo_enabled' => env('PIXELLOGO_ENABLED', true),
'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', true), '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', 'v2'),
'notifications' => [ 'notifications' => [
'battery_low' => [ 'battery_low' => [
@ -154,5 +156,5 @@ return [
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'), 'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
'github_repo' => env('GITHUB_REPO', 'usetrmnl/byos_laravel'), 'github_repo' => env('GITHUB_REPO', 'usetrmnl/larapaper'),
]; ];

View file

@ -36,6 +36,7 @@ return [
], ],
'trmnl' => [ 'trmnl' => [
'base_url' => 'https://trmnl.com',
'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'), 'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'),
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15), 'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'), 'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),

View file

@ -0,0 +1,38 @@
<?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->text('render_markup_half_horizontal')->nullable()->after('render_markup');
$table->text('render_markup_half_vertical')->nullable()->after('render_markup_half_horizontal');
$table->text('render_markup_quadrant')->nullable()->after('render_markup_half_vertical');
$table->text('render_markup_shared')->nullable()->after('render_markup_quadrant');
$table->text('transform_code')->nullable()->after('render_markup_shared');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn([
'render_markup_half_horizontal',
'render_markup_half_vertical',
'render_markup_quadrant',
'render_markup_shared',
'transform_code',
]);
});
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('devices', function (Blueprint $table): void {
$table->boolean('maximum_compatibility')->default(false);
});
}
public function down(): void
{
Schema::table('devices', function (Blueprint $table): void {
$table->dropColumn('maximum_compatibility');
});
}
};

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->string('css_name')->nullable()->after('kind');
$table->json('css_variables')->nullable()->after('css_name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->dropColumn(['css_name', 'css_variables']);
});
}
};

View file

@ -0,0 +1,160 @@
<?php
use App\Models\DeviceModel;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* CSS name and variables for device models created by seed_device_models (og_png until inky_impression_13_3).
*
* @var array<string, array{css_name: string, css_variables: array<string, string>}>
*/
private const SEEDED_CSS = [
'og_png' => [
'css_name' => 'og_png',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'og_plus' => [
'css_name' => 'ogv2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_2024' => [
'css_name' => 'amazon_kindle_2024',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '0.8',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_paperwhite_6th_gen' => [
'css_name' => 'amazon_kindle_paperwhite_6th_gen',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_paperwhite_7th_gen' => [
'css_name' => 'amazon_kindle_paperwhite_7th_gen',
'css_variables' => [
'--screen-w' => '905px',
'--screen-h' => '670px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inkplate_10' => [
'css_name' => 'inkplate_10',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '547px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_7' => [
'css_name' => 'amazon_kindle_7',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inky_impression_7_3' => [
'css_name' => 'inky_impression_7_3',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_libra_2' => [
'css_name' => 'kobo_libra_2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '602px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_oasis_2' => [
'css_name' => 'amazon_kindle_oasis_2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '602px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_aura_one' => [
'css_name' => 'kobo_aura_one',
'css_variables' => [
'--screen-w' => '1040px',
'--screen-h' => '780px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_aura_hd' => [
'css_name' => 'kobo_aura_hd',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inky_impression_13_3' => [
'css_name' => 'inky_impression_13_3',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach (self::SEEDED_CSS as $name => $payload) {
DeviceModel::query()
->where('name', $name)
->update([
'css_name' => $payload['css_name'],
'css_variables' => $payload['css_variables'],
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DeviceModel::query()
->whereIn('name', array_keys(self::SEEDED_CSS))
->update([
'css_name' => null,
'css_variables' => 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('plugins', function (Blueprint $table) {
$table->json('current_image_metadata')->nullable()->after('current_image');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn('current_image_metadata');
});
}
};

View file

@ -3,6 +3,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Device; use App\Models\Device;
use App\Models\Playlist;
use App\Models\Plugin; use App\Models\Plugin;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@ -23,9 +24,19 @@ class DatabaseSeeder extends Seeder
'password' => bcrypt('admin@example.com'), 'password' => bcrypt('admin@example.com'),
]); ]);
Device::factory(1)->create([ $device = Device::factory()->create([
'mac_address' => '00:00:00:00:00:00', 'mac_address' => '00:00:00:00:00:00',
'api_key' => 'test-api-key', 'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'Default',
'is_active' => true,
'active_from' => null,
'active_until' => null,
'weekdays' => null
]); ]);
// Device::factory(5)->create(); // Device::factory(5)->create();

View file

@ -1,9 +1,10 @@
services: services:
app: app:
image: ghcr.io/usetrmnl/byos_laravel:latest image: ghcr.io/usetrmnl/larapaper:latest
ports: ports:
- "4567:8080" - "4567:8080"
environment: environment:
# Generate the APP_KEY with `echo "base64:$(openssl rand -base64 32)"`
#- APP_KEY= #- APP_KEY=
- PHP_OPCACHE_ENABLE=1 - PHP_OPCACHE_ENABLE=1
- TRMNL_PROXY_REFRESH_MINUTES=15 - TRMNL_PROXY_REFRESH_MINUTES=15

View file

@ -9,7 +9,7 @@
#### Clone the repository #### Clone the repository
```bash ```bash
git clone git@github.com:usetrmnl/byos_laravel.git git clone git@github.com:usetrmnl/larapaper.git
``` ```
#### Copy environment file #### Copy environment file

1013
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,7 @@
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-liquid": "^6.3.0", "@codemirror/lang-liquid": "^6.3.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.3", "@codemirror/language": "^6.11.3",
"@codemirror/search": "^6.5.11", "@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2", "@codemirror/state": "^6.5.2",
@ -24,7 +25,7 @@
"codemirror": "^6.0.2", "codemirror": "^6.0.2",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0", "laravel-vite-plugin": "^2.0",
"puppeteer": "24.30.0", "puppeteer": "24.37.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"vite": "^7.0.4" "vite": "^7.0.4"
}, },

View file

@ -22,6 +22,7 @@
refreshTimer: null, refreshTimer: null,
renderedAt: 0, renderedAt: 0,
ui: {}, ui: {},
wakeLock: null,
showStatus: function (message) { showStatus: function (message) {
trmnl.ui.img.style.display = "none"; trmnl.ui.img.style.display = "none";
@ -39,8 +40,9 @@
var data = trmnl.getSettings(); var data = trmnl.getSettings();
trmnl.ui.apiKeyInput.value = data.api_key || ""; trmnl.ui.apiKeyInput.value = data.api_key || "";
trmnl.ui.baseURLInput.value = data.base_url || ""; trmnl.ui.baseURLInput.value = data.base_url || "";
trmnl.ui.macAddressInput.value = data.mac_address || "";
trmnl.ui.displayModeSelect.value = data.display_mode || ""; trmnl.ui.displayModeSelect.value = data.display_mode || "";
trmnl.ui.fullscreenToggle.checked = !!data.fullscreen;
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock || !!data.wake_lock;
trmnl.ui.setup.style.display = "flex"; trmnl.ui.setup.style.display = "flex";
}, },
@ -50,8 +52,9 @@
var apiKey = trmnl.ui.apiKeyInput.value; var apiKey = trmnl.ui.apiKeyInput.value;
var baseURL = trmnl.ui.baseURLInput.value; var baseURL = trmnl.ui.baseURLInput.value;
var macAddress = trmnl.ui.macAddressInput.value;
var displayMode = trmnl.ui.displayModeSelect.value; var displayMode = trmnl.ui.displayModeSelect.value;
var fullscreenEnabled = trmnl.ui.fullscreenToggle.checked;
var wakeLockEnabled = trmnl.ui.wakeLockToggle.checked;
if (!apiKey) { if (!apiKey) {
return; return;
@ -60,10 +63,26 @@
trmnl.saveSettings({ trmnl.saveSettings({
api_key: apiKey, api_key: apiKey,
base_url: baseURL, base_url: baseURL,
mac_address: macAddress, display_mode: displayMode,
display_mode: displayMode fullscreen: fullscreenEnabled,
wake_lock: wakeLockEnabled
}); });
if (wakeLockEnabled) {
trmnl.acquireWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
}).catch(function (err) {
console.warn("Wake Lock request failed:", err);
trmnl.ui.wakeLockToggle.checked = false;
});
} else {
trmnl.releaseWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = false;
}).catch(function (err) {
console.warn("Wake Lock release failed:", err);
});
}
trmnl.fetchDisplay(); trmnl.fetchDisplay();
}, },
@ -71,6 +90,144 @@
trmnl.ui.setup.style.display = "none"; trmnl.ui.setup.style.display = "none";
}, },
isFullscreenSupported: function () {
return !!(
document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.msFullscreenEnabled
);
},
isFullscreenActive: function () {
return !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement
);
},
enterFullscreen: function () {
if (!trmnl.isFullscreenSupported()) return;
var el = document.documentElement;
var promise;
if (el.requestFullscreen) {
promise = el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
promise = el.webkitRequestFullscreen();
} else if (el.msRequestFullscreen) {
promise = el.msRequestFullscreen();
}
if (promise && promise.catch) {
promise.catch(function (err) {
console.warn("Enter fullscreen failed:", err);
});
}
},
exitFullscreen: function () {
if (!trmnl.isFullscreenSupported()) return;
if (!trmnl.isFullscreenActive()) return;
var promise;
if (document.exitFullscreen) {
promise = document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
promise = document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
promise = document.msExitFullscreen();
}
if (promise && promise.catch) {
promise.catch(function (err) {
console.warn("Exit fullscreen failed:", err);
});
}
},
syncFullscreenToggle: function () {
var active = trmnl.isFullscreenActive();
trmnl.ui.fullscreenToggle.checked = active;
},
isWakeLockSupported: function () {
return (
window.isSecureContext &&
navigator.wakeLock &&
typeof navigator.wakeLock.request === "function"
);
},
acquireWakeLock: function () {
if (!trmnl.isWakeLockSupported()) {
return {
then: function () { return this; },
catch: function () { return this; }
};
}
if (trmnl.wakeLock) {
return Promise.resolve();
}
return navigator.wakeLock.request("screen")
.then(function (sentinel) {
trmnl.wakeLock = sentinel;
sentinel.addEventListener("release", function () {
trmnl.wakeLock = null;
trmnl.ui.wakeLockToggle.checked = false;
});
console.log("Wake Lock attivo");
})
.catch(function (err) {
console.warn("Wake Lock failed:", err);
trmnl.wakeLock = null;
trmnl.ui.wakeLockToggle.checked = false;
});
},
releaseWakeLock: function () {
if (!trmnl.wakeLock) {
return Promise.resolve();
}
return trmnl.wakeLock.release()
.then(function () {
trmnl.wakeLock = null;
console.log("Wake Lock rilasciato");
})
.catch(function (err) {
console.warn("Release failed:", err);
trmnl.wakeLock = null;
});
},
toggleWakeLock: function () {
if (!trmnl.isWakeLockSupported()) return;
if (trmnl.wakeLock) {
trmnl.releaseWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = false;
});
} else {
trmnl.acquireWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
});
}
},
fetchDisplay: function (opts) { fetchDisplay: function (opts) {
opts = opts || {}; opts = opts || {};
clearTimeout(trmnl.refreshTimer); clearTimeout(trmnl.refreshTimer);
@ -84,7 +241,6 @@
var apiKey = setup.api_key; var apiKey = setup.api_key;
var displayMode = setup.display_mode; var displayMode = setup.display_mode;
var baseURL = setup.base_url || "https://your-byos-trmnl.com"; 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") document.body.classList.remove("dark", "night")
if (displayMode) { if (displayMode) {
@ -92,8 +248,7 @@
} }
var headers = { var headers = {
"Access-Token": apiKey, "Access-Token": apiKey
"id": macAddress
}; };
var url = baseURL + "/api/display"; var url = baseURL + "/api/display";
@ -137,8 +292,12 @@
trmnl.showStatus("Error processing response: " + e.message); trmnl.showStatus("Error processing response: " + e.message);
} }
} else { } else {
var msg = xhr.statusText
if (xhr.status == 404) {
msg = "Maybe wrong API key";
}
trmnl.showStatus( trmnl.showStatus(
"Failed to fetch screen: " + xhr.status + " " + xhr.statusText "Failed to fetch screen: " + xhr.status + " " + msg
); );
} }
}; };
@ -210,10 +369,6 @@
hasOverrides = true; hasOverrides = true;
} }
if (key === "mac_address" && value) {
newSettings.mac_address = value;
hasOverrides = true;
}
} }
if (hasOverrides) { if (hasOverrides) {
@ -274,17 +429,111 @@
// settings // settings
trmnl.ui.apiKeyInput = document.getElementById("api_key"); trmnl.ui.apiKeyInput = document.getElementById("api_key");
trmnl.ui.baseURLInput = document.getElementById("base_url"); trmnl.ui.baseURLInput = document.getElementById("base_url");
trmnl.ui.macAddressInput = document.getElementById("mac_address");
trmnl.ui.displayModeSelect = document.getElementById("display_mode"); trmnl.ui.displayModeSelect = document.getElementById("display_mode");
trmnl.ui.fullscreenToggle = document.getElementById("fullscreenToggle");
trmnl.ui.wakeLockToggle = document.getElementById("wakeLockToggle");
trmnl.ui.setup = document.getElementById("setup"); trmnl.ui.setup = document.getElementById("setup");
// Sync fullscreen state
document.addEventListener("fullscreenchange", trmnl.syncFullscreenToggle);
document.addEventListener("webkitfullscreenchange", trmnl.syncFullscreenToggle);
document.addEventListener("msfullscreenchange", trmnl.syncFullscreenToggle);
// Fullscreen toggle
if (!trmnl.isFullscreenSupported()) {
trmnl.ui.fullscreenToggle.disabled = true;
trmnl.ui.fullscreenToggle.parentElement.style.opacity = "0.5";
trmnl.ui.fullscreenToggle.parentElement.style.cursor = "not-allowed";
} else {
trmnl.ui.fullscreenToggle.addEventListener("change", function (e) {
e.stopPropagation();
if (e.target.checked) {
trmnl.enterFullscreen();
} else {
trmnl.exitFullscreen();
}
});
}
var wakeLockHint = document.getElementById("wakeLockHint");
// Wake Lock toggle
if (trmnl.isWakeLockSupported()) {
trmnl.ui.wakeLockToggle.disabled = false;
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "1";
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "pointer";
if (wakeLockHint) wakeLockHint.style.display = "none";
trmnl.ui.wakeLockToggle.addEventListener("change", function () {
trmnl.toggleWakeLock();
});
document.addEventListener("visibilitychange", function () {
if (
document.visibilityState === "visible" &&
trmnl.ui.wakeLockToggle.checked
) {
trmnl.acquireWakeLock();
}
});
} else {
// unsupported (HTTP or old browser)
trmnl.ui.wakeLockToggle.disabled = true;
trmnl.ui.wakeLockToggle.checked = false;
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "0.5";
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "not-allowed";
if (!window.isSecureContext && wakeLockHint) {
wakeLockHint.style.display = "block";
}
}
// get settings from localstorage
var settings = trmnl.getSettings(); var settings = trmnl.getSettings();
// show setup form if missing apikey
if (!settings || !settings.api_key) { if (!settings || !settings.api_key) {
trmnl.showSetupForm(); trmnl.showSetupForm();
} else { } else {
trmnl.fetchDisplay(); trmnl.fetchDisplay();
} }
// Auto fullscreen at first click/touch if option enabled
if (settings.fullscreen && trmnl.isFullscreenSupported()) {
var activateFullscreenOnce = function () {
trmnl.enterFullscreen();
document.removeEventListener("click", activateFullscreenOnce);
document.removeEventListener("touchstart", activateFullscreenOnce);
};
document.addEventListener("click", activateFullscreenOnce, { once: true });
document.addEventListener("touchstart", activateFullscreenOnce, { once: true });
} }
// Auto Wake Lock at first click/touch if option enabled
if (settings.wake_lock && trmnl.isWakeLockSupported()) {
var acquireWakeLockOnce = function () {
trmnl.acquireWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
}).catch(function (err) {
console.warn("Wake Lock request failed:", err);
trmnl.ui.wakeLockToggle.checked = false;
});
document.removeEventListener("click", acquireWakeLockOnce);
document.removeEventListener("touchstart", acquireWakeLockOnce);
};
document.addEventListener("click", acquireWakeLockOnce, { once: true });
document.addEventListener("touchstart", acquireWakeLockOnce, { once: true });
}
trmnl.syncFullscreenToggle();
} //init end
}; };
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
@ -403,8 +652,7 @@
display: block; display: block;
} }
label, label {
summary {
font-size: 1.25em; font-size: 1.25em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
cursor: pointer; cursor: pointer;
@ -433,6 +681,10 @@
width: 100%; width: 100%;
} }
.btn-secondary {
background-color: #777;
}
.btn-clear { .btn-clear {
margin-top: 1em; margin-top: 1em;
background-color: #777; background-color: #777;
@ -457,8 +709,127 @@
background-color: #ffffff; background-color: #ffffff;
} }
#unsupported { .setting-row {
color: red; display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
flex-wrap: nowrap;
}
.setting-row label,
.setting-row .toggle-label {
font-size: 1.25em;
margin: 0;
cursor: default;
}
.setting-row select,
.setting-row .switch {
width: auto;
min-width: 52px;
height: 28px;
}
.switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: #ccc;
border-radius: 28px;
transition: background-color 0.2s ease;
}
.slider::before {
content: "";
position: absolute;
height: 22px;
width: 22px;
left: 3px;
top: 3px;
background-color: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.switch input:checked+.slider {
background-color: #f54900;
}
.switch input:checked+.slider::before {
transform: translateX(24px);
}
.switch input:disabled+.slider {
background-color: #ccc;
cursor: not-allowed;
}
.switch input:disabled+.slider::before {
background-color: #eee;
}
.form-select-small {
width: 6em;
font-size: 1em;
padding: 0.4em 0.5em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #ffffff;
}
.toggle-label {
font-size: 1.25em;
margin: 0;
cursor: default;
pointer-events: auto;
}
.setting-hint {
font-size: 0.75em;
color: #f41414;
margin-top: 0.2em;
margin-left: 0.5em;
}
/* Fallback for iOS 9 */
@media screen and (max-width: 1024px) and (-webkit-min-device-pixel-ratio: 1) {
.setting-row {
display: block;
overflow: hidden;
}
.setting-row label,
.setting-row .toggle-label {
float: left;
line-height: 28px;
margin-right: 0.5em;
}
.setting-row select,
.setting-row .switch {
float: right;
width: auto;
min-width: 52px;
height: 28px;
}
.setting-hint {
display: none !important;
}
} }
</style> </style>
</head> </head>
@ -469,24 +840,6 @@
<img src="/mirror/assets/logo--brand.svg" alt="TRMNL Logo" /> <img src="/mirror/assets/logo--brand.svg" alt="TRMNL Logo" />
<form onsubmit="return trmnl.saveSetup(event)"> <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> <fieldset>
<label for="base_url">Custom Server URL</label> <label for="base_url">Custom Server URL</label>
@ -494,11 +847,43 @@
class="form-control" value="" /> class="form-control" value="" />
</fieldset> </fieldset>
<button class="btn">Save</button> <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>
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()"> <fieldset class="setting-row">
Clear settings <label for="display_mode">Display Mode</label>
</button> <select id="display_mode" name="display_mode" class="form-select-small">
<option value="" selected>Light</option>
<option value="dark">Dark</option>
<option value="night">Night</option>
</select>
</fieldset>
<fieldset class="setting-row">
<span class="toggle-label">Fullscreen</span>
<label class="switch">
<input type="checkbox" id="fullscreenToggle">
<span class="slider"></span>
</label>
</fieldset>
<fieldset class="setting-row">
<div>
<span class="toggle-label">Screen Wake Lock</span>
<div id="wakeLockHint" class="setting-hint" style="display:none;">
Require HTTPS
</div>
</div>
<label class="switch">
<input type="checkbox" id="wakeLockToggle">
<span class="slider"></span>
</label>
</fieldset>
<button class="btn">Save</button>
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()">Clear settings</button>
</form> </form>
</div> </div>
@ -512,10 +897,11 @@
<div id="error-message"></div> <div id="error-message"></div>
<div style="display: flex; margin-top: 1em"> <div style="display: flex; margin-top: 1em">
<button class="btn" onclick="trmnl.showSetupForm()">Setup</button> <button class="btn" onclick="trmnl.showSetupForm()">Setup</button>
<button class="btn" onclick="trmnl.fetchDisplay()">Retry</button> <button class="btn btn-secondary" onclick="trmnl.fetchDisplay()">Retry</button>
</div> </div>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View file

@ -9,6 +9,7 @@ import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json'; import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css'; import { css } from '@codemirror/lang-css';
import { liquid } from '@codemirror/lang-liquid'; import { liquid } from '@codemirror/lang-liquid';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { githubLight } from '@fsegurai/codemirror-theme-github-light'; import { githubLight } from '@fsegurai/codemirror-theme-github-light';
@ -20,6 +21,8 @@ const LANGUAGE_MAP = {
'css': css, 'css': css,
'liquid': liquid, 'liquid': liquid,
'html': html, 'html': html,
'yaml': yaml,
'yml': yaml,
}; };
// Theme support mapping // Theme support mapping

View file

@ -2,5 +2,9 @@
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" /> <x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</div> </div>
<div class="ml-1 grid flex-1 text-left text-sm"> <div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-none font-semibold">TRMNL BYOS Laravel</span> @if(config('app.pixel_logo_enabled'))
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 pt-1.5 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
@else
<span class="mb-0.5 truncate leading-none font-semibold">LaraPaper</span>
@endif
</div> </div>

View file

@ -4,6 +4,10 @@
]) ])
<div class="flex w-full flex-col text-center"> <div class="flex w-full flex-col text-center">
<flux:heading size="xl">{{ $title }}</flux:heading> @if(config('app.pixel_logo_enabled'))
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
@else
<flux:heading size="xl">LaraPaper</flux:heading>
@endif
<flux:subheading>{{ $description }}</flux:subheading> <flux:subheading>{{ $description }}</flux:subheading>
</div> </div>

View file

@ -5,12 +5,14 @@
'deviceOrientation' => null, 'deviceOrientation' => null,
'colorDepth' => '1bit', 'colorDepth' => '1bit',
'scaleLevel' => null, 'scaleLevel' => null,
'cssVariables' => null,
'pluginName' => 'Recipe', 'pluginName' => 'Recipe',
]) ])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}" <x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"> scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center"> <x-trmnl::richtext gapSize="large" align="center">

View file

@ -5,18 +5,20 @@
'deviceOrientation' => null, 'deviceOrientation' => null,
'colorDepth' => '1bit', 'colorDepth' => '1bit',
'scaleLevel' => null, 'scaleLevel' => null,
'cssVariables' => null,
]) ])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}" <x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"> scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center"> <x-trmnl::richtext gapSize="large" align="center">
<x-trmnl::title>Welcome to BYOS Laravel!</x-trmnl::title> <x-trmnl::title>Welcome to LaraPaper!</x-trmnl::title>
<x-trmnl::content>Your device is connected.</x-trmnl::content> <x-trmnl::content>Your device is connected.</x-trmnl::content>
</x-trmnl::richtext> </x-trmnl::richtext>
</x-trmnl::layout> </x-trmnl::layout>
<x-trmnl::title-bar title="byos_laravel"/> <x-trmnl::title-bar title="LaraPaper"/>
</x-trmnl::view> </x-trmnl::view>
</x-trmnl::screen> </x-trmnl::screen>

View file

@ -5,11 +5,13 @@
'deviceOrientation' => null, 'deviceOrientation' => null,
'colorDepth' => '1bit', 'colorDepth' => '1bit',
'scaleLevel' => null, 'scaleLevel' => null,
'cssVariables' => null,
]) ])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}" <x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"> scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center"> <x-trmnl::richtext gapSize="large" align="center">
@ -23,6 +25,6 @@
<x-trmnl::title>Sleep Mode</x-trmnl::title> <x-trmnl::title>Sleep Mode</x-trmnl::title>
</x-trmnl::richtext> </x-trmnl::richtext>
</x-trmnl::layout> </x-trmnl::layout>
<x-trmnl::title-bar title="byos_laravel"/> <x-trmnl::title-bar title="LaraPaper"/>
</x-trmnl::view> </x-trmnl::view>
</x-trmnl::screen> </x-trmnl::screen>

View file

@ -49,10 +49,13 @@ class extends Component
try { try {
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page; $cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
$response = Cache::remember($cacheKey, 43200, function () { $response = Cache::remember($cacheKey, 43200, function () {
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ $response = Http::timeout(10)->get(
config('services.trmnl.base_url').'/recipes.json',
[
'sort-by' => 'newest', 'sort-by' => 'newest',
'page' => $this->page, 'page' => $this->page,
]); ]
);
if (! $response->successful()) { if (! $response->successful()) {
throw new RuntimeException('Failed to fetch TRMNL recipes'); throw new RuntimeException('Failed to fetch TRMNL recipes');
@ -86,11 +89,14 @@ class extends Component
try { try {
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page; $cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
$response = Cache::remember($cacheKey, 300, function () use ($term) { $response = Cache::remember($cacheKey, 300, function () use ($term) {
$response = Http::get('https://usetrmnl.com/recipes.json', [ $response = Http::get(
config('services.trmnl.base_url').'/recipes.json',
[
'search' => $term, 'search' => $term,
'sort-by' => 'newest', 'sort-by' => 'newest',
'page' => $this->page, 'page' => $this->page,
]); ]
);
if (! $response->successful()) { if (! $response->successful()) {
throw new RuntimeException('Failed to search TRMNL recipes'); throw new RuntimeException('Failed to search TRMNL recipes');
@ -155,7 +161,7 @@ class extends Component
abort_unless(auth()->user() !== null, 403); abort_unless(auth()->user() !== null, 403);
try { try {
$zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive"; $zipUrl = config('services.trmnl.base_url')."/api/plugin_settings/{$recipeId}/archive";
$recipe = collect($this->recipes)->firstWhere('id', $recipeId); $recipe = collect($this->recipes)->firstWhere('id', $recipeId);
@ -183,16 +189,21 @@ class extends Component
$this->previewData = []; $this->previewData = [];
try { try {
$response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json"); $response = Http::timeout(10)->get(
config('services.trmnl.base_url')."/recipes/{$recipeId}.json"
);
if ($response->successful()) { if ($response->successful()) {
$item = $response->json()['data'] ?? []; $item = $response->json()['data'] ?? [];
$this->previewData = $this->mapRecipe($item); $this->previewData = $this->mapRecipe($item);
} else { } else {
// Fallback to searching for the specific recipe if single endpoint doesn't exist // Fallback to searching for the specific recipe if single endpoint doesn't exist
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ $response = Http::timeout(10)->get(
config('services.trmnl.base_url').'/recipes.json',
[
'search' => $recipeId, 'search' => $recipeId,
]); ]
);
if ($response->successful()) { if ($response->successful()) {
$data = $response->json()['data'] ?? []; $data = $response->json()['data'] ?? [];
@ -240,7 +251,9 @@ class extends Component
'installs' => data_get($item, 'stats.installs'), 'installs' => data_get($item, 'stats.installs'),
'forks' => data_get($item, 'stats.forks'), 'forks' => data_get($item, 'stats.forks'),
], ],
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null, 'detail_url' => isset($item['id'])
? config('services.trmnl.base_url').'/recipes/'.$item['id']
: null,
]; ];
} }
}; ?> }; ?>

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Jobs\FetchDeviceModelsJob;
use App\Models\DeviceModel; use App\Models\DeviceModel;
use App\Models\DevicePalette; use App\Models\DevicePalette;
use Livewire\Component; use Livewire\Component;
@ -38,6 +39,11 @@ new class extends Component
public $palette_id; public $palette_id;
public $css_name;
/** @var array<int, array{key: string, value: string}> */
public array $css_variables = [];
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',
@ -66,6 +72,14 @@ new class extends Component
public $viewingDeviceModelId; public $viewingDeviceModelId;
public function updateFromApi(): void
{
FetchDeviceModelsJob::dispatchSync();
$this->deviceModels = DeviceModel::all();
$this->devicePalettes = DevicePalette::all();
session()->flash('message', 'Device models updated from API.');
}
public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void
{ {
if ($deviceModelId) { if ($deviceModelId) {
@ -93,10 +107,12 @@ new class extends Component
$this->offset_y = $deviceModel->offset_y; $this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id; $this->palette_id = $deviceModel->palette_id;
$this->css_name = $deviceModel->css_name;
$this->css_variables = collect($deviceModel->css_variables ?? [])->map(fn (string $value, string $key): array => ['key' => $key, 'value' => $value])->values()->all();
} else { } else {
$this->editingDeviceModelId = null; $this->editingDeviceModelId = null;
$this->viewingDeviceModelId = 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->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name', 'css_variables']);
$this->mime_type = 'image/png'; $this->mime_type = 'image/png';
$this->scale_factor = 1.0; $this->scale_factor = 1.0;
$this->rotation = 0; $this->rotation = 0;
@ -122,6 +138,10 @@ new class extends Component
'offset_y' => 'required|integer', 'offset_y' => 'required|integer',
'published_at' => 'nullable|date', 'published_at' => 'nullable|date',
'palette_id' => 'nullable|exists:device_palettes,id', 'palette_id' => 'nullable|exists:device_palettes,id',
'css_name' => 'nullable|string|max:255',
'css_variables' => 'nullable|array',
'css_variables.*.key' => 'nullable|string|max:255',
'css_variables.*.value' => 'nullable|string|max:500',
]; ];
if ($this->editingDeviceModelId) { if ($this->editingDeviceModelId) {
@ -149,6 +169,8 @@ new class extends Component
'offset_y' => $this->offset_y, 'offset_y' => $this->offset_y,
'published_at' => $this->published_at, 'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null, 'palette_id' => $this->palette_id ?: null,
'css_name' => $this->css_name ?: null,
'css_variables' => $this->normalizeCssVariables(),
]); ]);
$message = 'Device model updated successfully.'; $message = 'Device model updated successfully.';
} else { } else {
@ -167,12 +189,14 @@ new class extends Component
'offset_y' => $this->offset_y, 'offset_y' => $this->offset_y,
'published_at' => $this->published_at, 'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null, 'palette_id' => $this->palette_id ?: null,
'css_name' => $this->css_name ?: null,
'css_variables' => $this->normalizeCssVariables(),
'source' => 'manual', 'source' => 'manual',
]); ]);
$message = 'Device model created successfully.'; $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']); $this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name', 'css_variables', 'editingDeviceModelId', 'viewingDeviceModelId']);
Flux::modal('device-model-modal')->close(); Flux::modal('device-model-modal')->close();
$this->deviceModels = DeviceModel::all(); $this->deviceModels = DeviceModel::all();
@ -208,9 +232,38 @@ new class extends Component
$this->offset_y = $deviceModel->offset_y; $this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i'); $this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id; $this->palette_id = $deviceModel->palette_id;
$this->css_name = $deviceModel->css_name;
$this->css_variables = collect($deviceModel->css_variables ?? [])->map(fn (string $value, string $key): array => ['key' => $key, 'value' => $value])->values()->all();
$this->js('Flux.modal("device-model-modal").show()'); $this->js('Flux.modal("device-model-modal").show()');
} }
public function addCssVariable(): void
{
$this->css_variables = array_merge($this->css_variables, [['key' => '', 'value' => '']]);
}
public function removeCssVariable(int $index): void
{
$vars = $this->css_variables;
array_splice($vars, $index, 1);
$this->css_variables = array_values($vars);
}
/**
* @return array<string, string>|null
*/
private function normalizeCssVariables(): ?array
{
$pairs = collect($this->css_variables)
->filter(fn (array $p): bool => trim($p['key'] ?? '') !== '');
if ($pairs->isEmpty()) {
return null;
}
return $pairs->mapWithKeys(fn (array $p): array => [$p['key'] => $p['value'] ?? ''])->all();
}
} }
?> ?>
@ -229,9 +282,17 @@ new class extends Component
</flux:menu> </flux:menu>
</flux:dropdown> </flux:dropdown>
</div> </div>
<flux:button.group>
<flux:modal.trigger name="device-model-modal"> <flux:modal.trigger name="device-model-modal">
<flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button> <flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button>
</flux:modal.trigger> </flux:modal.trigger>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:menu.item icon="arrow-path" wire:click="updateFromApi">Update from Models API</flux:menu.item>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div> </div>
@if (session()->has('message')) @if (session()->has('message'))
<div class="mb-4"> <div class="mb-4">
@ -327,6 +388,40 @@ new class extends Component
</flux:select> </flux:select>
</div> </div>
<div class="mb-4">
<flux:input label="CSS Model Identifier" wire:model="css_name" id="css_name" class="block mt-1 w-full" type="text"
name="css_name" :disabled="(bool) $viewingDeviceModelId"/>
</div>
<div class="mb-4">
<flux:heading size="sm" class="mb-2">CSS Variables</flux:heading>
@if ($viewingDeviceModelId)
@if (count($css_variables) > 0)
<dl class="space-y-1.5 text-sm">
@foreach ($css_variables as $var)
<div class="flex gap-2">
<dt class="font-medium text-zinc-600 dark:text-zinc-400 min-w-[120px]">{{ $var['key'] }}</dt>
<dd class="text-zinc-800 dark:text-zinc-200">{{ $var['value'] }}</dd>
</div>
@endforeach
</dl>
@else
<p class="text-sm text-zinc-500 dark:text-zinc-400">No CSS variables</p>
@endif
@else
<div class="space-y-3">
@foreach ($css_variables as $index => $var)
<div class="flex gap-2 items-start" wire:key="css-var-{{ $index }}">
<flux:input wire:model="css_variables.{{ $index }}.key" placeholder="e.g. --screen-w" class="flex-1 min-w-0" type="text"/>
<flux:input wire:model="css_variables.{{ $index }}.value" placeholder="e.g. 800px" class="flex-1 min-w-0" type="text"/>
<flux:button type="button" wire:click="removeCssVariable({{ $index }})" icon="trash" variant="ghost" iconVariant="outline"/>
</div>
@endforeach
<flux:button type="button" wire:click="addCssVariable" variant="ghost" icon="plus" size="sm">Add variable</flux:button>
</div>
@endif
</div>
@if (!$viewingDeviceModelId) @if (!$viewingDeviceModelId)
<div class="flex"> <div class="flex">
<flux:spacer/> <flux:spacer/>

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Jobs\FetchDeviceModelsJob;
use App\Models\DevicePalette; use App\Models\DevicePalette;
use Livewire\Component; use Livewire\Component;
@ -58,6 +59,13 @@ new class extends Component
public $viewingDevicePaletteId; public $viewingDevicePaletteId;
public function updateFromApi(): void
{
FetchDeviceModelsJob::dispatchSync();
$this->devicePalettes = DevicePalette::all();
session()->flash('message', 'Device palettes updated from API.');
}
public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void
{ {
if ($devicePaletteId) { if ($devicePaletteId) {
@ -202,9 +210,17 @@ new class extends Component
</flux:menu> </flux:menu>
</flux:dropdown> </flux:dropdown>
</div> </div>
<flux:button.group>
<flux:modal.trigger name="device-palette-modal"> <flux:modal.trigger name="device-palette-modal">
<flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button> <flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button>
</flux:modal.trigger> </flux:modal.trigger>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:menu.item icon="arrow-path" wire:click="updateFromApi">Update from API</flux:menu.item>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div> </div>
@if (session()->has('message')) @if (session()->has('message'))
<div class="mb-4"> <div class="mb-4">

View file

@ -31,6 +31,13 @@ new class extends Component
public $device_model_id; public $device_model_id;
public $is_mirror = false;
public $mirror_device_id = null;
// Signal to device to use high compatibility approaches when redrawing content
public $maximum_compatibility = false;
// Sleep mode and special function // Sleep mode and special function
public $sleep_mode_enabled = false; public $sleep_mode_enabled = false;
@ -81,6 +88,7 @@ new class extends Component
$this->rotate = $device->rotate; $this->rotate = $device->rotate;
$this->image_format = $device->image_format; $this->image_format = $device->image_format;
$this->device_model_id = $device->device_model_id; $this->device_model_id = $device->device_model_id;
$this->maximum_compatibility = $device->maximum_compatibility;
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) { $this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
// Put TRMNL models at the top, then sort alphabetically within each group // Put TRMNL models at the top, then sort alphabetically within each group
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL'); $isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
@ -94,6 +102,8 @@ new class extends Component
$this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i'); $this->sleep_mode_from = optional($device->sleep_mode_from)->format('H:i');
$this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i'); $this->sleep_mode_to = optional($device->sleep_mode_to)->format('H:i');
$this->special_function = $device->special_function; $this->special_function = $device->special_function;
$this->is_mirror = $device->mirror_device_id !== null;
$this->mirror_device_id = $device->mirror_device_id;
return view('livewire.devices.configure', [ return view('livewire.devices.configure', [
'image' => ($current_image_uuid) ? url($current_image_path) : null, 'image' => ($current_image_uuid) ? url($current_image_path) : null,
@ -141,12 +151,21 @@ new class extends Component
'rotate' => 'required|integer|min:0|max:359', 'rotate' => 'required|integer|min:0|max:359',
'image_format' => 'required|string', 'image_format' => 'required|string',
'device_model_id' => 'nullable|exists:device_models,id', 'device_model_id' => 'nullable|exists:device_models,id',
'mirror_device_id' => 'required_if:is_mirror,true',
'maximum_compatibility' => 'boolean',
'sleep_mode_enabled' => 'boolean', 'sleep_mode_enabled' => 'boolean',
'sleep_mode_from' => 'nullable|date_format:H:i', 'sleep_mode_from' => 'nullable|date_format:H:i',
'sleep_mode_to' => 'nullable|date_format:H:i', 'sleep_mode_to' => 'nullable|date_format:H:i',
'special_function' => 'nullable|string', 'special_function' => 'nullable|string',
]); ]);
if ($this->is_mirror) {
$mirrorDevice = auth()->user()->devices()->find($this->mirror_device_id);
abort_unless($mirrorDevice, 403, 'Invalid mirror device selected');
abort_if($mirrorDevice->mirror_device_id !== null, 403, 'Cannot mirror a device that is already a mirror device');
abort_if((int) $this->mirror_device_id === (int) $this->device->id, 403, 'Device cannot mirror itself');
}
// Convert empty string to null for custom selection // Convert empty string to null for custom selection
$deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id; $deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id;
@ -160,6 +179,8 @@ new class extends Component
'rotate' => $this->rotate, 'rotate' => $this->rotate,
'image_format' => $this->image_format, 'image_format' => $this->image_format,
'device_model_id' => $deviceModelId, 'device_model_id' => $deviceModelId,
'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null,
'maximum_compatibility' => $this->maximum_compatibility,
'sleep_mode_enabled' => $this->sleep_mode_enabled, 'sleep_mode_enabled' => $this->sleep_mode_enabled,
'sleep_mode_from' => $this->sleep_mode_from, 'sleep_mode_from' => $this->sleep_mode_from,
'sleep_mode_to' => $this->sleep_mode_to, 'sleep_mode_to' => $this->sleep_mode_to,
@ -427,6 +448,20 @@ new class extends Component
@endforeach @endforeach
</flux:select> </flux:select>
<flux:checkbox wire:model.live="is_mirror" label="Mirrors Device"/>
@if($is_mirror)
<flux:select wire:model="mirror_device_id" label="Select Device to Mirror">
<flux:select.option value="">Select a device</flux:select.option>
@foreach(auth()->user()->devices->where('mirror_device_id', null)->where('id', '!=', $device->id) as $mirrorOption)
<flux:select.option value="{{ $mirrorOption->id }}">
{{ $mirrorOption->name }} ({{ $mirrorOption->friendly_id }})
</flux:select.option>
@endforeach
</flux:select>
@endif
<flux:checkbox wire:model="maximum_compatibility" label="Maximum Compatibility" description="Resolves display issues caused by certain e-ink driver chips. Disables fast refresh. TRMNL Firmware 1.6.0+ required." />
@if(empty($device_model_id)) @if(empty($device_model_id))
<flux:separator class="my-4" text="Advanced Device Settings" /> <flux:separator class="my-4" text="Advanced Device Settings" />
<div class="flex gap-4"> <div class="flex gap-4">
@ -524,7 +559,7 @@ new class extends Component
<flux:modal name="mirror-url" class="md:w-96"> <flux:modal name="mirror-url" class="md:w-96">
@php @php
$mirrorUrl = url('/mirror/index.html') . '?mac_address=' . urlencode($device->mac_address) . '&api_key=' . urlencode($device->api_key); $mirrorUrl = url('/mirror/index.html') . '?api_key=' . urlencode($device->api_key);
@endphp @endphp
<div class="space-y-6"> <div class="space-y-6">
@ -787,4 +822,3 @@ new class extends Component
</div> </div>
</div> </div>
</div> </div>

View file

@ -2,6 +2,7 @@
use App\Models\Plugin; use App\Models\Plugin;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
/* /*
@ -54,12 +55,22 @@ new class extends Component
/** /**
* Triggered by @close on the modal to discard any typed but unsaved changes * Triggered by @close on the modal to discard any typed but unsaved changes
*/ */
public int $resetIndex = 0; // Add this property public int $resetIndex = 0;
/**
* When recipe settings (or this modal) save, reload so Configuration Fields form stays in sync.
*/
#[On('config-updated')]
public function refreshFromParent(): void
{
$this->loadData();
$this->resetIndex++;
}
public function resetForm(): void public function resetForm(): void
{ {
$this->loadData(); $this->loadData();
++$this->resetIndex; // Increment to force DOM refresh ++$this->resetIndex;
} }
public function saveConfiguration() public function saveConfiguration()

View file

@ -258,14 +258,13 @@ new class extends Component
<div class="mb-4"> <div class="mb-4">
<flux:heading size="sm">Limitations</flux:heading> <flux:heading size="sm">Limitations</flux:heading>
<ul class="list-disc pl-5 mt-2"> <ul class="list-disc pl-5 mt-2">
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
<li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li> <li><flux:text>Some Liquid filters may be not supported or behave differently</flux:text></li>
<li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li> <li><flux:text>API responses in formats other than JSON are not yet supported</flux:text></li>
{{-- <ul class="list-disc pl-5 mt-2">--}} {{-- <ul class="list-disc pl-5 mt-2">--}}
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}} {{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
{{-- </ul>--}} {{-- </ul>--}}
</ul> </ul>
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li> <flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
</div> </div>
<form wire:submit="importZip"> <form wire:submit="importZip">
@ -312,12 +311,11 @@ new class extends Component
<flux:callout class="mb-4 mt-4" color="yellow"> <flux:callout class="mb-4 mt-4" color="yellow">
<flux:heading size="sm">Limitations</flux:heading> <flux:heading size="sm">Limitations</flux:heading>
<ul class="list-disc pl-5 mt-2"> <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>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>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> <li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
</ul> </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:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
</flux:callout> </flux:callout>
</div> </div>
<livewire:catalog.trmnl /> <livewire:catalog.trmnl />

View file

@ -72,8 +72,8 @@ new class extends Component
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center"> <x-trmnl::richtext gapSize="large" align="center">
<x-trmnl::title>TRMNL BYOS Laravel</x-trmnl::title> <x-trmnl::title>LaraPaper</x-trmnl::title>
<x-trmnl::content>“This screen was rendered by BYOS Laravel</x-trmnl::content> <x-trmnl::content>“This screen was rendered by BYOS LaraPaper</x-trmnl::content>
<x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label> <x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label>
</x-trmnl::richtext> </x-trmnl::richtext>
</x-trmnl::layout> </x-trmnl::layout>

View file

@ -65,6 +65,12 @@ new class extends Component
public string $preview_size = 'full'; public string $preview_size = 'full';
public array $markup_layouts = [];
public array $active_tabs = [];
public string $active_tab = 'full';
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);
@ -91,7 +97,24 @@ new class extends Component
$this->view_content = null; $this->view_content = null;
} }
} else { } else {
$this->markup_code = $this->plugin->render_markup; // Initialize layout markups from plugin columns
$this->markup_layouts = [
'full' => $this->plugin->render_markup ?? '',
'half_horizontal' => $this->plugin->render_markup_half_horizontal ?? '',
'half_vertical' => $this->plugin->render_markup_half_vertical ?? '',
'quadrant' => $this->plugin->render_markup_quadrant ?? '',
'shared' => $this->plugin->render_markup_shared ?? '',
];
// Set active tabs based on which layouts have content
$this->active_tabs = ['full']; // Full is always active
foreach (['half_horizontal', 'half_vertical', 'quadrant', 'shared'] as $layout) {
if (! empty($this->markup_layouts[$layout])) {
$this->active_tabs[] = $layout;
}
}
$this->markup_code = $this->markup_layouts['full'];
$this->markup_language = $this->plugin->markup_language ?? 'blade'; $this->markup_language = $this->plugin->markup_language ?? 'blade';
} }
@ -125,12 +148,108 @@ new class extends Component
{ {
abort_unless(auth()->user()->plugins->contains($this->plugin), 403); abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->validate(); $this->validate();
// Update markup_code for the active tab
if (isset($this->markup_layouts[$this->active_tab])) {
$this->markup_layouts[$this->active_tab] = $this->markup_code ?? '';
}
// Save all layout markups to respective columns
$this->plugin->update([ $this->plugin->update([
'render_markup' => $this->markup_code ?? null, 'render_markup' => $this->markup_layouts['full'] ?? null,
'render_markup_half_horizontal' => ! empty($this->markup_layouts['half_horizontal']) ? $this->markup_layouts['half_horizontal'] : null,
'render_markup_half_vertical' => ! empty($this->markup_layouts['half_vertical']) ? $this->markup_layouts['half_vertical'] : null,
'render_markup_quadrant' => ! empty($this->markup_layouts['quadrant']) ? $this->markup_layouts['quadrant'] : null,
'render_markup_shared' => ! empty($this->markup_layouts['shared']) ? $this->markup_layouts['shared'] : null,
'markup_language' => $this->markup_language ?? null, 'markup_language' => $this->markup_language ?? null,
]); ]);
} }
public function addLayoutTab(string $layout): void
{
if (! in_array($layout, $this->active_tabs, true)) {
$this->active_tabs[] = $layout;
if (! isset($this->markup_layouts[$layout])) {
$this->markup_layouts[$layout] = '';
}
$this->switchTab($layout);
}
}
public function removeLayoutTab(string $layout): void
{
if ($layout !== 'full') {
$this->active_tabs = array_values(array_filter($this->active_tabs, fn ($tab) => $tab !== $layout));
if (isset($this->markup_layouts[$layout])) {
$this->markup_layouts[$layout] = '';
}
if ($this->active_tab === $layout) {
$this->active_tab = 'full';
$this->markup_code = $this->markup_layouts['full'] ?? '';
}
}
}
public function switchTab(string $layout): void
{
if (in_array($layout, $this->active_tabs, true)) {
// Save current tab's content before switching
if (isset($this->markup_layouts[$this->active_tab])) {
$this->markup_layouts[$this->active_tab] = $this->markup_code ?? '';
}
$this->active_tab = $layout;
$this->markup_code = $this->markup_layouts[$layout] ?? '';
}
}
public function toggleLayoutTab(string $layout): void
{
if ($layout === 'full') {
return;
}
if (in_array($layout, $this->active_tabs, true)) {
$this->removeLayoutTab($layout);
} else {
$this->addLayoutTab($layout);
}
}
public function getAvailableLayouts(): array
{
return [
'half_horizontal' => 'Half Horizontal',
'half_vertical' => 'Half Vertical',
'quadrant' => 'Quadrant',
'shared' => 'Shared',
];
}
public function getLayoutLabel(string $layout): string
{
return match ($layout) {
'full' => $this->getFullTabLabel(),
'half_horizontal' => 'Half Horizontal',
'half_vertical' => 'Half Vertical',
'quadrant' => 'Quadrant',
'shared' => 'Shared',
default => ucfirst($layout),
};
}
public function getFullTabLabel(): string
{
// Return "Full" if any layout-specific markup exists, otherwise "Responsive"
if (! empty($this->markup_layouts['half_horizontal'])
|| ! empty($this->markup_layouts['half_vertical'])
|| ! empty($this->markup_layouts['quadrant'])) {
return 'Full';
}
return 'Responsive';
}
protected array $rules = [ protected array $rules = [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'data_stale_minutes' => 'required|integer|min:1', 'data_stale_minutes' => 'required|integer|min:1',
@ -450,11 +569,10 @@ HTML;
} }
#[On('config-updated')] #[On('config-updated')]
public function refreshPlugin() public function refreshPlugin(): void
{ {
// This pulls the fresh 'configuration' from the DB
// and re-triggers the @if check in the Blade template
$this->plugin = $this->plugin->fresh(); $this->plugin = $this->plugin->fresh();
$this->configuration_template = $this->plugin->configuration_template ?? [];
} }
// Laravel Livewire computed property: access with $this->parsed_urls // Laravel Livewire computed property: access with $this->parsed_urls
@ -1018,9 +1136,42 @@ HTML;
@if(!$plugin->render_markup_view) @if(!$plugin->render_markup_view)
<form wire:submit="saveMarkup"> <form wire:submit="saveMarkup">
<div class="mb-4"> <div class="mb-4">
<div>
<div class="flex items-end">
@foreach($active_tabs as $tab)
<button
type="button"
wire:click="switchTab('{{ $tab }}')"
class="tab-button {{ $active_tab === $tab ? 'is-active' : '' }}"
wire:key="tab-{{ $tab }}"
>
{{ $this->getLayoutLabel($tab) }}
</button>
@endforeach
<flux:dropdown>
<flux:button icon="plus" variant="ghost" size="sm" class="m-0.5"></flux:button>
<flux:menu>
@foreach($this->getAvailableLayouts() as $layout => $label)
<flux:menu.item wire:click="toggleLayoutTab('{{ $layout }}')">
<div class="flex items-center gap-2">
@if(in_array($layout, $active_tabs, true))
<flux:icon.check class="size-4" />
@else
<span class="inline-block w-4 h-4"></span>
@endif
<span>{{ $label }}</span>
</div>
</flux:menu.item>
@endforeach
</flux:menu>
</flux:dropdown>
</div>
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
<flux:field> <flux:field>
@php @php
$textareaId = 'code-' . uniqid(); $textareaId = 'code-' . $plugin->id;
@endphp @endphp
<flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label> <flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label>
<flux:textarea <flux:textarea
@ -1033,7 +1184,7 @@ HTML;
<div <div
x-data="codeEditorFormComponent({ x-data="codeEditorFormComponent({
isDisabled: false, isDisabled: false,
language: 'liquid', language: @js($markup_language === 'liquid' ? 'liquid' : 'html'),
state: $wire.entangle('markup_code'), state: $wire.entangle('markup_code'),
textareaId: @js($textareaId) textareaId: @js($textareaId)
})" })"
@ -1052,7 +1203,8 @@ HTML;
<div x-show="!isLoading" x-ref="editor" class="h-full"></div> <div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div> </div>
</flux:field> </flux:field>
</div>
</div>
</div> </div>
<div class="flex"> <div class="flex">

View file

@ -3,9 +3,11 @@
use App\Models\Plugin; use App\Models\Plugin;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Livewire\Component; use Livewire\Component;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/* /*
* This component contains the TRMNL Plugin Settings modal * This component contains the TRMNL Plugin Settings modal.
*/ */
new class extends Component new class extends Component
{ {
@ -17,16 +19,21 @@ new class extends Component
public bool $alias = false; public bool $alias = false;
public bool $use_trmnl_liquid_renderer = false;
public string $configurationTemplateYaml = '';
public int $resetIndex = 0; public int $resetIndex = 0;
public function mount(): void public function mount(): void
{ {
$this->resetErrorBag(); $this->resetErrorBag();
// Reload data
$this->plugin = $this->plugin->fresh(); $this->plugin = $this->plugin->fresh();
$this->trmnlp_id = $this->plugin->trmnlp_id; $this->trmnlp_id = $this->plugin->trmnlp_id;
$this->uuid = $this->plugin->uuid; $this->uuid = $this->plugin->uuid;
$this->alias = $this->plugin->alias ?? false; $this->alias = $this->plugin->alias ?? false;
$this->use_trmnl_liquid_renderer = $this->plugin->preferred_renderer === 'trmnl-liquid';
$this->configurationTemplateYaml = $this->plugin->getCustomFieldsEditorYaml();
} }
public function saveTrmnlpId(): void public function saveTrmnlpId(): void
@ -43,13 +50,47 @@ new class extends Component
->ignore($this->plugin->id), ->ignore($this->plugin->id),
], ],
'alias' => 'boolean', 'alias' => 'boolean',
'use_trmnl_liquid_renderer' => 'boolean',
'configurationTemplateYaml' => [
'nullable',
'string',
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === '') {
return;
}
try {
$parsed = Yaml::parse($value);
if (! is_array($parsed)) {
$fail('The configuration must be valid YAML and evaluate to an object/array.');
return;
}
Plugin::validateCustomFieldsList($parsed);
} catch (ParseException) {
$fail('The configuration must be valid YAML.');
} catch (\Illuminate\Validation\ValidationException $e) {
foreach ($e->errors() as $messages) {
foreach ($messages as $message) {
$fail($message);
}
}
}
},
],
]); ]);
$configurationTemplate = Plugin::configurationTemplateFromCustomFieldsYaml(
$this->configurationTemplateYaml,
$this->plugin->configuration_template
);
$this->plugin->update([ $this->plugin->update([
'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
'alias' => $this->alias, 'alias' => $this->alias,
'preferred_renderer' => $this->use_trmnl_liquid_renderer ? 'trmnl-liquid' : null,
'configuration_template' => $configurationTemplate,
]); ]);
$this->dispatch('config-updated');
Flux::modal('trmnlp-settings')->close(); Flux::modal('trmnlp-settings')->close();
} }
@ -59,7 +100,7 @@ new class extends Component
} }
}; ?> }; ?>
<flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6"> <flux:modal name="trmnlp-settings" class="min-w-[600px] max-w-2xl space-y-6">
<div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6"> <div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6">
<div> <div>
<flux:heading size="lg">Recipe Settings</flux:heading> <flux:heading size="lg">Recipe Settings</flux:heading>
@ -83,6 +124,53 @@ new class extends Component
<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: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> </flux:field>
@if(config('services.trmnl.liquid_enabled') && $plugin->markup_language === 'liquid')
<flux:field>
<flux:checkbox
wire:model.live="use_trmnl_liquid_renderer"
label="Use trmnl-liquid renderer"
/>
<flux:description>trmnl-liquid is a Ruby-based renderer that matches the Core services Liquid behavior for better compatibility.</flux:description>
</flux:field>
@endif
<flux:field>
<flux:label>Configuration template</flux:label>
<flux:description>
Build forms visually in the <a href="https://usetrmnl.github.io/trmnl-form-builder/" target="_blank" rel="noopener noreferrer">TRMNL YML Form Builder</a>.
Check the <a href="https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder" target="_blank" rel="noopener noreferrer">docs</a> for more information.
</flux:description>
@php
$configTemplateTextareaId = 'config-template-' . uniqid();
@endphp
<flux:textarea
wire:model="configurationTemplateYaml"
id="{{ $configTemplateTextareaId }}"
placeholder="[]"
rows="12"
hidden
/>
<div
x-data="codeEditorFormComponent({
isDisabled: false,
language: 'yaml',
state: $wire.entangle('configurationTemplateYaml'),
textareaId: @js($configTemplateTextareaId)
})"
wire:ignore
wire:key="cm-{{ $configTemplateTextareaId }}"
class="min-h-[200px] h-[300px] overflow-hidden resize-y"
>
<div x-show="isLoading" class="flex items-center justify-center h-full">
<div class="flex items-center space-x-2">
<flux:icon.loading />
</div>
</div>
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div>
<flux:error name="configurationTemplateYaml" />
</flux:field>
@if($alias) @if($alias)
<flux:field> <flux:field>
<flux:label>Alias URL</flux:label> <flux:label>Alias URL</flux:label>

View file

@ -29,7 +29,7 @@ new class extends Component {}
<div class="mt-3 flex items-center justify-start gap-2"> <div class="mt-3 flex items-center justify-start gap-2">
<flux:input value="laravel-trmnl" readonly copyable class="max-w-42"/> <flux:input value="laravel-trmnl" readonly copyable class="max-w-42"/>
<flux:button class="w-42" <flux:button class="w-42"
href="https://usetrmnl.com/?ref=laravel-trmnl" href="{{ config('services.trmnl.base_url') }}?ref=laravel-trmnl"
target="_blank" target="_blank"
icon:trailing="arrow-up-right">{{ __('Referral link') }}</flux:button> icon:trailing="arrow-up-right">{{ __('Referral link') }}</flux:button>
</div> </div>

View file

@ -248,7 +248,7 @@ new class extends Component
<flux:callout icon="check-circle" variant="success"> <flux:callout icon="check-circle" variant="success">
<flux:callout.heading>Up to Date</flux:callout.heading> <flux:callout.heading>Up to Date</flux:callout.heading>
<flux:callout.text> <flux:callout.text>
You are running the latest version {{ $latestVersion }}. You are running the latest version.
</flux:callout.text> </flux:callout.text>
</flux:callout> </flux:callout>
@endif @endif

View file

@ -1,7 +1,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ $title ?? 'TRMNL BYOS Laravel' }}</title> <title>{{ $title ?? 'LaraPaper' }}</title>
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />

View file

@ -79,7 +79,7 @@
<x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label> <x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label>
</td> </td>
<td> <td>
<x-trmnl::label variant="inverted">{{ $event['location'] ?? '—' }}</x-trmnl::label> <x-trmnl::label variant="inverted">{{ Str::limit($event['location'] ?? '—',100) }}</x-trmnl::label>
</td> </td>
</tr> </tr>
@empty @empty

View file

@ -12,7 +12,7 @@
<div class="grid" style="gap: 9px;"> <div class="grid" style="gap: 9px;">
<div class="row row--center col--span-3 col--end"> <div class="row row--center col--span-3 col--end">
<img class="weather-image" style="max-height: 150px; margin:auto;" <img class="weather-image" style="max-height: 150px; margin:auto;"
src="https://usetrmnl.com/images/plugins/weather/wi-thermometer.svg"> src="{{ config('services.trmnl.base_url') }}/images/plugins/weather/wi-thermometer.svg">
</div> </div>
<div class="col col--span-3 col--end"> <div class="col col--span-3 col--end">
<div class="item h--full"> <div class="item h--full">
@ -28,7 +28,7 @@
<div class="item"> <div class="item">
<div class="meta"></div> <div class="meta"></div>
<div class="icon"> <div class="icon">
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/plugins/weather/wi-thermometer.svg"> --}} {{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/plugins/weather/wi-thermometer.svg"> --}}
</div> </div>
<div class="content"> <div class="content">
<span class="value value--small">{{ $weatherEntity['attributes']['wind_speed'] }} {{ $weatherEntity['attributes']['wind_speed_unit'] }}</span> <span class="value value--small">{{ $weatherEntity['attributes']['wind_speed'] }} {{ $weatherEntity['attributes']['wind_speed_unit'] }}</span>
@ -39,7 +39,7 @@
<div class="item"> <div class="item">
<div class="meta"></div> <div class="meta"></div>
<div class="icon"> <div class="icon">
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-raindrops.svg"> --}} {{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-raindrops.svg"> --}}
</div> </div>
<div class="content"> <div class="content">
<span class="value value--small">{{ $weatherEntity['attributes']['humidity'] }}%</span> <span class="value value--small">{{ $weatherEntity['attributes']['humidity'] }}%</span>
@ -50,7 +50,7 @@
<div class="item"> <div class="item">
<div class="meta"></div> <div class="meta"></div>
<div class="icon"> <div class="icon">
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-day-sunny.svg"> --}} {{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-day-sunny.svg"> --}}
</div> </div>
<div class="content"> <div class="content">
<span class="value value--xsmall">{{ Str::title($weatherEntity['state']) }}</span> <span class="value value--xsmall">{{ Str::title($weatherEntity['state']) }}</span>

View file

@ -1,5 +1,5 @@
<script src="https://usetrmnl.com/js/highcharts/12.3.0/highcharts.js"></script> <script src="https://trmnl.com/js/highcharts/12.3.0/highcharts.js"></script>
<script src="https://usetrmnl.com/js/chartkick/5.0.1/chartkick.min.js"></script> <script src="https://trmnl.com/js/chartkick/5.0.1/chartkick.min.js"></script>
<div class="view view--{{ size }}"> <div class="view view--{{ size }}">
<div class="layout layout--col gap--space-between"> <div class="layout layout--col gap--space-between">

View file

@ -5,7 +5,7 @@
<div class="grid" style="gap: 9px;"> <div class="grid" style="gap: 9px;">
<div class="row row--center col--span-3 col--end"> <div class="row row--center col--span-3 col--end">
<img class="weather-image" style="max-height: 150px; margin:auto;" <img class="weather-image" style="max-height: 150px; margin:auto;"
src="https://usetrmnl.com/images/plugins/weather/wi-thermometer.svg"> src="{{ config('services.trmnl.base_url') }}/images/plugins/weather/wi-thermometer.svg">
</div> </div>
<div class="col col--span-3 col--center"> <div class="col col--span-3 col--center">
<div class="item"> <div class="item">
@ -21,7 +21,7 @@
<div class="item"> <div class="item">
<div class="meta"></div> <div class="meta"></div>
<div class="icon"> <div class="icon">
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-thermometer.svg">--}} {{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-thermometer.svg">--}}
</div> </div>
<div class="content"> <div class="content">
<span <span
@ -33,7 +33,7 @@
<div class="item"> <div class="item">
<div class="meta"></div> <div class="meta"></div>
<div class="icon"> <div class="icon">
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-raindrops.svg">--}} {{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-raindrops.svg">--}}
</div> </div>
<div class="content"> <div class="content">
<span class="value value--small">{{Arr::get($data, 'properties.timeseries.0.data.instant.details.relative_humidity', 'N/A')}}%</span> <span class="value value--small">{{Arr::get($data, 'properties.timeseries.0.data.instant.details.relative_humidity', 'N/A')}}%</span>
@ -44,7 +44,7 @@
<div class="item"> <div class="item">
<div class="meta"></div> <div class="meta"></div>
<div class="icon"> <div class="icon">
{{-- <img class="weather-icon" src="https://usetrmnl.com/images/weather/wi-day-sunny.svg">--}} {{-- <img class="weather-icon" src="{{ config('services.trmnl.base_url') }}/images/weather/wi-day-sunny.svg">--}}
</div> </div>
<div class="content"> <div class="content">
<span <span

View file

@ -1,7 +1,6 @@
{{--@dump($data)--}} {{--@dump($data)--}}
@props(['size' => 'full']) @props(['size' => 'full'])
<x-trmnl::view size="{{ $size }}"> <x-trmnl::view size="{{ $size }}">
<x-trmnl::layout>
<x-trmnl::layout class="layout--col"> <x-trmnl::layout class="layout--col">
<div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div> <div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div>
@if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant') @if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
@ -10,7 +9,6 @@
<p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p> <p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p>
@endif @endif
</x-trmnl::layout> </x-trmnl::layout>
</x-trmnl::layout>
<div class="title_bar"> <div class="title_bar">
<img class="image" src="https://img.icons8.com/books"/> <img class="image" src="https://img.icons8.com/books"/>

View file

@ -6,18 +6,22 @@
'deviceOrientation' => null, 'deviceOrientation' => null,
'colorDepth' => '1bit', 'colorDepth' => '1bit',
'scaleLevel' => null, 'scaleLevel' => null,
'cssVariables' => null,
]) ])
@if(config('app.puppeteer_window_size_strategy') === 'v2') @if(config('app.puppeteer_window_size_strategy') === 'v2')
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}" <x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"> scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}"> <x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
{!! $slot !!} {!! $slot !!}
</x-trmnl::mashup> </x-trmnl::mashup>
</x-trmnl::screen> </x-trmnl::screen>
@else @else
<x-trmnl::screen colorDepth="{{$colorDepth}}"> <x-trmnl::screen colorDepth="{{$colorDepth}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}"> <x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
{!! $slot !!} {!! $slot !!}
</x-trmnl::mashup> </x-trmnl::mashup>

View file

@ -5,16 +5,21 @@
'deviceOrientation' => null, 'deviceOrientation' => null,
'colorDepth' => '1bit', 'colorDepth' => '1bit',
'scaleLevel' => null, 'scaleLevel' => null,
'cssVariables' => null,
]) ])
@if(config('app.puppeteer_window_size_strategy') === 'v2') @if(config('app.puppeteer_window_size_strategy') === 'v2')
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}" <x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"> scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
{!! $slot !!} {!! $slot !!}
</x-trmnl::screen> </x-trmnl::screen>
@else @else
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"> <x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
{!! $slot !!} {!! $slot !!}
</x-trmnl::screen> </x-trmnl::screen>
@endif @endif

View file

@ -1,10 +1,11 @@
@props([ @props([
'noBleed' => false, 'noBleed' => false,
'darkMode' => false, 'darkMode' => false,
'deviceVariant' => 'og', 'deviceVariant' => 'ogv2',
'deviceOrientation' => null, 'deviceOrientation' => null,
'colorDepth' => '1bit', 'colorDepth' => '1bit',
'scaleLevel' => null, 'scaleLevel' => null,
'cssVariables' => null,
]) ])
<!DOCTYPE html> <!DOCTYPE html>
@ -18,14 +19,23 @@
href="{{ config('trmnl-blade.framework_css_url') }}"> href="{{ config('trmnl-blade.framework_css_url') }}">
@else @else
<link rel="stylesheet" <link rel="stylesheet"
href="https://usetrmnl.com/css/{{ config('trmnl-blade.framework_version', '1.2.0') }}/plugins.css"> href="{{ config('services.trmnl.base_url') }}/css/{{ config('trmnl-blade.framework_css_version') ?? config('trmnl-blade.framework_version', '2.1.0') }}/plugins.css">
@endif @endif
@if (config('trmnl-blade.framework_js_url')) @if (config('trmnl-blade.framework_js_url'))
<script src="{{ config('trmnl-blade.framework_js_url') }}"></script> <script src="{{ config('trmnl-blade.framework_js_url') }}"></script>
@else @else
<script src="https://usetrmnl.com/js/{{ config('trmnl-blade.framework_version', '1.2.0') }}/plugins.js"></script> <script src="{{ config('services.trmnl.base_url') }}/js/{{ config('trmnl-blade.framework_js_version') ?? config('trmnl-blade.framework_version', '2.1.0') }}/plugins.js"></script>
@endif @endif
<title>{{ $title ?? config('app.name') }}</title> <title>{{ $title ?? config('app.name') }}</title>
@if(config('app.puppeteer_window_size_strategy') === 'v2' && !empty($cssVariables) && is_array($cssVariables))
<style>
:root {
@foreach($cssVariables as $name => $value)
{{ $name }}: {{ $value }};
@endforeach
}
</style>
@endif
</head> </head>
<body class="environment trmnl"> <body class="environment trmnl">
<div class="screen {{ $noBleed ? 'screen--no-bleed' : '' }} {{ $darkMode ? 'dark-mode' : '' }} {{ $deviceVariant ? 'screen--' . $deviceVariant : '' }} {{ $deviceOrientation ? 'screen--' . $deviceOrientation : '' }} {{ $colorDepth ? 'screen--' . $colorDepth : '' }} {{ $scaleLevel ? 'screen--scale-' . $scaleLevel : '' }}"> <div class="screen {{ $noBleed ? 'screen--no-bleed' : '' }} {{ $darkMode ? 'dark-mode' : '' }} {{ $deviceVariant ? 'screen--' . $deviceVariant : '' }} {{ $deviceOrientation ? 'screen--' . $deviceOrientation : '' }} {{ $colorDepth ? 'screen--' . $colorDepth : '' }} {{ $scaleLevel ? 'screen--scale-' . $scaleLevel : '' }}">

View file

@ -1,6 +1,6 @@
<x-layouts::auth.card> <x-layouts::auth.card>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<x-auth-header title="TRMNL BYOS Laravel" description="Server is up and running."/> <x-auth-header title="LaraPaper" description="Server is up and running."/>
</div> </div>
<header class="w-full lg:max-w-4xl max-w-[335px] text-sm mt-6 not-has-[nav]:hidden"> <header class="w-full lg:max-w-4xl max-w-[335px] text-sm mt-6 not-has-[nav]:hidden">
@if (Route::has('login')) @if (Route::has('login'))
@ -32,6 +32,11 @@
@endif @endif
</header> </header>
@auth @auth
@if(config('app.version'))
<flux:text class="text-xs">Version: <a href="https://github.com/{{ config('app.github_repo') }}/releases/tag/{{ config('app.version') }}"
target="_blank">{{ config('app.version') }}</a>
</flux:text>
@endif
<livewire:update-check /> <livewire:update-check />
@endauth @endauth
</x-layouts::auth.card> </x-layouts::auth.card>

View file

@ -49,8 +49,9 @@ Route::get('/display', function (Request $request) {
'last_refreshed_at' => now(), 'last_refreshed_at' => now(),
]); ]);
if ($request->hasHeader('battery-percent')) { $batteryPercent = $request->header('battery-percent') ?? $request->header('percent-charged');
$batteryPercent = (int) $request->header('battery-percent'); if ($batteryPercent !== null) {
$batteryPercent = (int) $batteryPercent;
$batteryVoltage = $device->calculateVoltageFromPercent($batteryPercent); $batteryVoltage = $device->calculateVoltageFromPercent($batteryPercent);
$device->update([ $device->update([
'last_battery_voltage' => $batteryVoltage, 'last_battery_voltage' => $batteryVoltage,
@ -87,8 +88,8 @@ Route::get('/display', function (Request $request) {
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
$plugin = $playlistItem->plugin; $plugin = $playlistItem->plugin;
// Reset cache if Devices with different dimensions exist ImageGenerationService::resetIfNotCacheable($plugin, $device);
ImageGenerationService::resetIfNotCacheable($plugin); $plugin->refresh();
// 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) {
@ -194,6 +195,7 @@ Route::get('/display', function (Request $request) {
'update_firmware' => $device->update_firmware, 'update_firmware' => $device->update_firmware,
'firmware_url' => $device->firmware_url, 'firmware_url' => $device->firmware_url,
'special_function' => $device->special_function ?? 'sleep', 'special_function' => $device->special_function ?? 'sleep',
'maximum_compatibility' => $device->maximum_compatibility,
]; ];
if (config('services.trmnl.image_url_timeout')) { if (config('services.trmnl.image_url_timeout')) {
@ -697,6 +699,9 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
], 404); ], 404);
} }
ImageGenerationService::resetIfNotCacheable($plugin, $deviceModel);
$plugin->refresh();
// Check if we can use cached image (only for og_png and if data is not stale) // 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; $useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
@ -742,9 +747,13 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
palette: $deviceModel->palette palette: $deviceModel->palette
); );
// Update plugin cache if using og_png // Update plugin cache if using og_png (recipes only get metadata for cache comparison)
if ($deviceModelName === 'og_png') { if ($deviceModelName === 'og_png') {
$plugin->update(['current_image' => $imageUuid]); $update = ['current_image' => $imageUuid];
if ($plugin->plugin_type === 'recipe') {
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDeviceModel($deviceModel);
}
$plugin->update($update);
} }
// Return the generated image // Return the generated image

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -45,6 +45,7 @@ test('device can fetch display data with valid credentials', function (): void {
'update_firmware' => false, 'update_firmware' => false,
'firmware_url' => null, 'firmware_url' => null,
'special_function' => 'sleep', 'special_function' => 'sleep',
'maximum_compatibility' => false,
]); ]);
expect($device->fresh()) expect($device->fresh())
@ -95,6 +96,27 @@ test('display endpoint omits image_url_timeout when not configured', function ()
->assertJsonMissing(['image_url_timeout']); ->assertJsonMissing(['image_url_timeout']);
}); });
test('display endpoint includes maximum_compatibility value when true for device', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'maximum_compatibility' => true,
]);
$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()
->assertJson([
'maximum_compatibility' => true,
]);
});
test('new device is auto-assigned to user with auto-assign enabled', function (): void { test('new device is auto-assigned to user with auto-assign enabled', function (): void {
$user = User::factory()->create(['assign_new_devices' => true]); $user = User::factory()->create(['assign_new_devices' => true]);
@ -703,6 +725,40 @@ test('display endpoint updates last_refreshed_at timestamp', function (): void {
->and($device->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2); ->and($device->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2);
}); });
test('display endpoint accepts battery-percent header and updates device', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:56',
'api_key' => 'test-api-key-battery',
'last_battery_voltage' => null,
]);
$this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'battery-percent' => '67',
])->get('/api/display')->assertOk();
$device->refresh();
expect($device->battery_percent)->toEqual(67);
});
test('display endpoint accepts Percent-Charged header and updates device', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:57',
'api_key' => 'test-api-key-percent-charged',
'last_battery_voltage' => null,
]);
$this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'Percent-Charged' => '51',
])->get('/api/display')->assertOk();
$device->refresh();
expect($device->battery_percent)->toEqual(51);
});
test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function (): void { test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function (): void {
// Create source device // Create source device
$sourceDevice = Device::factory()->create([ $sourceDevice = Device::factory()->create([

View file

@ -17,8 +17,10 @@ test('firmware check command has correct signature', function (): void {
test('firmware check command runs without errors', function (): void { test('firmware check command runs without errors', function (): void {
// Mock the firmware API response // Mock the firmware API response
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'version' => '1.0.0', 'version' => '1.0.0',
'url' => 'https://example.com/firmware.bin', 'url' => 'https://example.com/firmware.bin',
], 200), ], 200),
@ -33,8 +35,10 @@ test('firmware check command runs without errors', function (): void {
test('firmware check command runs with download flag', function (): void { test('firmware check command runs with download flag', function (): void {
// Mock the firmware API response // Mock the firmware API response
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'version' => '1.0.0', 'version' => '1.0.0',
'url' => 'https://example.com/firmware.bin', 'url' => 'https://example.com/firmware.bin',
], 200), ], 200),
@ -57,8 +61,10 @@ test('firmware check command runs with download flag', function (): void {
test('firmware check command can run successfully', function (): void { test('firmware check command can run successfully', function (): void {
// Mock the firmware API response // Mock the firmware API response
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'version' => '1.0.0', 'version' => '1.0.0',
'url' => 'https://example.com/firmware.bin', 'url' => 'https://example.com/firmware.bin',
], 200), ], 200),

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
use App\Models\DeviceModel; use App\Models\DeviceModel;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
it('allows a user to view the device models page', function (): void { it('allows a user to view the device models page', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
@ -87,3 +89,38 @@ it('redirects unauthenticated users from the device models page', function (): v
$response->assertRedirect('/login'); $response->assertRedirect('/login');
}); });
it('update from API runs job and refreshes device models', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
Http::fake([
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [
[
'name' => 'api-model',
'label' => 'API Model',
'description' => 'From API',
'width' => 800,
'height' => 480,
'colors' => 4,
'bit_depth' => 2,
'scale_factor' => 1.0,
'rotation' => 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
'kind' => 'trmnl',
'published_at' => '2023-01-01T00:00:00Z',
],
],
], 200),
]);
$component = Livewire::test('device-models.index')
->call('updateFromApi');
$deviceModels = $component->get('deviceModels');
expect($deviceModels->pluck('name')->toArray())->toContain('api-model');
});

View file

@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Models\Device; use App\Models\Device;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use function Pest\Laravel\actingAs; use function Pest\Laravel\actingAs;
@ -23,3 +24,35 @@ test('configure view displays last_refreshed_at timestamp', function (): void {
$response->assertOk() $response->assertOk()
->assertSee('5 minutes ago'); ->assertSee('5 minutes ago');
}); });
test('configure edit modal shows mirror checkbox and allows unchecking mirror', function (): void {
$user = User::factory()->create();
actingAs($user);
$deviceAttributes = [
'user_id' => $user->id,
'width' => 800,
'height' => 480,
'rotate' => 0,
'image_format' => 'png',
'maximum_compatibility' => false,
];
$sourceDevice = Device::factory()->create($deviceAttributes);
$mirrorDevice = Device::factory()->create([
...$deviceAttributes,
'mirror_device_id' => $sourceDevice->id,
]);
$response = $this->get(route('devices.configure', $mirrorDevice));
$response->assertOk()
->assertSee('Mirrors Device')
->assertSee('Select Device to Mirror');
Livewire::test('devices.configure', ['device' => $mirrorDevice])
->set('is_mirror', false)
->call('updateDevice')
->assertHasNoErrors();
$mirrorDevice->refresh();
expect($mirrorDevice->mirror_device_id)->toBeNull();
});

View file

@ -2,6 +2,8 @@
use App\Jobs\GenerateScreenJob; use App\Jobs\GenerateScreenJob;
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceModel;
use App\Models\Plugin;
use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -58,3 +60,26 @@ test('it preserves gitignore file during cleanup', function (): void {
Storage::disk('public')->assertExists('/images/generated/.gitignore'); Storage::disk('public')->assertExists('/images/generated/.gitignore');
}); });
test('it saves current_image_metadata for recipe plugins', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$plugin = Plugin::factory()->create(['plugin_type' => 'recipe']);
$job = new GenerateScreenJob($device->id, $plugin->id, '<div>Test</div>');
$job->handle();
$plugin->refresh();
expect($plugin->current_image)->not->toBeNull();
expect($plugin->current_image_metadata)->toBeArray();
expect($plugin->current_image_metadata)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($plugin->current_image_metadata['width'])->toBe(800);
expect($plugin->current_image_metadata['height'])->toBe(480);
expect($plugin->current_image_metadata['mime_type'])->toBe('image/png');
});

View file

@ -8,6 +8,7 @@ use App\Models\DeviceModel;
use App\Services\ImageGenerationService; use App\Services\ImageGenerationService;
use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -22,6 +23,10 @@ afterEach(function (): void {
TrmnlPipeline::restore(); TrmnlPipeline::restore();
}); });
it('plugins table has current_image_metadata column', function (): void {
expect(Schema::hasColumn('plugins', 'current_image_metadata'))->toBeTrue();
});
it('generates image for device without device model', function (): void { it('generates image for device without device model', function (): void {
// Create a device without a DeviceModel (legacy behavior) // Create a device without a DeviceModel (legacy behavior)
$device = Device::factory()->create([ $device = Device::factory()->create([
@ -270,39 +275,15 @@ it('cleanupFolder preserves .gitignore', function (): void {
Storage::disk('public')->assertExists('/images/generated/.gitignore'); Storage::disk('public')->assertExists('/images/generated/.gitignore');
}); });
it('resetIfNotCacheable resets when device models exist', function (): void { it('resetIfNotCacheable does not reset recipe cache based on other devices', function (): void {
// Create a plugin // Cache validity is now determined at use-time via current_image_metadata
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']); $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']);
// Create a device with DeviceModel (should trigger cache reset) Device::factory()->create(['device_model_id' => DeviceModel::factory()->create()->id]);
Device::factory()->create([
'device_model_id' => DeviceModel::factory()->create()->id,
]);
// Run reset check
ImageGenerationService::resetIfNotCacheable($plugin); ImageGenerationService::resetIfNotCacheable($plugin);
// Assert plugin image was reset
$plugin->refresh(); $plugin->refresh();
expect($plugin->current_image)->toBeNull(); expect($plugin->current_image)->toBe('test-uuid');
});
it('resetIfNotCacheable resets when custom dimensions exist', function (): void {
// Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
// Create a device with custom dimensions (should trigger cache reset)
Device::factory()->create([
'width' => 1024, // Different from default 800
'height' => 768, // Different from default 480
]);
// Run reset check
ImageGenerationService::resetIfNotCacheable($plugin);
// Assert plugin image was reset
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
}); });
it('resetIfNotCacheable preserves image for standard devices', function (): void { it('resetIfNotCacheable preserves image for standard devices', function (): void {
@ -325,27 +306,122 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
}); });
it('cache is reset when plugin markup changes', function (): void { it('cache is reset when plugin markup changes', function (): void {
// Create a plugin with cached image // Create a plugin with cached image and metadata
$plugin = App\Models\Plugin::factory()->create([ $plugin = App\Models\Plugin::factory()->create([
'current_image' => 'cached-uuid', 'current_image' => 'cached-uuid',
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
'render_markup' => '<div>Original markup</div>', 'render_markup' => '<div>Original markup</div>',
]); ]);
// Create devices with standard dimensions (cacheable) $plugin->update(['render_markup' => '<div>Updated markup</div>']);
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(); $plugin->refresh();
expect($plugin->current_image)->toBeNull(); expect($plugin->current_image)->toBeNull();
expect($plugin->current_image_metadata)->toBeNull();
});
it('buildImageMetadataFromDevice returns canonical metadata shape', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($meta['width'])->toBe(800);
expect($meta['height'])->toBe(480);
expect($meta['rotation'])->toBe(0);
expect($meta['mime_type'])->toBe('image/png');
});
it('buildImageMetadataFromDeviceModel returns canonical metadata shape', function (): void {
$model = DeviceModel::factory()->create([
'width' => 1024,
'height' => 768,
'rotation' => 90,
'mime_type' => 'image/bmp',
'palette_id' => null,
]);
$meta = ImageGenerationService::buildImageMetadataFromDeviceModel($model);
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($meta['width'])->toBe(1024);
expect($meta['height'])->toBe(768);
expect($meta['rotation'])->toBe(90);
expect($meta['mime_type'])->toBe('image/bmp');
});
it('imageMetadataMatches returns false when stored is null or empty', function (): void {
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
expect(ImageGenerationService::imageMetadataMatches(null, $device))->toBeFalse();
expect(ImageGenerationService::imageMetadataMatches([], $device))->toBeFalse();
});
it('imageMetadataMatches returns true when metadata matches device', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$stored = ImageGenerationService::buildImageMetadataFromDevice($device);
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeTrue();
});
it('imageMetadataMatches returns false when metadata differs', function (): void {
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
$stored = ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'];
$device->update(['width' => 1024]);
$device->refresh();
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeFalse();
});
it('resetIfNotCacheable clears recipe cache when metadata does not match', function (): void {
$plugin = App\Models\Plugin::factory()->create([
'plugin_type' => 'recipe',
'current_image' => 'cached-uuid',
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
]);
$device = Device::factory()->create(['width' => 1024, 'height' => 768, 'rotate' => 0]);
ImageGenerationService::resetIfNotCacheable($plugin, $device);
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
expect($plugin->current_image_metadata)->toBeNull();
});
it('resetIfNotCacheable preserves cache when metadata matches', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
$plugin = App\Models\Plugin::factory()->create([
'plugin_type' => 'recipe',
'current_image' => 'cached-uuid',
'current_image_metadata' => $meta,
]);
ImageGenerationService::resetIfNotCacheable($plugin, $device);
$plugin->refresh();
expect($plugin->current_image)->toBe('cached-uuid');
}); });
it('determines correct image format from device model', function (): void { it('determines correct image format from device model', function (): void {

View file

@ -29,7 +29,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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [ 'data' => [
[ [
'name' => 'test-model', 'name' => 'test-model',
@ -82,7 +82,7 @@ test('fetch device models job handles successful api response', function (): voi
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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [ 'data' => [
[ [
'name' => 'model-1', 'name' => 'model-1',
@ -136,7 +136,7 @@ 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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [], 'data' => [],
], 200), ], 200),
]); ]);
@ -158,7 +158,7 @@ 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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'message' => 'No data available', 'message' => 'No data available',
], 200), ], 200),
]); ]);
@ -180,7 +180,7 @@ 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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => 'invalid-data', 'data' => 'invalid-data',
], 200), ], 200),
]); ]);
@ -202,7 +202,7 @@ 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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'error' => 'Internal Server Error', 'error' => 'Internal Server Error',
], 500), ], 500),
]); ]);
@ -227,7 +227,7 @@ 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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => function (): void { config('services.trmnl.base_url').'/api/models' => function (): void {
throw new Exception('Network connection failed'); throw new Exception('Network connection failed');
}, },
]); ]);
@ -249,7 +249,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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [ 'data' => [
[ [
'label' => 'Model without name', 'label' => 'Model without name',
@ -280,7 +280,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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [ 'data' => [
[ [
'name' => 'minimal-model', 'name' => 'minimal-model',
@ -329,7 +329,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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [ 'data' => [
[ [
'name' => 'existing-model', 'name' => 'existing-model',
@ -372,7 +372,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/palettes' => Http::response(['data' => []], 200),
'usetrmnl.com/api/models' => Http::response([ config('services.trmnl.base_url').'/api/models' => Http::response([
'data' => [ 'data' => [
[ [
'name' => 'valid-model', 'name' => 'valid-model',

View file

@ -10,8 +10,10 @@ beforeEach(function (): void {
}); });
test('it creates new firmware record when polling', function (): void { test('it creates new firmware record when polling', function (): void {
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'version' => '1.0.0', 'version' => '1.0.0',
'url' => 'https://example.com/firmware.bin', 'url' => 'https://example.com/firmware.bin',
], 200), ], 200),
@ -32,8 +34,10 @@ test('it updates existing firmware record when polling', function (): void {
'latest' => true, 'latest' => true,
]); ]);
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'version' => '1.0.0', 'version' => '1.0.0',
'url' => 'https://new-url.com/firmware.bin', 'url' => 'https://new-url.com/firmware.bin',
], 200), ], 200),
@ -52,8 +56,10 @@ test('it marks previous firmware as not latest when new version is found', funct
'latest' => true, 'latest' => true,
]); ]);
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'version' => '1.1.0', 'version' => '1.1.0',
'url' => 'https://example.com/firmware.bin', 'url' => 'https://example.com/firmware.bin',
], 200), ], 200),
@ -66,8 +72,10 @@ test('it marks previous firmware as not latest when new version is found', funct
}); });
test('it handles connection exception gracefully', function (): void { test('it handles connection exception gracefully', function (): void {
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => function (): void { $baseUrl.'/api/firmware/latest' => function (): void {
throw new ConnectionException('Connection failed'); throw new ConnectionException('Connection failed');
}, },
]); ]);
@ -79,8 +87,10 @@ test('it handles connection exception gracefully', function (): void {
}); });
test('it handles invalid response gracefully', function (): void { test('it handles invalid response gracefully', function (): void {
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response(null, 200), $baseUrl.'/api/firmware/latest' => Http::response(null, 200),
]); ]);
(new FirmwarePollJob)->handle(); (new FirmwarePollJob)->handle();
@ -90,8 +100,10 @@ test('it handles invalid response gracefully', function (): void {
}); });
test('it handles missing version in response gracefully', function (): void { test('it handles missing version in response gracefully', function (): void {
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'url' => 'https://example.com/firmware.bin', 'url' => 'https://example.com/firmware.bin',
], 200), ], 200),
]); ]);
@ -103,8 +115,10 @@ test('it handles missing version in response gracefully', function (): void {
}); });
test('it handles missing url in response gracefully', function (): void { test('it handles missing url in response gracefully', function (): void {
$baseUrl = config('services.trmnl.base_url');
Http::fake([ Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([ $baseUrl.'/api/firmware/latest' => Http::response([
'version' => '1.0.0', 'version' => '1.0.0',
], 200), ], 200),
]); ]);

View file

@ -8,7 +8,7 @@ use Livewire\Livewire;
it('loads newest TRMNL recipes on mount', function (): void { it('loads newest TRMNL recipes on mount', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([ config('services.trmnl.base_url').'/recipes.json*' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 123, 'id' => 123,
@ -33,7 +33,7 @@ it('loads newest TRMNL recipes on mount', function (): void {
it('shows preview button when screenshot_url is provided', function (): void { it('shows preview button when screenshot_url is provided', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([ config('services.trmnl.base_url').'/recipes.json*' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 123, 'id' => 123,
@ -57,7 +57,7 @@ it('shows preview button when screenshot_url is provided', function (): void {
it('searches TRMNL recipes when search term is provided', function (): void { it('searches TRMNL recipes when search term is provided', function (): void {
Http::fake([ Http::fake([
// First call (mount -> newest) // First call (mount -> newest)
'usetrmnl.com/recipes.json?*' => Http::sequence() config('services.trmnl.base_url').'/recipes.json?*' => Http::sequence()
->push([ ->push([
'data' => [ 'data' => [
[ [
@ -98,7 +98,7 @@ it('installs plugin successfully when user is authenticated', function (): void
$user = User::factory()->create(); $user = User::factory()->create();
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([ config('services.trmnl.base_url').'/recipes.json*' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 123, 'id' => 123,
@ -110,7 +110,7 @@ it('installs plugin successfully when user is authenticated', function (): void
], ],
], ],
], 200), ], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200), config('services.trmnl.base_url').'/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -125,7 +125,7 @@ it('installs plugin successfully when user is authenticated', function (): void
it('shows error when user is not authenticated', function (): void { it('shows error when user is not authenticated', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([ config('services.trmnl.base_url').'/recipes.json*' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 123, 'id' => 123,
@ -151,7 +151,7 @@ it('shows error when plugin installation fails', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([ config('services.trmnl.base_url').'/recipes.json*' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 123, 'id' => 123,
@ -163,7 +163,7 @@ it('shows error when plugin installation fails', function (): void {
], ],
], ],
], 200), ], 200),
'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200), config('services.trmnl.base_url').'/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200),
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -178,7 +178,7 @@ it('shows error when plugin installation fails', function (): void {
it('previews a recipe with async fetch', function (): void { it('previews a recipe with async fetch', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json*' => Http::response([ config('services.trmnl.base_url').'/recipes.json*' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 123, 'id' => 123,
@ -190,7 +190,7 @@ it('previews a recipe with async fetch', function (): void {
], ],
], ],
], 200), ], 200),
'usetrmnl.com/recipes/123.json' => Http::response([ config('services.trmnl.base_url').'/recipes/123.json' => Http::response([
'data' => [ 'data' => [
'id' => 123, 'id' => 123,
'name' => 'Weather Chum Updated', 'name' => 'Weather Chum Updated',
@ -216,7 +216,7 @@ it('previews a recipe with async fetch', function (): void {
it('supports pagination and loading more recipes', function (): void { it('supports pagination and loading more recipes', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([ config('services.trmnl.base_url').'/recipes.json?sort-by=newest&page=1' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 1, 'id' => 1,
@ -229,7 +229,7 @@ it('supports pagination and loading more recipes', function (): void {
], ],
'next_page_url' => '/recipes.json?page=2', 'next_page_url' => '/recipes.json?page=2',
], 200), ], 200),
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([ config('services.trmnl.base_url').'/recipes.json?sort-by=newest&page=2' => Http::response([
'data' => [ 'data' => [
[ [
'id' => 2, 'id' => 2,
@ -258,7 +258,7 @@ it('supports pagination and loading more recipes', function (): void {
it('resets pagination when search term changes', function (): void { it('resets pagination when search term changes', function (): void {
Http::fake([ Http::fake([
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence() config('services.trmnl.base_url').'/recipes.json?sort-by=newest&page=1' => Http::sequence()
->push([ ->push([
'data' => [['id' => 1, 'name' => 'Initial 1']], 'data' => [['id' => 1, 'name' => 'Initial 1']],
'next_page_url' => '/recipes.json?page=2', 'next_page_url' => '/recipes.json?page=2',
@ -267,7 +267,7 @@ it('resets pagination when search term changes', function (): void {
'data' => [['id' => 3, 'name' => 'Initial 1 Again']], 'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
'next_page_url' => null, 'next_page_url' => null,
]), ]),
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([ config('services.trmnl.base_url').'/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
'data' => [['id' => 2, 'name' => 'Weather Result']], 'data' => [['id' => 2, 'name' => 'Weather Result']],
'next_page_url' => null, 'next_page_url' => null,
]), ]),

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
use App\Models\DevicePalette; use App\Models\DevicePalette;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Http;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class); uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -570,3 +571,29 @@ test('component refreshes palette list after deleting', function (): void {
expect($palettes)->toHaveCount($initialCount + 1); expect($palettes)->toHaveCount($initialCount + 1);
expect(DevicePalette::count())->toBe($initialCount + 1); expect(DevicePalette::count())->toBe($initialCount + 1);
}); });
test('update from API runs job and refreshes device palettes', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
Http::fake([
'usetrmnl.com/api/palettes' => Http::response([
'data' => [
[
'id' => 'api-palette',
'name' => 'API Palette',
'grays' => 4,
'colors' => null,
'framework_class' => '',
],
],
], 200),
config('services.trmnl.base_url').'/api/models' => Http::response(['data' => []], 200),
]);
$component = Livewire::test('device-palettes.index')
->call('updateFromApi');
$devicePalettes = $component->get('devicePalettes');
expect($devicePalettes->pluck('name')->toArray())->toContain('api-palette');
});

View file

@ -109,3 +109,124 @@ test('recipe settings can clear trmnlp_id', function (): void {
expect($plugin->fresh()->trmnlp_id)->toBeNull(); expect($plugin->fresh()->trmnlp_id)->toBeNull();
}); });
test('recipe settings saves preferred_renderer when liquid enabled and recipe is liquid', function (): void {
config(['services.trmnl.liquid_enabled' => true]);
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'preferred_renderer' => null,
]);
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('use_trmnl_liquid_renderer', true)
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin->fresh()->preferred_renderer)->toBe('trmnl-liquid');
});
test('recipe settings clears preferred_renderer when checkbox unchecked', function (): void {
config(['services.trmnl.liquid_enabled' => true]);
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'markup_language' => 'liquid',
'preferred_renderer' => 'trmnl-liquid',
]);
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('use_trmnl_liquid_renderer', false)
->call('saveTrmnlpId')
->assertHasNoErrors();
expect($plugin->fresh()->preferred_renderer)->toBeNull();
});
test('recipe settings saves configuration_template from valid YAML', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => [],
]);
$yaml = "- keyname: reading_days\n field_type: text\n name: Reading Days\n";
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('configurationTemplateYaml', $yaml)
->call('saveTrmnlpId')
->assertHasNoErrors();
$expected = [
'custom_fields' => [
[
'keyname' => 'reading_days',
'field_type' => 'text',
'name' => 'Reading Days',
],
],
];
expect($plugin->fresh()->configuration_template)->toBe($expected);
});
test('recipe settings validates invalid YAML', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => [],
]);
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('configurationTemplateYaml', "foo: bar: baz\n")
->call('saveTrmnlpId')
->assertHasErrors(['configurationTemplateYaml']);
expect($plugin->fresh()->configuration_template)->toBe([]);
});
test('recipe settings validates YAML must evaluate to object or array', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => ['custom_fields' => []],
]);
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('configurationTemplateYaml', '123')
->call('saveTrmnlpId')
->assertHasErrors(['configurationTemplateYaml']);
expect($plugin->fresh()->configuration_template)->toBe(['custom_fields' => []]);
});
test('recipe settings validates each custom field has field_type and name', function (): void {
$user = User::factory()->create();
$this->actingAs($user);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'configuration_template' => [],
]);
$yaml = "- keyname: only_key\n field_type: text\n name: Has Name\n- keyname: missing_type\n name: No type\n";
Livewire::test('plugins.recipes.settings', ['plugin' => $plugin])
->set('configurationTemplateYaml', $yaml)
->call('saveTrmnlpId')
->assertHasErrors(['configurationTemplateYaml']);
expect($plugin->fresh()->configuration_template)->toBeEmpty();
});

View file

@ -0,0 +1,46 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Config;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('auth pages show pixel logo SVG when pixel_logo_enabled is true', function (): void {
Config::set('app.pixel_logo_enabled', true);
$response = $this->get('/login');
$response->assertStatus(200);
$response->assertSee('viewBox="0 0 1000 150"', false);
});
test('auth pages show heading instead of pixel logo when pixel_logo_enabled is false', function (): void {
Config::set('app.pixel_logo_enabled', false);
$response = $this->get('/login');
$response->assertStatus(200);
$response->assertDontSee('viewBox="0 0 1000 150"', false);
$response->assertSee('LaraPaper');
});
test('app logo shows text when pixel_logo_enabled is false', function (): void {
Config::set('app.pixel_logo_enabled', false);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertStatus(200);
$response->assertSee('LaraPaper');
$response->assertDontSee('viewBox="0 0 1000 150"', false);
});
test('app logo shows pixel logo SVG when pixel_logo_enabled is true', function (): void {
Config::set('app.pixel_logo_enabled', true);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertStatus(200);
$response->assertSee('viewBox="0 0 1000 150"', false);
});

View file

@ -52,8 +52,10 @@ it('imports plugin with shared.liquid file', function (): void {
$pluginImportService = new PluginImportService(); $pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user); $plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->render_markup)->toContain('{% comment %}Shared styles{% endcomment %}') expect($plugin->render_markup_shared)->toBe('{% comment %}Shared styles{% endcomment %}')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">'); ->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Shared styles{% endcomment %}')
->and($plugin->getMarkupForSize('full'))->toContain('<div class="view view--{{ size }}">');
}); });
it('imports plugin with files in root directory', function (): void { it('imports plugin with files in root directory', function (): void {
@ -202,8 +204,10 @@ it('imports plugin from monorepo with shared.liquid in subdirectory', function (
$pluginImportService = new PluginImportService(); $pluginImportService = new PluginImportService();
$plugin = $pluginImportService->importFromZip($zipFile, $user); $plugin = $pluginImportService->importFromZip($zipFile, $user);
expect($plugin->render_markup)->toContain('{% comment %}Monorepo shared styles{% endcomment %}') expect($plugin->render_markup_shared)->toBe('{% comment %}Monorepo shared styles{% endcomment %}')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">'); ->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Monorepo shared styles{% endcomment %}')
->and($plugin->getMarkupForSize('full'))->toContain('<div class="view view--{{ size }}">');
}); });
it('imports plugin from URL with zip_entry_path parameter', function (): void { it('imports plugin from URL with zip_entry_path parameter', function (): void {
@ -352,8 +356,10 @@ it('imports specific plugin from monorepo zip with zip_entry_path parameter', fu
expect($plugin)->toBeInstanceOf(Plugin::class) expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->user_id)->toBe($user->id) ->and($plugin->user_id)->toBe($user->id)
->and($plugin->name)->toBe('Example Plugin 2') // Should import example-plugin2, not example-plugin ->and($plugin->name)->toBe('Example Plugin 2') // Should import example-plugin2, not example-plugin
->and($plugin->render_markup)->toContain('{% comment %}Plugin 2 shared styles{% endcomment %}') ->and($plugin->render_markup_shared)->toBe('{% comment %}Plugin 2 shared styles{% endcomment %}')
->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>')
->and($plugin->getMarkupForSize('full'))->toContain('{% comment %}Plugin 2 shared styles{% endcomment %}')
->and($plugin->getMarkupForSize('full'))->toContain('<div class="plugin2-content">Plugin 2 content</div>');
}); });
it('sets icon_url when importing from URL with iconUrl parameter', function (): void { it('sets icon_url when importing from URL with iconUrl parameter', function (): void {
@ -516,8 +522,8 @@ it('imports plugin with only shared.liquid file', function (): void {
expect($plugin)->toBeInstanceOf(Plugin::class) expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->markup_language)->toBe('liquid') ->and($plugin->markup_language)->toBe('liquid')
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">') ->and($plugin->render_markup_shared)->toBe('<div class="shared-content">{{ data.title }}</div>')
->and($plugin->render_markup)->toContain('<div class="shared-content">{{ data.title }}</div>'); ->and($plugin->render_markup)->toBeNull();
}); });
it('imports plugin with only shared.blade.php file', function (): void { it('imports plugin with only shared.blade.php file', function (): void {
@ -535,8 +541,8 @@ it('imports plugin with only shared.blade.php file', function (): void {
expect($plugin)->toBeInstanceOf(Plugin::class) expect($plugin)->toBeInstanceOf(Plugin::class)
->and($plugin->markup_language)->toBe('blade') ->and($plugin->markup_language)->toBe('blade')
->and($plugin->render_markup)->toBe('<div class="shared-content">{{ $data["title"] }}</div>') ->and($plugin->render_markup_shared)->toBe('<div class="shared-content">{{ $data["title"] }}</div>')
->and($plugin->render_markup)->not->toContain('<div class="view view--{{ size }}">'); ->and($plugin->render_markup)->toBeNull();
}); });
// Helper methods // Helper methods

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Models\Plugin; use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -238,3 +239,34 @@ LIQUID
$this->assertStringContainsString('"35":[{"name":"Ryan","age":35}]', $result); $this->assertStringContainsString('"35":[{"name":"Ryan","age":35}]', $result);
$this->assertStringContainsString('"29":[{"name":"Sara","age":29},{"name":"Jimbob","age":29}]', $result); $this->assertStringContainsString('"29":[{"name":"Sara","age":29},{"name":"Jimbob","age":29}]', $result);
}); });
test('shared template receives trmnl context when', function (): void {
$user = User::factory()->create(['name' => 'Jane Smith']);
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'name' => 'Departures',
'markup_language' => 'liquid',
'render_markup_shared' => <<<'LIQUID'
{% template departures_view %}
<div class="title_bar">
<span class="title">Departures</span>
<span class="instance">{{ trmnl.user.name }}</span>
</div>
{% endtemplate %}
LIQUID
,
'render_markup' => <<<'LIQUID'
<div class="view">
{% render "departures_view", station: "Hauptbahnhof" %}
</div>
LIQUID
,
'data_payload' => [],
]);
$result = $plugin->render('full');
$this->assertStringContainsString('Jane Smith', $result);
$this->assertStringContainsString('class="instance"', $result);
});

View file

@ -227,7 +227,7 @@ test('hasMissingRequiredConfigurationFields returns true when required xhrSelect
'field_type' => 'xhrSelect', 'field_type' => 'xhrSelect',
'name' => 'Baseball Team', 'name' => 'Baseball Team',
'description' => 'Select your team', 'description' => 'Select your team',
'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json', 'endpoint' => config('services.trmnl.base_url').'/custom_plugin_example_xhr_select.json',
// Not marked as optional, so it's required // Not marked as optional, so it's required
], ],
], ],
@ -252,7 +252,7 @@ test('hasMissingRequiredConfigurationFields returns false when required xhrSelec
'field_type' => 'xhrSelect', 'field_type' => 'xhrSelect',
'name' => 'Baseball Team', 'name' => 'Baseball Team',
'description' => 'Select your team', 'description' => 'Select your team',
'endpoint' => 'https://usetrmnl.com/custom_plugin_example_xhr_select.json', 'endpoint' => config('services.trmnl.base_url').'/custom_plugin_example_xhr_select.json',
// Not marked as optional, so it's required // Not marked as optional, so it's required
], ],
], ],

View file

@ -36,7 +36,7 @@ test('plugin parses JSON responses correctly', function (): void {
]); ]);
}); });
test('plugin parses XML responses and wraps under rss key', function (): void { test('plugin parses RSS XML responses and wraps under rss key', function (): void {
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?> $xmlContent = '<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
<channel> <channel>
@ -73,6 +73,33 @@ test('plugin parses XML responses and wraps under rss key', function (): void {
expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2); expect($plugin->data_payload['rss']['channel']['item'])->toHaveCount(2);
}); });
test('plugin parses namespaces XML responses and wraps under root key', function (): void {
$xmlContent = '<?xml version="1.0" encoding="UTF-8"?>
<foo:cake version="2.0" xmlns:foo="http://example.com/foo">
<bar:icing xmlns:bar="http://example.com/bar">
<ontop>Cherry</ontop>
</bar:icing>
</foo:cake>';
Http::fake([
'example.com/namespace.xml' => Http::response($xmlContent, 200, ['Content-Type' => 'application/xml']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/namespace.xml',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
expect($plugin->data_payload)->toHaveKey('cake');
expect($plugin->data_payload['cake'])->toHaveKey('icing');
expect($plugin->data_payload['cake']['icing']['ontop'])->toBe('Cherry');
});
test('plugin parses JSON-parsable response body as JSON', function (): void { test('plugin parses JSON-parsable response body as JSON', function (): void {
$jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}'; $jsonContent = '{"title": "Test Data", "items": [1, 2, 3]}';
@ -164,8 +191,8 @@ test('plugin handles multiple URLs with mixed content types', function (): void
expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse); expect($plugin->data_payload['IDX_0'])->toBe($jsonResponse);
// Second URL should be XML wrapped under rss // Second URL should be XML wrapped under rss
expect($plugin->data_payload['IDX_1'])->toHaveKey('rss'); expect($plugin->data_payload['IDX_1'])->toHaveKey('root');
expect($plugin->data_payload['IDX_1']['rss']['item'])->toBe('XML Data'); expect($plugin->data_payload['IDX_1']['root']['item'])->toBe('XML Data');
}); });
test('plugin handles POST requests with XML responses', function (): void { test('plugin handles POST requests with XML responses', function (): void {
@ -186,11 +213,11 @@ test('plugin handles POST requests with XML responses', function (): void {
$plugin->refresh(); $plugin->refresh();
expect($plugin->data_payload)->toHaveKey('rss'); expect($plugin->data_payload)->toHaveKey('response');
expect($plugin->data_payload['rss'])->toHaveKey('status'); expect($plugin->data_payload['response'])->toHaveKey('status');
expect($plugin->data_payload['rss'])->toHaveKey('data'); expect($plugin->data_payload['response'])->toHaveKey('data');
expect($plugin->data_payload['rss']['status'])->toBe('success'); expect($plugin->data_payload['response']['status'])->toBe('success');
expect($plugin->data_payload['rss']['data'])->toBe('test'); expect($plugin->data_payload['response']['data'])->toBe('test');
}); });
test('plugin parses iCal responses and filters to recent window', function (): void { test('plugin parses iCal responses and filters to recent window', function (): void {

View file

@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use App\Models\Plugin;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
test('iCal plugin parses Google Calendar invitation event', function (): void {
// Set test time close to the event in the issue
Carbon::setTestNow(Carbon::parse('2026-03-10 12:00:00', 'Europe/Budapest'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//EN
BEGIN:VEVENT
DTSTART;TZID=Europe/Budapest:20260311T100000
DTEND;TZID=Europe/Budapest:20260311T110000
DTSTAMP:20260301T100000Z
ORGANIZER:mailto:organizer@example.com
UID:xxxxxxxxxxxxxxxxxxx@google.com
SEQUENCE:0
DESCRIPTION:-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
·:~:~:~:~:~:~:~:~::~:~::-
Csatlakozás a Google Meet szolgáltatással: https://meet.google.com/xxx-xxxx-xxx
További információ a Meetről: https://support.google.com/a/users/answer/9282720
Kérjük, ne szerkeszd ezt a szakaszt.
-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
·:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
LOCATION:Meet XY Street, ZIP; https://meet.google.com/xxx-xxxx-xxx
SUMMARY:Meeting
STATUS:CONFIRMED
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant1@example.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant2@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant2@example.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant3@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=NEEDS-ACTION:mailto:participant3@example.com
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();
expect($plugin->data_payload)->not->toHaveKey('error');
expect($plugin->data_payload)->toHaveKey('ical');
expect($plugin->data_payload['ical'])->toHaveCount(1);
expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Meeting');
Carbon::setTestNow();
});
test('iCal plugin parses recurring events with multiple BYDAY correctly', function (): void {
// Set test now to Monday 2024-03-25
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//EN
BEGIN:VEVENT
DESCRIPTION:XXX-REDACTED
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
UID:040000008200E00074C5B7101A82E00800000000E07AF34F937EDA01000000000000000
01000000061F3E918C753424E8154B36E55452933
SUMMARY:Recurring Meeting
DTSTART;VALUE=DATE:20240326
DTEND;VALUE=DATE:20240327
DTSTAMP:20240605T082436Z
CLASS:PUBLIC
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR
ICS;
Http::fake([
'example.com/recurring.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/recurring.ics',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
$ical = $plugin->data_payload['ical'];
// Week of March 25, 2024:
// Tue March 26: 2024-03-26 (DTSTART)
// Thu March 28: 2024-03-28 (Recurrence)
// The parser window is now-7 days to now+30 days.
// Window: 2024-03-18 to 2024-04-24.
$summaries = collect($ical)->pluck('SUMMARY');
expect($summaries)->toContain('Recurring Meeting');
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
// Check if Tuesday March 26 is present
expect($dates)->toContain('2024-03-26');
// Check if Thursday March 28 is present (THIS IS WHERE IT IS EXPECTED TO FAIL BASED ON THE ISSUE)
expect($dates)->toContain('2024-03-28');
Carbon::setTestNow();
});
test('iCal plugin parses recurring events with multiple BYDAY and specific DTSTART correctly', function (): void {
// Set test now to Monday 2024-03-25
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
X-WR-TIMEZONE:UTC
PRODID:-//Example Corp.//EN
BEGIN:VEVENT
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
UID:recurring-event-2
SUMMARY:Recurring Meeting 2
DTSTART:20240326T100000
DTEND:20240326T110000
DTSTAMP:20240605T082436Z
END:VEVENT
END:VCALENDAR
ICS;
Http::fake([
'example.com/recurring2.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/recurring2.ics',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
$ical = $plugin->data_payload['ical'];
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
expect($dates)->toContain('2024-03-26');
expect($dates)->toContain('2024-03-28');
Carbon::setTestNow();
});

Some files were not shown because too many files have changed in this diff Show more