diff --git a/.cursor/mcp.json b/.cursor/mcp.json
deleted file mode 100644
index ea30195..0000000
--- a/.cursor/mcp.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "mcpServers": {
- "laravel-boost": {
- "command": "php",
- "args": [
- "./artisan",
- "boost:mcp"
- ]
- }
- }
-}
\ No newline at end of file
diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc
deleted file mode 100644
index 9464f06..0000000
--- a/.cursor/rules/laravel-boost.mdc
+++ /dev/null
@@ -1,534 +0,0 @@
----
-alwaysApply: true
----
-
-=== 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.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- 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()`.
- - public function __construct(public GitHub $github) { }
-- 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.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## 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] ` 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:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== 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)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== 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:
-
-
-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);
- });
-});
-
-
-
-=== 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 (
-)]))
-
-
-
-### 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:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-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();
-});
-
-
-
-### Common Patterns
-
-
-
- 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();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== 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 `.
-- 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:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### 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.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### 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.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== 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.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### 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:
-
-
-
-
-### 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.
-
\ No newline at end of file
diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile
index 908acef..ab13330 100644
--- a/.devcontainer/cli/Dockerfile
+++ b/.devcontainer/cli/Dockerfile
@@ -1,5 +1,5 @@
# From official php image.
-FROM php:8.3-cli-alpine
+FROM php:8.4-cli-alpine
# Create a user group and account under id 1000.
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
# Install quality-of-life packages.
@@ -9,21 +9,22 @@ 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
RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick
-RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
+RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-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/php83
-RUN ln -s /usr/local/bin/php /usr/bin/php83
+RUN rm -f /usr/bin/php84
+RUN ln -s /usr/local/bin/php /usr/bin/php84
# Install postgres pdo driver.
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
# Install redis driver.
diff --git a/.devcontainer/fpm/Dockerfile b/.devcontainer/fpm/Dockerfile
index b9770e3..3e658b6 100644
--- a/.devcontainer/fpm/Dockerfile
+++ b/.devcontainer/fpm/Dockerfile
@@ -1,5 +1,5 @@
# From official php image.
-FROM php:8.3-fpm-alpine
+FROM php:8.4-fpm-alpine
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
# Install postgres pdo driver.
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
@@ -14,17 +14,18 @@ RUN apk add --no-cache \
nodejs \
npm \
imagemagick-dev \
- chromium
+ chromium \
+ libzip-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1
RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick
-RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
+RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-RUN docker-php-ext-install imagick
+RUN docker-php-ext-install imagick zip
-RUN rm -f /usr/bin/php83
-RUN ln -s /usr/local/bin/php /usr/bin/php83
+RUN rm -f /usr/bin/php84
+RUN ln -s /usr/local/bin/php /usr/bin/php84
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index a331541..0000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,531 +0,0 @@
-
-=== 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.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- 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()`.
- - public function __construct(public GitHub $github) { }
-- 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.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## 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] ` 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:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== 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)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== 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:
-
-
-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);
- });
-});
-
-
-
-=== 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 (
-)]))
-
-
-
-### 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:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-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();
-});
-
-
-
-### Common Patterns
-
-
-
- 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();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== 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 `.
-- 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:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### 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.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### 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.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== 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.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### 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:
-
-
-
-
-### 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.
-
\ No newline at end of file
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 048db51..a4ff129 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -42,8 +42,7 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
- type=ref,event=tag
- latest
+ type=semver,pattern={{version}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fd03705..78e4fbb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -22,7 +22,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.3
+ php-version: 8.4
coverage: xdebug
- name: Setup Node
diff --git a/.gitignore b/.gitignore
index 3a2ae5a..0eb46d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,17 @@ 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
+/.claude
+/AGENTS.md
+/opencode.json
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
deleted file mode 100644
index a331541..0000000
--- a/.junie/guidelines.md
+++ /dev/null
@@ -1,531 +0,0 @@
-
-=== 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.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- 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()`.
- - public function __construct(public GitHub $github) { }
-- 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.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## 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] ` 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:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== 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)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== 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:
-
-
-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);
- });
-});
-
-
-
-=== 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 (
-)]))
-
-
-
-### 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:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-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();
-});
-
-
-
-### Common Patterns
-
-
-
- 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();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== 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 `.
-- 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:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### 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.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### 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.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== 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.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### 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:
-
-
-
-
-### 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.
-
\ No newline at end of file
diff --git a/.mcp.json b/.mcp.json
deleted file mode 100644
index ea30195..0000000
--- a/.mcp.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "mcpServers": {
- "laravel-boost": {
- "command": "php",
- "args": [
- "./artisan",
- "boost:mcp"
- ]
- }
- }
-}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index a331541..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,531 +0,0 @@
-
-=== 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.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- 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()`.
- - public function __construct(public GitHub $github) { }
-- 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.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## 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] ` 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:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== 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)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== 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:
-
-
-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);
- });
-});
-
-
-
-=== 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 (
-)]))
-
-
-
-### 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:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-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();
-});
-
-
-
-### Common Patterns
-
-
-
- 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();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== 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 `.
-- 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:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### 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.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### 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.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== 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.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### 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:
-
-
-
-
-### 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.
-
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 6a9931d..2d761ed 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
########################
# Base Image
########################
-FROM bnussbau/serversideup-php:8.3-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
diff --git a/README.md b/README.md
index 73b9a6b..acb0b5c 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,7 @@
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
-It enables you to manage TRMNL devices, generate screens dynamically, and can act as a proxy for the native cloud service (native plugins, recipes).
-
-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).
+It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.


@@ -16,21 +14,32 @@ If you are looking for a Laravel package designed to streamline the development
* 📡 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 (even Mashups), Recipes, API, Markup, or updates via Code.
- * Supported Devices / Apps: TRMNL, ESP32 with TRMNL firmware, [trmnl-android](https://github.com/usetrmnl/trmnl-android), [trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27), …
+* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
+ * 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
+ * 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.

-### 🎯 Target Audience
-
-This project is for developers who are looking for a self-hosted server for devices running the TRMNL firmware.
-It serves as a starter kit, giving you the flexibility to build and extend it however you like.
-
### Support ❤️
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
@@ -40,6 +49,8 @@ or
[](https://www.buymeacoffee.com/bnussbau)
+[GitHub Sponsors](https://github.com/sponsors/bnussbau/)
+
### Hosting
Run everywhere, where Docker is supported: Raspberry Pi, VPS, NAS, Container Cloud Service (Cloud Run, ...).
@@ -65,9 +76,12 @@ docker compose up -d
If you’re using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
It’s a quick way to get started without having to manually manage Docker setup.
-### PikaPods
+#### PikaPods
You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
+#### Umbrel
+Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
+
#### Other Hosting Options
Laravel Forge, or bare metal PHP server with Nginx or Apache is also supported.
@@ -204,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.
diff --git a/app/Console/Commands/FirmwareCheckCommand.php b/app/Console/Commands/FirmwareCheckCommand.php
index f407314..91922ba 100644
--- a/app/Console/Commands/FirmwareCheckCommand.php
+++ b/app/Console/Commands/FirmwareCheckCommand.php
@@ -23,7 +23,7 @@ class FirmwareCheckCommand extends Command
);
$latestFirmware = Firmware::getLatest();
- if ($latestFirmware) {
+ if ($latestFirmware instanceof Firmware) {
table(
rows: [
['Latest Version', $latestFirmware->version_tag],
diff --git a/app/Console/Commands/FirmwareUpdateCommand.php b/app/Console/Commands/FirmwareUpdateCommand.php
index 97d9d58..bd43786 100644
--- a/app/Console/Commands/FirmwareUpdateCommand.php
+++ b/app/Console/Commands/FirmwareUpdateCommand.php
@@ -42,15 +42,14 @@ class FirmwareUpdateCommand extends Command
label: 'Which devices should be updated?',
options: [
'all' => 'ALL Devices',
- ...Device::all()->mapWithKeys(function ($device) {
+ ...Device::all()->mapWithKeys(fn ($device): array =>
// without _ returns index
- return ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"];
- })->toArray(),
+ ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"])->toArray(),
],
scroll: 10
);
- if (empty($devices)) {
+ if ($devices === []) {
$this->error('No devices selected. Aborting.');
return;
@@ -59,9 +58,7 @@ class FirmwareUpdateCommand extends Command
if (in_array('all', $devices)) {
$devices = Device::pluck('id')->toArray();
} else {
- $devices = array_map(function ($selected) {
- return (int) str_replace('_', '', $selected);
- }, $devices);
+ $devices = array_map(fn ($selected): int => (int) str_replace('_', '', $selected), $devices);
}
foreach ($devices as $deviceId) {
diff --git a/app/Console/Commands/GenerateDefaultImagesCommand.php b/app/Console/Commands/GenerateDefaultImagesCommand.php
new file mode 100644
index 0000000..e2887df
--- /dev/null
+++ b/app/Console/Commands/GenerateDefaultImagesCommand.php
@@ -0,0 +1,201 @@
+info('Starting generation of default images for all device models...');
+
+ $deviceModels = DeviceModel::all();
+
+ if ($deviceModels->isEmpty()) {
+ $this->warn('No device models found in the database.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Found {$deviceModels->count()} device models to process.");
+
+ // Create the target directory
+ $targetDir = 'images/default-screens';
+ if (! Storage::disk('public')->exists($targetDir)) {
+ Storage::disk('public')->makeDirectory($targetDir);
+ $this->info("Created directory: {$targetDir}");
+ }
+
+ $successCount = 0;
+ $skipCount = 0;
+ $errorCount = 0;
+
+ foreach ($deviceModels as $deviceModel) {
+ $this->info("Processing device model: {$deviceModel->label} (ID: {$deviceModel->id})");
+
+ try {
+ // Process setup-logo
+ $setupResult = $this->transformImage('setup-logo', $deviceModel, $targetDir);
+ if ($setupResult) {
+ ++$successCount;
+ } else {
+ ++$skipCount;
+ }
+
+ // Process sleep
+ $sleepResult = $this->transformImage('sleep', $deviceModel, $targetDir);
+ if ($sleepResult) {
+ ++$successCount;
+ } else {
+ ++$skipCount;
+ }
+
+ } catch (Exception $e) {
+ $this->error("Error processing device model {$deviceModel->label}: ".$e->getMessage());
+ ++$errorCount;
+ }
+ }
+
+ $this->info("\nGeneration completed!");
+ $this->info("Successfully processed: {$successCount} images");
+ $this->info("Skipped (already exist): {$skipCount} images");
+ $this->info("Errors: {$errorCount} images");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Transform a single image for a device model using Blade templates
+ */
+ private function transformImage(string $imageType, DeviceModel $deviceModel, string $targetDir): bool
+ {
+ // Generate filename: {width}_{height}_{bit_depth}_{rotation}.{extension}
+ $extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
+ $filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}";
+ $targetPath = "{$targetDir}/{$imageType}_{$filename}";
+
+ // Check if target already exists and force is not set
+ if (Storage::disk('public')->exists($targetPath) && ! $this->option('force')) {
+ $this->line(" Skipping {$imageType} - already exists: {$filename}");
+
+ return false;
+ }
+
+ try {
+ // Create custom Browsershot instance if using AWS Lambda
+ $browsershotInstance = null;
+ if (config('app.puppeteer_mode') === 'sidecar-aws') {
+ $browsershotInstance = new BrowsershotLambda();
+ }
+
+ // Generate HTML from Blade template
+ $html = $this->generateHtmlFromTemplate($imageType, $deviceModel);
+ // dump($html);
+
+ $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);
+
+ $browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
+
+ if (config('app.puppeteer_docker')) {
+ $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
+ }
+
+ $outputPath = Storage::disk('public')->path($targetPath);
+
+ $imageStage = new ImageStage();
+ $imageStage->format($extension)
+ ->width($deviceModel->width)
+ ->height($deviceModel->height)
+ ->colors($deviceModel->colors)
+ ->bitDepth($deviceModel->bit_depth)
+ ->rotation($deviceModel->rotation)
+ // ->offsetX($deviceModel->offset_x)
+ // ->offsetY($deviceModel->offset_y)
+ ->outputPath($outputPath);
+
+ (new TrmnlPipeline())->pipe($browserStage)
+ ->pipe($imageStage)
+ ->process();
+
+ if (! file_exists($outputPath)) {
+ throw new RuntimeException('Image file was not created: '.$outputPath);
+ }
+
+ if (filesize($outputPath) === 0) {
+ throw new RuntimeException('Image file is empty: '.$outputPath);
+ }
+
+ $this->line(" ✓ Generated {$imageType}: {$filename}");
+
+ return true;
+
+ } catch (Exception $e) {
+ $this->error(" ✗ Failed to generate {$imageType} for {$deviceModel->label}: ".$e->getMessage());
+
+ return false;
+ }
+ }
+
+ /**
+ * Generate HTML from Blade template for the given image type and device model
+ */
+ private function generateHtmlFromTemplate(string $imageType, DeviceModel $deviceModel): string
+ {
+ // Map image type to template name
+ $templateName = match ($imageType) {
+ 'setup-logo' => 'default-screens.setup',
+ 'sleep' => 'default-screens.sleep',
+ default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
+ };
+
+ // Determine device properties from DeviceModel
+ $deviceVariant = $deviceModel->name ?? 'og';
+ $colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
+ $scaleLevel = $deviceModel->scale_level; // Use the accessor method
+ $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
+
+ // Render the Blade template
+ return view($templateName, [
+ 'noBleed' => false,
+ 'darkMode' => $darkMode,
+ 'deviceVariant' => $deviceVariant,
+ 'colorDepth' => $colorDepth,
+ 'scaleLevel' => $scaleLevel,
+ ])->render();
+ }
+}
diff --git a/app/Console/Commands/MashupCreateCommand.php b/app/Console/Commands/MashupCreateCommand.php
index d6f1378..7201274 100644
--- a/app/Console/Commands/MashupCreateCommand.php
+++ b/app/Console/Commands/MashupCreateCommand.php
@@ -9,9 +9,6 @@ use App\Models\Plugin;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
-use function Laravel\Prompts\select;
-use function Laravel\Prompts\text;
-
class MashupCreateCommand extends Command
{
/**
@@ -31,17 +28,17 @@ class MashupCreateCommand extends Command
/**
* Execute the console command.
*/
- public function handle()
+ public function handle(): int
{
// Select device
$device = $this->selectDevice();
- if (! $device) {
+ if (! $device instanceof Device) {
return 1;
}
// Select playlist
$playlist = $this->selectPlaylist($device);
- if (! $playlist) {
+ if (! $playlist instanceof Playlist) {
return 1;
}
@@ -88,9 +85,9 @@ class MashupCreateCommand extends Command
return null;
}
- $deviceId = select(
- label: 'Select a device',
- options: $devices->mapWithKeys(fn ($device) => [$device->id => $device->name])->toArray()
+ $deviceId = $this->choice(
+ 'Select a device',
+ $devices->mapWithKeys(fn ($device): array => [$device->id => $device->name])->toArray()
);
return $devices->firstWhere('id', $deviceId);
@@ -106,9 +103,9 @@ class MashupCreateCommand extends Command
return null;
}
- $playlistId = select(
- label: 'Select a playlist',
- options: $playlists->mapWithKeys(fn (Playlist $playlist) => [$playlist->id => $playlist->name])->toArray()
+ $playlistId = $this->choice(
+ 'Select a playlist',
+ $playlists->mapWithKeys(fn (Playlist $playlist): array => [$playlist->id => $playlist->name])->toArray()
);
return $playlists->firstWhere('id', $playlistId);
@@ -116,24 +113,29 @@ class MashupCreateCommand extends Command
protected function selectLayout(): ?string
{
- return select(
- label: 'Select a layout',
- options: PlaylistItem::getAvailableLayouts()
+ return $this->choice(
+ 'Select a layout',
+ PlaylistItem::getAvailableLayouts()
);
}
protected function getMashupName(): ?string
{
- return text(
- label: 'Enter a name for this mashup',
- required: true,
- default: 'Mashup',
- validate: fn (string $value) => match (true) {
- mb_strlen($value) < 1 => 'The name must be at least 2 characters.',
- mb_strlen($value) > 50 => 'The name must not exceed 50 characters.',
- default => null,
- }
- );
+ $name = $this->ask('Enter a name for this mashup', 'Mashup');
+
+ if (mb_strlen((string) $name) < 2) {
+ $this->error('The name must be at least 2 characters.');
+
+ return null;
+ }
+
+ if (mb_strlen((string) $name) > 50) {
+ $this->error('The name must not exceed 50 characters.');
+
+ return null;
+ }
+
+ return $name;
}
protected function selectPlugins(string $layout): Collection
@@ -148,7 +150,7 @@ class MashupCreateCommand extends Command
}
$selectedPlugins = collect();
- $availablePlugins = $plugins->mapWithKeys(fn ($plugin) => [$plugin->id => $plugin->name])->toArray();
+ $availablePlugins = $plugins->mapWithKeys(fn ($plugin): array => [$plugin->id => $plugin->name])->toArray();
for ($i = 0; $i < $requiredCount; ++$i) {
$position = match ($i) {
@@ -159,9 +161,9 @@ class MashupCreateCommand extends Command
default => ($i + 1).'th'
};
- $pluginId = select(
- label: "Select the $position plugin",
- options: $availablePlugins
+ $pluginId = $this->choice(
+ "Select the $position plugin",
+ $availablePlugins
);
$selectedPlugins->push($plugins->firstWhere('id', $pluginId));
diff --git a/app/Console/Commands/OidcTestCommand.php b/app/Console/Commands/OidcTestCommand.php
index 2ecfef2..81dff0b 100644
--- a/app/Console/Commands/OidcTestCommand.php
+++ b/app/Console/Commands/OidcTestCommand.php
@@ -2,7 +2,9 @@
namespace App\Console\Commands;
+use Exception;
use Illuminate\Console\Command;
+use InvalidArgumentException;
use Laravel\Socialite\Facades\Socialite;
class OidcTestCommand extends Command
@@ -24,27 +26,32 @@ class OidcTestCommand extends Command
/**
* Execute the console command.
*/
- public function handle()
+ public function handle(): int
{
$this->info('Testing OIDC Configuration...');
$this->newLine();
// Check if OIDC is enabled
$enabled = config('services.oidc.enabled');
- $this->line("OIDC Enabled: " . ($enabled ? '✅ Yes' : '❌ No'));
+ $this->line('OIDC Enabled: '.($enabled ? '✅ Yes' : '❌ No'));
// Check configuration values
$endpoint = config('services.oidc.endpoint');
$clientId = config('services.oidc.client_id');
$clientSecret = config('services.oidc.client_secret');
$redirect = config('services.oidc.redirect');
+ if (! $redirect) {
+ $redirect = config('app.url', 'http://localhost').'/auth/oidc/callback';
+ }
$scopes = config('services.oidc.scopes', []);
+ $defaultScopes = ['openid', 'profile', 'email'];
+ $effectiveScopes = empty($scopes) ? $defaultScopes : $scopes;
- $this->line("OIDC Endpoint: " . ($endpoint ? "✅ {$endpoint}" : '❌ Not set'));
- $this->line("Client ID: " . ($clientId ? "✅ {$clientId}" : '❌ Not set'));
- $this->line("Client Secret: " . ($clientSecret ? '✅ Set' : '❌ Not set'));
- $this->line("Redirect URL: " . ($redirect ? "✅ {$redirect}" : '❌ Not set'));
- $this->line("Scopes: " . (empty($scopes) ? '❌ Not set' : '✅ ' . implode(', ', $scopes)));
+ $this->line('OIDC Endpoint: '.($endpoint ? "✅ {$endpoint}" : '❌ Not set'));
+ $this->line('Client ID: '.($clientId ? "✅ {$clientId}" : '❌ Not set'));
+ $this->line('Client Secret: '.($clientSecret ? '✅ Set' : '❌ Not set'));
+ $this->line('Redirect URL: '.($redirect ? "✅ {$redirect}" : '❌ Not set'));
+ $this->line('Scopes: ✅ '.implode(', ', $effectiveScopes));
$this->newLine();
@@ -53,38 +60,45 @@ class OidcTestCommand extends Command
// Only test driver if we have basic configuration
if ($endpoint && $clientId && $clientSecret) {
$driver = Socialite::driver('oidc');
- $this->line("OIDC Driver: ✅ Successfully registered and accessible");
-
+ $this->line('OIDC Driver: ✅ Successfully registered and accessible');
+
if ($enabled) {
- $this->info("✅ OIDC is fully configured and ready to use!");
- $this->line("You can test the login flow at: /auth/oidc/redirect");
+ $this->info('✅ OIDC is fully configured and ready to use!');
+ $this->line('You can test the login flow at: /auth/oidc/redirect');
} else {
- $this->warn("⚠️ OIDC driver is working but OIDC_ENABLED is false.");
+ $this->warn('⚠️ OIDC driver is working but OIDC_ENABLED is false.');
}
} else {
- $this->line("OIDC Driver: ✅ Registered (configuration test skipped due to missing values)");
- $this->warn("⚠️ OIDC driver is registered but missing required configuration.");
- $this->line("Please set the following environment variables:");
- if (!$enabled) $this->line(" - OIDC_ENABLED=true");
- if (!$endpoint) {
- $this->line(" - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)");
- $this->line(" OR");
- $this->line(" - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)");
+ $this->line('OIDC Driver: ✅ Registered (configuration test skipped due to missing values)');
+ $this->warn('⚠️ OIDC driver is registered but missing required configuration.');
+ $this->line('Please set the following environment variables:');
+ if (! $enabled) {
+ $this->line(' - OIDC_ENABLED=true');
+ }
+ if (! $endpoint) {
+ $this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)');
+ $this->line(' OR');
+ $this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)');
+ }
+ if (! $clientId) {
+ $this->line(' - OIDC_CLIENT_ID=your-client-id');
+ }
+ if (! $clientSecret) {
+ $this->line(' - OIDC_CLIENT_SECRET=your-client-secret');
}
- if (!$clientId) $this->line(" - OIDC_CLIENT_ID=your-client-id");
- if (!$clientSecret) $this->line(" - OIDC_CLIENT_SECRET=your-client-secret");
}
- } catch (\InvalidArgumentException $e) {
+ } catch (InvalidArgumentException $e) {
if (str_contains($e->getMessage(), 'Driver [oidc] not supported')) {
- $this->error("❌ OIDC Driver registration failed: Driver not supported");
+ $this->error('❌ OIDC Driver registration failed: Driver not supported');
} else {
- $this->error("❌ OIDC Driver error: " . $e->getMessage());
+ $this->error('❌ OIDC Driver error: '.$e->getMessage());
}
- } catch (\Exception $e) {
- $this->warn("⚠️ OIDC Driver registered but configuration error: " . $e->getMessage());
+ } catch (Exception $e) {
+ $this->warn('⚠️ OIDC Driver registered but configuration error: '.$e->getMessage());
}
$this->newLine();
+
return Command::SUCCESS;
}
}
diff --git a/app/Console/Commands/ScreenGeneratorCommand.php b/app/Console/Commands/ScreenGeneratorCommand.php
index ac74fba..c0a2cc3 100644
--- a/app/Console/Commands/ScreenGeneratorCommand.php
+++ b/app/Console/Commands/ScreenGeneratorCommand.php
@@ -25,7 +25,7 @@ class ScreenGeneratorCommand extends Command
/**
* Execute the console command.
*/
- public function handle()
+ public function handle(): int
{
$deviceId = $this->argument('deviceId');
$view = $this->argument('view');
diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php
index 305dd49..f7847d9 100644
--- a/app/Http/Controllers/Auth/OidcController.php
+++ b/app/Http/Controllers/Auth/OidcController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
+use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
@@ -17,23 +18,25 @@ class OidcController extends Controller
*/
public function redirect()
{
- if (!config('services.oidc.enabled')) {
+ if (! config('services.oidc.enabled')) {
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
}
// Check if all required OIDC configuration is present
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
foreach ($requiredConfig as $key) {
- if (!config("services.oidc.{$key}")) {
+ if (! config("services.oidc.{$key}")) {
Log::error("OIDC configuration missing: {$key}");
+
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
}
}
try {
return Socialite::driver('oidc')->redirect();
- } catch (\Exception $e) {
- Log::error('OIDC redirect error: ' . $e->getMessage());
+ } catch (Exception $e) {
+ Log::error('OIDC redirect error: '.$e->getMessage());
+
return redirect()->route('login')->withErrors(['oidc' => 'Failed to initiate OIDC authentication.']);
}
}
@@ -43,32 +46,34 @@ class OidcController extends Controller
*/
public function callback(Request $request)
{
- if (!config('services.oidc.enabled')) {
+ if (! config('services.oidc.enabled')) {
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
}
// Check if all required OIDC configuration is present
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
foreach ($requiredConfig as $key) {
- if (!config("services.oidc.{$key}")) {
+ if (! config("services.oidc.{$key}")) {
Log::error("OIDC configuration missing: {$key}");
+
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
}
}
try {
$oidcUser = Socialite::driver('oidc')->user();
-
+
// Find or create the user
$user = $this->findOrCreateUser($oidcUser);
-
+
// Log the user in
Auth::login($user, true);
-
+
return redirect()->intended(route('dashboard', absolute: false));
-
- } catch (\Exception $e) {
- Log::error('OIDC callback error: ' . $e->getMessage());
+
+ } catch (Exception $e) {
+ Log::error('OIDC callback error: '.$e->getMessage());
+
return redirect()->route('login')->withErrors(['oidc' => 'Failed to authenticate with OIDC provider.']);
}
}
@@ -80,26 +85,28 @@ class OidcController extends Controller
{
// First, try to find user by OIDC subject ID
$user = User::where('oidc_sub', $oidcUser->getId())->first();
-
+
if ($user) {
// Update user information from OIDC
$user->update([
'name' => $oidcUser->getName() ?: $user->name,
'email' => $oidcUser->getEmail() ?: $user->email,
]);
+
return $user;
}
// If not found by OIDC sub, try to find by email
if ($oidcUser->getEmail()) {
$user = User::where('email', $oidcUser->getEmail())->first();
-
+
if ($user) {
// Link the existing user with OIDC
$user->update([
'oidc_sub' => $oidcUser->getId(),
'name' => $oidcUser->getName() ?: $user->name,
]);
+
return $user;
}
}
@@ -108,9 +115,9 @@ class OidcController extends Controller
return User::create([
'oidc_sub' => $oidcUser->getId(),
'name' => $oidcUser->getName() ?: 'OIDC User',
- 'email' => $oidcUser->getEmail() ?: $oidcUser->getId() . '@oidc.local',
+ 'email' => $oidcUser->getEmail() ?: $oidcUser->getId().'@oidc.local',
'password' => bcrypt(Str::random(32)), // Random password since we're using OIDC
'email_verified_at' => now(), // OIDC users are considered verified
]);
}
-}
\ No newline at end of file
+}
diff --git a/app/Jobs/CleanupDeviceLogsJob.php b/app/Jobs/CleanupDeviceLogsJob.php
index b49f507..d2f1dd9 100644
--- a/app/Jobs/CleanupDeviceLogsJob.php
+++ b/app/Jobs/CleanupDeviceLogsJob.php
@@ -18,7 +18,7 @@ class CleanupDeviceLogsJob implements ShouldQueue
*/
public function handle(): void
{
- Device::each(function ($device) {
+ Device::each(function ($device): void {
$keepIds = $device->logs()->latest('device_timestamp')->take(50)->pluck('id');
// Delete all other logs for this device
diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php
index 695041f..475c5c7 100644
--- a/app/Jobs/FetchDeviceModelsJob.php
+++ b/app/Jobs/FetchDeviceModelsJob.php
@@ -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;
+ }
}
diff --git a/app/Jobs/FetchProxyCloudResponses.php b/app/Jobs/FetchProxyCloudResponses.php
index b560085..ac23130 100644
--- a/app/Jobs/FetchProxyCloudResponses.php
+++ b/app/Jobs/FetchProxyCloudResponses.php
@@ -23,7 +23,7 @@ class FetchProxyCloudResponses implements ShouldQueue
*/
public function handle(): void
{
- Device::where('proxy_cloud', true)->each(function ($device) {
+ Device::where('proxy_cloud', true)->each(function ($device): void {
if (! $device->getNextPlaylistItem()) {
try {
$response = Http::withHeaders([
diff --git a/app/Jobs/FirmwareDownloadJob.php b/app/Jobs/FirmwareDownloadJob.php
index 6b4fc36..dfc851d 100644
--- a/app/Jobs/FirmwareDownloadJob.php
+++ b/app/Jobs/FirmwareDownloadJob.php
@@ -18,12 +18,7 @@ class FirmwareDownloadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- private Firmware $firmware;
-
- public function __construct(Firmware $firmware)
- {
- $this->firmware = $firmware;
- }
+ public function __construct(private Firmware $firmware) {}
public function handle(): void
{
@@ -33,16 +28,25 @@ class FirmwareDownloadJob implements ShouldQueue
try {
$filename = "FW{$this->firmware->version_tag}.bin";
- Http::sink(storage_path("app/public/firmwares/$filename"))
- ->get($this->firmware->url);
+ $response = Http::get($this->firmware->url);
+ if (! $response->successful()) {
+ throw new Exception('HTTP request failed with status: '.$response->status());
+ }
+
+ // Save the response content to file
+ Storage::disk('public')->put("firmwares/$filename", $response->body());
+
+ // Only update storage location if download was successful
$this->firmware->update([
'storage_location' => "firmwares/$filename",
]);
} catch (ConnectionException $e) {
Log::error('Firmware download failed: '.$e->getMessage());
+ // Don't update storage_location on failure
} catch (Exception $e) {
Log::error('An unexpected error occurred: '.$e->getMessage());
+ // Don't update storage_location on failure
}
}
}
diff --git a/app/Jobs/FirmwarePollJob.php b/app/Jobs/FirmwarePollJob.php
index 7110b9c..c1a2267 100644
--- a/app/Jobs/FirmwarePollJob.php
+++ b/app/Jobs/FirmwarePollJob.php
@@ -17,12 +17,7 @@ class FirmwarePollJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- private bool $download;
-
- public function __construct(bool $download = false)
- {
- $this->download = $download;
- }
+ public function __construct(private bool $download = false) {}
public function handle(): void
{
diff --git a/app/Jobs/NotifyDeviceBatteryLowJob.php b/app/Jobs/NotifyDeviceBatteryLowJob.php
index 2508365..9b1001b 100644
--- a/app/Jobs/NotifyDeviceBatteryLowJob.php
+++ b/app/Jobs/NotifyDeviceBatteryLowJob.php
@@ -15,8 +15,6 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- public function __construct() {}
-
public function handle(): void
{
$devices = Device::all();
@@ -32,9 +30,11 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue
continue;
}
-
// Skip if battery is not low or notification was already sent
- if ($batteryPercent > $batteryThreshold || $device->battery_notification_sent) {
+ if ($batteryPercent > $batteryThreshold) {
+ continue;
+ }
+ if ($device->battery_notification_sent) {
continue;
}
diff --git a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
index 01adf1b..dbde888 100644
--- a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
+++ b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Liquid\FileSystems;
+use InvalidArgumentException;
use Keepsuit\Liquid\Contracts\LiquidFileSystem;
/**
@@ -52,10 +53,10 @@ class InlineTemplatesFileSystem implements LiquidFileSystem
public function readTemplateFile(string $templateName): string
{
- if (!isset($this->templates[$templateName])) {
- throw new \InvalidArgumentException("Template '{$templateName}' not found in inline templates");
+ if (! isset($this->templates[$templateName])) {
+ throw new InvalidArgumentException("Template '{$templateName}' not found in inline templates");
}
return $this->templates[$templateName];
}
-}
\ No newline at end of file
+}
diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php
index 4437032..2387ac5 100644
--- a/app/Liquid/Filters/Data.php
+++ b/app/Liquid/Filters/Data.php
@@ -2,6 +2,7 @@
namespace App\Liquid\Filters;
+use App\Liquid\Utils\ExpressionUtils;
use Keepsuit\Liquid\Filters\FiltersProvider;
/**
@@ -63,4 +64,73 @@ class Data extends FiltersProvider
return $grouped;
}
+
+ /**
+ * Return a random element from an array
+ *
+ * @param array $array The array to sample from
+ * @return mixed A random element from the array
+ */
+ public function sample(array $array): mixed
+ {
+ if ($array === []) {
+ return null;
+ }
+
+ return $array[array_rand($array)];
+ }
+
+ /**
+ * Parse a JSON string into a PHP value
+ *
+ * @param string $json The JSON string to parse
+ * @return mixed The parsed JSON value
+ */
+ public function parse_json(string $json): mixed
+ {
+ 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);
+ }
}
diff --git a/app/Liquid/Filters/Date.php b/app/Liquid/Filters/Date.php
new file mode 100644
index 0000000..6bc81fc
--- /dev/null
+++ b/app/Liquid/Filters/Date.php
@@ -0,0 +1,55 @@
+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 <> 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('<>', $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);
+ }
+}
diff --git a/app/Liquid/Filters/Numbers.php b/app/Liquid/Filters/Numbers.php
index 53d1973..0e31de1 100644
--- a/app/Liquid/Filters/Numbers.php
+++ b/app/Liquid/Filters/Numbers.php
@@ -40,15 +40,11 @@ class Numbers extends FiltersProvider
$currency = 'GBP';
}
- if ($delimiter === '.' && $separator === ',') {
- $locale = 'de';
- } else {
- $locale = 'en';
- }
+ $locale = $delimiter === '.' && $separator === ',' ? 'de' : 'en';
// 2 decimal places for floats, 0 for integers
$decimal = is_float($value + 0) ? 2 : 0;
- return Number::currency($value, in: $currency, precision: $decimal, locale: $locale);
+ return Number::currency($value, in: $currency, locale: $locale, precision: $decimal);
}
}
diff --git a/app/Liquid/Filters/StandardFilters.php b/app/Liquid/Filters/StandardFilters.php
new file mode 100644
index 0000000..4db86a0
--- /dev/null
+++ b/app/Liquid/Filters/StandardFilters.php
@@ -0,0 +1,20 @@
+params->expression();
-
+
$this->templateName = match (true) {
- is_string($templateNameExpression) => trim($templateNameExpression),
+ is_string($templateNameExpression) => mb_trim($templateNameExpression),
is_numeric($templateNameExpression) => (string) $templateNameExpression,
$templateNameExpression instanceof VariableLookup => (string) $templateNameExpression,
- default => throw new SyntaxException("Template name must be a string, number, or variable"),
+ default => throw new SyntaxException('Template name must be a string, number, or variable'),
};
// Validate template name (letters, numbers, underscores, and slashes only)
- if (!preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) {
+ if (! preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) {
throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes");
}
@@ -74,7 +75,7 @@ class TemplateTag extends TagBlock
// Get the file system from the environment
$fileSystem = $context->environment->fileSystem;
- if (!$fileSystem instanceof InlineTemplatesFileSystem) {
+ if (! $fileSystem instanceof InlineTemplatesFileSystem) {
// If no inline file system is available, just return empty string
// This allows the template to be used in contexts where inline templates aren't supported
return '';
@@ -96,4 +97,4 @@ class TemplateTag extends TagBlock
{
return $this->body;
}
-}
\ No newline at end of file
+}
diff --git a/app/Liquid/Utils/ExpressionUtils.php b/app/Liquid/Utils/ExpressionUtils.php
new file mode 100644
index 0000000..8a5bdb0
--- /dev/null
+++ b/app/Liquid/Utils/ExpressionUtils.php
@@ -0,0 +1,210 @@
+ '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);
+ }
+}
diff --git a/app/Livewire/Actions/DeviceAutoJoin.php b/app/Livewire/Actions/DeviceAutoJoin.php
index c16322c..183add4 100644
--- a/app/Livewire/Actions/DeviceAutoJoin.php
+++ b/app/Livewire/Actions/DeviceAutoJoin.php
@@ -10,14 +10,14 @@ class DeviceAutoJoin extends Component
public bool $isFirstUser = false;
- public function mount()
+ public function mount(): void
{
$this->deviceAutojoin = auth()->user()->assign_new_devices;
$this->isFirstUser = auth()->user()->id === 1;
}
- public function updating($name, $value)
+ public function updating($name, $value): void
{
$this->validate([
'deviceAutojoin' => 'boolean',
@@ -30,7 +30,7 @@ class DeviceAutoJoin extends Component
}
}
- public function render()
+ public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
{
return view('livewire.actions.device-auto-join');
}
diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php
index 45993bb..c26fa72 100644
--- a/app/Livewire/Actions/Logout.php
+++ b/app/Livewire/Actions/Logout.php
@@ -10,7 +10,7 @@ class Logout
/**
* Log the current user out of the application.
*/
- public function __invoke()
+ public function __invoke(): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
{
Auth::guard('web')->logout();
diff --git a/app/Livewire/DeviceDashboard.php b/app/Livewire/DeviceDashboard.php
index 78309cb..a2a3692 100644
--- a/app/Livewire/DeviceDashboard.php
+++ b/app/Livewire/DeviceDashboard.php
@@ -6,7 +6,7 @@ use Livewire\Component;
class DeviceDashboard extends Component
{
- public function render()
+ public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
{
return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]);
}
diff --git a/app/Models/Device.php b/app/Models/Device.php
index 420975a..3583f48 100644
--- a/app/Models/Device.php
+++ b/app/Models/Device.php
@@ -10,12 +10,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Storage;
+/**
+ * @property-read DeviceModel|null $deviceModel
+ * @property-read DevicePalette|null $palette
+ */
class Device extends Model
{
use HasFactory;
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',
@@ -32,7 +44,7 @@ class Device extends Model
'pause_until' => 'datetime',
];
- public function getBatteryPercentAttribute()
+ public function getBatteryPercentAttribute(): int|float
{
$volts = $this->last_battery_voltage;
@@ -80,7 +92,7 @@ class Device extends Model
return round($voltage, 2);
}
- public function getWifiStrengthAttribute()
+ public function getWifiStrengthAttribute(): int
{
$rssi = $this->last_rssi_level;
if ($rssi >= 0) {
@@ -103,11 +115,7 @@ class Device extends Model
return true;
}
- if ($this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware']) {
- return true;
- }
-
- return false;
+ return $this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware'];
}
public function getFirmwareUrlAttribute(): ?string
@@ -182,10 +190,41 @@ class Device extends Model
{
return $this->belongsTo(Firmware::class, 'update_firmware_id');
}
+
public function deviceModel(): BelongsTo
{
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.
+ */
+ public function colorDepth(): ?string
+ {
+ return $this->deviceModel?->color_depth;
+ }
+
+ /**
+ * Get the scale level (e.g., large/xlarge/xxlarge) for the associated device model.
+ */
+ public function scaleLevel(): ?string
+ {
+ return $this->deviceModel?->scale_level;
+ }
+
+ /**
+ * Get the device variant name, defaulting to 'og' if not available.
+ */
+ public function deviceVariant(): string
+ {
+ return $this->deviceModel->name ?? 'og';
+ }
+
public function logs(): HasMany
{
return $this->hasMany(DeviceLog::class);
@@ -202,7 +241,7 @@ class Device extends Model
return false;
}
- $now = $now ? Carbon::instance($now) : now();
+ $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
// Handle overnight ranges (e.g. 22:00 to 06:00)
return $this->sleep_mode_from < $this->sleep_mode_to
@@ -216,7 +255,7 @@ class Device extends Model
return null;
}
- $now = $now ? Carbon::instance($now) : now();
+ $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
$from = $this->sleep_mode_from;
$to = $this->sleep_mode_to;
@@ -224,19 +263,20 @@ class Device extends Model
if ($from < $to) {
// Normal range, same day
return $now->between($from, $to) ? (int) $now->diffInSeconds($to, false) : null;
- } else {
- // Overnight range
- if ($now->gte($from)) {
- // After 'from', before midnight
- return (int) $now->diffInSeconds($to->copy()->addDay(), false);
- } elseif ($now->lt($to)) {
- // After midnight, before 'to'
- return (int) $now->diffInSeconds($to, false);
- } else {
- // Not in sleep window
- return null;
- }
}
+ // Overnight range
+ if ($now->gte($from)) {
+ // After 'from', before midnight
+ return (int) $now->diffInSeconds($to->copy()->addDay(), false);
+ }
+ if ($now->lt($to)) {
+ // After midnight, before 'to'
+ return (int) $now->diffInSeconds($to, false);
+ }
+
+ // Not in sleep window
+ return null;
+
}
public function isPauseActive(): bool
diff --git a/app/Models/DeviceModel.php b/app/Models/DeviceModel.php
index c9de2af..6132a76 100644
--- a/app/Models/DeviceModel.php
+++ b/app/Models/DeviceModel.php
@@ -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;
@@ -24,4 +28,51 @@ final class DeviceModel extends Model
'offset_y' => 'integer',
'published_at' => 'datetime',
];
+
+ public function getColorDepthAttribute(): ?string
+ {
+ if (! $this->bit_depth) {
+ return null;
+ }
+
+ if ($this->bit_depth === 3) {
+ return '2bit';
+ }
+
+ // if higher than 4 return 4bit
+ if ($this->bit_depth > 4) {
+ return '4bit';
+ }
+
+ return $this->bit_depth.'bit';
+ }
+
+ /**
+ * Returns the scale level based on the device width.
+ */
+ public function getScaleLevelAttribute(): ?string
+ {
+ if (! $this->width) {
+ return null;
+ }
+
+ if ($this->width > 800 && $this->width <= 1000) {
+ return 'large';
+ }
+
+ if ($this->width > 1000 && $this->width <= 1400) {
+ return 'xlarge';
+ }
+
+ if ($this->width > 1400) {
+ return 'xxlarge';
+ }
+
+ return null;
+ }
+
+ public function palette(): BelongsTo
+ {
+ return $this->belongsTo(DevicePalette::class, 'palette_id');
+ }
}
diff --git a/app/Models/DevicePalette.php b/app/Models/DevicePalette.php
new file mode 100644
index 0000000..54b0876
--- /dev/null
+++ b/app/Models/DevicePalette.php
@@ -0,0 +1,23 @@
+ 'integer',
+ 'colors' => 'array',
+ ];
+}
diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php
index d20798c..b4daf5e 100644
--- a/app/Models/Playlist.php
+++ b/app/Models/Playlist.php
@@ -37,26 +37,33 @@ class Playlist extends Model
return false;
}
- // Check weekday
- if ($this->weekdays !== null) {
- if (! in_array(now()->dayOfWeek, $this->weekdays)) {
- return false;
- }
+ // 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) {
- return true;
- }
- } else {
- if ($now >= $this->active_from && $now <= $this->active_until) {
+ if ($now >= $activeFrom || $now <= $activeUntil) {
return true;
}
+ } elseif ($now >= $activeFrom && $now <= $activeUntil) {
+ return true;
}
return false;
diff --git a/app/Models/PlaylistItem.php b/app/Models/PlaylistItem.php
index 2459257..ad11f1d 100644
--- a/app/Models/PlaylistItem.php
+++ b/app/Models/PlaylistItem.php
@@ -135,10 +135,13 @@ class PlaylistItem extends Model
/**
* Render all plugins with appropriate layout
*/
- public function render(): string
+ public function render(?Device $device = null): string
{
if (! $this->isMashup()) {
return view('trmnl-layouts.single', [
+ 'colorDepth' => $device?->colorDepth(),
+ 'deviceVariant' => $device?->deviceVariant() ?? 'og',
+ 'scaleLevel' => $device?->scaleLevel(),
'slot' => $this->plugin instanceof Plugin
? $this->plugin->render('full', false)
: throw new Exception('Invalid plugin instance'),
@@ -150,9 +153,7 @@ class PlaylistItem extends Model
$plugins = Plugin::whereIn('id', $pluginIds)->get();
// Sort the collection to match plugin_ids order
- $plugins = $plugins->sortBy(function ($plugin) use ($pluginIds) {
- return array_search($plugin->id, $pluginIds);
- })->values();
+ $plugins = $plugins->sortBy(fn ($plugin): int|string|false => array_search($plugin->id, $pluginIds))->values();
foreach ($plugins as $index => $plugin) {
$size = $this->getLayoutSize($index);
@@ -160,6 +161,9 @@ class PlaylistItem extends Model
}
return view('trmnl-layouts.mashup', [
+ 'colorDepth' => $device?->colorDepth(),
+ 'deviceVariant' => $device?->deviceVariant() ?? 'og',
+ 'scaleLevel' => $device?->scaleLevel(),
'mashupLayout' => $this->getMashupLayoutType(),
'slot' => implode('', $pluginMarkups),
])->render();
diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php
index e2b3260..68f8e7e 100644
--- a/app/Models/Plugin.php
+++ b/app/Models/Plugin.php
@@ -4,19 +4,28 @@ namespace App\Models;
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
use App\Liquid\Filters\Data;
+use App\Liquid\Filters\Date;
use App\Liquid\Filters\Localization;
use App\Liquid\Filters\Numbers;
+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;
@@ -33,17 +42,34 @@ 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()
{
parent::boot();
- static::creating(function ($model) {
+ static::creating(function ($model): void {
if (empty($model->uuid)) {
$model->uuid = Str::uuid();
}
});
+
+ static::updating(function ($model): void {
+ // Reset image cache when markup changes
+ if ($model->isDirty('render_markup')) {
+ $model->current_image = null;
+ }
+ });
+
+ // Sanitize configuration template on save
+ static::saving(function ($model): void {
+ $model->sanitizeTemplate();
+ });
}
public function user()
@@ -51,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'])) {
@@ -80,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 === '' || (is_array($currentValue) && empty($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
}
}
@@ -91,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());
@@ -104,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", trim($resolvedHeader));
- foreach ($headerLines as $line) {
- $parts = explode(':', $line, 2);
- if (count($parts) === 2) {
- $headers[trim($parts[0])] = 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) => ! 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'];
}
/**
@@ -213,14 +243,15 @@ class Plugin extends Model
*/
private function applyLiquidReplacements(string $template): string
{
- $replacements = [
- 'date: "%N"' => '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',
@@ -228,90 +259,239 @@ class Plugin extends Model
$template
);
+ // Convert for loops with filters to use temporary variables
+ // This handles: {% for item in collection | filter: "key", "value" %}
+ // 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 (array $matches): string {
+ $variableName = mb_trim($matches[1]);
+ $collection = mb_trim($matches[2]);
+ $filter = mb_trim($matches[3]);
+ $tempVarName = '_temp_'.uniqid();
+
+ return "{% assign {$tempVarName} = {$collection} | {$filter} %}{% for {$variableName} in {$tempVarName} %}";
+ },
+ (string) $template
+ );
+
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);
$liquidTemplate = $environment->parseString($template);
$context = $environment->newRenderContext(data: $variables);
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
*
* @throws LiquidException
*/
- public function render(string $size = 'full', bool $standalone = true): string
+ public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
{
+ if ($this->plugin_type !== 'recipe') {
+ throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
+ }
+
if ($this->render_markup) {
$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()]
- );
+ // Get timezone from user or fall back to app timezone
+ $timezone = $this->user->timezone ?? config('app.timezone');
- // Register all custom filters
- $environment->filterRegistry->register(Numbers::class);
- $environment->filterRegistry->register(Data::class);
- $environment->filterRegistry->register(StringMarkup::class);
- $environment->filterRegistry->register(Uniqueness::class);
- $environment->filterRegistry->register(Localization::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,
@@ -321,9 +501,26 @@ class Plugin extends Model
}
if ($standalone) {
- return view('trmnl-layouts.single', [
+ 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' => $renderedContent,
+ ])->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' => $renderedContent,
])->render();
+
}
return $renderedContent;
@@ -331,12 +528,30 @@ class Plugin extends Model
if ($this->render_markup_view) {
if ($standalone) {
- return view('trmnl-layouts.single', [
- 'slot' => view($this->render_markup_view, [
- 'size' => $size,
- 'data' => $this->data_payload,
- 'config' => $this->configuration ?? [],
- ])->render(),
+ $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' => $renderedView,
])->render();
}
@@ -358,4 +573,70 @@ class Plugin extends Model
{
return $this->configuration[$key] ?? $default;
}
+
+ public function getPreviewMashupLayoutForSize(string $size): string
+ {
+ return match ($size) {
+ 'half_vertical' => '1Lx1R',
+ 'quadrant' => '2x2',
+ 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);
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index a1c83ab..c6d39b8 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -27,6 +27,7 @@ class User extends Authenticatable // implements MustVerifyEmail
'assign_new_devices',
'assign_new_device_id',
'oidc_sub',
+ 'timezone',
];
/**
diff --git a/app/Notifications/BatteryLow.php b/app/Notifications/BatteryLow.php
index c76c87f..17fb1da 100644
--- a/app/Notifications/BatteryLow.php
+++ b/app/Notifications/BatteryLow.php
@@ -13,15 +13,10 @@ class BatteryLow extends Notification
{
use Queueable;
- private Device $device;
-
/**
* Create a new notification instance.
*/
- public function __construct(Device $device)
- {
- $this->device = $device;
- }
+ public function __construct(private Device $device) {}
/**
* Get the notification's delivery channels.
@@ -41,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([
diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php
index d116200..796cb24 100644
--- a/app/Notifications/Channels/WebhookChannel.php
+++ b/app/Notifications/Channels/WebhookChannel.php
@@ -11,13 +11,7 @@ use Illuminate\Support\Arr;
class WebhookChannel extends Notification
{
- /** @var Client */
- protected $client;
-
- public function __construct(Client $client)
- {
- $this->client = $client;
- }
+ public function __construct(protected Client $client) {}
/**
* Send the given notification.
diff --git a/app/Notifications/Messages/WebhookMessage.php b/app/Notifications/Messages/WebhookMessage.php
index 920c16d..6dc58eb 100644
--- a/app/Notifications/Messages/WebhookMessage.php
+++ b/app/Notifications/Messages/WebhookMessage.php
@@ -13,13 +13,6 @@ final class WebhookMessage extends Notification
*/
private $query;
- /**
- * The POST data of the Webhook request.
- *
- * @var mixed
- */
- private $data;
-
/**
* The headers to send with the request.
*
@@ -36,9 +29,8 @@ final class WebhookMessage extends Notification
/**
* @param mixed $data
- * @return static
*/
- public static function create($data = '')
+ public static function create($data = ''): self
{
return new self($data);
}
@@ -46,10 +38,12 @@ final class WebhookMessage extends Notification
/**
* @param mixed $data
*/
- public function __construct($data = '')
- {
- $this->data = $data;
- }
+ public function __construct(
+ /**
+ * The POST data of the Webhook request.
+ */
+ private $data = ''
+ ) {}
/**
* Set the Webhook parameters to be URL encoded.
@@ -57,7 +51,7 @@ final class WebhookMessage extends Notification
* @param mixed $query
* @return $this
*/
- public function query($query)
+ public function query($query): self
{
$this->query = $query;
@@ -70,7 +64,7 @@ final class WebhookMessage extends Notification
* @param mixed $data
* @return $this
*/
- public function data($data)
+ public function data($data): self
{
$this->data = $data;
@@ -84,7 +78,7 @@ final class WebhookMessage extends Notification
* @param string $value
* @return $this
*/
- public function header($name, $value)
+ public function header($name, $value): self
{
$this->headers[$name] = $value;
@@ -97,7 +91,7 @@ final class WebhookMessage extends Notification
* @param string $userAgent
* @return $this
*/
- public function userAgent($userAgent)
+ public function userAgent($userAgent): self
{
$this->headers['User-Agent'] = $userAgent;
@@ -109,17 +103,14 @@ final class WebhookMessage extends Notification
*
* @return $this
*/
- public function verify($value = true)
+ public function verify($value = true): self
{
$this->verify = $value;
return $this;
}
- /**
- * @return array
- */
- public function toArray()
+ public function toArray(): array
{
return [
'query' => $this->query,
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 8433d76..b8ad9bb 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -33,16 +33,19 @@ class AppServiceProvider extends ServiceProvider
$http = clone $this;
$http->server->set('HTTPS', 'off');
+ if (URL::hasValidSignature($https, $absolute, $ignoreQuery)) {
+ return true;
+ }
- return URL::hasValidSignature($https, $absolute, $ignoreQuery)
- || URL::hasValidSignature($http, $absolute, $ignoreQuery);
+ return URL::hasValidSignature($http, $absolute, $ignoreQuery);
});
// Register OIDC provider with Socialite
- Socialite::extend('oidc', function ($app) {
- $config = $app['config']['services.oidc'] ?? [];
+ Socialite::extend('oidc', function (\Illuminate\Contracts\Foundation\Application $app): OidcProvider {
+ $config = $app->make('config')->get('services.oidc', []);
+
return new OidcProvider(
- $app['request'],
+ $app->make(Request::class),
$config['client_id'] ?? null,
$config['client_secret'] ?? null,
$config['redirect'] ?? null,
diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php
index d0ecddc..405ea3f 100644
--- a/app/Services/ImageGenerationService.php
+++ b/app/Services/ImageGenerationService.php
@@ -6,65 +6,33 @@ use App\Enums\ImageFormat;
use App\Models\Device;
use App\Models\DeviceModel;
use App\Models\Plugin;
+use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
+use Bnussbau\TrmnlPipeline\Stages\ImageStage;
+use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Exception;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
-use Imagick;
-use ImagickException;
-use ImagickPixel;
-use Log;
+use InvalidArgumentException;
use Ramsey\Uuid\Uuid;
use RuntimeException;
-use Spatie\Browsershot\Browsershot;
use Wnx\SidecarBrowsershot\BrowsershotLambda;
+use function config;
+use function file_exists;
+use function filesize;
+
class ImageGenerationService
{
public static function generateImage(string $markup, $deviceId): string
{
- $device = Device::with('deviceModel')->find($deviceId);
- $uuid = Uuid::uuid4()->toString();
- $pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
- $bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
-
- // Get image generation settings from DeviceModel if available, otherwise use device settings
- $imageSettings = self::getImageSettings($device);
-
- // Generate PNG
- if (config('app.puppeteer_mode') === 'sidecar-aws') {
- try {
- $browsershot = BrowsershotLambda::html($markup)
- ->windowSize(800, 480);
-
- if (config('app.puppeteer_wait_for_network_idle')) {
- $browsershot->waitUntilNetworkIdle();
- }
-
- $browsershot->save($pngPath);
- } catch (Exception $e) {
- Log::error('Failed to generate PNG: '.$e->getMessage());
- throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
- }
- } else {
- try {
- $browsershot = Browsershot::html($markup)
- ->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []);
- if (config('app.puppeteer_wait_for_network_idle')) {
- $browsershot->waitUntilNetworkIdle();
- }
- if (config('app.puppeteer_window_size_strategy') == 'v2') {
- $browsershot->windowSize($imageSettings['width'], $imageSettings['height']);
- } else {
- $browsershot->windowSize(800, 480);
- }
- $browsershot->save($pngPath);
- } catch (Exception $e) {
- Log::error('Failed to generate PNG: '.$e->getMessage());
- throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
- }
- }
-
- // Convert image based on DeviceModel settings or fallback to device settings
- self::convertImage($pngPath, $bmpPath, $imageSettings);
+ $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");
@@ -72,6 +40,116 @@ class ImageGenerationService
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 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);
+
+ // Create custom Browsershot instance if using AWS Lambda
+ $browsershotInstance = null;
+ if (config('app.puppeteer_mode') === 'sidecar-aws') {
+ $browsershotInstance = new BrowsershotLambda();
+ }
+
+ $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'])
+ ->height($imageSettings['height']);
+ } else {
+ // default behaviour for Framework v1
+ $browserStage->useDefaultDimensions();
+ }
+
+ if (config('app.puppeteer_wait_for_network_idle')) {
+ $browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
+ }
+
+ if (config('app.puppeteer_docker')) {
+ $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'])
+ ->height($imageSettings['height'])
+ ->colors($imageSettings['colors'])
+ ->bitDepth($imageSettings['bit_depth'])
+ ->rotation($imageSettings['rotation'])
+ ->offsetX($imageSettings['offset_x'])
+ ->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();
+
+ if (! file_exists($outputPath)) {
+ throw new RuntimeException('Image file was not created: '.$outputPath);
+ }
+
+ if (filesize($outputPath) === 0) {
+ throw new RuntimeException('Image file is empty: '.$outputPath);
+ }
+
+ Log::info("Generated image: $uuid");
+
+ return $uuid;
+
+ } catch (Exception $e) {
+ Log::error('Failed to generate image: '.$e->getMessage());
+ throw new RuntimeException('Failed to generate image: '.$e->getMessage(), 0, $e);
+ }
+ }
+
/**
* Get image generation settings from DeviceModel if available, otherwise use device settings
*/
@@ -79,36 +157,63 @@ class ImageGenerationService
{
// If device has a DeviceModel, use its settings
if ($device->deviceModel) {
- /** @var DeviceModel $model */
- $model = $device->deviceModel;
+ return self::getImageSettingsFromModel($device->deviceModel);
+ }
+ // Fallback to device settings
+ $imageFormat = $device->image_format ?? ImageFormat::AUTO->value;
+ $mimeType = self::getMimeTypeFromImageFormat($imageFormat);
+ $colors = self::getColorsFromImageFormat($imageFormat);
+ $bitDepth = self::getBitDepthFromImageFormat($imageFormat);
+
+ return [
+ 'width' => $device->width ?? 800,
+ 'height' => $device->height ?? 480,
+ 'colors' => $colors,
+ 'bit_depth' => $bitDepth,
+ 'scale_factor' => 1.0,
+ 'rotation' => $device->rotate ?? 0,
+ 'mime_type' => $mimeType,
+ 'offset_x' => 0,
+ 'offset_y' => 0,
+ 'image_format' => $imageFormat,
+ 'use_model_settings' => false,
+ ];
+ }
+
+ /**
+ * Get image generation settings from a DeviceModel
+ */
+ private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
+ {
+ if ($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),
+ '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,
];
}
- // Fallback to device settings
+ // Default settings if no device model provided
return [
- 'width' => $device->width ?? 800,
- 'height' => $device->height ?? 480,
+ 'width' => 800,
+ 'height' => 480,
'colors' => 2,
'bit_depth' => 1,
'scale_factor' => 1.0,
- 'rotation' => $device->rotate ?? 0,
+ 'rotation' => 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
- 'image_format' => $device->image_format,
+ 'image_format' => ImageFormat::AUTO->value,
'use_model_settings' => false,
];
}
@@ -137,197 +242,73 @@ class ImageGenerationService
}
/**
- * Convert image based on the provided settings
+ * Get MIME type from ImageFormat
*/
- private static function convertImage(string $pngPath, string $bmpPath, array $settings): void
+ private static function getMimeTypeFromImageFormat(string $imageFormat): string
{
- $imageFormat = $settings['image_format'];
- $useModelSettings = $settings['use_model_settings'] ?? false;
-
- if ($useModelSettings) {
- // Use DeviceModel-specific conversion
- self::convertUsingModelSettings($pngPath, $bmpPath, $settings);
- } else {
- // Use legacy device-specific conversion
- self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings);
- }
+ return match ($imageFormat) {
+ ImageFormat::BMP3_1BIT_SRGB->value => 'image/bmp',
+ ImageFormat::PNG_8BIT_GRAYSCALE->value,
+ ImageFormat::PNG_8BIT_256C->value,
+ ImageFormat::PNG_2BIT_4C->value => 'image/png',
+ ImageFormat::AUTO->value => 'image/png', // Default for AUTO
+ default => 'image/png',
+ };
}
/**
- * Convert image using DeviceModel settings
+ * Get colors from ImageFormat
*/
- private static function convertUsingModelSettings(string $pngPath, string $bmpPath, array $settings): void
+ private static function getColorsFromImageFormat(string $imageFormat): int
{
- try {
- $imagick = new Imagick($pngPath);
-
- // Apply scale factor if needed
- if ($settings['scale_factor'] !== 1.0) {
- $newWidth = (int) ($settings['width'] * $settings['scale_factor']);
- $newHeight = (int) ($settings['height'] * $settings['scale_factor']);
- $imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1, true);
- } else {
- // Resize to model dimensions if different from generated size
- if ($imagick->getImageWidth() !== $settings['width'] || $imagick->getImageHeight() !== $settings['height']) {
- $imagick->resizeImage($settings['width'], $settings['height'], Imagick::FILTER_LANCZOS, 1, true);
- }
- }
-
- // Apply rotation
- if ($settings['rotation'] !== 0) {
- $imagick->rotateImage(new ImagickPixel('black'), $settings['rotation']);
- }
-
- // Apply offset if specified
- if ($settings['offset_x'] !== 0 || $settings['offset_y'] !== 0) {
- $imagick->rollImage($settings['offset_x'], $settings['offset_y']);
- }
-
- // Handle special case for 4-color, 2-bit PNG
- if ($settings['colors'] === 4 && $settings['bit_depth'] === 2 && $settings['mime_type'] === 'image/png') {
- self::convertTo4Color2BitPng($imagick, $settings['width'], $settings['height']);
- } else {
- // Set image type and color depth based on model settings
- $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
-
- if ($settings['bit_depth'] === 1) {
- $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
- $imagick->setImageDepth(1);
- } else {
- $imagick->quantizeImage($settings['colors'], Imagick::COLORSPACE_GRAY, 0, true, false);
- $imagick->setImageDepth($settings['bit_depth']);
- }
- }
-
- $imagick->stripImage();
-
- // Save in the appropriate format
- if ($settings['mime_type'] === 'image/bmp') {
- $imagick->setFormat('BMP3');
- $imagick->writeImage($bmpPath);
- } else {
- $imagick->setFormat('png');
- $imagick->writeImage($pngPath);
- }
-
- $imagick->clear();
- } catch (ImagickException $e) {
- throw new RuntimeException('Failed to convert image using model settings: '.$e->getMessage(), 0, $e);
- }
+ return match ($imageFormat) {
+ ImageFormat::BMP3_1BIT_SRGB->value,
+ ImageFormat::PNG_8BIT_GRAYSCALE->value => 2,
+ ImageFormat::PNG_8BIT_256C->value => 256,
+ ImageFormat::PNG_2BIT_4C->value => 4,
+ ImageFormat::AUTO->value => 2, // Default for AUTO
+ default => 2,
+ };
}
/**
- * Convert image to 4-color, 2-bit PNG using custom colormap and dithering
+ * Get bit depth from ImageFormat
*/
- private static function convertTo4Color2BitPng(Imagick $imagick, int $width, int $height): void
+ private static function getBitDepthFromImageFormat(string $imageFormat): int
{
- // Step 1: Create 4-color grayscale colormap in memory
- $colors = ['#000000', '#555555', '#aaaaaa', '#ffffff'];
- $colormap = new Imagick();
-
- foreach ($colors as $color) {
- $swatch = new Imagick();
- $swatch->newImage(1, 1, new ImagickPixel($color));
- $swatch->setImageFormat('png');
- $colormap->addImage($swatch);
- }
-
- $colormap = $colormap->appendImages(true); // horizontal
- $colormap->setType(Imagick::IMGTYPE_PALETTE);
- $colormap->setImageFormat('png');
-
- // Step 2: Resize to target dimensions without keeping aspect ratio
- $imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, false);
-
- // Step 3: Apply Floyd–Steinberg dithering
- $imagick->setOption('dither', 'FloydSteinberg');
-
- // Step 4: Remap to our 4-color colormap
- // $imagick->remapImage($colormap, Imagick::DITHERMETHOD_FLOYDSTEINBERG);
-
- // Step 5: Force 2-bit grayscale PNG
- $imagick->setImageFormat('png');
- $imagick->setImageDepth(2);
- $imagick->setType(Imagick::IMGTYPE_GRAYSCALE);
-
- // Cleanup colormap
- $colormap->clear();
+ return match ($imageFormat) {
+ ImageFormat::BMP3_1BIT_SRGB->value,
+ ImageFormat::PNG_8BIT_GRAYSCALE->value => 1,
+ ImageFormat::PNG_8BIT_256C->value => 8,
+ ImageFormat::PNG_2BIT_4C->value => 2,
+ ImageFormat::AUTO->value => 1, // Default for AUTO
+ default => 1,
+ };
}
/**
- * Convert image using legacy device settings
+ * Detect whether the provided HTML markup contains an tag with class "image-dither".
*/
- private static function convertUsingLegacySettings(string $pngPath, string $bmpPath, string $imageFormat, array $settings): void
+ private static function markupContainsDitherImage(string $markup): bool
{
- switch ($imageFormat) {
- case ImageFormat::BMP3_1BIT_SRGB->value:
- try {
- self::convertToBmpImageMagick($pngPath, $bmpPath);
- } catch (ImagickException $e) {
- throw new RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
- }
- break;
- case ImageFormat::PNG_8BIT_GRAYSCALE->value:
- case ImageFormat::PNG_8BIT_256C->value:
- try {
- self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation'], quantize: $imageFormat === ImageFormat::PNG_8BIT_GRAYSCALE->value);
- } catch (ImagickException $e) {
- throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e);
- }
- break;
- case ImageFormat::AUTO->value:
- default:
- // For AUTO format, we need to check if this is a legacy device
- // This would require checking if the device has a firmware version
- // For now, we'll use the device's current logic
- try {
- self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation']);
- } catch (ImagickException $e) {
- throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e);
- }
- }
- }
-
- /**
- * @throws ImagickException
- */
- private static function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
- {
- $imagick = new Imagick($pngPath);
- $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
- $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
- $imagick->setImageDepth(1);
- $imagick->stripImage();
- $imagick->setFormat('BMP3');
- $imagick->writeImage($bmpPath);
- $imagick->clear();
- }
-
- /**
- * @throws ImagickException
- */
- private static function convertToPngImageMagick(string $pngPath, ?int $width, ?int $height, ?int $rotate, $quantize = true): void
- {
- $imagick = new Imagick($pngPath);
- if ($width !== 800 || $height !== 480) {
- $imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, true);
- }
- if ($rotate !== null && $rotate !== 0) {
- $imagick->rotateImage(new ImagickPixel('black'), $rotate);
+ if (mb_trim($markup) === '') {
+ return false;
}
- $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
- $imagick->setOption('dither', 'FloydSteinberg');
-
- if ($quantize) {
- $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
+ // Find (or with single quotes) and inspect class tokens
+ $imgWithClassPattern = '/]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i';
+ if (! preg_match_all($imgWithClassPattern, $markup, $matches)) {
+ return false;
}
- $imagick->setImageDepth(8);
- $imagick->stripImage();
- $imagick->setFormat('png');
- $imagick->writeImage($pngPath);
- $imagick->clear();
+ 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
@@ -353,16 +334,20 @@ 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) {
+ ->where(function ($query): void {
$query->where('width', '!=', 800)
->orWhere('height', '!=', 480)
->orWhere('rotate', '!=', 0);
})
- ->orWhereHas('deviceModel', function ($query) {
+ ->orWhereHas('deviceModel', function ($query): void {
// Only allow caching if all device models have standard dimensions (800x480, rotation=0)
- $query->where(function ($subQuery) {
+ $query->where(function ($subQuery): void {
$subQuery->where('width', '!=', 800)
->orWhere('height', '!=', 480)
->orWhere('rotation', '!=', 0);
@@ -377,4 +362,180 @@ class ImageGenerationService
}
}
}
+
+ /**
+ * Get device-specific default image path for setup or sleep mode
+ */
+ public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
+ {
+ // Validate image type
+ if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
+ return null;
+ }
+
+ // If device has a DeviceModel, try to find device-specific image
+ if ($device->deviceModel) {
+ $model = $device->deviceModel;
+ $extension = $model->mime_type === 'image/bmp' ? 'bmp' : 'png';
+ $filename = "{$model->width}_{$model->height}_{$model->bit_depth}_{$model->rotation}.{$extension}";
+ $deviceSpecificPath = "images/default-screens/{$imageType}_{$filename}";
+
+ if (Storage::disk('public')->exists($deviceSpecificPath)) {
+ return $deviceSpecificPath;
+ }
+ }
+
+ // Fallback to original hardcoded images
+ $fallbackPath = "images/{$imageType}.bmp";
+ if (Storage::disk('public')->exists($fallbackPath)) {
+ return $fallbackPath;
+ }
+
+ // Try PNG fallback
+ $fallbackPathPng = "images/{$imageType}.png";
+ if (Storage::disk('public')->exists($fallbackPathPng)) {
+ return $fallbackPathPng;
+ }
+
+ return null;
+ }
+
+ /**
+ * Generate a default screen image from Blade template
+ */
+ public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
+ {
+ // Validate image type
+ 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);
+
+ $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
+ $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
+
+ // Generate HTML from Blade template
+ $html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
+
+ // Create custom Browsershot instance if using AWS Lambda
+ $browsershotInstance = null;
+ if (config('app.puppeteer_mode') === 'sidecar-aws') {
+ $browsershotInstance = new BrowsershotLambda();
+ }
+
+ $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'])
+ ->height($imageSettings['height']);
+ } else {
+ $browserStage->useDefaultDimensions();
+ }
+
+ if (config('app.puppeteer_wait_for_network_idle')) {
+ $browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
+ }
+
+ if (config('app.puppeteer_docker')) {
+ $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'])
+ ->height($imageSettings['height'])
+ ->colors($imageSettings['colors'])
+ ->bitDepth($imageSettings['bit_depth'])
+ ->rotation($imageSettings['rotation'])
+ ->offsetX($imageSettings['offset_x'])
+ ->offsetY($imageSettings['offset_y'])
+ ->outputPath($outputPath);
+
+ // Apply color palette if available
+ if ($colorPalette) {
+ $imageStage->colormap($colorPalette);
+ }
+
+ (new TrmnlPipeline())->pipe($browserStage)
+ ->pipe($imageStage)
+ ->process();
+
+ if (! file_exists($outputPath)) {
+ throw new RuntimeException('Image file was not created: '.$outputPath);
+ }
+
+ if (filesize($outputPath) === 0) {
+ throw new RuntimeException('Image file is empty: '.$outputPath);
+ }
+
+ Log::info("Device $device->id: generated default screen image: $uuid for type: $imageType");
+
+ return $uuid;
+
+ } catch (Exception $e) {
+ Log::error('Failed to generate default screen image: '.$e->getMessage());
+ throw new RuntimeException('Failed to generate default screen image: '.$e->getMessage(), 0, $e);
+ }
+ }
+
+ /**
+ * Generate HTML from Blade template for default screens
+ */
+ 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}")
+ };
+
+ // Determine device properties from DeviceModel or device settings
+ $deviceVariant = $device->deviceVariant();
+ $deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
+ $colorDepth = $device->colorDepth() ?? '1bit';
+ $scaleLevel = $device->scaleLevel();
+ $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
+
+ // Build view data
+ $viewData = [
+ 'noBleed' => false,
+ 'darkMode' => $darkMode,
+ 'deviceVariant' => $deviceVariant,
+ 'deviceOrientation' => $deviceOrientation,
+ 'colorDepth' => $colorDepth,
+ 'scaleLevel' => $scaleLevel,
+ ];
+
+ // Add plugin name for error screens
+ if ($imageType === 'error' && $pluginName !== null) {
+ $viewData['pluginName'] = $pluginName;
+ }
+
+ // Render the Blade template
+ return view($templateName, $viewData)->render();
+ }
}
diff --git a/app/Services/OidcProvider.php b/app/Services/OidcProvider.php
index ad9799d..8ea2e44 100644
--- a/app/Services/OidcProvider.php
+++ b/app/Services/OidcProvider.php
@@ -2,11 +2,11 @@
namespace App\Services;
+use Exception;
+use GuzzleHttp\Client;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\ProviderInterface;
use Laravel\Socialite\Two\User;
-use GuzzleHttp\Client;
-use Illuminate\Support\Arr;
class OidcProvider extends AbstractProvider implements ProviderInterface
{
@@ -33,22 +33,22 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
/**
* Create a new provider instance.
*/
- public function __construct($request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = [])
+ public function __construct(\Illuminate\Http\Request $request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = [])
{
parent::__construct($request, $clientId, $clientSecret, $redirectUrl, $guzzle);
-
+
$endpoint = config('services.oidc.endpoint');
- if (!$endpoint) {
- throw new \Exception('OIDC endpoint is not configured. Please set OIDC_ENDPOINT environment variable.');
+ if (! $endpoint) {
+ throw new Exception('OIDC endpoint is not configured. Please set OIDC_ENDPOINT environment variable.');
}
-
+
// Handle both full well-known URL and base URL
- if (str_ends_with($endpoint, '/.well-known/openid-configuration')) {
+ if (str_ends_with((string) $endpoint, '/.well-known/openid-configuration')) {
$this->baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint);
} else {
- $this->baseUrl = rtrim($endpoint, '/');
+ $this->baseUrl = mb_rtrim($endpoint, '/');
}
-
+
$this->scopes = $scopes ?: ['openid', 'profile', 'email'];
$this->loadOidcConfiguration();
}
@@ -59,21 +59,21 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
protected function loadOidcConfiguration()
{
try {
- $url = $this->baseUrl . '/.well-known/openid-configuration';
- $client = new Client();
+ $url = $this->baseUrl.'/.well-known/openid-configuration';
+ $client = app(Client::class);
$response = $client->get($url);
$this->oidcConfig = json_decode($response->getBody()->getContents(), true);
-
- if (!$this->oidcConfig) {
- throw new \Exception('OIDC configuration is empty or invalid JSON');
+
+ if (! $this->oidcConfig) {
+ throw new Exception('OIDC configuration is empty or invalid JSON');
}
-
- if (!isset($this->oidcConfig['authorization_endpoint'])) {
- throw new \Exception('authorization_endpoint not found in OIDC configuration');
+
+ if (! isset($this->oidcConfig['authorization_endpoint'])) {
+ throw new Exception('authorization_endpoint not found in OIDC configuration');
}
-
- } catch (\Exception $e) {
- throw new \Exception('Failed to load OIDC configuration: ' . $e->getMessage());
+
+ } catch (Exception $e) {
+ throw new Exception('Failed to load OIDC configuration: '.$e->getMessage(), $e->getCode(), $e);
}
}
@@ -82,9 +82,10 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
*/
protected function getAuthUrl($state)
{
- if (!$this->oidcConfig || !isset($this->oidcConfig['authorization_endpoint'])) {
- throw new \Exception('OIDC configuration not loaded or authorization_endpoint not found.');
+ if (! $this->oidcConfig || ! isset($this->oidcConfig['authorization_endpoint'])) {
+ throw new Exception('OIDC configuration not loaded or authorization_endpoint not found.');
}
+
return $this->buildAuthUrlFromBase($this->oidcConfig['authorization_endpoint'], $state);
}
@@ -93,9 +94,10 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
*/
protected function getTokenUrl()
{
- if (!$this->oidcConfig || !isset($this->oidcConfig['token_endpoint'])) {
- throw new \Exception('OIDC configuration not loaded or token_endpoint not found.');
+ if (! $this->oidcConfig || ! isset($this->oidcConfig['token_endpoint'])) {
+ throw new Exception('OIDC configuration not loaded or token_endpoint not found.');
}
+
return $this->oidcConfig['token_endpoint'];
}
@@ -104,13 +106,13 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
*/
protected function getUserByToken($token)
{
- if (!$this->oidcConfig || !isset($this->oidcConfig['userinfo_endpoint'])) {
- throw new \Exception('OIDC configuration not loaded or userinfo_endpoint not found.');
+ if (! $this->oidcConfig || ! isset($this->oidcConfig['userinfo_endpoint'])) {
+ throw new Exception('OIDC configuration not loaded or userinfo_endpoint not found.');
}
-
+
$response = $this->getHttpClient()->get($this->oidcConfig['userinfo_endpoint'], [
'headers' => [
- 'Authorization' => 'Bearer ' . $token,
+ 'Authorization' => 'Bearer '.$token,
],
]);
@@ -120,7 +122,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
/**
* Map the raw user array to a Socialite User instance.
*/
- protected function mapUserToObject(array $user)
+ public function mapUserToObject(array $user)
{
return (new User)->setRaw($user)->map([
'id' => $user['sub'],
@@ -153,4 +155,4 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
'grant_type' => 'authorization_code',
]);
}
-}
\ No newline at end of file
+}
diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php
new file mode 100644
index 0000000..c8f2b58
--- /dev/null
+++ b/app/Services/Plugin/Parsers/IcalResponseParser.php
@@ -0,0 +1,111 @@
+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;
+ }
+}
diff --git a/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php b/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
new file mode 100644
index 0000000..44ea0cb
--- /dev/null
+++ b/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
@@ -0,0 +1,26 @@
+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'];
+ }
+ }
+}
diff --git a/app/Services/Plugin/Parsers/ResponseParser.php b/app/Services/Plugin/Parsers/ResponseParser.php
new file mode 100644
index 0000000..b8f9c05
--- /dev/null
+++ b/app/Services/Plugin/Parsers/ResponseParser.php
@@ -0,0 +1,15 @@
+
+ */
+ private readonly array $parsers;
+
+ /**
+ * @param array $parsers
+ */
+ public function __construct(array $parsers = [])
+ {
+ $this->parsers = $parsers ?: [
+ new XmlResponseParser(),
+ new IcalResponseParser(),
+ new JsonOrTextResponseParser(),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getParsers(): array
+ {
+ return $this->parsers;
+ }
+}
diff --git a/app/Services/Plugin/Parsers/XmlResponseParser.php b/app/Services/Plugin/Parsers/XmlResponseParser.php
new file mode 100644
index 0000000..b82ba80
--- /dev/null
+++ b/app/Services/Plugin/Parsers/XmlResponseParser.php
@@ -0,0 +1,46 @@
+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;
+ }
+}
diff --git a/app/Services/PluginExportService.php b/app/Services/PluginExportService.php
index 3c2c3d0..241764d 100644
--- a/app/Services/PluginExportService.php
+++ b/app/Services/PluginExportService.php
@@ -47,44 +47,34 @@ class PluginExportService
$tempDirName = 'temp/'.uniqid('plugin_export_', true);
Storage::makeDirectory($tempDirName);
$tempDir = Storage::path($tempDirName);
-
- try {
- // Generate settings.yml content
- $settings = $this->generateSettingsYaml($plugin);
- $settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
- File::put($tempDir.'/settings.yml', $settingsYaml);
-
- // Generate full template content
- $fullTemplate = $this->generateFullTemplate($plugin);
- $extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
- File::put($tempDir.'/full.'.$extension, $fullTemplate);
-
- // Generate shared.liquid if needed (for liquid templates)
- if ($plugin->markup_language === 'liquid') {
- $sharedTemplate = $this->generateSharedTemplate($plugin);
- if ($sharedTemplate) {
- File::put($tempDir.'/shared.liquid', $sharedTemplate);
- }
+ // Generate settings.yml content
+ $settings = $this->generateSettingsYaml($plugin);
+ $settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
+ File::put($tempDir.'/settings.yml', $settingsYaml);
+ // Generate full template content
+ $fullTemplate = $this->generateFullTemplate($plugin);
+ $extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
+ File::put($tempDir.'/full.'.$extension, $fullTemplate);
+ // Generate shared.liquid if needed (for liquid templates)
+ if ($plugin->markup_language === 'liquid') {
+ $sharedTemplate = $this->generateSharedTemplate();
+ /** @phpstan-ignore-next-line */
+ if ($sharedTemplate) {
+ File::put($tempDir.'/shared.liquid', $sharedTemplate);
}
-
- // Create ZIP file
- $zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
- $zip = new ZipArchive();
-
- if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
- throw new Exception('Could not create ZIP file.');
- }
-
- // Add files directly to ZIP root
- $this->addDirectoryToZip($zip, $tempDir, '');
- $zip->close();
-
- // Return the ZIP file as a download response
- return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip');
-
- } catch (Exception $e) {
- throw $e;
}
+ // Create ZIP file
+ $zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
+ $zip = new ZipArchive();
+ if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
+ throw new Exception('Could not create ZIP file.');
+ }
+ // Add files directly to ZIP root
+ $this->addDirectoryToZip($zip, $tempDir, '');
+ $zip->close();
+
+ // Return the ZIP file as a download response
+ return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip');
}
/**
@@ -144,13 +134,13 @@ class PluginExportService
$markup = preg_replace('/^
\s*/', '', $markup);
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
- return trim($markup);
+ return mb_trim($markup);
}
/**
* Generate the shared template content (for liquid templates)
*/
- private function generateSharedTemplate(Plugin $plugin)
+ private function generateSharedTemplate(): null
{
// For now, we don't have a way to store shared templates separately
// TODO - add support for shared templates
@@ -170,14 +160,10 @@ class PluginExportService
foreach ($files as $file) {
if (! $file->isDir()) {
$filePath = $file->getRealPath();
- $fileName = basename($filePath);
+ $fileName = basename((string) $filePath);
// For root directory, just use the filename
- if ($zipPath === '') {
- $relativePath = $fileName;
- } else {
- $relativePath = $zipPath.'/'.mb_substr($filePath, mb_strlen($dirPath) + 1);
- }
+ $relativePath = $zipPath === '' ? $fileName : $zipPath.'/'.mb_substr((string) $filePath, mb_strlen($dirPath) + 1);
$zip->addFile($filePath, $relativePath);
}
diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php
index dbd8ec8..49dce99 100644
--- a/app/Services/PluginImportService.php
+++ b/app/Services/PluginImportService.php
@@ -7,6 +7,7 @@ use App\Models\User;
use Exception;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
+use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
@@ -16,16 +17,45 @@ 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
*
* @param UploadedFile $zipFile The uploaded ZIP file
* @param User $user The user importing the plugin
+ * @param string|null $zipEntryPath Optional path to specific plugin in monorepo
* @return Plugin The created plugin instance
*
* @throws Exception If the ZIP file is invalid or required files are missing
*/
- public function importFromZip(UploadedFile $zipFile, User $user): Plugin
+ public function importFromZip(UploadedFile $zipFile, User $user, ?string $zipEntryPath = null): Plugin
{
// Create a temporary directory using Laravel's temporary directory helper
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
@@ -45,33 +75,55 @@ class PluginImportService
$zip->extractTo($tempDir);
$zip->close();
- // Find the required files (settings.yml and full.liquid/full.blade.php)
- $filePaths = $this->findRequiredFiles($tempDir);
+ // 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;
- }
-
- $fullLiquid = '
'."\n".$fullLiquid."\n".'
';
-
- // 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($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 = '
';
+ } elseif ($filePaths['sharedBladePath']) {
+ $templatePath = $filePaths['sharedBladePath'];
+ $fullLiquid = File::get($templatePath);
+ $markupLanguage = 'blade';
}
// Ensure custom_fields is properly formatted
@@ -79,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'],
@@ -132,11 +187,226 @@ class PluginImportService
}
}
- private function findRequiredFiles(string $tempDir): array
+ /**
+ * Import a plugin from a ZIP URL
+ *
+ * @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, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
+ {
+ // Download the ZIP file
+ $response = Http::timeout(60)->get($zipUrl);
+
+ if (! $response->successful()) {
+ throw new Exception('Could not download the ZIP file from the provided URL.');
+ }
+
+ // Create a temporary file
+ $tempDirName = 'temp/'.uniqid('plugin_import_', true);
+ Storage::makeDirectory($tempDirName);
+ $tempDir = Storage::path($tempDirName);
+ $zipPath = $tempDir.'/plugin.zip';
+
+ // Save the downloaded content to a temporary file
+ File::put($zipPath, $response->body());
+
+ try {
+ // Extract the ZIP file using ZipArchive
+ $zip = new ZipArchive();
+ if ($zip->open($zipPath) !== true) {
+ throw new Exception('Could not open the downloaded ZIP file.');
+ }
+
+ $zip->extractTo($tempDir);
+ $zip->close();
+
+ // 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']) {
+ 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);
+
+ // Determine which template file to use and read its content
+ $templatePath = null;
+ $markupLanguage = 'blade';
+
+ 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 = '