Compare commits

...

118 commits
0.17.1 ... main

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

fix idx bug and add tests

fix light mode styling and remove transitions

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

A test suite of simple xss attacks is also added.
2026-01-04 17:18:46 +01:00
jerremyng
9019561bb3 add zip dependencies to dev-container dockerfiles 2026-01-04 17:18:46 +01:00
Benjamin Nussbaum
838b4fd33b feat: bump to Design Framework 2.1
Some checks failed
tests / ci (push) Has been cancelled
2026-01-02 22:20:42 +01:00
Benjamin Nussbaum
4451361f15 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2026-01-02 14:53:45 +01:00
Benjamin Nussbaum
265972ac24 fix(#130): server error on faulty recipes
Some checks are pending
tests / ci (push) Waiting to run
2025-12-30 14:09:31 +01:00
Benjamin Nussbaum
7f97114f6e feat: add trmnl catalog paginator 2025-12-30 10:52:54 +01:00
Benjamin Nussbaum
3250bb0402 fix: install loading spinner not shown after catalog search 2025-12-30 10:28:41 +01:00
Benjamin Nussbaum
50853728bc refactor(#120): remove unnecessary js, improve cache handling 2025-12-30 10:22:46 +01:00
Benjamin Nussbaum
3cdc267809 chore: pint
Some checks are pending
tests / ci (push) Waiting to run
2025-12-29 23:08:52 +01:00
Benjamin Nussbaum
1298814521 fix(#136): mac address matching is case senstive 2025-12-29 23:07:21 +01:00
Benjamin Nussbaum
a5cb38421e fix(#131): invalidate cache when updating recipe markup 2025-12-29 22:24:32 +01:00
Benjamin Nussbaum
e6d66af298 fix(#135): use user configured timezone in Playlists 2025-12-29 22:16:29 +01:00
Benjamin Nussbaum
d4b5cf99d5 chore: update dependencies 2025-12-29 22:05:20 +01:00
Benjamin Nussbaum
d81c1b99f1
Update download and star counts in README
Some checks are pending
tests / ci (push) Waiting to run
2025-12-29 11:39:21 +01:00
dowjames
0b2b5bf25f Update holidays-ical.blade.php
Some checks failed
tests / ci (push) Has been cancelled
*Past events are removed.
*Events that started earlier but are still ongoing today remain visible.
*Anything from today onward displays.
2025-12-27 23:25:20 +01:00
Benjamin Nussbaum
f1a9103f0d chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-12-23 12:50:24 +01:00
Benjamin Nussbaum
d49a2d4f6c fix: styling in line with project standards
Some checks failed
tests / ci (push) Has been cancelled
2025-12-13 14:01:10 +01:00
andrzejskowron
be2bb637c9 styling in line with project standards
Some checks failed
tests / ci (push) Has been cancelled
2025-12-12 23:06:33 +01:00
andrzejskowron
f3538048d4 use flux design 2025-12-12 23:06:33 +01:00
andrzejskowron
a7963947f8 use flux design 2025-12-12 23:06:33 +01:00
andrzejskowron
b1467204f8 add preview import list 2025-12-12 23:06:33 +01:00
Benjamin Nussbaum
fb9469d9cd chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-12-10 16:43:27 +01:00
Benjamin Nussbaum
b6faa2f232 chore: update puppeteer 24.30.0
Some checks are pending
tests / ci (push) Waiting to run
2025-12-09 21:29:46 +01:00
Benjamin Nussbaum
60f2a38169 feat(#129): add iCal response parser 2025-12-09 21:07:48 +01:00
Benjamin Nussbaum
838db288e7 feat: update Docker image to include php ext: intl
Some checks failed
tests / ci (push) Has been cancelled
2025-12-07 09:49:01 +01:00
Benjamin Nussbaum
8776c668b4 chore: update node dependencies 2025-12-05 17:54:07 +01:00
Benjamin Nussbaum
1096118e03 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-12-05 15:39:40 +01:00
Benjamin Nussbaum
b10bbca774 fix(#124): improve timezone support
Some checks failed
tests / ci (push) Has been cancelled
2025-12-02 16:54:57 +01:00
Benjamin Nussbaum
0322ec899e fix(#123): shared layout not prepended when installing recipe 2025-12-02 15:14:15 +01:00
Benjamin Nussbaum
7c8e55588a fix(#123): normalizes non-named select config options for recipes 2025-12-02 14:58:27 +01:00
Benjamin Nussbaum
dac8064938 fix(#112): error when config field of a recipe expects json 2025-12-02 14:34:46 +01:00
Benjamin Nussbaum
fd41e77e7d chore: update dependencies 2025-12-02 14:29:22 +01:00
Benjamin Nussbaum
568bd69fea feat(#91): add multi color and palette support 2025-11-25 18:56:41 +01:00
Benjamin Nussbaum
61b9ff56e0 pin trmnl-pipeline-php color palette branch 2025-11-25 18:56:41 +01:00
Benjamin Nussbaum
73f0fd26c2 fix: typo 2025-11-25 18:56:41 +01:00
Benjamin Nussbaum
7014250ac5 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-11-21 11:48:39 +01:00
Benjamin Nussbaum
c157dcf3b6 chore: node audit
Some checks failed
tests / ci (push) Has been cancelled
2025-11-15 21:16:26 +01:00
Benjamin Nussbaum
742fd86c77
Revise README with updated downloads and recipe links
Some checks are pending
tests / ci (push) Waiting to run
Updated statistics and links in the README.
2025-11-15 08:48:42 +01:00
Benjamin Nussbaum
7489d85592 fix: tests
Some checks are pending
tests / ci (push) Waiting to run
2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
22a24383b2 feat: catalog add loading spinner 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
468e8a130d chore: set PUPPETEER_WAIT_FOR_NETWORK_IDLE to true by default 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
346f04a7af test: add coverage for ext renderer 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
31a73ccc6e ci(docker): optimize multi-stage build 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
042654993a feat: improve polling url rendering with liquid loops. support external liquid renderer 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
6c438ff4d4 chore: add limitation hint 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
b7ce0b6152 fix: lazy load plugin images
fix: lazy load catalog
2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
cdf477e2ed chore: OSS catalog, archive import are now beta 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
e63953dc13 feat: reposition filter button 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
a8f3232ccc feat: add TRMNL recipe catalog 2025-11-14 19:42:56 +01:00
Benjamin Nussbaum
41baff51a6 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-11-13 16:07:46 +01:00
Benjamin Nussbaum
f0f6b28107 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2025-11-12 18:26:01 +01:00
Benjamin Nussbaum
e53c584eed ci: metadata-action change to semver tag type
Some checks failed
tests / ci (push) Has been cancelled
2025-11-06 21:53:41 +01:00
Benjamin Nussbaum
1ccaa8382b
Update recipe count in README.md 2025-11-06 15:38:09 +01:00
Benjamin Nussbaum
36f783ac60 chore: update dependencies 2025-11-06 15:36:27 +01:00
Benjamin Nussbaum
dd4237360c ci: update action
Some checks are pending
tests / ci (push) Waiting to run
2025-11-05 14:12:41 +01:00
Benjamin Nussbaum
ef9cb81edb ci: skip latest tag for prereleases 2025-11-05 13:56:22 +01:00
kwlo
10b53c3772 Wrapping text in json object with 'data' as key
Some checks failed
tests / ci (push) Has been cancelled
2025-11-04 09:08:25 +01:00
kwlo
52dfe92054 Allow plain text response for plugin data polling 2025-11-04 09:08:25 +01:00
Benjamin Nussbaum
882cbff7fe chore: update js dependencies
Some checks are pending
tests / ci (push) Waiting to run
2025-11-03 12:21:55 +01:00
Benjamin Nussbaum
80e2e8058a fix(#103): add recipe options to remove bleed margin and enable dark mode
Some checks failed
tests / ci (push) Has been cancelled
2025-10-30 15:13:50 +01:00
Benjamin Nussbaum
38e1b6f2a6 fix(#103): apply dithering if requested by markup 2025-10-30 14:26:59 +01:00
Benjamin Nussbaum
315fbac261 chore: update dependencies 2025-10-29 22:30:05 +01:00
Benjamin Nussbaum
5abc452770 chore: update dependencies
Some checks are pending
tests / ci (push) Waiting to run
2025-10-29 13:34:46 +01:00
Benjamin Nussbaum
4de32e9d47 feat: add xml support
Some checks failed
tests / ci (push) Has been cancelled
2025-10-23 20:12:41 +02:00
Benjamin Nussbaum
aa46dff00b
Update README.md
Updated download count from 15k to 20k in the README.
2025-10-23 20:04:40 +02:00
Benjamin Nussbaum
311236a70d chore: update dependencies 2025-10-23 20:03:08 +02:00
Benjamin Nussbaum
5e0d0ad73f chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-10-22 11:16:23 +02:00
Benjamin Nussbaum
f6897fdfc7 chore: update node dependencies
Some checks are pending
tests / ci (push) Waiting to run
2025-10-21 12:48:46 +02:00
Benjamin Nussbaum
04ae695a14 chore: update dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-10-14 21:07:36 +02:00
Benjamin Nussbaum
a7e76f3c07 fix: remove label
Some checks failed
tests / ci (push) Has been cancelled
2025-10-10 18:04:12 +02:00
Benjamin Nussbaum
627d9ad09b chore: update dependencies 2025-10-10 16:44:01 +02:00
Benjamin Nussbaum
583d8b2440 feat: add support for configuration field multi_string 2025-10-10 16:35:10 +02:00
Benjamin Nussbaum
b18d561361 feat: add codemirror 2025-10-10 16:05:42 +02:00
Benjamin Nussbaum
4c65c015b9
Update README.md
Some checks are pending
tests / ci (push) Waiting to run
2025-10-10 12:03:05 +02:00
Benjamin Nussbaum
58e1fc32a4 chore: update npm dependencies
Some checks failed
tests / ci (push) Has been cancelled
2025-10-07 20:47:10 +02:00
Benjamin Nussbaum
74a65d6daf chore: update dependencies 2025-10-07 19:47:37 +02:00
Benjamin Nussbaum
8aea83703c chore: format
Some checks are pending
tests / ci (push) Waiting to run
2025-10-06 23:48:43 +02:00
Benjamin Nussbaum
161200df44 fix: add timestamp_utc system varibale 2025-10-06 23:44:37 +02:00
Benjamin Nussbaum
23a7a217db fix(#95): improve compatibilty with strftime in Liquid for date and l_date filters 2025-10-06 23:08:19 +02:00
Benjamin Nussbaum
c8f6dd3bec fix: convert ruby date format to php in Liquid 2025-10-06 23:00:18 +02:00
Benjamin Nussbaum
c1786dfb6d feat: add Liquid filter ordinalize 2025-10-06 22:33:13 +02:00
Benjamin Nussbaum
91e222f7a6 chore: rector
Some checks failed
tests / ci (push) Has been cancelled
2025-10-02 22:29:46 +02:00
Benjamin Nussbaum
203584107f chore: update dependencies 2025-10-02 22:25:12 +02:00
Benjamin Nussbaum
56548a96cb
Update README.md 2025-10-02 22:12:34 +02:00
Benjamin Nussbaum
e812f56c11 test: use faker for GenerateDefaultImagesTest, TransformDefaultImagesTest
Some checks are pending
tests / ci (push) Waiting to run
2025-10-01 22:27:23 +02:00
Benjamin Nussbaum
50318b8b9d test: mock firmware endpoint 2025-10-01 22:10:36 +02:00
Benjamin Nussbaum
93dacb0baf feat: add Liquid filters where_exp and map_to_i 2025-10-01 21:57:11 +02:00
Benjamin Nussbaum
4af4bfe14a
Update README.md 2025-10-01 20:37:38 +02:00
andrzejskowron
96e0223f2f feat: add plugin filtering by name and sorting by name/date
- Add client-side filtering using Alpine.js for instant search
- Add sorting options: Oldest First, Newest First, Name (A-Z), Name (Z-A)
- Use Flux UI components for consistent styling
- Filter activates when typing 2+ characters
- Sorting handled server-side with Livewire
2025-10-01 18:40:53 +02:00
Benjamin Nussbaum
6f7efd9e36
Update README.md
Some checks failed
tests / ci (push) Has been cancelled
2025-09-30 08:28:02 +02:00
115 changed files with 11809 additions and 4652 deletions

View file

@ -1,11 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

View file

@ -1,580 +0,0 @@
---
alwaysApply: true
---
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.12
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- livewire/volt (VOLT) - v1
- larastan/larastan (LARASTAN) - v3
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-free/core rules ===
## Flux UI Free
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Usage Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
</available-flux-components>
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== volt/core rules ===
## Livewire Volt
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
)]))
</code-snippet>
### Volt Class Based Component Example
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
use Livewire\Volt\Component;
new class extends Component {
public $count = 0;
public function increment()
{
$this->count++;
}
} ?>
<div>
<h1>{{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
</code-snippet>
### Testing Volt & Volt Components
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
<code-snippet name="Livewire Test Example" lang="php">
use Livewire\Volt\Volt;
test('counter increments', function () {
Volt::test('counter')
->assertSee('Count: 0')
->call('increment')
->assertSee('Count: 1');
});
</code-snippet>
<code-snippet name="Volt Component Test Using Pest" lang="php">
declare(strict_types=1);
use App\Models\{User, Product};
use Livewire\Volt\Volt;
test('product form creates product', function () {
$user = User::factory()->create();
Volt::test('pages.products.create')
->actingAs($user)
->set('form.name', 'Test Product')
->set('form.description', 'Test Description')
->set('form.price', 99.99)
->call('create')
->assertHasNoErrors();
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
});
</code-snippet>
### Common Patterns
<code-snippet name="CRUD With Volt" lang="php">
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['editing' => null, 'search' => '']);
$products = computed(fn() => Product::when($this->search,
fn($q) => $q->where('name', 'like', "%{$this->search}%")
)->get());
$edit = fn(Product $product) => $this->editing = $product->id;
$delete = fn(Product $product) => $product->delete();
?>
<!-- HTML / UI Here -->
</code-snippet>
<code-snippet name="Real-Time Search With Volt" lang="php">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Search..."
/>
</code-snippet>
<code-snippet name="Loading States With Volt" lang="php">
<flux:button wire:click="save" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</flux:button>
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>

View file

@ -9,7 +9,8 @@ RUN apk add --no-cache composer
# Add Chromium and Image Magick for puppeteer.
RUN apk add --no-cache \
imagemagick-dev \
chromium
chromium \
libzip-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1
@ -19,7 +20,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
RUN docker-php-ext-install imagick
RUN docker-php-ext-install imagick zip
# Composer uses its php binary, but we want it to use the container's one
RUN rm -f /usr/bin/php84

View file

@ -14,7 +14,8 @@ RUN apk add --no-cache \
nodejs \
npm \
imagemagick-dev \
chromium
chromium \
libzip-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1
@ -24,7 +25,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
RUN docker-php-ext-install imagick
RUN docker-php-ext-install imagick zip
RUN rm -f /usr/bin/php84
RUN ln -s /usr/local/bin/php /usr/bin/php84

View file

@ -1,577 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.12
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- livewire/volt (VOLT) - v1
- larastan/larastan (LARASTAN) - v3
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-free/core rules ===
## Flux UI Free
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Usage Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
</available-flux-components>
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== volt/core rules ===
## Livewire Volt
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
)]))
</code-snippet>
### Volt Class Based Component Example
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
use Livewire\Volt\Component;
new class extends Component {
public $count = 0;
public function increment()
{
$this->count++;
}
} ?>
<div>
<h1>{{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
</code-snippet>
### Testing Volt & Volt Components
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
<code-snippet name="Livewire Test Example" lang="php">
use Livewire\Volt\Volt;
test('counter increments', function () {
Volt::test('counter')
->assertSee('Count: 0')
->call('increment')
->assertSee('Count: 1');
});
</code-snippet>
<code-snippet name="Volt Component Test Using Pest" lang="php">
declare(strict_types=1);
use App\Models\{User, Product};
use Livewire\Volt\Volt;
test('product form creates product', function () {
$user = User::factory()->create();
Volt::test('pages.products.create')
->actingAs($user)
->set('form.name', 'Test Product')
->set('form.description', 'Test Description')
->set('form.price', 99.99)
->call('create')
->assertHasNoErrors();
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
});
</code-snippet>
### Common Patterns
<code-snippet name="CRUD With Volt" lang="php">
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['editing' => null, 'search' => '']);
$products = computed(fn() => Product::when($this->search,
fn($q) => $q->where('name', 'like', "%{$this->search}%")
)->get());
$edit = fn(Product $product) => $this->editing = $product->id;
$delete = fn(Product $product) => $product->delete();
?>
<!-- HTML / UI Here -->
</code-snippet>
<code-snippet name="Real-Time Search With Volt" lang="php">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Search..."
/>
</code-snippet>
<code-snippet name="Loading States With Volt" lang="php">
<flux:button wire:click="save" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</flux:button>
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>

View file

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

11
.gitignore vendored
View file

@ -23,3 +23,14 @@ yarn-error.log
/.zed
/database/seeders/PersonalDeviceSeeder.php
/.junie/mcp/mcp.json
/.cursor/mcp.json
/.cursor/rules/laravel-boost.mdc
/.github/copilot-instructions.md
/.junie/guidelines.md
/CLAUDE.md
/.mcp.json
/.ai
.DS_Store
/boost.json
/.gemini
/GEMINI.md

View file

@ -1,577 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.12
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- livewire/volt (VOLT) - v1
- larastan/larastan (LARASTAN) - v3
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-free/core rules ===
## Flux UI Free
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Usage Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
</available-flux-components>
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== volt/core rules ===
## Livewire Volt
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
)]))
</code-snippet>
### Volt Class Based Component Example
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
use Livewire\Volt\Component;
new class extends Component {
public $count = 0;
public function increment()
{
$this->count++;
}
} ?>
<div>
<h1>{{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
</code-snippet>
### Testing Volt & Volt Components
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
<code-snippet name="Livewire Test Example" lang="php">
use Livewire\Volt\Volt;
test('counter increments', function () {
Volt::test('counter')
->assertSee('Count: 0')
->call('increment')
->assertSee('Count: 1');
});
</code-snippet>
<code-snippet name="Volt Component Test Using Pest" lang="php">
declare(strict_types=1);
use App\Models\{User, Product};
use Livewire\Volt\Volt;
test('product form creates product', function () {
$user = User::factory()->create();
Volt::test('pages.products.create')
->actingAs($user)
->set('form.name', 'Test Product')
->set('form.description', 'Test Description')
->set('form.price', 99.99)
->call('create')
->assertHasNoErrors();
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
});
</code-snippet>
### Common Patterns
<code-snippet name="CRUD With Volt" lang="php">
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['editing' => null, 'search' => '']);
$products = computed(fn() => Product::when($this->search,
fn($q) => $q->where('name', 'like', "%{$this->search}%")
)->get());
$edit = fn(Product $product) => $this->editing = $product->id;
$delete = fn(Product $product) => $product->delete();
?>
<!-- HTML / UI Here -->
</code-snippet>
<code-snippet name="Real-Time Search With Volt" lang="php">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Search..."
/>
</code-snippet>
<code-snippet name="Loading States With Volt" lang="php">
<flux:button wire:click="save" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</flux:button>
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>

View file

@ -1,11 +0,0 @@
{
"mcpServers": {
"laravel-boost": {
"command": "php",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

577
CLAUDE.md
View file

@ -1,577 +0,0 @@
<laravel-boost-guidelines>
=== foundation rules ===
# Laravel Boost Guidelines
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.12
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- laravel/socialite (SOCIALITE) - v5
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- livewire/volt (VOLT) - v1
- larastan/larastan (LARASTAN) - v3
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
- Use the `database-query` tool when you only need to read from the database.
## Reading Browser Logs With the `browser-logs` Tool
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
=== php rules ===
## PHP
- Always use curly braces for control structures, even if it has one line.
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
- Use appropriate PHP type hints for method parameters.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== laravel/core rules ===
## Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
- If you're creating a generic PHP class, use `artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
### APIs & Eloquent Resources
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
### Controllers & Validation
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
- Check sibling Form Requests to see if the application uses array or string based validation rules.
### Queues
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
### Authentication & Authorization
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
### Configuration
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
## Laravel 12
- Use the `search-docs` tool to get version specific documentation.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
### Laravel 12 Structure
- No middleware files in `app/Http/Middleware/`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-free/core rules ===
## Flux UI Free
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Usage Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
</available-flux-components>
=== livewire/core rules ===
## Livewire Core
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
- State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
## Livewire Best Practices
- Livewire components require a single root element.
- Use `wire:loading` and `wire:dirty` for delightful loading states.
- Add `wire:key` in loops:
```blade
@foreach ($items as $item)
<div wire:key="item-{{ $item->id }}">
{{ $item->name }}
</div>
@endforeach
```
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php">
public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); }
</code-snippet>
## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php">
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee(1)
->assertStatus(200);
</code-snippet>
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
$this->get('/posts/create')
->assertSeeLivewire(CreatePost::class);
</code-snippet>
=== livewire/v3 rules ===
## Livewire 3
### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
### New Directives
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js">
document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) {
alert('Your session expired');
}
});
Livewire.hook('message.failed', (message, component) => {
console.error(message);
});
});
</code-snippet>
=== volt/core rules ===
## Livewire Volt
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
)]))
</code-snippet>
### Volt Class Based Component Example
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
use Livewire\Volt\Component;
new class extends Component {
public $count = 0;
public function increment()
{
$this->count++;
}
} ?>
<div>
<h1>{{ $count }}</h1>
<button wire:click="increment">+</button>
</div>
</code-snippet>
### Testing Volt & Volt Components
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
<code-snippet name="Livewire Test Example" lang="php">
use Livewire\Volt\Volt;
test('counter increments', function () {
Volt::test('counter')
->assertSee('Count: 0')
->call('increment')
->assertSee('Count: 1');
});
</code-snippet>
<code-snippet name="Volt Component Test Using Pest" lang="php">
declare(strict_types=1);
use App\Models\{User, Product};
use Livewire\Volt\Volt;
test('product form creates product', function () {
$user = User::factory()->create();
Volt::test('pages.products.create')
->actingAs($user)
->set('form.name', 'Test Product')
->set('form.description', 'Test Description')
->set('form.price', 99.99)
->call('create')
->assertHasNoErrors();
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
});
</code-snippet>
### Common Patterns
<code-snippet name="CRUD With Volt" lang="php">
<?php
use App\Models\Product;
use function Livewire\Volt\{state, computed};
state(['editing' => null, 'search' => '']);
$products = computed(fn() => Product::when($this->search,
fn($q) => $q->where('name', 'like', "%{$this->search}%")
)->get());
$edit = fn(Product $product) => $this->editing = $product->id;
$delete = fn(Product $product) => $product->delete();
?>
<!-- HTML / UI Here -->
</code-snippet>
<code-snippet name="Real-Time Search With Volt" lang="php">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="Search..."
/>
</code-snippet>
<code-snippet name="Loading States With Volt" lang="php">
<flux:button wire:click="save" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving...</span>
</flux:button>
</code-snippet>
=== pint/core rules ===
## Laravel Pint Code Formatter
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== pest/core rules ===
## Pest
### Testing
- If you need to verify a feature is working, write or update a Unit / Feature test.
### Pest Tests
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
expect(true)->toBeTrue();
});
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
it('returns all', function () {
$response = $this->postJson('/api/docs', []);
$response->assertSuccessful();
});
</code-snippet>
### Mocking
- Mocking can be very helpful when appropriate.
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
- You can also create partial mocks using the same import or self method.
### Datasets
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
<code-snippet name="Pest Dataset Example" lang="php">
it('has emails', function (string $email) {
expect($email)->not->toBeEmpty();
})->with([
'james' => 'james@laravel.com',
'taylor' => 'taylor@laravel.com',
]);
</code-snippet>
=== pest/v4 rules ===
## Pest 4
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
- Browser testing is incredibly powerful and useful for this project.
- Browser tests should live in `tests/Browser/`.
- Use the `search-docs` tool for detailed guidance on utilizing these features.
### Browser Testing
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
- Switch color schemes (light/dark mode) when appropriate.
- Take screenshots or pause tests for debugging when appropriate.
### Example Tests
<code-snippet name="Pest Browser Test Example" lang="php">
it('may reset the password', function () {
Notification::fake();
$this->actingAs(User::factory()->create());
$page = visit('/sign-in'); // Visit on a real browser...
$page->assertSee('Sign In')
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
->click('Forgot Password?')
->fill('email', 'nuno@laravel.com')
->click('Send Reset Link')
->assertSee('We have emailed your password reset link!')
Notification::assertSent(ResetPassword::class);
});
</code-snippet>
<code-snippet name="Pest Smoke Testing Example" lang="php">
$pages = visit(['/', '/about', '/contact']);
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
=== tailwindcss/core rules ===
## Tailwind Core
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
- When listing items, use gap utilities for spacing, don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules ===
## Tailwind 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
|------------+--------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>

View file

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

View file

@ -3,7 +3,7 @@
[![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
It allows you to manage TRMNL devices, generate screens using native plugins, recipes (45+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core).
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, its the most popular community-driven BYOS.
![Screenshot](README_byos-screenshot.png)
![Screenshot](README_byos-screenshot-dark.png)
@ -15,19 +15,27 @@ It allows you to manage TRMNL devices, generate screens using native plugins, re
* 📡 Device Information Display battery status, WiFi strength, firmware version, and more.
* 🔍 Auto-Join Automatically detects and adds devices from your local network.
* 🖥️ Screen Generation Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
* Over 45 compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
* Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
* Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes)
* Supported Devices
* TRMNL OG (1-bit & 2-bit)
* SeeedStudio TRMNL 7,5" (OG) DIY Kit
* Seeed Studio (XIAO 7.5" ePaper Panel)
* reTerminal E1001 Monochrome ePaper Display
* Custom ESP32 with TRMNL firmware
* Kindle Devices with [trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27)
* E-Reader Devices
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
* Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
* Raspberry Pi (HDMI output) [trmnl-display](https://github.com/usetrmnl/trmnl-display)
* 🔄 TRMNL API Proxy Can act as a proxy for the native cloud service (requires TRMNL Developer Edition).
* This enables a hybrid setup for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day.
* 🌙 Dark Mode Switch between light and dark mode.
* 🐳 Deployment Dockerized setup for easier hosting (Dockerfile, docker-compose).
* 💾 Flexible Database configuration uses SQLite by default, also compatible with MySQL or PostgreSQL
* 🛠️ Devcontainer support for easier development.
![Devices](README_byos-devices.jpeg)
@ -43,8 +51,6 @@ or
[GitHub Sponsors](https://github.com/sponsors/bnussbau/)
If you are looking for a Laravel package designed to streamline the development of both public and private TRMNL plugins, check out [bnussbau/trmnl-laravel](https://github.com/bnussbau/laravel-trmnl).
### Hosting
Run everywhere, where Docker is supported: Raspberry Pi, VPS, NAS, Container Cloud Service (Cloud Run, ...).
@ -212,6 +218,12 @@ You can dynamically update screens by sending a POST request.
}
```
### Releated Work
* [bnussbau/laravel-trmnl-blade](https://github.com/bnussbau/laravel-trmnl-blade) Blade Components on top of the TRMNL Design System
* [bnussbau/trmnl-pipeline-php](https://github.com/bnussbau/trmnl-pipeline-php) Browser Rendering and Image Conversion Pipeline with support for TRMNL Models API
* [bnussbau/trmnl-recipe-catalog](https://github.com/bnussbau/trmnl-recipe-catalog) A community-driven catalog of public repositories containing trmnlp-compatible recipes.
### 🤝 Contribution
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.

View file

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

View file

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

View file

@ -2,6 +2,7 @@
namespace App\Liquid\Filters;
use App\Liquid\Utils\ExpressionUtils;
use Keepsuit\Liquid\Filters\FiltersProvider;
/**
@ -89,4 +90,47 @@ class Data extends FiltersProvider
{
return json_decode($json, true);
}
/**
* Filter a collection using an expression
*
* @param mixed $input The collection to filter
* @param string $variable The variable name to use in the expression
* @param string $expression The expression to evaluate
* @return array The filtered collection
*/
public function where_exp(mixed $input, string $variable, string $expression): array
{
// Return input as-is if it's not an array or doesn't have values method
if (! is_array($input)) {
return is_string($input) ? [$input] : [];
}
// Convert hash to array of values if needed
if (ExpressionUtils::isAssociativeArray($input)) {
$input = array_values($input);
}
$condition = ExpressionUtils::parseCondition($expression);
$result = [];
foreach ($input as $object) {
if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) {
$result[] = $object;
}
}
return $result;
}
/**
* Convert array of strings to integers
*
* @param array $input Array of string numbers
* @return array Array of integers
*/
public function map_to_i(array $input): array
{
return array_map(intval(...), $input);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Liquid\Filters;
use App\Liquid\Utils\ExpressionUtils;
use Carbon\Carbon;
use Keepsuit\Liquid\Filters\FiltersProvider;
@ -22,4 +23,33 @@ class Date extends FiltersProvider
return Carbon::now()->subDays($days)->toDateString();
}
/**
* Format a date string with ordinal day (1st, 2nd, 3rd, etc.)
*
* @param string $dateStr The date string to parse
* @param string $strftimeExp The strftime format string with <<ordinal_day>> placeholder
* @return string The formatted date with ordinal day
*/
public function ordinalize(string $dateStr, string $strftimeExp): string
{
$date = Carbon::parse($dateStr);
$ordinalDay = $date->ordinal('day');
// Convert strftime format to PHP date format
$phpFormat = ExpressionUtils::strftimeToPhpFormat($strftimeExp);
// Split the format string by the ordinal day placeholder
$parts = explode('<<ordinal_day>>', $phpFormat);
if (count($parts) === 2) {
$before = $date->format($parts[0]);
$after = $date->format($parts[1]);
return $before.$ordinalDay.$after;
}
// Fallback: if no placeholder found, just format normally
return $date->format($phpFormat);
}
}

View file

@ -0,0 +1,210 @@
<?php
namespace App\Liquid\Utils;
/**
* Utility class for parsing and evaluating expressions in Liquid filters
*/
class ExpressionUtils
{
/**
* Check if an array is associative
*/
public static function isAssociativeArray(array $array): bool
{
if ($array === []) {
return false;
}
return array_keys($array) !== range(0, count($array) - 1);
}
/**
* Parse a condition expression into a structured format
*/
public static function parseCondition(string $expression): array
{
$expression = mb_trim($expression);
// Handle logical operators (and, or)
if (str_contains($expression, ' and ')) {
$parts = explode(' and ', $expression, 2);
return [
'type' => 'and',
'left' => self::parseCondition(mb_trim($parts[0])),
'right' => self::parseCondition(mb_trim($parts[1])),
];
}
if (str_contains($expression, ' or ')) {
$parts = explode(' or ', $expression, 2);
return [
'type' => 'or',
'left' => self::parseCondition(mb_trim($parts[0])),
'right' => self::parseCondition(mb_trim($parts[1])),
];
}
// Handle comparison operators
$operators = ['>=', '<=', '!=', '==', '>', '<', '='];
foreach ($operators as $operator) {
if (str_contains($expression, $operator)) {
$parts = explode($operator, $expression, 2);
return [
'type' => 'comparison',
'left' => mb_trim($parts[0]),
'operator' => $operator === '=' ? '==' : $operator,
'right' => mb_trim($parts[1]),
];
}
}
// If no operator found, treat as a simple expression
return [
'type' => 'simple',
'expression' => $expression,
];
}
/**
* Evaluate a condition against an object
*/
public static function evaluateCondition(array $condition, string $variable, mixed $object): bool
{
switch ($condition['type']) {
case 'and':
return self::evaluateCondition($condition['left'], $variable, $object) &&
self::evaluateCondition($condition['right'], $variable, $object);
case 'or':
if (self::evaluateCondition($condition['left'], $variable, $object)) {
return true;
}
return self::evaluateCondition($condition['right'], $variable, $object);
case 'comparison':
$leftValue = self::resolveValue($condition['left'], $variable, $object);
$rightValue = self::resolveValue($condition['right'], $variable, $object);
return match ($condition['operator']) {
'==' => $leftValue === $rightValue,
'!=' => $leftValue !== $rightValue,
'>' => $leftValue > $rightValue,
'<' => $leftValue < $rightValue,
'>=' => $leftValue >= $rightValue,
'<=' => $leftValue <= $rightValue,
default => false,
};
case 'simple':
$value = self::resolveValue($condition['expression'], $variable, $object);
return (bool) $value;
default:
return false;
}
}
/**
* Resolve a value from an expression, variable, or literal
*/
public static function resolveValue(string $expression, string $variable, mixed $object): mixed
{
$expression = mb_trim($expression);
// If it's the variable name, return the object
if ($expression === $variable) {
return $object;
}
// If it's a property access (e.g., "n.age"), resolve it
if (str_starts_with($expression, $variable.'.')) {
$property = mb_substr($expression, mb_strlen($variable) + 1);
if (is_array($object) && array_key_exists($property, $object)) {
return $object[$property];
}
if (is_object($object) && property_exists($object, $property)) {
return $object->$property;
}
return null;
}
// Try to parse as a number
if (is_numeric($expression)) {
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
}
// Try to parse as boolean
if (in_array(mb_strtolower($expression), ['true', 'false'])) {
return mb_strtolower($expression) === 'true';
}
// Try to parse as null
if (mb_strtolower($expression) === 'null') {
return null;
}
// Return as string (remove quotes if present)
if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) ||
(str_starts_with($expression, "'") && str_ends_with($expression, "'"))) {
return mb_substr($expression, 1, -1);
}
return $expression;
}
/**
* Convert strftime format string to PHP date format string
*
* @param string $strftimeFormat The strftime format string
* @return string The PHP date format string
*/
public static function strftimeToPhpFormat(string $strftimeFormat): string
{
$conversions = [
// Special Ruby format cases
'%N' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
'%u' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
'%-m' => 'n', // Month without leading zero (Ruby) -> month without leading zero (PHP)
'%-d' => 'j', // Day without leading zero (Ruby) -> day without leading zero (PHP)
'%-H' => 'G', // Hour without leading zero (Ruby) -> hour without leading zero (PHP)
'%-I' => 'g', // Hour 12h without leading zero (Ruby) -> hour 12h without leading zero (PHP)
'%-M' => 'i', // Minute without leading zero (Ruby) -> minute without leading zero (PHP)
'%-S' => 's', // Second without leading zero (Ruby) -> second without leading zero (PHP)
'%z' => 'O', // Timezone offset (Ruby) -> timezone offset (PHP)
'%Z' => 'T', // Timezone name (Ruby) -> timezone name (PHP)
// Standard strftime conversions
'%A' => 'l', // Full weekday name
'%a' => 'D', // Abbreviated weekday name
'%B' => 'F', // Full month name
'%b' => 'M', // Abbreviated month name
'%Y' => 'Y', // Full year (4 digits)
'%y' => 'y', // Year without century (2 digits)
'%m' => 'm', // Month as decimal number (01-12)
'%d' => 'd', // Day of month as decimal number (01-31)
'%H' => 'H', // Hour in 24-hour format (00-23)
'%I' => 'h', // Hour in 12-hour format (01-12)
'%M' => 'i', // Minute as decimal number (00-59)
'%S' => 's', // Second as decimal number (00-59)
'%p' => 'A', // AM/PM
'%P' => 'a', // am/pm
'%j' => 'z', // Day of year as decimal number (001-366)
'%w' => 'w', // Weekday as decimal number (0-6, Sunday is 0)
'%U' => 'W', // Week number of year (00-53, Sunday is first day)
'%W' => 'W', // Week number of year (00-53, Monday is first day)
'%c' => 'D M j H:i:s Y', // Date and time representation
'%x' => 'm/d/Y', // Date representation
'%X' => 'H:i:s', // Time representation
];
return str_replace(array_keys($conversions), array_values($conversions), $strftimeFormat);
}
}

View file

@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage;
/**
* @property-read DeviceModel|null $deviceModel
* @property-read DevicePalette|null $palette
*/
class Device extends Model
{
@ -19,6 +20,14 @@ class Device extends Model
protected $guarded = ['id'];
/**
* Set the MAC address attribute, normalizing to uppercase.
*/
public function setMacAddressAttribute(?string $value): void
{
$this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null;
}
protected $casts = [
'battery_notification_sent' => 'boolean',
'proxy_cloud' => 'boolean',
@ -187,6 +196,11 @@ class Device extends Model
return $this->belongsTo(DeviceModel::class);
}
public function palette(): BelongsTo
{
return $this->belongsTo(DevicePalette::class, 'palette_id');
}
/**
* Get the color depth string (e.g., "4bit") for the associated device model.
*/

View file

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

View file

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

View file

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

View file

@ -11,14 +11,20 @@ use App\Liquid\Filters\StandardFilters;
use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag;
use App\Services\Plugin\Parsers\ResponseParserRegistry;
use App\Services\PluginImportService;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use InvalidArgumentException;
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Keepsuit\Liquid\Extensions\StandardExtension;
@ -36,6 +42,11 @@ class Plugin extends Model
'markup_language' => 'string',
'configuration' => 'json',
'configuration_template' => 'json',
'no_bleed' => 'boolean',
'dark_mode' => 'boolean',
'preferred_renderer' => 'string',
'plugin_type' => 'string',
'alias' => 'boolean',
];
protected static function boot()
@ -47,6 +58,18 @@ class Plugin extends Model
$model->uuid = Str::uuid();
}
});
static::updating(function ($model): void {
// Reset image cache when markup changes
if ($model->isDirty('render_markup')) {
$model->current_image = null;
}
});
// Sanitize configuration template on save
static::saving(function ($model): void {
$model->sanitizeTemplate();
});
}
public function user()
@ -54,6 +77,25 @@ class Plugin extends Model
return $this->belongsTo(User::class);
}
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
protected function sanitizeTemplate(): void
{
$template = $this->configuration_template;
if (isset($template['custom_fields']) && is_array($template['custom_fields'])) {
foreach ($template['custom_fields'] as &$field) {
if (isset($field['description'])) {
$field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']);
}
if (isset($field['help_text'])) {
$field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']);
}
}
$this->configuration_template = $template;
}
}
public function hasMissingRequiredConfigurationFields(): bool
{
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
@ -83,7 +125,7 @@ class Plugin extends Model
$currentValue = $this->configuration[$fieldKey] ?? null;
// If the field has a default value and no current value is set, it's not missing
if (($currentValue === null || $currentValue === '' || ($currentValue === [])) && ! isset($field['default'])) {
if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) {
return true; // Found a required field that is not set and has no default
}
}
@ -94,6 +136,11 @@ class Plugin extends Model
public function isDataStale(): bool
{
// Image webhook plugins don't use data staleness - images are pushed directly
if ($this->plugin_type === 'image_webhook') {
return false;
}
if ($this->data_strategy === 'webhook') {
// Treat as stale if any webhook event has occurred in the past hour
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
@ -107,108 +154,88 @@ class Plugin extends Model
public function updateDataPayload(): void
{
if ($this->data_strategy === 'polling' && $this->polling_url) {
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
if ($this->polling_header) {
// Resolve Liquid variables in the polling header
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", mb_trim($resolvedHeader));
foreach ($headerLines as $line) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
}
}
}
// Split URLs by newline and filter out empty lines
$urls = array_filter(
array_map('trim', explode("\n", $this->polling_url)),
fn ($url): bool => ! empty($url)
);
// If only one URL, use the original logic without nesting
if (count($urls) === 1) {
$url = reset($urls);
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try {
// Make the request based on the verb
if ($this->polling_verb === 'post') {
$response = $httpRequest->post($resolvedUrl)->json();
} else {
$response = $httpRequest->get($resolvedUrl)->json();
}
$this->update([
'data_payload' => $response,
'data_payload_updated_at' => now(),
]);
} catch (Exception $e) {
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$this->update([
'data_payload' => ['error' => 'Failed to fetch data'],
'data_payload_updated_at' => now(),
]);
}
return;
}
// Multiple URLs - use nested response logic
$combinedResponse = [];
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try {
// Make the request based on the verb
if ($this->polling_verb === 'post') {
$response = $httpRequest->post($resolvedUrl)->json();
} else {
$response = $httpRequest->get($resolvedUrl)->json();
}
// Check if response is an array at root level
if (is_array($response) && array_keys($response) === range(0, count($response) - 1)) {
// Response is a sequential array, nest under .data
$combinedResponse["IDX_{$index}"] = ['data' => $response];
} else {
// Response is an object or associative array, keep as is
$combinedResponse["IDX_{$index}"] = $response;
}
} catch (Exception $e) {
// Log error and continue with other URLs
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
}
}
$this->update([
'data_payload' => $combinedResponse,
'data_payload_updated_at' => now(),
]);
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
return;
}
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
// resolve headers
if ($this->polling_header) {
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", mb_trim($resolvedHeader));
foreach ($headerLines as $line) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
}
}
}
// resolve and clean URLs
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
array_map('trim', explode("\n", $resolvedPollingUrls)),
fn ($url): bool => filled($url)
));
$combinedResponse = [];
// Loop through all URLs (Handles 1 or many)
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
try {
$httpResponse = ($this->polling_verb === 'post')
? $httpRequest->post($url)
: $httpRequest->get($url);
$response = $this->parseResponse($httpResponse);
// Nest if it's a sequential array
if (array_keys($response) === range(0, count($response) - 1)) {
$combinedResponse["IDX_{$index}"] = ['data' => $response];
} else {
$combinedResponse["IDX_{$index}"] = $response;
}
} catch (Exception $e) {
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
}
}
// unwrap IDX_0 if only one URL
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
$this->update([
'data_payload' => $finalPayload,
'data_payload_updated_at' => now(),
]);
}
private function parseResponse(Response $httpResponse): array
{
$parsers = app(ResponseParserRegistry::class)->getParsers();
foreach ($parsers as $parser) {
$parserName = class_basename($parser);
try {
$result = $parser->parse($httpResponse);
if ($result !== null) {
return $result;
}
} catch (Exception $e) {
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
}
}
return ['error' => 'Failed to parse response'];
}
/**
@ -216,15 +243,15 @@ class Plugin extends Model
*/
private function applyLiquidReplacements(string $template): string
{
$replacements = [
'date: "%N"' => 'date: "u"',
'date: "%u"' => 'date: "u"',
'%-m/%-d/%Y' => 'm/d/Y',
];
$replacements = [];
// Apply basic replacements
$template = str_replace(array_keys($replacements), array_values($replacements), $template);
// Convert Ruby/strftime date formats to PHP date formats
$template = $this->convertDateFormats($template);
// Convert {% render "template" with %} syntax to {% render "template", %} syntax
$template = preg_replace(
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
@ -237,7 +264,7 @@ class Plugin extends Model
// Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %}
$template = preg_replace_callback(
'/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/',
function ($matches): string {
function (array $matches): string {
$variableName = mb_trim($matches[1]);
$collection = mb_trim($matches[2]);
$filter = mb_trim($matches[3]);
@ -251,19 +278,82 @@ class Plugin extends Model
return $template;
}
/**
* Convert Ruby/strftime date formats to PHP date formats in Liquid templates
*/
private function convertDateFormats(string $template): string
{
// Handle date filter formats: date: "format" or date: 'format'
$template = preg_replace_callback(
'/date:\s*(["\'])([^"\']+)\1/',
function (array $matches): string {
$quote = $matches[1];
$format = $matches[2];
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
return 'date: '.$quote.$convertedFormat.$quote;
},
$template
);
// Handle l_date filter formats: l_date: "format" or l_date: 'format'
$template = preg_replace_callback(
'/l_date:\s*(["\'])([^"\']+)\1/',
function (array $matches): string {
$quote = $matches[1];
$format = $matches[2];
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
return 'l_date: '.$quote.$convertedFormat.$quote;
},
(string) $template
);
return $template;
}
/**
* Check if a template contains a Liquid for loop pattern
*
* @param string $template The template string to check
* @return bool True if the template contains a for loop pattern
*/
private function containsLiquidForLoop(string $template): bool
{
return preg_match('/{%-?\s*for\s+/i', $template) === 1;
}
/**
* Resolve Liquid variables in a template string using the Liquid template engine
*
* Uses the external trmnl-liquid renderer when:
* - preferred_renderer is 'trmnl-liquid'
* - External renderer is enabled in config
* - Template contains a Liquid for loop pattern
*
* Otherwise uses the internal PHP-based Liquid renderer.
*
* @param string $template The template string containing Liquid variables
* @return string The resolved template with variables replaced with their values
*
* @throws LiquidException
* @throws Exception
*/
public function resolveLiquidVariables(string $template): string
{
// Get configuration variables - make them available at root level
$variables = $this->configuration ?? [];
// Check if external renderer should be used
$useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid'
&& config('services.trmnl.liquid_enabled')
&& $this->containsLiquidForLoop($template);
if ($useExternalRenderer) {
// Use external Ruby liquid renderer
return $this->renderWithExternalLiquidRenderer($template, $variables);
}
// Use the Liquid template engine to resolve variables
$environment = App::make('liquid.environment');
$environment->filterRegistry->register(StandardFilters::class);
@ -273,6 +363,53 @@ class Plugin extends Model
return $liquidTemplate->render($context);
}
/**
* Render template using external Ruby liquid renderer
*
* @param string $template The liquid template string
* @param array $context The render context data
* @return string The rendered HTML
*
* @throws Exception
*/
private function renderWithExternalLiquidRenderer(string $template, array $context): string
{
$liquidPath = config('services.trmnl.liquid_path');
if (empty($liquidPath)) {
throw new Exception('External liquid renderer path is not configured');
}
// HTML encode the template
$encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
// Encode context as JSON
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($jsonContext === false) {
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg());
}
// Validate argument sizes
app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath);
// Execute the external renderer
$process = Process::run([
$liquidPath,
'--template',
$encodedTemplate,
'--context',
$jsonContext,
]);
if (! $process->successful()) {
$errorOutput = $process->errorOutput() ?: $process->output();
throw new Exception('External liquid renderer failed: '.$errorOutput);
}
return $process->output();
}
/**
* Render the plugin's markup
*
@ -280,60 +417,81 @@ class Plugin extends Model
*/
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
{
if ($this->plugin_type !== 'recipe') {
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
}
if ($this->render_markup) {
$renderedContent = '';
if ($this->markup_language === 'liquid') {
// Create a custom environment with inline templates support
$inlineFileSystem = new InlineTemplatesFileSystem();
$environment = new \Keepsuit\Liquid\Environment(
fileSystem: $inlineFileSystem,
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
);
// Get timezone from user or fall back to app timezone
$timezone = $this->user->timezone ?? config('app.timezone');
// Register all custom filters
$environment->filterRegistry->register(Data::class);
$environment->filterRegistry->register(Date::class);
$environment->filterRegistry->register(Localization::class);
$environment->filterRegistry->register(Numbers::class);
$environment->filterRegistry->register(StringMarkup::class);
$environment->filterRegistry->register(Uniqueness::class);
// Calculate UTC offset in seconds
$utcOffset = (string) Carbon::now($timezone)->getOffset();
// Register the template tag for inline templates
$environment->tagRegistry->register(TemplateTag::class);
// Apply Liquid replacements (including 'with' syntax conversion)
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
$template = $environment->parseString($processedMarkup);
$context = $environment->newRenderContext(
data: [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
...(is_array($this->data_payload) ? $this->data_payload : []),
'trmnl' => [
'user' => [
'utc_offset' => '0',
'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en',
'time_zone_iana' => config('app.timezone'),
],
'plugin_settings' => [
'instance_name' => $this->name,
'strategy' => $this->data_strategy,
'dark_mode' => 'no',
'no_screen_padding' => 'no',
'polling_headers' => $this->polling_header,
'polling_url' => $this->polling_url,
'custom_fields_values' => [
...(is_array($this->configuration) ? $this->configuration : []),
],
// Build render context
$context = [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
...(is_array($this->data_payload) ? $this->data_payload : []),
'trmnl' => [
'system' => [
'timestamp_utc' => now()->utc()->timestamp,
],
'user' => [
'utc_offset' => $utcOffset,
'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en',
'time_zone_iana' => $timezone,
],
'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 : []),
],
],
]
);
$renderedContent = $template->render($context);
],
];
// Check if external renderer should be used
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
// Use external Ruby renderer - pass raw template without preprocessing
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
} else {
// Use PHP keepsuit/liquid renderer
// Create a custom environment with inline templates support
$inlineFileSystem = new InlineTemplatesFileSystem();
$environment = new \Keepsuit\Liquid\Environment(
fileSystem: $inlineFileSystem,
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
);
// Register all custom filters
$environment->filterRegistry->register(Data::class);
$environment->filterRegistry->register(Date::class);
$environment->filterRegistry->register(Localization::class);
$environment->filterRegistry->register(Numbers::class);
$environment->filterRegistry->register(StringMarkup::class);
$environment->filterRegistry->register(Uniqueness::class);
// Register the template tag for inline templates
$environment->tagRegistry->register(TemplateTag::class);
// Apply Liquid replacements (including 'with' syntax conversion)
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
$template = $environment->parseString($processedMarkup);
$liquidContext = $environment->newRenderContext(data: $context);
$renderedContent = $template->render($liquidContext);
}
} else {
$renderedContent = Blade::render($this->render_markup, [
'size' => $size,
@ -347,6 +505,8 @@ class Plugin extends Model
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'slot' => $renderedContent,
])->render();
@ -356,6 +516,7 @@ class Plugin extends Model
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'slot' => $renderedContent,
])->render();
@ -367,15 +528,30 @@ class Plugin extends Model
if ($this->render_markup_view) {
if ($standalone) {
return view('trmnl-layouts.single', [
$renderedView = view($this->render_markup_view, [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
])->render();
if ($size === 'full') {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'slot' => $renderedView,
])->render();
}
return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'slot' => view($this->render_markup_view, [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
])->render(),
'slot' => $renderedView,
])->render();
}
@ -406,4 +582,61 @@ class Plugin extends Model
default => '1Tx1B',
};
}
/**
* Duplicate the plugin, copying all attributes and handling render_markup_view
*
* @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id.
* @return Plugin The newly created duplicate plugin
*/
public function duplicate(?int $userId = null): self
{
// Get all attributes except id and uuid
// Use toArray() to get cast values (respects JSON casts)
$attributes = $this->toArray();
unset($attributes['id'], $attributes['uuid']);
// Handle render_markup_view - copy file content to render_markup
if ($this->render_markup_view) {
try {
$basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view));
$paths = [
$basePath.'.blade.php',
$basePath.'.liquid',
];
$fileContent = null;
$markupLanguage = null;
foreach ($paths as $path) {
if (file_exists($path)) {
$fileContent = file_get_contents($path);
// Determine markup language based on file extension
$markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade';
break;
}
}
if ($fileContent !== null) {
$attributes['render_markup'] = $fileContent;
$attributes['markup_language'] = $markupLanguage;
$attributes['render_markup_view'] = null;
} else {
// File doesn't exist, remove the view reference
$attributes['render_markup_view'] = null;
}
} catch (Exception $e) {
// If file reading fails, remove the view reference
$attributes['render_markup_view'] = null;
}
}
// Append " (Copy)" to the name
$attributes['name'] = $this->name.' (Copy)';
// Set user_id - use provided userId or fall back to original plugin's user_id
$attributes['user_id'] = $userId ?? $this->user_id;
// Create and return the new plugin
return self::create($attributes);
}
}

View file

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

View file

@ -36,7 +36,7 @@ class BatteryLow extends Notification
return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]);
}
public function toWebhook(object $notifiable)
public function toWebhook(object $notifiable): WebhookMessage
{
return WebhookMessage::create()
->data([

View file

@ -25,12 +25,45 @@ class ImageGenerationService
{
public static function generateImage(string $markup, $deviceId): string
{
$device = Device::with('deviceModel')->find($deviceId);
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
$uuid = self::generateImageFromModel(
markup: $markup,
deviceModel: $device->deviceModel,
user: $device->user,
palette: $device->palette ?? $device->deviceModel?->palette,
device: $device
);
$device->update(['current_screen_image' => $uuid]);
Log::info("Device $device->id: updated with new image: $uuid");
return $uuid;
}
/**
* Generate an image from markup using a DeviceModel
*
* @param string $markup The HTML markup to render
* @param DeviceModel|null $deviceModel The device model to use for image generation
* @param \App\Models\User|null $user Optional user for timezone settings
* @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
* @param Device|null $device Optional device for legacy devices without DeviceModel
* @return string The UUID of the generated image
*/
public static function generateImageFromModel(
string $markup,
?DeviceModel $deviceModel = null,
?\App\Models\User $user = null,
?\App\Models\DevicePalette $palette = null,
?Device $device = null
): string {
$uuid = Uuid::uuid4()->toString();
try {
// Get image generation settings from DeviceModel if available, otherwise use device settings
$imageSettings = self::getImageSettings($device);
// Get image generation settings from DeviceModel or Device (for legacy devices)
$imageSettings = $deviceModel
? self::getImageSettingsFromModel($deviceModel)
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
@ -44,6 +77,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($markup);
// Set timezone from user or fall back to app timezone
$timezone = $user?->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage
->width($imageSettings['width'])
@ -61,6 +98,14 @@ class ImageGenerationService
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
}
// Get palette from parameter or fallback to device model's default palette
$colorPalette = null;
if ($palette && $palette->colors) {
$colorPalette = $palette->colors;
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
$colorPalette = $deviceModel->palette->colors;
}
$imageStage = new ImageStage();
$imageStage->format($fileExtension)
->width($imageSettings['width'])
@ -72,6 +117,17 @@ class ImageGenerationService
->offsetY($imageSettings['offset_y'])
->outputPath($outputPath);
// Apply color palette if available
if ($colorPalette) {
$imageStage->colormap($colorPalette);
}
// Apply dithering if requested by markup
$shouldDither = self::markupContainsDitherImage($markup);
if ($shouldDither) {
$imageStage->dither();
}
(new TrmnlPipeline())->pipe($browserStage)
->pipe($imageStage)
->process();
@ -84,8 +140,7 @@ class ImageGenerationService
throw new RuntimeException('Image file is empty: '.$outputPath);
}
$device->update(['current_screen_image' => $uuid]);
Log::info("Device $device->id: updated with new image: $uuid");
Log::info("Generated image: $uuid");
return $uuid;
@ -102,22 +157,7 @@ class ImageGenerationService
{
// If device has a DeviceModel, use its settings
if ($device->deviceModel) {
/** @var DeviceModel $model */
$model = $device->deviceModel;
return [
'width' => $model->width,
'height' => $model->height,
'colors' => $model->colors,
'bit_depth' => $model->bit_depth,
'scale_factor' => $model->scale_factor,
'rotation' => $model->rotation,
'mime_type' => $model->mime_type,
'offset_x' => $model->offset_x,
'offset_y' => $model->offset_y,
'image_format' => self::determineImageFormatFromModel($model),
'use_model_settings' => true,
];
return self::getImageSettingsFromModel($device->deviceModel);
}
// Fallback to device settings
@ -141,6 +181,43 @@ class ImageGenerationService
];
}
/**
* Get image generation settings from a DeviceModel
*/
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
{
if ($deviceModel) {
return [
'width' => $deviceModel->width,
'height' => $deviceModel->height,
'colors' => $deviceModel->colors,
'bit_depth' => $deviceModel->bit_depth,
'scale_factor' => $deviceModel->scale_factor,
'rotation' => $deviceModel->rotation,
'mime_type' => $deviceModel->mime_type,
'offset_x' => $deviceModel->offset_x,
'offset_y' => $deviceModel->offset_y,
'image_format' => self::determineImageFormatFromModel($deviceModel),
'use_model_settings' => true,
];
}
// Default settings if no device model provided
return [
'width' => 800,
'height' => 480,
'colors' => 2,
'bit_depth' => 1,
'scale_factor' => 1.0,
'rotation' => 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
'image_format' => ImageFormat::AUTO->value,
'use_model_settings' => false,
];
}
/**
* Determine the appropriate ImageFormat based on DeviceModel settings
*/
@ -209,6 +286,31 @@ class ImageGenerationService
};
}
/**
* Detect whether the provided HTML markup contains an <img> tag with class "image-dither".
*/
private static function markupContainsDitherImage(string $markup): bool
{
if (mb_trim($markup) === '') {
return false;
}
// Find <img ... class="..."> (or with single quotes) and inspect class tokens
$imgWithClassPattern = '/<img\b[^>]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i';
if (! preg_match_all($imgWithClassPattern, $markup, $matches)) {
return false;
}
foreach ($matches[2] as $classValue) {
// Look for class token 'image-dither' or 'image--dither'
if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) {
return true;
}
}
return false;
}
public static function cleanupFolder(): void
{
$activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
@ -232,6 +334,10 @@ class ImageGenerationService
public static function resetIfNotCacheable(?Plugin $plugin): void
{
if ($plugin?->id) {
// Image webhook plugins have finalized images that shouldn't be reset
if ($plugin->plugin_type === 'image_webhook') {
return;
}
// Check if any devices have custom dimensions or use non-standard DeviceModels
$hasCustomDimensions = Device::query()
->where(function ($query): void {
@ -263,7 +369,7 @@ class ImageGenerationService
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
{
// Validate image type
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
return null;
}
@ -297,16 +403,19 @@ class ImageGenerationService
/**
* Generate a default screen image from Blade template
*/
public static function generateDefaultScreenImage(Device $device, string $imageType): string
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
{
// Validate image type
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
throw new InvalidArgumentException("Invalid image type: {$imageType}");
}
$uuid = Uuid::uuid4()->toString();
try {
// Load device with relationships
$device->load(['palette', 'deviceModel.palette', 'user']);
// Get image generation settings from DeviceModel if available, otherwise use device settings
$imageSettings = self::getImageSettings($device);
@ -314,7 +423,7 @@ class ImageGenerationService
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
// Generate HTML from Blade template
$html = self::generateDefaultScreenHtml($device, $imageType);
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
// Create custom Browsershot instance if using AWS Lambda
$browsershotInstance = null;
@ -325,6 +434,10 @@ class ImageGenerationService
$browserStage = new BrowserStage($browsershotInstance);
$browserStage->html($html);
// Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone');
$browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') {
$browserStage
->width($imageSettings['width'])
@ -341,6 +454,14 @@ class ImageGenerationService
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
}
// Get palette from device or fallback to device model's default palette
$palette = $device->palette ?? $device->deviceModel?->palette;
$colorPalette = null;
if ($palette && $palette->colors) {
$colorPalette = $palette->colors;
}
$imageStage = new ImageStage();
$imageStage->format($fileExtension)
->width($imageSettings['width'])
@ -352,6 +473,11 @@ class ImageGenerationService
->offsetY($imageSettings['offset_y'])
->outputPath($outputPath);
// Apply color palette if available
if ($colorPalette) {
$imageStage->colormap($colorPalette);
}
(new TrmnlPipeline())->pipe($browserStage)
->pipe($imageStage)
->process();
@ -377,12 +503,13 @@ class ImageGenerationService
/**
* Generate HTML from Blade template for default screens
*/
private static function generateDefaultScreenHtml(Device $device, string $imageType): string
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
{
// Map image type to template name
$templateName = match ($imageType) {
'setup-logo' => 'default-screens.setup',
'sleep' => 'default-screens.sleep',
'error' => 'default-screens.error',
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
};
@ -393,14 +520,22 @@ class ImageGenerationService
$scaleLevel = $device->scaleLevel();
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
// Render the Blade template
return view($templateName, [
// Build view data
$viewData = [
'noBleed' => false,
'darkMode' => $darkMode,
'deviceVariant' => $deviceVariant,
'deviceOrientation' => $deviceOrientation,
'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel,
])->render();
];
// Add plugin name for error screens
if ($imageType === 'error' && $pluginName !== null) {
$viewData['pluginName'] = $pluginName;
}
// Render the Blade template
return view($templateName, $viewData)->render();
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -58,6 +58,7 @@ class PluginExportService
// 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);
}

View file

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

View file

@ -6,15 +6,17 @@
"keywords": [
"trmnl",
"trmnl-server",
"trmnl-byos",
"laravel"
],
"license": "MIT",
"require": {
"php": "^8.2",
"ext-imagick": "*",
"ext-simplexml": "*",
"ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "2.0.*",
"bnussbau/trmnl-pipeline-php": "^0.3.0",
"bnussbau/laravel-trmnl-blade": "2.1.*",
"bnussbau/trmnl-pipeline-php": "^0.6.0",
"keepsuit/laravel-liquid": "^0.5.2",
"laravel/framework": "^12.1",
"laravel/sanctum": "^4.0",
@ -22,7 +24,9 @@
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0",
"livewire/volt": "^1.7",
"om/icalparser": "^3.2",
"spatie/browsershot": "^5.0",
"stevebauman/purify": "^6.3",
"symfony/yaml": "^7.3",
"wnx/sidecar-browsershot": "^2.6"
},

1923
composer.lock generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

6
config/trustedproxy.php Normal file
View file

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

View file

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

View file

@ -29,8 +29,24 @@ class PluginFactory extends Factory
'icon_url' => null,
'flux_icon_name' => null,
'author_name' => $this->faker->name(),
'plugin_type' => 'recipe',
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
}
/**
* Indicate that the plugin is an image webhook plugin.
*/
public function imageWebhook(): static
{
return $this->state(fn (array $attributes): array => [
'plugin_type' => 'image_webhook',
'data_strategy' => 'static',
'data_stale_minutes' => 60,
'polling_url' => null,
'polling_verb' => 'get',
'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
]);
}
}

View file

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

View file

@ -0,0 +1,32 @@
<?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('plugins', function (Blueprint $table): void {
if (! Schema::hasColumn('plugins', 'no_bleed')) {
$table->boolean('no_bleed')->default(false)->after('configuration_template');
}
if (! Schema::hasColumn('plugins', 'dark_mode')) {
$table->boolean('dark_mode')->default(false)->after('no_bleed');
}
});
}
public function down(): void
{
Schema::table('plugins', function (Blueprint $table): void {
if (Schema::hasColumn('plugins', 'dark_mode')) {
$table->dropColumn('dark_mode');
}
if (Schema::hasColumn('plugins', 'no_bleed')) {
$table->dropColumn('no_bleed');
}
});
}
};

View file

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

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('device_palettes', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('description')->nullable();
$table->integer('grays');
$table->json('colors')->nullable();
$table->string('framework_class')->default('');
$table->string('source')->default('api');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('device_palettes');
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->foreignId('palette_id')->nullable()->after('source')->constrained('device_palettes')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->dropForeign(['palette_id']);
$table->dropColumn('palette_id');
});
}
};

View file

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->foreignId('palette_id')->nullable()->constrained('device_palettes')->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('devices', function (Blueprint $table) {
$table->dropForeign(['palette_id']);
$table->dropColumn('palette_id');
});
}
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -144,5 +144,42 @@ class ExampleRecipesSeeder extends Seeder
'flux_icon_name' => 'flower',
]
);
Plugin::updateOrCreate(
[
'uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90',
'name' => 'Holidays (iCal)',
'user_id' => $user_id,
'data_payload' => null,
'data_stale_minutes' => 720,
'data_strategy' => 'polling',
'configuration_template' => [
'custom_fields' => [
[
'keyname' => 'calendar',
'field_type' => 'select',
'name' => 'Public Holidays Calendar',
'options' => [
['USA' => 'usa'],
['Austria' => 'austria'],
['Australia' => 'australia'],
['Canada' => 'canada'],
['Germany' => 'germany'],
['UK' => 'united-kingdom'],
],
],
],
],
'configuration' => ['calendar' => 'usa'],
'polling_url' => 'https://www.officeholidays.com/ics-clean/{{calendar}}',
'polling_verb' => 'get',
'polling_header' => null,
'render_markup' => null,
'render_markup_view' => 'recipes.holidays-ical',
'detail_view_route' => null,
'icon_url' => null,
'flux_icon_name' => 'calendar',
]
);
}
}

1424
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,14 +6,27 @@
"dev": "vite"
},
"dependencies": {
"@tailwindcss/vite": "^4.0.7",
"@codemirror/commands": "^6.9.0",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-liquid": "^6.3.0",
"@codemirror/language": "^6.11.3",
"@codemirror/search": "^6.5.11",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.5",
"@fsegurai/codemirror-theme-github-light": "^6.2.2",
"@tailwindcss/vite": "^4.1.11",
"autoprefixer": "^10.4.20",
"axios": "^1.8.2",
"codemirror": "^6.0.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^1.0",
"puppeteer": "^24.3.0",
"laravel-vite-plugin": "^2.0",
"puppeteer": "24.30.0",
"tailwindcss": "^4.0.7",
"vite": "^6.3"
"vite": "^7.0.4"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

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

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
import { codeEditorFormComponent } from './codemirror-alpine.js';
window.codeEditorFormComponent = codeEditorFormComponent;

View file

@ -0,0 +1,198 @@
import { createCodeMirror, getSystemTheme, watchThemeChange } from './codemirror-core.js';
import { EditorView } from '@codemirror/view';
/**
* Alpine.js component for CodeMirror that integrates with textarea and Livewire
* Inspired by Filament's approach with proper state entanglement
* @param {Object} config - Configuration object
* @returns {Object} Alpine.js component object
*/
export function codeEditorFormComponent(config) {
return {
editor: null,
textarea: null,
isLoading: false,
unwatchTheme: null,
// Configuration
isDisabled: config.isDisabled || false,
language: config.language || 'html',
state: config.state || '',
textareaId: config.textareaId || null,
/**
* Initialize the component
*/
async init() {
this.isLoading = true;
try {
// Wait for textarea if provided
if (this.textareaId) {
await this.waitForTextarea();
}
await this.$nextTick();
this.createEditor();
this.setupEventListeners();
} finally {
this.isLoading = false;
}
},
/**
* Wait for textarea to be available in the DOM
*/
async waitForTextarea() {
let attempts = 0;
const maxAttempts = 50; // 5 seconds max wait
while (attempts < maxAttempts) {
this.textarea = document.getElementById(this.textareaId);
if (this.textarea) {
return;
}
// Wait 100ms before trying again
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
console.error(`Textarea with ID "${this.textareaId}" not found after ${maxAttempts} attempts`);
},
/**
* Update both Livewire state and textarea with new value
*/
updateState(value) {
this.state = value;
if (this.textarea) {
this.textarea.value = value;
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
},
/**
* Create the CodeMirror editor instance
*/
createEditor() {
// Clean up any existing editor first
if (this.editor) {
this.editor.destroy();
}
const effectiveTheme = this.getEffectiveTheme();
const initialValue = this.textarea ? this.textarea.value : this.state;
this.editor = createCodeMirror(this.$refs.editor, {
value: initialValue || '',
language: this.language,
theme: effectiveTheme,
readOnly: this.isDisabled,
onChange: (value) => this.updateState(value),
onUpdate: (value) => this.updateState(value),
onBlur: () => {
if (this.editor) {
this.updateState(this.editor.state.doc.toString());
}
}
});
},
/**
* Get effective theme
*/
getEffectiveTheme() {
return getSystemTheme();
},
/**
* Update editor content with new value
*/
updateEditorContent(value) {
if (this.editor && value !== this.editor.state.doc.toString()) {
this.editor.dispatch({
changes: {
from: 0,
to: this.editor.state.doc.length,
insert: value
}
});
}
},
/**
* Setup event listeners for theme changes and state synchronization
*/
setupEventListeners() {
// Watch for state changes from Livewire
this.$watch('state', (newValue) => {
this.updateEditorContent(newValue);
});
// Watch for disabled state changes
this.$watch('isDisabled', (newValue) => {
if (this.editor) {
this.editor.dispatch({
effects: EditorView.editable.reconfigure(!newValue)
});
}
});
// Watch for textarea changes (from Livewire updates)
if (this.textarea) {
this.textarea.addEventListener('input', (event) => {
this.updateEditorContent(event.target.value);
this.state = event.target.value;
});
// Listen for Livewire updates that might change the textarea value
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
this.updateEditorContent(this.textarea.value);
this.state = this.textarea.value;
}
});
});
observer.observe(this.textarea, {
attributes: true,
attributeFilter: ['value']
});
}
// Listen for theme changes
this.unwatchTheme = watchThemeChange(() => {
this.recreateEditor();
});
},
/**
* Recreate the editor (useful for theme changes)
*/
async recreateEditor() {
if (this.editor) {
this.editor.destroy();
this.editor = null;
await this.$nextTick();
this.createEditor();
}
},
/**
* Clean up resources when component is destroyed
*/
destroy() {
if (this.editor) {
this.editor.destroy();
this.editor = null;
}
if (this.unwatchTheme) {
this.unwatchTheme();
}
}
};
}

View file

@ -0,0 +1,265 @@
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
import { ViewPlugin } from '@codemirror/view';
import { indentWithTab, selectAll } from '@codemirror/commands';
import { foldGutter, foldKeymap } from '@codemirror/language';
import { history, historyKeymap } from '@codemirror/commands';
import { searchKeymap } from '@codemirror/search';
import { html } from '@codemirror/lang-html';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { css } from '@codemirror/lang-css';
import { liquid } from '@codemirror/lang-liquid';
import { oneDark } from '@codemirror/theme-one-dark';
import { githubLight } from '@fsegurai/codemirror-theme-github-light';
// Language support mapping
const LANGUAGE_MAP = {
'javascript': javascript,
'js': javascript,
'json': json,
'css': css,
'liquid': liquid,
'html': html,
};
// Theme support mapping
const THEME_MAP = {
'light': githubLight,
'dark': oneDark,
};
/**
* Get language support based on language parameter
* @param {string} language - Language name or comma-separated list
* @returns {Array|Extension} Language extension(s)
*/
function getLanguageSupport(language) {
// Handle comma-separated languages
if (language.includes(',')) {
const languages = language.split(',').map(lang => lang.trim().toLowerCase());
const languageExtensions = [];
languages.forEach(lang => {
const languageFn = LANGUAGE_MAP[lang];
if (languageFn) {
languageExtensions.push(languageFn());
}
});
return languageExtensions;
}
// Handle single language
const languageFn = LANGUAGE_MAP[language.toLowerCase()] || LANGUAGE_MAP.html;
return languageFn();
}
/**
* Get theme support
* @param {string} theme - Theme name
* @returns {Array} Theme extensions
*/
function getThemeSupport(theme) {
const themeFn = THEME_MAP[theme] || THEME_MAP.light;
return [themeFn];
}
/**
* Create a resize plugin that handles container resizing
* @returns {ViewPlugin} Resize plugin
*/
function createResizePlugin() {
return ViewPlugin.fromClass(class {
constructor(view) {
this.view = view;
this.resizeObserver = null;
this.setupResizeObserver();
}
setupResizeObserver() {
const container = this.view.dom.parentElement;
if (container) {
this.resizeObserver = new ResizeObserver(() => {
// Use requestAnimationFrame to ensure proper timing
requestAnimationFrame(() => {
this.view.requestMeasure();
});
});
this.resizeObserver.observe(container);
}
}
destroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
}
}
});
}
/**
* Get Flux-like theme styling based on theme
* @param {string} theme - Theme name ('light', 'dark', or 'auto')
* @returns {Object} Theme-specific styling
*/
function getFluxThemeStyling(theme) {
const isDark = theme === 'dark' || (theme === 'auto' && getSystemTheme() === 'dark');
if (isDark) {
return {
backgroundColor: 'oklab(0.999994 0.0000455678 0.0000200868 / 0.1)',
gutterBackgroundColor: 'oklch(26.9% 0 0)',
borderColor: '#374151',
focusBorderColor: 'rgb(224 91 68)',
};
} else {
return {
backgroundColor: '#fff', // zinc-50
gutterBackgroundColor: '#fafafa', // zinc-50
borderColor: '#e5e7eb', // gray-200
focusBorderColor: 'rgb(224 91 68)', // red-500
};
}
}
/**
* Create CodeMirror editor instance
* @param {HTMLElement} element - DOM element to mount editor
* @param {Object} options - Editor options
* @returns {EditorView} CodeMirror editor instance
*/
export function createCodeMirror(element, options = {}) {
const {
value = '',
language = 'html',
theme = 'light',
readOnly = false,
onChange = () => {},
onUpdate = () => {},
onBlur = () => {}
} = options;
// Get language and theme support
const languageSupport = getLanguageSupport(language);
const themeSupport = getThemeSupport(theme);
const fluxStyling = getFluxThemeStyling(theme);
// Create editor
const editor = new EditorView({
doc: value,
extensions: [
lineNumbers(),
foldGutter(),
history(),
EditorView.lineWrapping,
createResizePlugin(),
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
...themeSupport,
keymap.of([
indentWithTab,
...foldKeymap,
...historyKeymap,
...searchKeymap,
{
key: 'Mod-a',
run: selectAll,
},
]),
EditorView.theme({
'&': {
fontSize: '14px',
border: `1px solid ${fluxStyling.borderColor}`,
borderRadius: '0.375rem',
height: '100%',
maxHeight: '100%',
overflow: 'hidden',
backgroundColor: fluxStyling.backgroundColor + ' !important',
resize: 'vertical',
minHeight: '200px',
},
'.cm-gutters': {
borderTopLeftRadius: '0.375rem',
backgroundColor: fluxStyling.gutterBackgroundColor + ' !important',
},
'.cm-gutter': {
backgroundColor: fluxStyling.gutterBackgroundColor + ' !important',
},
'&.cm-focused': {
outline: 'none',
borderColor: fluxStyling.focusBorderColor,
},
'.cm-content': {
padding: '12px',
},
'.cm-scroller': {
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
height: '100%',
overflow: 'auto',
},
'.cm-editor': {
height: '100%',
},
'.cm-editor .cm-scroller': {
height: '100%',
overflow: 'auto',
},
'.cm-foldGutter': {
width: '12px',
},
'.cm-foldGutter .cm-gutterElement': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: '12px',
color: '#6b7280',
},
'.cm-foldGutter .cm-gutterElement:hover': {
color: '#374151',
},
'.cm-foldGutter .cm-gutterElement.cm-folded': {
color: '#3b82f6',
}
}),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newValue = update.state.doc.toString();
onChange(newValue);
onUpdate(newValue);
}
}),
EditorView.domEventHandlers({
blur: onBlur
}),
EditorView.editable.of(!readOnly),
],
parent: element
});
return editor;
}
/**
* Auto-detect system theme preference
* @returns {string} 'dark' or 'light'
*/
export function getSystemTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
/**
* Watch for system theme changes
* @param {Function} callback - Callback function when theme changes
* @returns {Function} Unwatch function
*/
export function watchThemeChange(callback) {
if (window.matchMedia) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', callback);
return () => mediaQuery.removeEventListener('change', callback);
}
return () => {};
}

View file

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

View file

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

View file

@ -1,29 +1,52 @@
<?php
use App\Services\PluginImportService;
use Livewire\Volt\Component;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
use Symfony\Component\Yaml\Yaml;
new class extends Component {
new
#[Lazy]
class extends Component
{
public array $catalogPlugins = [];
public string $installingPlugin = '';
public string $previewingPlugin = '';
public array $previewData = [];
public function mount(): void
{
$this->loadCatalogPlugins();
}
public function placeholder()
{
return <<<'HTML'
<div class="space-y-4">
<div class="flex items-center justify-center py-12">
<div class="flex items-center space-x-2">
<flux:icon.loading />
<flux:text>Loading recipes...</flux:text>
</div>
</div>
</div>
HTML;
}
private function loadCatalogPlugins(): void
{
$catalogUrl = config('app.catalog_url');
$this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) {
try {
$response = Http::get($catalogUrl);
$response = Http::timeout(10)->get($catalogUrl);
$catalogContent = $response->body();
$catalog = Yaml::parse($catalogContent);
@ -32,7 +55,7 @@ new class extends Component {
return collect($catalog)
->filter(function ($plugin) use ($currentVersion) {
// Check if Laravel compatibility is true
if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
return false;
}
@ -62,8 +85,9 @@ new class extends Component {
})
->sortBy('name')
->toArray();
} catch (\Exception $e) {
Log::error('Failed to load catalog from URL: ' . $e->getMessage());
} catch (Exception $e) {
Log::error('Failed to load catalog from URL: '.$e->getMessage());
return [];
}
});
@ -75,31 +99,59 @@ new class extends Component {
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
if (!$plugin || !$plugin['zip_url']) {
if (! $plugin || ! $plugin['zip_url']) {
$this->addError('installation', 'Plugin not found or no download URL available.');
return;
}
$this->installingPlugin = $pluginId;
try {
$importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null);
$importedPlugin = $pluginImportService->importFromUrl(
$plugin['zip_url'],
auth()->user(),
$plugin['zip_entry_path'] ?? null,
null,
$plugin['logo_url'] ?? null,
allowDuplicate: true
);
$this->dispatch('plugin-installed');
Flux::modal('import-from-catalog')->close();
} catch (\Exception $e) {
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
} catch (Exception $e) {
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
} finally {
$this->installingPlugin = '';
}
}
public function previewPlugin(string $pluginId): void
{
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
if (! $plugin) {
$this->addError('preview', 'Plugin not found.');
return;
}
$this->previewingPlugin = $pluginId;
$this->previewData = $plugin;
}
public function closePreview(): void
{
$this->previewingPlugin = '';
$this->previewData = [];
}
}; ?>
<div class="space-y-4">
@if(empty($catalogPlugins))
<div class="text-center py-8">
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
<flux:heading class="mt-2">No plugins available</flux:heading>
<flux:subheading>Catalog is empty</flux:subheading>
</div>
@ -110,30 +162,30 @@ new class extends Component {
@enderror
@foreach($catalogPlugins as $plugin)
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
<div wire:key="plugin-{{ $plugin['id'] }}" class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
<div class="flex items-start space-x-4">
@if($plugin['logo_url'])
<img src="{{ $plugin['logo_url'] }}" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
</div>
@endif
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
@if ($plugin['github'])
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
@endif
</div>
<div class="flex items-center space-x-2">
@if($plugin['license'])
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
@endif
@if($plugin['repo_url'])
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
<flux:icon name="github" class="w-5 h-5" />
</a>
@endif
@ -141,7 +193,7 @@ new class extends Component {
</div>
@if($plugin['description'])
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
@endif
<div class="mt-4 flex items-center space-x-3">
@ -151,6 +203,19 @@ new class extends Component {
Install
</flux:button>
@if($plugin['screenshot_url'])
<flux:modal.trigger name="catalog-preview">
<flux:button
wire:click="previewPlugin('{{ $plugin['id'] }}')"
variant="subtle"
icon="eye">
Preview
</flux:button>
</flux:modal.trigger>
@endif
@if($plugin['learn_more_url'])
<flux:button
href="{{ $plugin['learn_more_url'] }}"
@ -166,4 +231,38 @@ new class extends Component {
@endforeach
</div>
@endif
<!-- Preview Modal -->
<flux:modal name="catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
@if($previewingPlugin && !empty($previewData))
<div>
<flux:heading size="lg">Preview {{ $previewData['name'] ?? 'Plugin' }}</flux:heading>
</div>
<div class="space-y-4">
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
<img src="{{ $previewData['screenshot_url'] }}"
alt="Preview of {{ $previewData['name'] }}"
class="w-full h-auto max-h-[480px] object-contain">
</div>
@if($previewData['description'])
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
<flux:heading size="sm" class="mb-2">Description</flux:heading>
<flux:text size="sm">{{ $previewData['description'] }}</flux:text>
</div>
@endif
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
<flux:modal.close>
<flux:button
wire:click="installPlugin('{{ $previewingPlugin }}')"
variant="primary">
Install Plugin
</flux:button>
</flux:modal.close>
</div>
</div>
@endif
</flux:modal>
</div>

View file

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

View file

@ -0,0 +1,64 @@
<?php
use Livewire\Volt\Component;
use Livewire\Attributes\Modelable;
new class extends Component
{
#[Modelable]
public $model = '';
public $language = 'html';
public $theme = 'auto';
public $readonly = false;
public $placeholder = '';
public $height = '200px';
public $id = '';
public function mount($language = 'html', $theme = 'auto', $readonly = false, $placeholder = '', $height = '200px', $id = '')
{
$this->language = $language;
$this->theme = $theme;
$this->readonly = $readonly;
$this->placeholder = $placeholder;
$this->height = $height;
$this->id = $id;
}
public function toJSON()
{
return json_encode([
'model' => $this->model,
'language' => $this->language,
'theme' => $this->theme,
'readonly' => $this->readonly,
'placeholder' => $this->placeholder,
'height' => $this->height,
'id' => $this->id,
]);
}
} ?>
<div
x-data="codeMirrorComponent(@js($language), @js($theme), @js($readonly), @js($placeholder), @js($height), @js($id ?: uniqid()))"
x-init="init()"
wire:ignore
class="codemirror-container"
@if($id) id="{{ $id }}" @endif
autocomplete="off"
>
<!-- Loading state -->
<div x-show="isLoading" class="flex items-center justify-center p-4 border border-gray-300 rounded-md" style="height: {{ $height }};">
<div class="flex items-center space-x-2 text-gray-500">
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Loading editor...</span>
</div>
</div>
<!-- Editor container -->
<div x-show="!isLoading" x-ref="editor" style="height: {{ $height }};"></div>
</div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,11 +19,15 @@ new class extends Component {
public array $plugins;
public $zipFile;
public string $sortBy = 'date_asc';
public array $native_plugins = [
'markup' =>
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
'api' =>
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
'image-webhook' =>
['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'],
];
protected $rules = [
@ -38,8 +42,53 @@ new class extends Component {
public function refreshPlugins(): void
{
$userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray();
$this->plugins = array_merge($this->native_plugins, $userPlugins ?? []);
// Only show recipe plugins in the main list (image_webhook has its own management page)
$userPlugins = auth()->user()?->plugins()
->where('plugin_type', 'recipe')
->get()
->makeHidden(['render_markup', 'data_payload'])
->toArray();
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
$allPlugins = array_values($allPlugins);
$allPlugins = $this->sortPlugins($allPlugins);
$this->plugins = $allPlugins;
}
protected function sortPlugins(array $plugins): array
{
$pluginsToSort = array_values($plugins);
switch ($this->sortBy) {
case 'name_asc':
usort($pluginsToSort, function($a, $b) {
return strcasecmp($a['name'] ?? '', $b['name'] ?? '');
});
break;
case 'name_desc':
usort($pluginsToSort, function($a, $b) {
return strcasecmp($b['name'] ?? '', $a['name'] ?? '');
});
break;
case 'date_desc':
usort($pluginsToSort, function($a, $b) {
$aDate = $a['created_at'] ?? '1970-01-01';
$bDate = $b['created_at'] ?? '1970-01-01';
return strcmp($bDate, $aDate);
});
break;
case 'date_asc':
usort($pluginsToSort, function($a, $b) {
$aDate = $a['created_at'] ?? '1970-01-01';
$bDate = $b['created_at'] ?? '1970-01-01';
return strcmp($aDate, $bDate);
});
break;
}
return $pluginsToSort;
}
public function mount(): void
@ -47,6 +96,18 @@ new class extends Component {
$this->refreshPlugins();
}
public function updatedSortBy(): void
{
$this->refreshPlugins();
}
public function getListeners(): array
{
return [
'plugin-installed' => 'refreshPlugins',
];
}
public function addPlugin(): void
{
abort_unless(auth()->user() !== null, 403);
@ -74,7 +135,6 @@ new class extends Component {
{
Artisan::call(ExampleRecipesSeederCommand::class, ['user_id' => auth()->id()]);
$this->refreshPlugins();
}
@ -101,38 +161,77 @@ new class extends Component {
};
?>
<div class="py-12">
<div class="py-12" x-data="{
searchTerm: '',
showFilters: false,
filterPlugins(plugins) {
if (this.searchTerm.length <= 1) return plugins;
const search = this.searchTerm.toLowerCase();
return plugins.filter(p => p.name.toLowerCase().includes(search));
}
}">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Plugins &amp; Recipes</h2>
<div class="flex items-center space-x-2">
<flux:button icon="funnel" variant="ghost" @click="showFilters = !showFilters"></flux:button>
<flux:button.group>
<flux:modal.trigger name="add-plugin">
<flux:button icon="plus" variant="primary">Add Recipe</flux:button>
</flux:modal.trigger>
<flux:button.group>
<flux:modal.trigger name="add-plugin">
<flux:button icon="plus" variant="primary">Add Recipe</flux:button>
</flux:modal.trigger>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:modal.trigger name="import-zip">
<flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item>
</flux:modal.trigger>
<flux:modal.trigger name="import-from-catalog">
<flux:menu.item icon="book-open">Import from Catalog</flux:menu.item>
</flux:modal.trigger>
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
</flux:menu>
</flux:dropdown>
</flux:button.group>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:modal.trigger name="import-from-catalog">
<flux:menu.item icon="book-open">Import from OSS Catalog</flux:menu.item>
</flux:modal.trigger>
@if(config('services.trmnl.liquid_enabled'))
<flux:modal.trigger name="import-from-trmnl-catalog">
<flux:menu.item icon="book-open">Import from TRMNL Catalog</flux:menu.item>
</flux:modal.trigger>
@endif
<flux:separator />
<flux:modal.trigger name="import-zip">
<flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item>
</flux:modal.trigger>
<flux:separator />
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div>
</div>
<div x-show="showFilters" class="mb-6 flex flex-col sm:flex-row gap-4" style="display: none;">
<div class="flex-1">
<flux:input
x-model="searchTerm"
placeholder="Search plugins by name (min. 2 characters)..."
icon="magnifying-glass"
/>
</div>
<div class="sm:w-64">
<flux:select wire:model.live="sortBy">
<option value="date_asc">Oldest First</option>
<option value="date_desc">Newest First</option>
<option value="name_asc">Name (A-Z)</option>
<option value="name_desc">Name (Z-A)</option>
</flux:select>
</div>
</div>
<div x-show="searchTerm.length > 1" class="mb-4" style="display: none;">
<p class="text-sm text-zinc-600 dark:text-zinc-400">
<span x-text="'Showing results for: ' + searchTerm"></span>
</p>
</div>
<flux:modal name="import-zip" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Import Recipe
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
<flux:badge color="blue" class="ml-2">Beta</flux:badge>
</flux:heading>
<flux:subheading>Upload a ZIP archive containing a TRMNL recipe either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading>
</div>
@ -190,11 +289,32 @@ new class extends Component {
<div class="space-y-6">
<div>
<flux:heading size="lg">Import from Catalog
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
<flux:badge color="blue" class="ml-2">Beta</flux:badge>
</flux:heading>
<flux:subheading>Browse and install Recipes from the community. Add yours <a href="https://github.com/bnussbau/trmnl-recipe-catalog" class="underline" target="_blank">here</a>.</flux:subheading>
</div>
<livewire:catalog.index @plugin-installed="refreshPlugins" />
<livewire:catalog.index />
</div>
</flux:modal>
<flux:modal name="import-from-trmnl-catalog">
<div class="space-y-6">
<div>
<flux:heading size="lg">Import from TRMNL Recipe Catalog
<flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
</flux:heading>
<flux:callout class="mb-4 mt-4" color="yellow">
<flux:heading size="sm">Limitations</flux:heading>
<ul class="list-disc pl-5 mt-2">
<li><flux:text>Only full view will be imported; shared markup will be prepended</flux:text></li>
<li><flux:text>Requires <span class="font-mono">trmnl-liquid-cli</span> executable.</flux:text></li>
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
</ul>
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
</flux:callout>
</div>
<livewire:catalog.trmnl />
</div>
</flux:modal>
@ -265,15 +385,26 @@ new class extends Component {
</div>
</flux:modal>
@php
$allPlugins = $this->plugins;
@endphp
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@foreach($plugins as $plugin)
@foreach($allPlugins as $index => $plugin)
<div
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}"
x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }"
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
class="styled-container">
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
class="block">
<div class="flex items-center space-x-4 px-10 py-8">
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}"
class="block h-full">
<div class="flex items-center space-x-4 px-10 py-8 h-full">
@isset($plugin['icon_url'])
<img src="{{ $plugin['icon_url'] }}" class="h-6"/>
@else
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}"
class="text-4xl text-accent"/>
@endif
<h3 class="text-lg font-medium dark:text-zinc-200">{{$plugin['name']}}</h3>
</div>
</a>

View file

@ -1,12 +1,16 @@
<?php
use App\Models\Device;
use App\Models\Plugin;
use App\Models\DeviceModel;
use Illuminate\Support\Carbon;
use Keepsuit\Liquid\Exceptions\LiquidException;
use Livewire\Volt\Component;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\On;
use Livewire\Attributes\Computed;
new class extends Component {
public Plugin $plugin;
@ -15,6 +19,8 @@ new class extends Component {
public string|null $markup_language;
public string $name;
public bool $no_bleed = false;
public bool $dark_mode = false;
public int $data_stale_minutes;
public string $data_strategy;
public string|null $polling_url;
@ -32,16 +38,15 @@ new class extends Component {
public string $mashup_layout = 'full';
public array $mashup_plugins = [];
public array $configuration_template = [];
public array $configuration = [];
public array $xhrSelectOptions = [];
public array $searchQueries = [];
public ?int $preview_device_model_id = null;
public string $preview_size = 'full';
public function mount(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->blade_code = $this->plugin->render_markup;
// required to render some stuff
$this->configuration_template = $this->plugin->configuration_template ?? [];
$this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
if ($this->plugin->render_markup_view) {
try {
@ -66,8 +71,18 @@ new class extends Component {
$this->markup_language = $this->plugin->markup_language ?? 'blade';
}
// Initialize screen settings from the model
$this->no_bleed = (bool) ($this->plugin->no_bleed ?? false);
$this->dark_mode = (bool) ($this->plugin->dark_mode ?? false);
$this->fillformFields();
$this->data_payload_updated_at = $this->plugin->data_payload_updated_at;
// Set default preview device model
if ($this->preview_device_model_id === null) {
$defaultModel = DeviceModel::where('name', 'og_plus')->first() ?? DeviceModel::first();
$this->preview_device_model_id = $defaultModel?->id;
}
}
public function fillFormFields(): void
@ -109,6 +124,8 @@ new class extends Component {
'device_weekdays' => 'array',
'device_active_from' => 'array',
'device_active_until' => 'array',
'no_bleed' => 'boolean',
'dark_mode' => 'boolean',
];
public function editSettings()
@ -121,6 +138,19 @@ new class extends Component {
$validated = $this->validate();
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
$this->plugin->update($validated);
foreach ($this->configuration_template as $fieldKey => $field) {
if (($field['field_type'] ?? null) !== 'multi_string') {
continue;
}
if (!isset($this->multiValues[$fieldKey])) {
continue;
}
$validated[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
}
}
protected function validatePollingUrl(): void
@ -246,27 +276,6 @@ new class extends Component {
Flux::modal('add-to-playlist')->close();
}
public function saveConfiguration()
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$configurationValues = [];
if (isset($this->configuration_template['custom_fields'])) {
foreach ($this->configuration_template['custom_fields'] as $field) {
$fieldKey = $field['keyname'];
if (isset($this->configuration[$fieldKey])) {
$configurationValues[$fieldKey] = $this->configuration[$fieldKey];
}
}
}
$this->plugin->update([
'configuration' => $configurationValues
]);
Flux::modal('configuration-modal')->close();
}
public function getDevicePlaylists($deviceId)
{
return \App\Models\Playlist::where('device_id', $deviceId)->get();
@ -287,8 +296,6 @@ new class extends Component {
return $this->configuration[$key] ?? $default;
}
public function renderExample(string $example)
{
switch ($example) {
@ -357,13 +364,17 @@ HTML;
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->preview_size = $size;
// If data strategy is polling and data_payload is null, fetch the data first
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
$this->updateData();
}
try {
$previewMarkup = $this->plugin->render($size);
// Create a device object with og_plus model and the selected bitdepth
$device = $this->createPreviewDevice();
$previewMarkup = $this->plugin->render($size, true, $device);
$this->dispatch('preview-updated', preview: $previewMarkup);
} catch (LiquidException $e) {
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
@ -372,6 +383,38 @@ HTML;
}
}
private function createPreviewDevice(): \App\Models\Device
{
$deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id)
?? DeviceModel::with(['palette'])->first();
$device = new Device();
$device->setRelation('deviceModel', $deviceModel);
return $device;
}
public function getDeviceModels()
{
return DeviceModel::whereKind('trmnl')->orderBy('label')->get();
}
public function updatedPreviewDeviceModelId(): void
{
$this->renderPreview($this->preview_size);
}
public function duplicatePlugin(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
// Use the model's duplicate method
$newPlugin = $this->plugin->duplicate(auth()->id());
// Redirect to the new plugin's detail page
$this->redirect(route('plugins.recipe', ['plugin' => $newPlugin]));
}
public function deletePlugin(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
@ -379,42 +422,31 @@ HTML;
$this->redirect(route('plugins.index'));
}
public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
#[On('config-updated')]
public function refreshPlugin()
{
// This pulls the fresh 'configuration' from the DB
// and re-triggers the @if check in the Blade template
$this->plugin = $this->plugin->fresh();
}
try {
$requestData = [];
if ($query !== null) {
$requestData = [
'function' => $fieldKey,
'query' => $query
];
}
$response = $query !== null
? Http::post($endpoint, $requestData)
: Http::post($endpoint);
if ($response->successful()) {
$this->xhrSelectOptions[$fieldKey] = $response->json();
} else {
$this->xhrSelectOptions[$fieldKey] = [];
}
} catch (\Exception $e) {
$this->xhrSelectOptions[$fieldKey] = [];
}
// Laravel Livewire computed property: access with $this->parsed_urls
#[Computed]
private function parsedUrls()
{
if (!isset($this->polling_url)) {
return null;
}
public function searchXhrSelect(string $fieldKey, string $endpoint): void
{
$query = $this->searchQueries[$fieldKey] ?? '';
if (!empty($query)) {
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
}
try {
return $this->plugin->resolveLiquidVariables($this->polling_url);
} catch (\Exception $e) {
return 'PARSE_ERROR: ' . $e->getMessage();
}
}
}
?>
<div class="py-12">
@ -446,7 +478,6 @@ HTML;
</flux:modal.trigger>
</flux:menu>
</flux:dropdown>
</flux:button.group>
<flux:button.group>
<flux:modal.trigger name="add-to-playlist">
@ -456,6 +487,11 @@ HTML;
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:modal.trigger name="trmnlp-settings">
<flux:menu.item icon="cog">Recipe Settings</flux:menu.item>
</flux:modal.trigger>
<flux:menu.separator />
<flux:menu.item icon="document-duplicate" wire:click="duplicatePlugin">Duplicate Plugin</flux:menu.item>
<flux:modal.trigger name="delete-plugin">
<flux:menu.item icon="trash" variant="danger">Delete Plugin</flux:menu.item>
</flux:modal.trigger>
@ -597,8 +633,15 @@ HTML;
</flux:modal>
<flux:modal name="preview-plugin" class="min-w-[850px] min-h-[480px] space-y-6">
<div>
<div class="flex items-center gap-4">
<flux:heading size="lg">Preview {{ $plugin->name }}</flux:heading>
<flux:field class="w-48">
<flux:select wire:model.live="preview_device_model_id">
@foreach($this->getDeviceModels() as $model)
<option value="{{ $model->id }}">{{ $model->label ?? $model->name }}</option>
@endforeach
</flux:select>
</flux:field>
</div>
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
@ -606,253 +649,9 @@ HTML;
</div>
</flux:modal>
<flux:modal name="configuration-modal" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Configuration</flux:heading>
<flux:subheading>Configure your plugin settings</flux:subheading>
</div>
<livewire:plugins.recipes.settings :plugin="$plugin" />
<form wire:submit="saveConfiguration">
@if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields']))
@foreach($configuration_template['custom_fields'] as $field)
@php
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
$currentValue = $configuration[$fieldKey] ?? '';
@endphp
<div class="mb-4">
@if($field['field_type'] === 'author_bio')
@continue
@endif
@if($field['field_type'] === 'copyable_webhook_url')
@continue
@endif
@if($field['field_type'] === 'string' || $field['field_type'] === 'url')
<flux:input
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'text')
<flux:textarea
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'code')
<flux:textarea
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
rows="{{ $field['rows'] ?? 3 }}"
placeholder="{{ $field['placeholder'] ?? null }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
class="font-mono"
/>
@elseif($field['field_type'] === 'password')
<flux:input
type="password"
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
viewable
/>
@elseif($field['field_type'] === 'copyable')
<flux:input
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
value="{{ $field['value'] }}"
copyable
/>
@elseif($field['field_type'] === 'time_zone')
<flux:select
label="{{ $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
>
<option value="">Select timezone...</option>
@foreach(timezone_identifiers_list() as $timezone)
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
@endforeach
</flux:select>
@elseif($field['field_type'] === 'number')
<flux:input
type="number"
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? $field['name'] }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'boolean')
<flux:checkbox
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? $field['name'] }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
:checked="$currentValue"
/>
@elseif($field['field_type'] === 'date')
<flux:input
type="date"
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? $field['name'] }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'time')
<flux:input
type="time"
label="{{ $field['name'] }}"
description="{{ $field['description'] ?? $field['name'] }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:model="configuration.{{ $fieldKey }}"
value="{{ $currentValue }}"
/>
@elseif($field['field_type'] === 'select')
@if(isset($field['multiple']) && $field['multiple'] === true)
<flux:checkbox.group
label="{{ $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
>
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<flux:checkbox label="{{ $option }}" value="{{ $key }}"/>
@endif
@endforeach
@endif
</flux:checkbox.group>
@else
<flux:select
label="{{ $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
>
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($field['options']) && is_array($field['options']))
@foreach($field['options'] as $option)
@if(is_array($option))
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@else
@php
$key = mb_strtolower(str_replace(' ', '_', $option));
@endphp
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
@endif
@elseif($field['field_type'] === 'xhrSelect')
<flux:select
label="{{ $field['name'] }}"
wire:model="configuration.{{ $fieldKey }}"
description="{{ $field['description'] ?? '' }}"
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
wire:init="loadXhrSelectOptions('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
>
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
@foreach($xhrSelectOptions[$fieldKey] as $option)
@if(is_array($option))
@if(isset($option['id']) && isset($option['name']))
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
</flux:select>
@elseif($field['field_type'] === 'xhrSelectSearch')
<div class="space-y-2">
<flux:label>{{ $field['name'] }}</flux:label>
<flux:description>{{ $field['description'] ?? '' }}</flux:description>
<flux:input.group>
<flux:input
wire:model="searchQueries.{{ $fieldKey }}"
placeholder="Enter search query..."
/>
<flux:button
wire:click="searchXhrSelect('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
icon="magnifying-glass"/>
</flux:input.group>
<flux:description>{{ $field['help_text'] ?? '' }}</flux:description>
@if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue))
<flux:select
wire:model="configuration.{{ $fieldKey }}"
>
<option value="">Select {{ $field['name'] }}...</option>
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
@foreach($xhrSelectOptions[$fieldKey] as $option)
@if(is_array($option))
@if(isset($option['id']) && isset($option['name']))
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
@else
{{-- xhrSelect format: { 'Braves' => 123 } --}}
@foreach($option as $label => $value)
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
@endif
@else
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
@endif
@endforeach
@endif
@if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey])))
{{-- Show current value even if no options are loaded --}}
<option value="{{ $currentValue }}" selected>{{ $currentValue }}</option>
@endif
</flux:select>
@endif
</div>
@else
<flux:callout variant="warning">Field type "{{ $field['field_type'] }}" not yet supported</flux:callout>
@endif
</div>
@endforeach
@endif
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Save Configuration</flux:button>
</div>
</form>
</div>
</flux:modal>
<livewire:plugins.config-modal :plugin="$plugin" />
<div class="mt-5 mb-5">
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
@ -940,7 +739,7 @@ HTML;
@endif
<div class="mb-4">
<flux:modal.trigger name="configuration-modal">
<flux:button icon="cog" class="block mt-1 w-full">Configuration</flux:button>
<flux:button icon="variable" class="block mt-1 w-full">Configuration Fields</flux:button>
</flux:modal.trigger>
</div>
@endif
@ -953,15 +752,62 @@ HTML;
</div>
@if($data_strategy === 'polling')
<div class="mb-4">
<flux:textarea label="Polling URL" description="You can use configuration variables with Liquid syntax. Supports multiple requests via line break separation" wire:model="polling_url" id="polling_url"
<flux:label>Polling URL</flux:label>
<div x-data="{ subTab: 'settings' }" class="mt-2 mb-4">
<div class="flex">
<button
@click="subTab = 'settings'"
class="tab-button"
:class="subTab === 'settings' ? 'is-active' : ''"
>
<flux:icon.cog-6-tooth class="size-4"/>
Settings
</button>
<button
@click="subTab = 'preview'"
class="tab-button"
:class="subTab === 'preview' ? 'is-active' : ''"
>
<flux:icon.eye class="size-4" />
Preview URL
</button>
</div>
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
<div x-show="subTab === 'settings'">
<flux:field>
<flux:description>Enter the URL(s) to poll for data:</flux:description>
<flux:textarea
wire:model.live="polling_url"
placeholder="https://example.com/api"
class="block w-full" type="text" name="polling_url" autofocus>
</flux:input>
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full">
rows="5"
/>
<flux:description>
{!! 'Hint: Supports multiple requests via line break separation. You can also use configuration variables with <a href="https://help.usetrmnl.com/en/articles/12689499-dynamic-polling-urls">Liquid syntax</a>. ' !!}
</flux:description>
</flux:field>
</div>
<div x-show="subTab === 'preview'" x-cloak>
<flux:field>
<flux:description>Preview computed URLs here (readonly):</flux:description>
<flux:textarea
readonly
placeholder="Nothing to show..."
rows="5"
>
{{ $this->parsed_urls }}
</flux:textarea>
</flux:field>
</div>
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="w-full mt-4">
Fetch data now
</flux:button>
</div>
</div>
<div class="mb-4">
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
@ -1015,6 +861,22 @@ HTML;
<flux:text class="mb-2">Enter static JSON data in the Data Payload field.</flux:text>
@endif
<div class="mb-4">
<flux:label>Screen Settings</flux:label>
<div class="mt-2 space-y-2">
<flux:checkbox
wire:model="no_bleed"
label="Remove bleed margin?"
description="If selected, padding around your markup will be removed."
/>
<flux:checkbox
wire:model="dark_mode"
label="Enable Dark Mode?"
description="Inverts black/white pixels for the entire screen. Add class 'image' to img tags as needed."
/>
</div>
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary" class="w-full">Save</flux:button>
@ -1022,14 +884,48 @@ HTML;
</form>
</div>
<div>
<flux:label>Data Payload</flux:label>
@isset($this->data_payload_updated_at)
<flux:badge icon="clock" size="sm" variant="pill" class="ml-2">{{ $this->data_payload_updated_at?->diffForHumans() ?? 'Never' }}</flux:badge>
@endisset
<div class="mb-1">
<flux:label>Data Payload</flux:label>
@isset($this->data_payload_updated_at)
<flux:badge icon="clock" size="sm" variant="pill" class="ml-2">{{ $this->data_payload_updated_at?->diffForHumans() ?? 'Never' }}</flux:badge>
@endisset
</div>
<flux:error name="data_payload"/>
<flux:textarea wire:model="data_payload" id="data_payload"
class="block mt-1 w-full font-mono" type="text" name="data_payload"
:readonly="$data_strategy !== 'static'" rows="24"/>
<flux:field>
@php
$textareaId = 'payload-' . uniqid();
@endphp
<flux:textarea
wire:model="data_payload"
id="{{ $textareaId }}"
placeholder="Enter your HTML code here..."
rows="12"
hidden
/>
<div
x-data="codeEditorFormComponent({
isDisabled: @js($data_strategy !== 'static'),
language: 'json',
state: $wire.entangle('data_payload'),
textareaId: @js($textareaId)
})"
wire:ignore
wire:key="cm-{{ $textareaId }}"
class="max-w-2xl min-h-[300px] h-[565px] overflow-hidden resize-y"
>
<!-- Loading state -->
<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>
<!-- Editor container -->
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div>
</flux:field>
</div>
</div>
<flux:separator class="my-5"/>
@ -1041,15 +937,40 @@ HTML;
<span class="font-mono text-accent mb-4">{{ $plugin->render_markup_view }}</span> to update.
</div>
<div class="mb-4 mt-4">
<flux:textarea
label="File Content"
class="font-mono"
wire:model="view_content"
id="view_content"
name="view_content"
rows="15"
readonly
/>
<flux:field>
@php
$textareaId = 'code-view-' . uniqid();
@endphp
<flux:textarea
wire:model="view_content"
id="{{ $textareaId }}"
placeholder="Enter your HTML code here..."
rows="25"
hidden
/>
<div
x-data="codeEditorFormComponent({
isDisabled: false,
language: 'liquid',
state: $wire.entangle('markup_code'),
textareaId: @js($textareaId)
})"
wire:ignore
wire:key="cm-{{ $textareaId }}"
class="min-h-[300px] h-[300px] overflow-hidden resize-y"
>
<!-- Loading state -->
<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>
<!-- Editor container -->
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div>
</flux:field>
</div>
@else
<div class="flex items-center gap-6 mb-4 mt-4">
@ -1071,15 +992,41 @@ HTML;
@if(!$plugin->render_markup_view)
<form wire:submit="saveMarkup">
<div class="mb-4">
<flux:textarea
label="{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}"
class="font-mono"
wire:model="markup_code"
id="markup_code"
name="markup_code"
rows="15"
placeholder="{{ $markup_language === 'liquid' ? 'Enter your liquid code here...' : 'Enter your blade code here...' }}"
/>
<flux:field>
@php
$textareaId = 'code-' . uniqid();
@endphp
<flux:label>{{ $markup_language === 'liquid' ? 'Liquid Code' : 'Blade Code' }}</flux:label>
<flux:textarea
wire:model="markup_code"
id="{{ $textareaId }}"
placeholder="Enter your HTML code here..."
rows="25"
hidden
/>
<div
x-data="codeEditorFormComponent({
isDisabled: false,
language: 'liquid',
state: $wire.entangle('markup_code'),
textareaId: @js($textareaId)
})"
wire:ignore
wire:key="cm-{{ $textareaId }}"
class="min-h-[300px] h-[300px] overflow-hidden resize-y"
>
<!-- Loading state -->
<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>
<!-- Editor container -->
<div x-show="!isLoading" x-ref="editor" class="h-full"></div>
</div>
</flux:field>
</div>
<div class="flex">

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
{!! $slot !!}
</x-trmnl::screen>
@else
<x-trmnl::screen colorDepth="{{$colorDepth}}">
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}">
{!! $slot !!}
</x-trmnl::screen>
@endif

View file

@ -18,7 +18,7 @@ use Illuminate\Support\Str;
Route::get('/display', function (Request $request) {
$mac_address = $request->header('id');
$access_token = $request->header('access-token');
$device = Device::where('mac_address', $mac_address)
$device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
->where('api_key', $access_token)
->first();
@ -29,7 +29,7 @@ Route::get('/display', function (Request $request) {
if ($auto_assign_user) {
// Create a new device and assign it to this user
$device = Device::create([
'mac_address' => $mac_address,
'mac_address' => mb_strtoupper($mac_address ?? ''),
'api_key' => $access_token,
'user_id' => $auto_assign_user->id,
'name' => "{$auto_assign_user->name}'s TRMNL",
@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) {
// Check and update stale data if needed
if ($plugin->isDataStale() || $plugin->current_image === null) {
$plugin->updateDataPayload();
$markup = $plugin->render(device: $device);
try {
$markup = $plugin->render(device: $device);
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
} catch (Exception $e) {
Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
// Generate error display
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name);
$device->update(['current_screen_image' => $errorImageUuid]);
}
}
$plugin->refresh();
@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) {
}
}
$markup = $playlistItem->render(device: $device);
GenerateScreenJob::dispatchSync($device->id, null, $markup);
try {
$markup = $playlistItem->render(device: $device);
GenerateScreenJob::dispatchSync($device->id, null, $markup);
} catch (Exception $e) {
Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage());
// For mashups, show error for the first plugin or a generic error
$firstPlugin = $plugins->first();
$pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe';
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName);
$device->update(['current_screen_image' => $errorImageUuid]);
}
$device->refresh();
@ -204,7 +220,7 @@ Route::get('/setup', function (Request $request) {
], 404);
}
$device = Device::where('mac_address', $mac_address)->first();
$device = Device::where('mac_address', mb_strtoupper($mac_address))->first();
if (! $device) {
// Check if there's a user with assign_new_devices enabled
@ -219,7 +235,7 @@ Route::get('/setup', function (Request $request) {
// Create a new device and assign it to this user
$device = Device::create([
'mac_address' => $mac_address,
'mac_address' => mb_strtoupper($mac_address),
'api_key' => Str::random(22),
'user_id' => $auto_assign_user->id,
'name' => "{$auto_assign_user->name}'s TRMNL",
@ -345,7 +361,7 @@ Route::post('/display/update', function (Request $request) {
Route::post('/screens', function (Request $request) {
$mac_address = $request->header('id');
$access_token = $request->header('access-token');
$device = Device::where('mac_address', $mac_address)
$device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
->where('api_key', $access_token)
->first();
@ -533,6 +549,91 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) {
return response()->json(['message' => 'Data updated successfully']);
})->name('api.custom_plugins.webhook');
Route::post('plugin_settings/{uuid}/image', function (Request $request, string $uuid) {
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
// Check if plugin is image_webhook type
if ($plugin->plugin_type !== 'image_webhook') {
return response()->json(['error' => 'Plugin is not an image webhook plugin'], 400);
}
// Accept image from either multipart form or raw binary
$image = null;
$extension = null;
if ($request->hasFile('image')) {
$file = $request->file('image');
$extension = mb_strtolower($file->getClientOriginalExtension());
$image = $file->get();
} elseif ($request->has('image')) {
// Base64 encoded image
$imageData = $request->input('image');
if (preg_match('/^data:image\/(\w+);base64,/', $imageData, $matches)) {
$extension = mb_strtolower($matches[1]);
$image = base64_decode(mb_substr($imageData, mb_strpos($imageData, ',') + 1));
} else {
return response()->json(['error' => 'Invalid image format. Expected base64 data URI.'], 400);
}
} else {
// Try raw binary
$image = $request->getContent();
$contentType = $request->header('Content-Type', '');
$trimmedContent = mb_trim($image);
// Check if content is empty or just empty JSON
if (empty($image) || $trimmedContent === '' || $trimmedContent === '{}') {
return response()->json(['error' => 'No image data provided'], 400);
}
// If it's a JSON request without image field, return error
if (str_contains($contentType, 'application/json')) {
return response()->json(['error' => 'No image data provided'], 400);
}
// Detect image type from content
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_buffer($finfo, $image);
finfo_close($finfo);
$extension = match ($mimeType) {
'image/png' => 'png',
'image/bmp' => 'bmp',
default => null,
};
if (! $extension) {
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
}
}
// Validate extension
$allowedExtensions = ['png', 'bmp'];
if (! in_array($extension, $allowedExtensions)) {
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
}
// Generate a new UUID for each image upload to prevent device caching
$imageUuid = Str::uuid()->toString();
$filename = $imageUuid.'.'.$extension;
$path = 'images/generated/'.$filename;
// Save image to storage
Storage::disk('public')->put($path, $image);
// Update plugin's current_image field with the new UUID
$plugin->update([
'current_image' => $imageUuid,
]);
// Clean up old images
ImageGenerationService::cleanupFolder();
return response()->json([
'message' => 'Image uploaded successfully',
'image_url' => url('storage/'.$path),
]);
})->name('api.plugin_settings.image');
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
return response()->json([
@ -577,3 +678,90 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s
],
]);
})->middleware('auth:sanctum');
Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
// Check if alias is active
if (! $plugin->alias) {
return response()->json([
'message' => 'Alias is not active for this plugin',
], 403);
}
// Get device model name from query parameter, default to 'og_png'
$deviceModelName = $request->query('device-model', 'og_png');
$deviceModel = DeviceModel::where('name', $deviceModelName)->first();
if (! $deviceModel) {
return response()->json([
'message' => "Device model '{$deviceModelName}' not found",
], 404);
}
// Check if we can use cached image (only for og_png and if data is not stale)
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
if ($useCache) {
// Return cached image
$imageUuid = $plugin->current_image;
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
$imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension;
// Check if image exists, otherwise fall back to generation
if (Storage::disk('public')->exists($imagePath)) {
return response()->file(Storage::disk('public')->path($imagePath), [
'Content-Type' => $deviceModel->mime_type,
]);
}
}
// Generate new image
try {
// Update data if needed
if ($plugin->isDataStale()) {
$plugin->updateDataPayload();
$plugin->refresh();
}
// Load device model with palette relationship
$deviceModel->load('palette');
// Create a virtual device for rendering (Plugin::render needs a Device object)
$virtualDevice = new Device();
$virtualDevice->setRelation('deviceModel', $deviceModel);
$virtualDevice->setRelation('user', $plugin->user);
$virtualDevice->setRelation('palette', $deviceModel->palette);
// Render the plugin markup
$markup = $plugin->render(device: $virtualDevice);
// Generate image using the new method that doesn't require a device
$imageUuid = ImageGenerationService::generateImageFromModel(
markup: $markup,
deviceModel: $deviceModel,
user: $plugin->user,
palette: $deviceModel->palette
);
// Update plugin cache if using og_png
if ($deviceModelName === 'og_png') {
$plugin->update(['current_image' => $imageUuid]);
}
// Return the generated image
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
$imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension);
return response()->file($imagePath, [
'Content-Type' => $deviceModel->mime_type,
]);
} catch (Exception $e) {
Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
return response()->json([
'message' => 'Failed to generate image',
'error' => $e->getMessage(),
], 500);
}
})->name('api.display.alias');

View file

@ -24,12 +24,15 @@ Route::middleware(['auth'])->group(function () {
Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs');
Volt::route('/device-models', 'device-models.index')->name('device-models.index');
Volt::route('/device-palettes', 'device-palettes.index')->name('device-palettes.index');
Volt::route('plugins', 'plugins.index')->name('plugins.index');
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook');
Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance');
Volt::route('playlists', 'playlists.index')->name('playlists.index');
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {

View file

@ -7,6 +7,7 @@ use App\Models\Playlist;
use App\Models\PlaylistItem;
use App\Models\Plugin;
use App\Models\User;
use App\Services\ImageGenerationService;
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
@ -954,3 +955,232 @@ test('setup endpoint handles non-existent device model gracefully', function ():
expect($device)->not->toBeNull()
->and($device->device_model_id)->toBeNull();
});
test('setup endpoint matches MAC address case-insensitively', function (): void {
// Create device with lowercase MAC address
$device = Device::factory()->create([
'mac_address' => 'a1:b2:c3:d4:e5:f6',
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
]);
// Request with uppercase MAC address should still match
$response = $this->withHeaders([
'id' => 'A1:B2:C3:D4:E5:F6',
])->get('/api/setup');
$response->assertOk()
->assertJson([
'status' => 200,
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
'message' => 'Welcome to TRMNL BYOS',
]);
});
test('display endpoint matches MAC address case-insensitively', function (): void {
// Create device with lowercase MAC address
$device = Device::factory()->create([
'mac_address' => 'a1:b2:c3:d4:e5:f6',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
]);
// Request with uppercase MAC address should still match
$response = $this->withHeaders([
'id' => 'A1:B2:C3:D4:E5:F6',
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'status' => '0',
'filename' => 'test-image.bmp',
]);
});
test('screens endpoint matches MAC address case-insensitively', function (): void {
Queue::fake();
// Create device with uppercase MAC address
$device = Device::factory()->create([
'mac_address' => 'A1:B2:C3:D4:E5:F6',
'api_key' => 'test-api-key',
]);
// Request with lowercase MAC address should still match
$response = $this->withHeaders([
'id' => 'a1:b2:c3:d4:e5:f6',
'access-token' => $device->api_key,
])->post('/api/screens', [
'image' => [
'content' => '<div>Test content</div>',
],
]);
$response->assertOk();
Queue::assertPushed(GenerateScreenJob::class);
});
test('display endpoint handles plugin rendering errors gracefully', function (): void {
TrmnlPipeline::fake();
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
// Create a plugin with Blade markup that will cause an exception when accessing data[0]
// when data is not an array or doesn't have index 0
$plugin = Plugin::factory()->create([
'name' => 'Broken Recipe',
'data_strategy' => 'polling',
'polling_url' => null,
'data_stale_minutes' => 1,
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail if data[0] doesn't exist
'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail
'data_payload_updated_at' => now()->subMinutes(2), // Make it stale
'current_image' => null,
]);
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'test_playlist',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
PlaylistItem::factory()->create([
'playlist_id' => $playlist->id,
'plugin_id' => $plugin->id,
'order' => 1,
'is_active' => true,
'last_displayed_at' => null,
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
// Verify error screen was generated and set on device
$device->refresh();
expect($device->current_screen_image)->not->toBeNull();
// Verify the error image exists
$errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png");
// The TrmnlPipeline is faked, so we just verify the UUID was set
expect($device->current_screen_image)->toBeString();
});
test('display endpoint handles mashup rendering errors gracefully', function (): void {
TrmnlPipeline::fake();
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
// Create plugins for mashup, one with invalid markup
$plugin1 = Plugin::factory()->create([
'name' => 'Working Plugin',
'data_strategy' => 'polling',
'polling_url' => null,
'data_stale_minutes' => 1,
'render_markup_view' => 'trmnl',
'data_payload_updated_at' => now()->subMinutes(2),
'current_image' => null,
]);
$plugin2 = Plugin::factory()->create([
'name' => 'Broken Plugin',
'data_strategy' => 'polling',
'polling_url' => null,
'data_stale_minutes' => 1,
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail
'data_payload' => ['error' => 'Failed to fetch data'],
'data_payload_updated_at' => now()->subMinutes(2),
'current_image' => null,
]);
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'test_playlist',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
// Create mashup playlist item
$playlistItem = PlaylistItem::createMashup(
$playlist,
'1Lx1R',
[$plugin1->id, $plugin2->id],
'Test Mashup',
1
);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
// Verify error screen was generated and set on device
$device->refresh();
expect($device->current_screen_image)->not->toBeNull();
// Verify the error image UUID was set
expect($device->current_screen_image)->toBeString();
});
test('generateDefaultScreenImage creates error screen with plugin name', function (): void {
TrmnlPipeline::fake();
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/generated');
$device = Device::factory()->create();
$errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name');
expect($errorUuid)->not->toBeEmpty();
// Verify the error image path would be created
$errorPath = "images/generated/{$errorUuid}.png";
// Since TrmnlPipeline is faked, we just verify the UUID was generated
expect($errorUuid)->toBeString();
});
test('generateDefaultScreenImage throws exception for invalid error image type', function (): void {
$device = Device::factory()->create();
expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type'))
->toThrow(InvalidArgumentException::class);
});
test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void {
$device = new Device();
$device->deviceModel = null;
$result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error');
expect($result)->toBeNull();
});

View file

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

View file

@ -3,6 +3,8 @@
declare(strict_types=1);
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
@ -14,16 +16,57 @@ test('firmware check command has correct signature', function (): void {
});
test('firmware check command runs without errors', function (): void {
// Mock the firmware API response
Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([
'version' => '1.0.0',
'url' => 'https://example.com/firmware.bin',
], 200),
]);
$this->artisan('trmnl:firmware:check')
->assertExitCode(0);
// Verify that the firmware was created
expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue();
});
test('firmware check command runs with download flag', function (): void {
// Mock the firmware API response
Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([
'version' => '1.0.0',
'url' => 'https://example.com/firmware.bin',
], 200),
'https://example.com/firmware.bin' => Http::response('fake firmware content', 200),
]);
// Mock storage to prevent actual file operations
Storage::fake('public');
$this->artisan('trmnl:firmware:check', ['--download' => true])
->assertExitCode(0);
// Verify that the firmware was created and marked as latest
expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue();
// Verify that the firmware was downloaded (storage_location should be set)
$firmware = App\Models\Firmware::where('version_tag', '1.0.0')->first();
expect($firmware->storage_location)->toBe('firmwares/FW1.0.0.bin');
});
test('firmware check command can run successfully', function (): void {
// Mock the firmware API response
Http::fake([
'https://usetrmnl.com/api/firmware/latest' => Http::response([
'version' => '1.0.0',
'url' => 'https://example.com/firmware.bin',
], 200),
]);
$this->artisan('trmnl:firmware:check')
->assertExitCode(0);
// Verify that the firmware was created
expect(App\Models\Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue();
});

View file

@ -3,9 +3,22 @@
use App\Models\Device;
use App\Models\DeviceModel;
use App\Services\ImageGenerationService;
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Support\Facades\Storage;
test('command transforms default images for all device models', function () {
beforeEach(function (): void {
TrmnlPipeline::fake();
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/default-screens');
Storage::disk('public')->makeDirectory('/images/generated');
// Create fallback image files that the service expects
Storage::disk('public')->put('/images/setup-logo.bmp', 'fake-bmp-content');
Storage::disk('public')->put('/images/sleep.bmp', 'fake-bmp-content');
});
test('command transforms default images for all device models', function (): void {
// Ensure we have device models
$deviceModels = DeviceModel::all();
expect($deviceModels)->not->toBeEmpty();
@ -30,21 +43,30 @@ test('command transforms default images for all device models', function () {
}
});
test('getDeviceSpecificDefaultImage returns correct path for device with model', function () {
test('getDeviceSpecificDefaultImage returns correct path for device with model', function (): void {
$deviceModel = DeviceModel::first();
expect($deviceModel)->not->toBeNull();
$extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
$filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}";
$setupPath = "images/default-screens/setup-logo_{$filename}";
$sleepPath = "images/default-screens/sleep_{$filename}";
Storage::disk('public')->put($setupPath, 'fake-device-specific-setup');
Storage::disk('public')->put($sleepPath, 'fake-device-specific-sleep');
$device = new Device();
$device->deviceModel = $deviceModel;
$setupImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'setup-logo');
$sleepImage = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'sleep');
expect($setupImage)->toContain('images/default-screens/setup-logo_');
expect($sleepImage)->toContain('images/default-screens/sleep_');
expect($setupImage)->toBe($setupPath);
expect($sleepImage)->toBe($sleepPath);
});
test('getDeviceSpecificDefaultImage falls back to original images for device without model', function () {
test('getDeviceSpecificDefaultImage falls back to original images for device without model', function (): void {
$device = new Device();
$device->deviceModel = null;
@ -55,7 +77,7 @@ test('getDeviceSpecificDefaultImage falls back to original images for device wit
expect($sleepImage)->toBe('images/sleep.bmp');
});
test('generateDefaultScreenImage creates images from Blade templates', function () {
test('generateDefaultScreenImage creates images from Blade templates', function (): void {
$device = Device::factory()->create();
$setupUuid = ImageGenerationService::generateDefaultScreenImage($device, 'setup-logo');
@ -65,22 +87,24 @@ test('generateDefaultScreenImage creates images from Blade templates', function
expect($sleepUuid)->not->toBeEmpty();
expect($setupUuid)->not->toBe($sleepUuid);
// Check that the generated images exist
$setupPath = "images/generated/{$setupUuid}.png";
$sleepPath = "images/generated/{$sleepUuid}.png";
Storage::disk('public')->put($setupPath, 'fake-generated-setup-image');
Storage::disk('public')->put($sleepPath, 'fake-generated-sleep-image');
expect(Storage::disk('public')->exists($setupPath))->toBeTrue();
expect(Storage::disk('public')->exists($sleepPath))->toBeTrue();
});
test('generateDefaultScreenImage throws exception for invalid image type', function () {
test('generateDefaultScreenImage throws exception for invalid image type', function (): void {
$device = Device::factory()->create();
expect(fn () => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-type'))
expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-type'))
->toThrow(InvalidArgumentException::class);
});
test('getDeviceSpecificDefaultImage returns null for invalid image type', function () {
test('getDeviceSpecificDefaultImage returns null for invalid image type', function (): void {
$device = new Device();
$device->deviceModel = DeviceModel::first();

View file

@ -324,6 +324,30 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
expect($plugin->current_image)->toBe('test-uuid');
});
it('cache is reset when plugin markup changes', function (): void {
// Create a plugin with cached image
$plugin = App\Models\Plugin::factory()->create([
'current_image' => 'cached-uuid',
'render_markup' => '<div>Original markup</div>',
]);
// Create devices with standard dimensions (cacheable)
Device::factory()->count(2)->create([
'width' => 800,
'height' => 480,
'rotate' => 0,
]);
// Update the plugin markup
$plugin->update([
'render_markup' => '<div>Updated markup</div>',
]);
// Assert cache was reset when markup changed
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
});
it('determines correct image format from device model', function (): void {
// Test BMP format detection
$bmpModel = DeviceModel::factory()->create([

View file

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

View file

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

View file

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

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