diff --git a/.cursor/mcp.json b/.cursor/mcp.json
new file mode 100644
index 0000000..ea30195
--- /dev/null
+++ b/.cursor/mcp.json
@@ -0,0 +1,11 @@
+{
+ "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
new file mode 100644
index 0000000..9464f06
--- /dev/null
+++ b/.cursor/rules/laravel-boost.mdc
@@ -0,0 +1,534 @@
+---
+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 ab13330..908acef 100644
--- a/.devcontainer/cli/Dockerfile
+++ b/.devcontainer/cli/Dockerfile
@@ -1,5 +1,5 @@
# From official php image.
-FROM php:8.4-cli-alpine
+FROM php:8.3-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,22 +9,21 @@ RUN apk add --no-cache composer
# Add Chromium and Image Magick for puppeteer.
RUN apk add --no-cache \
imagemagick-dev \
- chromium \
- libzip-dev
+ chromium
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.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
+RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-RUN docker-php-ext-install imagick zip
+RUN docker-php-ext-install imagick
# Composer uses its php binary, but we want it to use the container's one
-RUN rm -f /usr/bin/php84
-RUN ln -s /usr/local/bin/php /usr/bin/php84
+RUN rm -f /usr/bin/php83
+RUN ln -s /usr/local/bin/php /usr/bin/php83
# 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 3e658b6..b9770e3 100644
--- a/.devcontainer/fpm/Dockerfile
+++ b/.devcontainer/fpm/Dockerfile
@@ -1,5 +1,5 @@
# From official php image.
-FROM php:8.4-fpm-alpine
+FROM php:8.3-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,18 +14,17 @@ RUN apk add --no-cache \
nodejs \
npm \
imagemagick-dev \
- chromium \
- libzip-dev
+ chromium
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.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
+RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-RUN docker-php-ext-install imagick zip
+RUN docker-php-ext-install imagick
-RUN rm -f /usr/bin/php84
-RUN ln -s /usr/local/bin/php /usr/bin/php84
+RUN rm -f /usr/bin/php83
+RUN ln -s /usr/local/bin/php /usr/bin/php83
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..a331541
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,531 @@
+
+=== 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 a4ff129..048db51 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -42,7 +42,8 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
- type=semver,pattern={{version}}
+ type=ref,event=tag
+ latest
- 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 78e4fbb..fd03705 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.4
+ php-version: 8.3
coverage: xdebug
- name: Setup Node
diff --git a/.gitignore b/.gitignore
index 0eb46d3..3a2ae5a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,17 +23,3 @@ 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
new file mode 100644
index 0000000..a331541
--- /dev/null
+++ b/.junie/guidelines.md
@@ -0,0 +1,531 @@
+
+=== 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
new file mode 100644
index 0000000..ea30195
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "./artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..a331541
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,531 @@
+
+=== 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 2d761ed..6a9931d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
########################
# Base Image
########################
-FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
+FROM bnussbau/serversideup-php:8.3-fpm-nginx-alpine-imagick-chromium AS base
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
@@ -12,14 +12,9 @@ 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
@@ -53,5 +48,6 @@ 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 acb0b5c..73b9a6b 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,9 @@
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
-It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.
+It 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).


@@ -14,32 +16,21 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
-* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
- * 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)
+* 🖥️ 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), …
* 🔄 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).
@@ -49,8 +40,6 @@ 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, ...).
@@ -76,12 +65,9 @@ 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.
@@ -218,12 +204,6 @@ 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 91922ba..f407314 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 instanceof Firmware) {
+ if ($latestFirmware) {
table(
rows: [
['Latest Version', $latestFirmware->version_tag],
diff --git a/app/Console/Commands/FirmwareUpdateCommand.php b/app/Console/Commands/FirmwareUpdateCommand.php
index bd43786..97d9d58 100644
--- a/app/Console/Commands/FirmwareUpdateCommand.php
+++ b/app/Console/Commands/FirmwareUpdateCommand.php
@@ -42,14 +42,15 @@ class FirmwareUpdateCommand extends Command
label: 'Which devices should be updated?',
options: [
'all' => 'ALL Devices',
- ...Device::all()->mapWithKeys(fn ($device): array =>
+ ...Device::all()->mapWithKeys(function ($device) {
// without _ returns index
- ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"])->toArray(),
+ return ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"];
+ })->toArray(),
],
scroll: 10
);
- if ($devices === []) {
+ if (empty($devices)) {
$this->error('No devices selected. Aborting.');
return;
@@ -58,7 +59,9 @@ class FirmwareUpdateCommand extends Command
if (in_array('all', $devices)) {
$devices = Device::pluck('id')->toArray();
} else {
- $devices = array_map(fn ($selected): int => (int) str_replace('_', '', $selected), $devices);
+ $devices = array_map(function ($selected) {
+ return (int) str_replace('_', '', $selected);
+ }, $devices);
}
foreach ($devices as $deviceId) {
diff --git a/app/Console/Commands/GenerateDefaultImagesCommand.php b/app/Console/Commands/GenerateDefaultImagesCommand.php
deleted file mode 100644
index e2887df..0000000
--- a/app/Console/Commands/GenerateDefaultImagesCommand.php
+++ /dev/null
@@ -1,201 +0,0 @@
-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 7201274..d6f1378 100644
--- a/app/Console/Commands/MashupCreateCommand.php
+++ b/app/Console/Commands/MashupCreateCommand.php
@@ -9,6 +9,9 @@ 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
{
/**
@@ -28,17 +31,17 @@ class MashupCreateCommand extends Command
/**
* Execute the console command.
*/
- public function handle(): int
+ public function handle()
{
// Select device
$device = $this->selectDevice();
- if (! $device instanceof Device) {
+ if (! $device) {
return 1;
}
// Select playlist
$playlist = $this->selectPlaylist($device);
- if (! $playlist instanceof Playlist) {
+ if (! $playlist) {
return 1;
}
@@ -85,9 +88,9 @@ class MashupCreateCommand extends Command
return null;
}
- $deviceId = $this->choice(
- 'Select a device',
- $devices->mapWithKeys(fn ($device): array => [$device->id => $device->name])->toArray()
+ $deviceId = select(
+ label: 'Select a device',
+ options: $devices->mapWithKeys(fn ($device) => [$device->id => $device->name])->toArray()
);
return $devices->firstWhere('id', $deviceId);
@@ -103,9 +106,9 @@ class MashupCreateCommand extends Command
return null;
}
- $playlistId = $this->choice(
- 'Select a playlist',
- $playlists->mapWithKeys(fn (Playlist $playlist): array => [$playlist->id => $playlist->name])->toArray()
+ $playlistId = select(
+ label: 'Select a playlist',
+ options: $playlists->mapWithKeys(fn (Playlist $playlist) => [$playlist->id => $playlist->name])->toArray()
);
return $playlists->firstWhere('id', $playlistId);
@@ -113,29 +116,24 @@ class MashupCreateCommand extends Command
protected function selectLayout(): ?string
{
- return $this->choice(
- 'Select a layout',
- PlaylistItem::getAvailableLayouts()
+ return select(
+ label: 'Select a layout',
+ options: PlaylistItem::getAvailableLayouts()
);
}
protected function getMashupName(): ?string
{
- $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;
+ 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,
+ }
+ );
}
protected function selectPlugins(string $layout): Collection
@@ -150,7 +148,7 @@ class MashupCreateCommand extends Command
}
$selectedPlugins = collect();
- $availablePlugins = $plugins->mapWithKeys(fn ($plugin): array => [$plugin->id => $plugin->name])->toArray();
+ $availablePlugins = $plugins->mapWithKeys(fn ($plugin) => [$plugin->id => $plugin->name])->toArray();
for ($i = 0; $i < $requiredCount; ++$i) {
$position = match ($i) {
@@ -161,9 +159,9 @@ class MashupCreateCommand extends Command
default => ($i + 1).'th'
};
- $pluginId = $this->choice(
- "Select the $position plugin",
- $availablePlugins
+ $pluginId = select(
+ label: "Select the $position plugin",
+ options: $availablePlugins
);
$selectedPlugins->push($plugins->firstWhere('id', $pluginId));
diff --git a/app/Console/Commands/OidcTestCommand.php b/app/Console/Commands/OidcTestCommand.php
index 81dff0b..2ecfef2 100644
--- a/app/Console/Commands/OidcTestCommand.php
+++ b/app/Console/Commands/OidcTestCommand.php
@@ -2,9 +2,7 @@
namespace App\Console\Commands;
-use Exception;
use Illuminate\Console\Command;
-use InvalidArgumentException;
use Laravel\Socialite\Facades\Socialite;
class OidcTestCommand extends Command
@@ -26,32 +24,27 @@ class OidcTestCommand extends Command
/**
* Execute the console command.
*/
- public function handle(): int
+ public function handle()
{
$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: ✅ '.implode(', ', $effectiveScopes));
+ $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->newLine();
@@ -60,45 +53,38 @@ 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)');
- }
- if (! $clientId) {
- $this->line(' - OIDC_CLIENT_ID=your-client-id');
- }
- if (! $clientSecret) {
- $this->line(' - OIDC_CLIENT_SECRET=your-client-secret');
+ $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");
}
- } 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 c0a2cc3..ac74fba 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(): int
+ public function handle()
{
$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 f7847d9..305dd49 100644
--- a/app/Http/Controllers/Auth/OidcController.php
+++ b/app/Http/Controllers/Auth/OidcController.php
@@ -4,7 +4,6 @@ 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;
@@ -18,25 +17,23 @@ 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.']);
}
}
@@ -46,34 +43,32 @@ 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.']);
}
}
@@ -85,28 +80,26 @@ 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;
}
}
@@ -115,9 +108,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 d2f1dd9..b49f507 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): void {
+ Device::each(function ($device) {
$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 475c5c7..695041f 100644
--- a/app/Jobs/FetchDeviceModelsJob.php
+++ b/app/Jobs/FetchDeviceModelsJob.php
@@ -5,7 +5,6 @@ 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;
@@ -21,8 +20,6 @@ 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.
*/
@@ -37,8 +34,6 @@ final class FetchDeviceModelsJob implements ShouldQueue
public function handle(): void
{
try {
- $this->processPalettes();
-
$response = Http::timeout(30)->get(self::API_URL);
if (! $response->successful()) {
@@ -74,86 +69,6 @@ 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.
*/
@@ -199,49 +114,12 @@ 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 ac23130..b560085 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): void {
+ Device::where('proxy_cloud', true)->each(function ($device) {
if (! $device->getNextPlaylistItem()) {
try {
$response = Http::withHeaders([
diff --git a/app/Jobs/FirmwareDownloadJob.php b/app/Jobs/FirmwareDownloadJob.php
index dfc851d..6b4fc36 100644
--- a/app/Jobs/FirmwareDownloadJob.php
+++ b/app/Jobs/FirmwareDownloadJob.php
@@ -18,7 +18,12 @@ class FirmwareDownloadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- public function __construct(private Firmware $firmware) {}
+ private Firmware $firmware;
+
+ public function __construct(Firmware $firmware)
+ {
+ $this->firmware = $firmware;
+ }
public function handle(): void
{
@@ -28,25 +33,16 @@ class FirmwareDownloadJob implements ShouldQueue
try {
$filename = "FW{$this->firmware->version_tag}.bin";
- $response = Http::get($this->firmware->url);
+ Http::sink(storage_path("app/public/firmwares/$filename"))
+ ->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 c1a2267..7110b9c 100644
--- a/app/Jobs/FirmwarePollJob.php
+++ b/app/Jobs/FirmwarePollJob.php
@@ -17,7 +17,12 @@ class FirmwarePollJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- public function __construct(private bool $download = false) {}
+ private bool $download;
+
+ public function __construct(bool $download = false)
+ {
+ $this->download = $download;
+ }
public function handle(): void
{
diff --git a/app/Jobs/NotifyDeviceBatteryLowJob.php b/app/Jobs/NotifyDeviceBatteryLowJob.php
index 9b1001b..2508365 100644
--- a/app/Jobs/NotifyDeviceBatteryLowJob.php
+++ b/app/Jobs/NotifyDeviceBatteryLowJob.php
@@ -15,6 +15,8 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ public function __construct() {}
+
public function handle(): void
{
$devices = Device::all();
@@ -30,11 +32,9 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue
continue;
}
+
// Skip if battery is not low or notification was already sent
- if ($batteryPercent > $batteryThreshold) {
- continue;
- }
- if ($device->battery_notification_sent) {
+ if ($batteryPercent > $batteryThreshold || $device->battery_notification_sent) {
continue;
}
diff --git a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
index dbde888..01adf1b 100644
--- a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
+++ b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Liquid\FileSystems;
-use InvalidArgumentException;
use Keepsuit\Liquid\Contracts\LiquidFileSystem;
/**
@@ -53,10 +52,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 2387ac5..4437032 100644
--- a/app/Liquid/Filters/Data.php
+++ b/app/Liquid/Filters/Data.php
@@ -2,7 +2,6 @@
namespace App\Liquid\Filters;
-use App\Liquid\Utils\ExpressionUtils;
use Keepsuit\Liquid\Filters\FiltersProvider;
/**
@@ -64,73 +63,4 @@ 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
deleted file mode 100644
index 6bc81fc..0000000
--- a/app/Liquid/Filters/Date.php
+++ /dev/null
@@ -1,55 +0,0 @@
-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 0e31de1..53d1973 100644
--- a/app/Liquid/Filters/Numbers.php
+++ b/app/Liquid/Filters/Numbers.php
@@ -40,11 +40,15 @@ class Numbers extends FiltersProvider
$currency = 'GBP';
}
- $locale = $delimiter === '.' && $separator === ',' ? 'de' : 'en';
+ if ($delimiter === '.' && $separator === ',') {
+ $locale = 'de';
+ } else {
+ $locale = 'en';
+ }
// 2 decimal places for floats, 0 for integers
$decimal = is_float($value + 0) ? 2 : 0;
- return Number::currency($value, in: $currency, locale: $locale, precision: $decimal);
+ return Number::currency($value, in: $currency, precision: $decimal, locale: $locale);
}
}
diff --git a/app/Liquid/Filters/StandardFilters.php b/app/Liquid/Filters/StandardFilters.php
deleted file mode 100644
index 4db86a0..0000000
--- a/app/Liquid/Filters/StandardFilters.php
+++ /dev/null
@@ -1,20 +0,0 @@
-params->expression();
-
+
$this->templateName = match (true) {
- is_string($templateNameExpression) => mb_trim($templateNameExpression),
+ is_string($templateNameExpression) => 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");
}
@@ -75,7 +74,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 '';
@@ -97,4 +96,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
deleted file mode 100644
index 8a5bdb0..0000000
--- a/app/Liquid/Utils/ExpressionUtils.php
+++ /dev/null
@@ -1,210 +0,0 @@
- '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 183add4..c16322c 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(): void
+ public function mount()
{
$this->deviceAutojoin = auth()->user()->assign_new_devices;
$this->isFirstUser = auth()->user()->id === 1;
}
- public function updating($name, $value): void
+ public function updating($name, $value)
{
$this->validate([
'deviceAutojoin' => 'boolean',
@@ -30,7 +30,7 @@ class DeviceAutoJoin extends Component
}
}
- public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
+ public function render()
{
return view('livewire.actions.device-auto-join');
}
diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php
index c26fa72..45993bb 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(): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
+ public function __invoke()
{
Auth::guard('web')->logout();
diff --git a/app/Livewire/DeviceDashboard.php b/app/Livewire/DeviceDashboard.php
index a2a3692..78309cb 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(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
+ public function render()
{
return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]);
}
diff --git a/app/Models/Device.php b/app/Models/Device.php
index 3583f48..420975a 100644
--- a/app/Models/Device.php
+++ b/app/Models/Device.php
@@ -10,24 +10,12 @@ 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',
@@ -44,7 +32,7 @@ class Device extends Model
'pause_until' => 'datetime',
];
- public function getBatteryPercentAttribute(): int|float
+ public function getBatteryPercentAttribute()
{
$volts = $this->last_battery_voltage;
@@ -92,7 +80,7 @@ class Device extends Model
return round($voltage, 2);
}
- public function getWifiStrengthAttribute(): int
+ public function getWifiStrengthAttribute()
{
$rssi = $this->last_rssi_level;
if ($rssi >= 0) {
@@ -115,7 +103,11 @@ class Device extends Model
return true;
}
- return $this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware'];
+ if ($this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware']) {
+ return true;
+ }
+
+ return false;
}
public function getFirmwareUrlAttribute(): ?string
@@ -190,41 +182,10 @@ 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);
@@ -241,7 +202,7 @@ class Device extends Model
return false;
}
- $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
+ $now = $now ? Carbon::instance($now) : now();
// Handle overnight ranges (e.g. 22:00 to 06:00)
return $this->sleep_mode_from < $this->sleep_mode_to
@@ -255,7 +216,7 @@ class Device extends Model
return null;
}
- $now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
+ $now = $now ? Carbon::instance($now) : now();
$from = $this->sleep_mode_from;
$to = $this->sleep_mode_to;
@@ -263,20 +224,19 @@ 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 6132a76..c9de2af 100644
--- a/app/Models/DeviceModel.php
+++ b/app/Models/DeviceModel.php
@@ -6,11 +6,7 @@ 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;
@@ -28,51 +24,4 @@ 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
deleted file mode 100644
index 54b0876..0000000
--- a/app/Models/DevicePalette.php
+++ /dev/null
@@ -1,23 +0,0 @@
- 'integer',
- 'colors' => 'array',
- ];
-}
diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php
index b4daf5e..d20798c 100644
--- a/app/Models/Playlist.php
+++ b/app/Models/Playlist.php
@@ -37,33 +37,26 @@ class Playlist extends Model
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;
+ // Check weekday
+ if ($this->weekdays !== null) {
+ if (! in_array(now()->dayOfWeek, $this->weekdays)) {
+ return false;
+ }
}
if ($this->active_from !== null && $this->active_until !== null) {
- // 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);
+ $now = now();
// Handle time ranges that span across midnight
- if ($activeFrom > $activeUntil) {
+ if ($this->active_from > $this->active_until) {
// Time range spans midnight (e.g., 09:01 to 03:58)
- if ($now >= $activeFrom || $now <= $activeUntil) {
+ if ($now >= $this->active_from || $now <= $this->active_until) {
+ return true;
+ }
+ } else {
+ if ($now >= $this->active_from && $now <= $this->active_until) {
return true;
}
- } elseif ($now >= $activeFrom && $now <= $activeUntil) {
- return true;
}
return false;
diff --git a/app/Models/PlaylistItem.php b/app/Models/PlaylistItem.php
index ad11f1d..2459257 100644
--- a/app/Models/PlaylistItem.php
+++ b/app/Models/PlaylistItem.php
@@ -135,13 +135,10 @@ class PlaylistItem extends Model
/**
* Render all plugins with appropriate layout
*/
- public function render(?Device $device = null): string
+ public function render(): 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'),
@@ -153,7 +150,9 @@ class PlaylistItem extends Model
$plugins = Plugin::whereIn('id', $pluginIds)->get();
// Sort the collection to match plugin_ids order
- $plugins = $plugins->sortBy(fn ($plugin): int|string|false => array_search($plugin->id, $pluginIds))->values();
+ $plugins = $plugins->sortBy(function ($plugin) use ($pluginIds) {
+ return array_search($plugin->id, $pluginIds);
+ })->values();
foreach ($plugins as $index => $plugin) {
$size = $this->getLayoutSize($index);
@@ -161,9 +160,6 @@ 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 68f8e7e..e2b3260 100644
--- a/app/Models/Plugin.php
+++ b/app/Models/Plugin.php
@@ -4,28 +4,19 @@ 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;
@@ -42,34 +33,17 @@ 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): void {
+ static::creating(function ($model) {
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()
@@ -77,25 +51,6 @@ 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'])) {
@@ -125,7 +80,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 ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) {
+ if (($currentValue === null || $currentValue === '' || (is_array($currentValue) && empty($currentValue))) && ! isset($field['default'])) {
return true; // Found a required field that is not set and has no default
}
}
@@ -136,11 +91,6 @@ 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());
@@ -154,88 +104,108 @@ class Plugin extends Model
public function updateDataPayload(): void
{
- if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
- return;
- }
- $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
+ if ($this->data_strategy === 'polling' && $this->polling_url) {
- // 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]);
+ $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]);
+ }
}
}
- }
- // 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)
- ));
+ // Split URLs by newline and filter out empty lines
+ $urls = array_filter(
+ array_map('trim', explode("\n", $this->polling_url)),
+ fn ($url) => ! empty($url)
+ );
- $combinedResponse = [];
+ // If only one URL, use the original logic without nesting
+ if (count($urls) === 1) {
+ $url = reset($urls);
+ $httpRequest = Http::withHeaders($headers);
- // 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;
+ 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);
}
- } 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;
+ // Resolve Liquid variables in the polling URL
+ $resolvedUrl = $this->resolveLiquidVariables($url);
- $this->update([
- 'data_payload' => $finalPayload,
- 'data_payload_updated_at' => now(),
- ]);
- }
+ try {
+ // Make the request based on the verb
+ if ($this->polling_verb === 'post') {
+ $response = $httpRequest->post($resolvedUrl)->json();
+ } else {
+ $response = $httpRequest->get($resolvedUrl)->json();
+ }
- 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;
+ $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(),
+ ]);
}
- } catch (Exception $e) {
- Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
- }
- }
- return ['error' => 'Failed to parse response'];
+ 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(),
+ ]);
+ }
}
/**
@@ -243,15 +213,14 @@ class Plugin extends Model
*/
private function applyLiquidReplacements(string $template): string
{
-
- $replacements = [];
+ $replacements = [
+ 'date: "%N"' => 'date: "u"',
+ '%-m/%-d/%Y' => 'm/d/Y',
+ ];
// 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',
@@ -259,239 +228,90 @@ 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, ?Device $device = null): string
+ public function render(string $size = 'full', bool $standalone = true): 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') {
- // Get timezone from user or fall back to app timezone
- $timezone = $this->user->timezone ?? config('app.timezone');
+ // Create a custom environment with inline templates support
+ $inlineFileSystem = new InlineTemplatesFileSystem();
+ $environment = new \Keepsuit\Liquid\Environment(
+ fileSystem: $inlineFileSystem,
+ extensions: [new StandardExtension()]
+ );
- // Calculate UTC offset in seconds
- $utcOffset = (string) Carbon::now($timezone)->getOffset();
+ // 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);
- // 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 : []),
+ // 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 : []),
+ ],
],
],
- ],
- ];
-
- // 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);
- }
+ ]
+ );
+ $renderedContent = $template->render($context);
} else {
$renderedContent = Blade::render($this->render_markup, [
'size' => $size,
@@ -501,26 +321,9 @@ class Plugin extends Model
}
if ($standalone) {
- 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(),
+ return view('trmnl-layouts.single', [
'slot' => $renderedContent,
])->render();
-
}
return $renderedContent;
@@ -528,30 +331,12 @@ class Plugin extends Model
if ($this->render_markup_view) {
if ($standalone) {
- $renderedView = view($this->render_markup_view, [
- 'size' => $size,
- 'data' => $this->data_payload,
- 'config' => $this->configuration ?? [],
- ])->render();
-
- if ($size === 'full') {
- return view('trmnl-layouts.single', [
- '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,
+ return view('trmnl-layouts.single', [
+ 'slot' => view($this->render_markup_view, [
+ 'size' => $size,
+ 'data' => $this->data_payload,
+ 'config' => $this->configuration ?? [],
+ ])->render(),
])->render();
}
@@ -573,70 +358,4 @@ 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 c6d39b8..a1c83ab 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -27,7 +27,6 @@ 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 17fb1da..c76c87f 100644
--- a/app/Notifications/BatteryLow.php
+++ b/app/Notifications/BatteryLow.php
@@ -13,10 +13,15 @@ class BatteryLow extends Notification
{
use Queueable;
+ private Device $device;
+
/**
* Create a new notification instance.
*/
- public function __construct(private Device $device) {}
+ public function __construct(Device $device)
+ {
+ $this->device = $device;
+ }
/**
* Get the notification's delivery channels.
@@ -36,7 +41,7 @@ class BatteryLow extends Notification
return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]);
}
- public function toWebhook(object $notifiable): WebhookMessage
+ public function toWebhook(object $notifiable)
{
return WebhookMessage::create()
->data([
diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php
index 796cb24..d116200 100644
--- a/app/Notifications/Channels/WebhookChannel.php
+++ b/app/Notifications/Channels/WebhookChannel.php
@@ -11,7 +11,13 @@ use Illuminate\Support\Arr;
class WebhookChannel extends Notification
{
- public function __construct(protected Client $client) {}
+ /** @var Client */
+ protected $client;
+
+ public function __construct(Client $client)
+ {
+ $this->client = $client;
+ }
/**
* Send the given notification.
diff --git a/app/Notifications/Messages/WebhookMessage.php b/app/Notifications/Messages/WebhookMessage.php
index 6dc58eb..920c16d 100644
--- a/app/Notifications/Messages/WebhookMessage.php
+++ b/app/Notifications/Messages/WebhookMessage.php
@@ -13,6 +13,13 @@ 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.
*
@@ -29,8 +36,9 @@ final class WebhookMessage extends Notification
/**
* @param mixed $data
+ * @return static
*/
- public static function create($data = ''): self
+ public static function create($data = '')
{
return new self($data);
}
@@ -38,12 +46,10 @@ final class WebhookMessage extends Notification
/**
* @param mixed $data
*/
- public function __construct(
- /**
- * The POST data of the Webhook request.
- */
- private $data = ''
- ) {}
+ public function __construct($data = '')
+ {
+ $this->data = $data;
+ }
/**
* Set the Webhook parameters to be URL encoded.
@@ -51,7 +57,7 @@ final class WebhookMessage extends Notification
* @param mixed $query
* @return $this
*/
- public function query($query): self
+ public function query($query)
{
$this->query = $query;
@@ -64,7 +70,7 @@ final class WebhookMessage extends Notification
* @param mixed $data
* @return $this
*/
- public function data($data): self
+ public function data($data)
{
$this->data = $data;
@@ -78,7 +84,7 @@ final class WebhookMessage extends Notification
* @param string $value
* @return $this
*/
- public function header($name, $value): self
+ public function header($name, $value)
{
$this->headers[$name] = $value;
@@ -91,7 +97,7 @@ final class WebhookMessage extends Notification
* @param string $userAgent
* @return $this
*/
- public function userAgent($userAgent): self
+ public function userAgent($userAgent)
{
$this->headers['User-Agent'] = $userAgent;
@@ -103,14 +109,17 @@ final class WebhookMessage extends Notification
*
* @return $this
*/
- public function verify($value = true): self
+ public function verify($value = true)
{
$this->verify = $value;
return $this;
}
- public function toArray(): array
+ /**
+ * @return array
+ */
+ public function toArray()
{
return [
'query' => $this->query,
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index b8ad9bb..8433d76 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -33,19 +33,16 @@ class AppServiceProvider extends ServiceProvider
$http = clone $this;
$http->server->set('HTTPS', 'off');
- if (URL::hasValidSignature($https, $absolute, $ignoreQuery)) {
- return true;
- }
- return URL::hasValidSignature($http, $absolute, $ignoreQuery);
+ return URL::hasValidSignature($https, $absolute, $ignoreQuery)
+ || URL::hasValidSignature($http, $absolute, $ignoreQuery);
});
// Register OIDC provider with Socialite
- Socialite::extend('oidc', function (\Illuminate\Contracts\Foundation\Application $app): OidcProvider {
- $config = $app->make('config')->get('services.oidc', []);
-
+ Socialite::extend('oidc', function ($app) {
+ $config = $app['config']['services.oidc'] ?? [];
return new OidcProvider(
- $app->make(Request::class),
+ $app['request'],
$config['client_id'] ?? null,
$config['client_secret'] ?? null,
$config['redirect'] ?? null,
diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php
index 405ea3f..d0ecddc 100644
--- a/app/Services/ImageGenerationService.php
+++ b/app/Services/ImageGenerationService.php
@@ -6,33 +6,65 @@ 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 InvalidArgumentException;
+use Imagick;
+use ImagickException;
+use ImagickPixel;
+use Log;
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', '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 = 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->update(['current_screen_image' => $uuid]);
Log::info("Device $device->id: updated with new image: $uuid");
@@ -40,116 +72,6 @@ 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
*/
@@ -157,63 +79,36 @@ class ImageGenerationService
{
// If device has a DeviceModel, use its settings
if ($device->deviceModel) {
- return self::getImageSettingsFromModel($device->deviceModel);
- }
+ /** @var DeviceModel $model */
+ $model = $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' => $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),
+ 'width' => $model->width,
+ 'height' => $model->height,
+ 'colors' => $model->colors,
+ 'bit_depth' => $model->bit_depth,
+ 'scale_factor' => $model->scale_factor,
+ 'rotation' => $model->rotation,
+ 'mime_type' => $model->mime_type,
+ 'offset_x' => $model->offset_x,
+ 'offset_y' => $model->offset_y,
+ 'image_format' => self::determineImageFormatFromModel($model),
'use_model_settings' => true,
];
}
- // Default settings if no device model provided
+ // Fallback to device settings
return [
- 'width' => 800,
- 'height' => 480,
+ 'width' => $device->width ?? 800,
+ 'height' => $device->height ?? 480,
'colors' => 2,
'bit_depth' => 1,
'scale_factor' => 1.0,
- 'rotation' => 0,
+ 'rotation' => $device->rotate ?? 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
- 'image_format' => ImageFormat::AUTO->value,
+ 'image_format' => $device->image_format,
'use_model_settings' => false,
];
}
@@ -242,73 +137,197 @@ class ImageGenerationService
}
/**
- * Get MIME type from ImageFormat
+ * Convert image based on the provided settings
*/
- private static function getMimeTypeFromImageFormat(string $imageFormat): string
+ private static function convertImage(string $pngPath, string $bmpPath, array $settings): void
{
- 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',
- };
- }
+ $imageFormat = $settings['image_format'];
+ $useModelSettings = $settings['use_model_settings'] ?? false;
- /**
- * Get colors from ImageFormat
- */
- private static function getColorsFromImageFormat(string $imageFormat): int
- {
- 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,
- };
- }
-
- /**
- * Get bit depth from ImageFormat
- */
- private static function getBitDepthFromImageFormat(string $imageFormat): int
- {
- 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,
- };
- }
-
- /**
- * Detect whether the provided HTML markup contains an tag with class "image-dither".
- */
- private static function markupContainsDitherImage(string $markup): bool
- {
- if (mb_trim($markup) === '') {
- return false;
+ if ($useModelSettings) {
+ // Use DeviceModel-specific conversion
+ self::convertUsingModelSettings($pngPath, $bmpPath, $settings);
+ } else {
+ // Use legacy device-specific conversion
+ self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings);
}
+ }
- // Find (or with single quotes) and inspect class tokens
- $imgWithClassPattern = '/]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i';
- if (! preg_match_all($imgWithClassPattern, $markup, $matches)) {
- return false;
- }
+ /**
+ * Convert image using DeviceModel settings
+ */
+ private static function convertUsingModelSettings(string $pngPath, string $bmpPath, array $settings): void
+ {
+ try {
+ $imagick = new Imagick($pngPath);
- foreach ($matches[2] as $classValue) {
- // Look for class token 'image-dither' or 'image--dither'
- if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) {
- return true;
+ // 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);
+ }
+ }
+
+ /**
+ * Convert image to 4-color, 2-bit PNG using custom colormap and dithering
+ */
+ private static function convertTo4Color2BitPng(Imagick $imagick, int $width, int $height): void
+ {
+ // 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);
}
- return false;
+ $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();
+ }
+
+ /**
+ * Convert image using legacy device settings
+ */
+ private static function convertUsingLegacySettings(string $pngPath, string $bmpPath, string $imageFormat, array $settings): void
+ {
+ 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);
+ }
+
+ $imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
+ $imagick->setOption('dither', 'FloydSteinberg');
+
+ if ($quantize) {
+ $imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
+ }
+ $imagick->setImageDepth(8);
+ $imagick->stripImage();
+
+ $imagick->setFormat('png');
+ $imagick->writeImage($pngPath);
+ $imagick->clear();
}
public static function cleanupFolder(): void
@@ -334,20 +353,16 @@ class ImageGenerationService
public static function resetIfNotCacheable(?Plugin $plugin): void
{
if ($plugin?->id) {
- // Image webhook plugins have finalized images that shouldn't be reset
- if ($plugin->plugin_type === 'image_webhook') {
- return;
- }
// Check if any devices have custom dimensions or use non-standard DeviceModels
$hasCustomDimensions = Device::query()
- ->where(function ($query): void {
+ ->where(function ($query) {
$query->where('width', '!=', 800)
->orWhere('height', '!=', 480)
->orWhere('rotate', '!=', 0);
})
- ->orWhereHas('deviceModel', function ($query): void {
+ ->orWhereHas('deviceModel', function ($query) {
// Only allow caching if all device models have standard dimensions (800x480, rotation=0)
- $query->where(function ($subQuery): void {
+ $query->where(function ($subQuery) {
$subQuery->where('width', '!=', 800)
->orWhere('height', '!=', 480)
->orWhere('rotation', '!=', 0);
@@ -362,180 +377,4 @@ 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 8ea2e44..ad9799d 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(\Illuminate\Http\Request $request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = [])
+ public function __construct($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((string) $endpoint, '/.well-known/openid-configuration')) {
+ if (str_ends_with($endpoint, '/.well-known/openid-configuration')) {
$this->baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint);
} else {
- $this->baseUrl = mb_rtrim($endpoint, '/');
+ $this->baseUrl = 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 = app(Client::class);
+ $url = $this->baseUrl . '/.well-known/openid-configuration';
+ $client = new Client();
$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(), $e->getCode(), $e);
+
+ } catch (\Exception $e) {
+ throw new \Exception('Failed to load OIDC configuration: ' . $e->getMessage());
}
}
@@ -82,10 +82,9 @@ 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);
}
@@ -94,10 +93,9 @@ 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'];
}
@@ -106,13 +104,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,
],
]);
@@ -122,7 +120,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
/**
* Map the raw user array to a Socialite User instance.
*/
- public function mapUserToObject(array $user)
+ protected function mapUserToObject(array $user)
{
return (new User)->setRaw($user)->map([
'id' => $user['sub'],
@@ -155,4 +153,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
deleted file mode 100644
index c8f2b58..0000000
--- a/app/Services/Plugin/Parsers/IcalResponseParser.php
+++ /dev/null
@@ -1,111 +0,0 @@
-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
deleted file mode 100644
index 44ea0cb..0000000
--- a/app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index b8f9c05..0000000
--- a/app/Services/Plugin/Parsers/ResponseParser.php
+++ /dev/null
@@ -1,15 +0,0 @@
-
- */
- 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
deleted file mode 100644
index b82ba80..0000000
--- a/app/Services/Plugin/Parsers/XmlResponseParser.php
+++ /dev/null
@@ -1,46 +0,0 @@
-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 241764d..3c2c3d0 100644
--- a/app/Services/PluginExportService.php
+++ b/app/Services/PluginExportService.php
@@ -47,34 +47,44 @@ class PluginExportService
$tempDirName = 'temp/'.uniqid('plugin_export_', true);
Storage::makeDirectory($tempDirName);
$tempDir = Storage::path($tempDirName);
- // 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');
+ 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);
+ }
+ }
+
+ // 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;
+ }
}
/**
@@ -134,13 +144,13 @@ class PluginExportService
$markup = preg_replace('/^
\s*/', '', $markup);
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
- return mb_trim($markup);
+ return trim($markup);
}
/**
* Generate the shared template content (for liquid templates)
*/
- private function generateSharedTemplate(): null
+ private function generateSharedTemplate(Plugin $plugin)
{
// For now, we don't have a way to store shared templates separately
// TODO - add support for shared templates
@@ -160,10 +170,14 @@ class PluginExportService
foreach ($files as $file) {
if (! $file->isDir()) {
$filePath = $file->getRealPath();
- $fileName = basename((string) $filePath);
+ $fileName = basename($filePath);
// For root directory, just use the filename
- $relativePath = $zipPath === '' ? $fileName : $zipPath.'/'.mb_substr((string) $filePath, mb_strlen($dirPath) + 1);
+ if ($zipPath === '') {
+ $relativePath = $fileName;
+ } else {
+ $relativePath = $zipPath.'/'.mb_substr($filePath, mb_strlen($dirPath) + 1);
+ }
$zip->addFile($filePath, $relativePath);
}
diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php
index 49dce99..dbd8ec8 100644
--- a/app/Services/PluginImportService.php
+++ b/app/Services/PluginImportService.php
@@ -7,7 +7,6 @@ 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;
@@ -17,45 +16,16 @@ 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, ?string $zipEntryPath = null): Plugin
+ public function importFromZip(UploadedFile $zipFile, User $user): Plugin
{
// Create a temporary directory using Laravel's temporary directory helper
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
@@ -75,55 +45,33 @@ class PluginImportService
$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);
+ // Find the required files (settings.yml and full.liquid/full.blade.php)
+ $filePaths = $this->findRequiredFiles($tempDir);
// 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.');
+ if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
+ throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
}
// 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;
+ // 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
$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 = '
';
- } elseif ($filePaths['sharedBladePath']) {
- $templatePath = $filePaths['sharedBladePath'];
- $fullLiquid = File::get($templatePath);
- $markupLanguage = 'blade';
}
// Ensure custom_fields is properly formatted
@@ -131,9 +79,6 @@ 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'],
@@ -187,226 +132,11 @@ class PluginImportService
}
}
- /**
- * 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 = '