diff --git a/.cursor/mcp.json b/.cursor/mcp.json
deleted file mode 100644
index ea30195..0000000
--- a/.cursor/mcp.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "mcpServers": {
- "laravel-boost": {
- "command": "php",
- "args": [
- "./artisan",
- "boost:mcp"
- ]
- }
- }
-}
\ No newline at end of file
diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc
deleted file mode 100644
index 9464f06..0000000
--- a/.cursor/rules/laravel-boost.mdc
+++ /dev/null
@@ -1,534 +0,0 @@
----
-alwaysApply: true
----
-
-=== foundation rules ===
-
-# Laravel Boost Guidelines
-
-The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
-
-## Foundational Context
-This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-
-- php - 8.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- tailwindcss (TAILWINDCSS) - v4
-
-
-## Conventions
-- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
-- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
-- Check for existing components to reuse before writing a new one.
-
-## Verification Scripts
-- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
-
-## Application Structure & Architecture
-- Stick to existing directory structure - don't create new base folders without approval.
-- Do not change the application's dependencies without approval.
-
-## Frontend Bundling
-- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
-
-## Replies
-- Be concise in your explanations - focus on what's important rather than explaining obvious details.
-
-## Documentation Files
-- You must only create documentation files if explicitly requested by the user.
-
-
-=== boost rules ===
-
-## Laravel Boost
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
-
-## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
-
-## URLs
-- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
-
-## Tinker / Debugging
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
-
-## Reading Browser Logs With the `browser-logs` Tool
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
-
-## Searching Documentation (Critically Important)
-- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
-- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
-- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
-
-### Available Search Syntax
-- You can and should pass multiple queries at once. The most relevant results will be returned first.
-
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
-
-
-=== php rules ===
-
-## PHP
-
-- Always use curly braces for control structures, even if it has one line.
-
-### Constructors
-- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters.
-
-### Type Declarations
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## Comments
-- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
-
-## PHPDoc Blocks
-- Add useful array shape type definitions for arrays when appropriate.
-
-## Enums
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-
-=== laravel/core rules ===
-
-## Do Things the Laravel Way
-
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
-- If you're creating a generic PHP class, use `artisan make:class`.
-- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-
-### Database
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
-### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
-
-### APIs & Eloquent Resources
-- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-
-### Controllers & Validation
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-### Authentication & Authorization
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
-### URL Generation
-- When generating links to other pages, prefer named routes and the `route()` function.
-
-### Configuration
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
-### Testing
-- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
-- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
-- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-
-### Vite Error
-- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
-
-
-=== laravel/v12 rules ===
-
-## Laravel 12
-
-- Use the `search-docs` tool to get version specific documentation.
-- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-
-### Laravel 12 Structure
-- No middleware files in `app/Http/Middleware/`.
-- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
-- `bootstrap/providers.php` contains application specific service providers.
-- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
-- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
-
-### Database
-- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
-
-### Models
-- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-
-
-=== fluxui-free/core rules ===
-
-## Flux UI Free
-
-- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
-- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
-- You should use Flux UI components when available.
-- Fallback to standard Blade components if Flux is unavailable.
-- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
-- Flux UI components look like this:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== livewire/core rules ===
-
-## Livewire Core
-- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
-- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== livewire/v3 rules ===
-
-## Livewire 3
-
-### Key Changes From Livewire 2
-- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
-
-### New Directives
-- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
-
-### Alpine
-- Alpine is now included with Livewire, don't manually include Alpine.js.
-- Plugins included with Alpine: persist, intersect, collapse, and focus.
-
-### Lifecycle Hooks
-- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
-
-
-document.addEventListener('livewire:init', function () {
- Livewire.hook('request', ({ fail }) => {
- if (fail && fail.status === 419) {
- alert('Your session expired');
- }
- });
-
- Livewire.hook('message.failed', (message, component) => {
- console.error(message);
- });
-});
-
-
-
-=== volt/core rules ===
-
-## Livewire Volt
-
-- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
-- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
-- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
-- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
-)]))
-
-
-
-### Volt Class Based Component Example
-To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-declare(strict_types=1);
-
-use App\Models\{User, Product};
-use Livewire\Volt\Volt;
-
-test('product form creates product', function () {
- $user = User::factory()->create();
-
- Volt::test('pages.products.create')
- ->actingAs($user)
- ->set('form.name', 'Test Product')
- ->set('form.description', 'Test Description')
- ->set('form.price', 99.99)
- ->call('create')
- ->assertHasNoErrors();
-
- expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
-});
-
-
-
-### Common Patterns
-
-
-
- null, 'search' => '']);
-
-$products = computed(fn() => Product::when($this->search,
- fn($q) => $q->where('name', 'like', "%{$this->search}%")
-)->get());
-
-$edit = fn(Product $product) => $this->editing = $product->id;
-$delete = fn(Product $product) => $product->delete();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== pint/core rules ===
-
-## Laravel Pint Code Formatter
-
-- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
-
-
-=== pest/core rules ===
-
-## Pest
-
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-
-### Pest Tests
-- All tests must be written using Pest. Use `php artisan make:test --pest `.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `php artisan test`.
-- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== tailwindcss/core rules ===
-
-## Tailwind Core
-
-- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
-- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
-- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
-
-### Spacing
-- When listing items, use gap utilities for spacing, don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### Dark Mode
-- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
-
-=== tailwindcss/v4 rules ===
-
-## Tailwind 4
-
-- Always use Tailwind CSS v4 - do not use the deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
-
-
-
-
-### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
-- Opacity values are still numeric.
-
-| Deprecated | Replacement |
-|------------+--------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
-
-
-=== tests rules ===
-
-## Test Enforcement
-
-- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
-
\ No newline at end of file
diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile
index 908acef..ab13330 100644
--- a/.devcontainer/cli/Dockerfile
+++ b/.devcontainer/cli/Dockerfile
@@ -1,5 +1,5 @@
# From official php image.
-FROM php:8.3-cli-alpine
+FROM php:8.4-cli-alpine
# Create a user group and account under id 1000.
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
# Install quality-of-life packages.
@@ -9,21 +9,22 @@ RUN apk add --no-cache composer
# Add Chromium and Image Magick for puppeteer.
RUN apk add --no-cache \
imagemagick-dev \
- chromium
+ chromium \
+ libzip-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1
RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick
-RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
+RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-RUN docker-php-ext-install imagick
+RUN docker-php-ext-install imagick zip
# Composer uses its php binary, but we want it to use the container's one
-RUN rm -f /usr/bin/php83
-RUN ln -s /usr/local/bin/php /usr/bin/php83
+RUN rm -f /usr/bin/php84
+RUN ln -s /usr/local/bin/php /usr/bin/php84
# Install postgres pdo driver.
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
# Install redis driver.
diff --git a/.devcontainer/fpm/Dockerfile b/.devcontainer/fpm/Dockerfile
index b9770e3..3e658b6 100644
--- a/.devcontainer/fpm/Dockerfile
+++ b/.devcontainer/fpm/Dockerfile
@@ -1,5 +1,5 @@
# From official php image.
-FROM php:8.3-fpm-alpine
+FROM php:8.4-fpm-alpine
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
# Install postgres pdo driver.
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
@@ -14,17 +14,18 @@ RUN apk add --no-cache \
nodejs \
npm \
imagemagick-dev \
- chromium
+ chromium \
+ libzip-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1
RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick
-RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
+RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
# Install PHP extensions
-RUN docker-php-ext-install imagick
+RUN docker-php-ext-install imagick zip
-RUN rm -f /usr/bin/php83
-RUN ln -s /usr/local/bin/php /usr/bin/php83
+RUN rm -f /usr/bin/php84
+RUN ln -s /usr/local/bin/php /usr/bin/php84
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
deleted file mode 100644
index a331541..0000000
--- a/.github/copilot-instructions.md
+++ /dev/null
@@ -1,531 +0,0 @@
-
-=== foundation rules ===
-
-# Laravel Boost Guidelines
-
-The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
-
-## Foundational Context
-This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-
-- php - 8.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- tailwindcss (TAILWINDCSS) - v4
-
-
-## Conventions
-- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
-- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
-- Check for existing components to reuse before writing a new one.
-
-## Verification Scripts
-- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
-
-## Application Structure & Architecture
-- Stick to existing directory structure - don't create new base folders without approval.
-- Do not change the application's dependencies without approval.
-
-## Frontend Bundling
-- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
-
-## Replies
-- Be concise in your explanations - focus on what's important rather than explaining obvious details.
-
-## Documentation Files
-- You must only create documentation files if explicitly requested by the user.
-
-
-=== boost rules ===
-
-## Laravel Boost
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
-
-## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
-
-## URLs
-- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
-
-## Tinker / Debugging
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
-
-## Reading Browser Logs With the `browser-logs` Tool
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
-
-## Searching Documentation (Critically Important)
-- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
-- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
-- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
-
-### Available Search Syntax
-- You can and should pass multiple queries at once. The most relevant results will be returned first.
-
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
-
-
-=== php rules ===
-
-## PHP
-
-- Always use curly braces for control structures, even if it has one line.
-
-### Constructors
-- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters.
-
-### Type Declarations
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## Comments
-- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
-
-## PHPDoc Blocks
-- Add useful array shape type definitions for arrays when appropriate.
-
-## Enums
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-
-=== laravel/core rules ===
-
-## Do Things the Laravel Way
-
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
-- If you're creating a generic PHP class, use `artisan make:class`.
-- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-
-### Database
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
-### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
-
-### APIs & Eloquent Resources
-- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-
-### Controllers & Validation
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-### Authentication & Authorization
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
-### URL Generation
-- When generating links to other pages, prefer named routes and the `route()` function.
-
-### Configuration
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
-### Testing
-- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
-- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
-- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-
-### Vite Error
-- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
-
-
-=== laravel/v12 rules ===
-
-## Laravel 12
-
-- Use the `search-docs` tool to get version specific documentation.
-- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-
-### Laravel 12 Structure
-- No middleware files in `app/Http/Middleware/`.
-- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
-- `bootstrap/providers.php` contains application specific service providers.
-- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
-- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
-
-### Database
-- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
-
-### Models
-- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-
-
-=== fluxui-free/core rules ===
-
-## Flux UI Free
-
-- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
-- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
-- You should use Flux UI components when available.
-- Fallback to standard Blade components if Flux is unavailable.
-- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
-- Flux UI components look like this:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== livewire/core rules ===
-
-## Livewire Core
-- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
-- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== livewire/v3 rules ===
-
-## Livewire 3
-
-### Key Changes From Livewire 2
-- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
-
-### New Directives
-- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
-
-### Alpine
-- Alpine is now included with Livewire, don't manually include Alpine.js.
-- Plugins included with Alpine: persist, intersect, collapse, and focus.
-
-### Lifecycle Hooks
-- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
-
-
-document.addEventListener('livewire:init', function () {
- Livewire.hook('request', ({ fail }) => {
- if (fail && fail.status === 419) {
- alert('Your session expired');
- }
- });
-
- Livewire.hook('message.failed', (message, component) => {
- console.error(message);
- });
-});
-
-
-
-=== volt/core rules ===
-
-## Livewire Volt
-
-- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
-- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
-- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
-- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
-)]))
-
-
-
-### Volt Class Based Component Example
-To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-declare(strict_types=1);
-
-use App\Models\{User, Product};
-use Livewire\Volt\Volt;
-
-test('product form creates product', function () {
- $user = User::factory()->create();
-
- Volt::test('pages.products.create')
- ->actingAs($user)
- ->set('form.name', 'Test Product')
- ->set('form.description', 'Test Description')
- ->set('form.price', 99.99)
- ->call('create')
- ->assertHasNoErrors();
-
- expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
-});
-
-
-
-### Common Patterns
-
-
-
- null, 'search' => '']);
-
-$products = computed(fn() => Product::when($this->search,
- fn($q) => $q->where('name', 'like', "%{$this->search}%")
-)->get());
-
-$edit = fn(Product $product) => $this->editing = $product->id;
-$delete = fn(Product $product) => $product->delete();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== pint/core rules ===
-
-## Laravel Pint Code Formatter
-
-- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
-
-
-=== pest/core rules ===
-
-## Pest
-
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-
-### Pest Tests
-- All tests must be written using Pest. Use `php artisan make:test --pest `.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `php artisan test`.
-- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== tailwindcss/core rules ===
-
-## Tailwind Core
-
-- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
-- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
-- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
-
-### Spacing
-- When listing items, use gap utilities for spacing, don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### Dark Mode
-- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
-
-=== tailwindcss/v4 rules ===
-
-## Tailwind 4
-
-- Always use Tailwind CSS v4 - do not use the deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
-
-
-
-
-### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
-- Opacity values are still numeric.
-
-| Deprecated | Replacement |
-|------------+--------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
-
-
-=== tests rules ===
-
-## Test Enforcement
-
-- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
-
\ No newline at end of file
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 048db51..a4ff129 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -42,8 +42,7 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
- type=ref,event=tag
- latest
+ type=semver,pattern={{version}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fd03705..78e4fbb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -22,7 +22,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.3
+ php-version: 8.4
coverage: xdebug
- name: Setup Node
diff --git a/.gitignore b/.gitignore
index 3a2ae5a..0eb46d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,17 @@ yarn-error.log
/.zed
/database/seeders/PersonalDeviceSeeder.php
/.junie/mcp/mcp.json
+/.cursor/mcp.json
+/.cursor/rules/laravel-boost.mdc
+/.github/copilot-instructions.md
+/.junie/guidelines.md
+/CLAUDE.md
+/.mcp.json
+/.ai
+.DS_Store
+/boost.json
+/.gemini
+/GEMINI.md
+/.claude
+/AGENTS.md
+/opencode.json
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
deleted file mode 100644
index a331541..0000000
--- a/.junie/guidelines.md
+++ /dev/null
@@ -1,531 +0,0 @@
-
-=== foundation rules ===
-
-# Laravel Boost Guidelines
-
-The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
-
-## Foundational Context
-This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-
-- php - 8.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- tailwindcss (TAILWINDCSS) - v4
-
-
-## Conventions
-- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
-- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
-- Check for existing components to reuse before writing a new one.
-
-## Verification Scripts
-- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
-
-## Application Structure & Architecture
-- Stick to existing directory structure - don't create new base folders without approval.
-- Do not change the application's dependencies without approval.
-
-## Frontend Bundling
-- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
-
-## Replies
-- Be concise in your explanations - focus on what's important rather than explaining obvious details.
-
-## Documentation Files
-- You must only create documentation files if explicitly requested by the user.
-
-
-=== boost rules ===
-
-## Laravel Boost
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
-
-## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
-
-## URLs
-- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
-
-## Tinker / Debugging
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
-
-## Reading Browser Logs With the `browser-logs` Tool
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
-
-## Searching Documentation (Critically Important)
-- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
-- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
-- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
-
-### Available Search Syntax
-- You can and should pass multiple queries at once. The most relevant results will be returned first.
-
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
-
-
-=== php rules ===
-
-## PHP
-
-- Always use curly braces for control structures, even if it has one line.
-
-### Constructors
-- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters.
-
-### Type Declarations
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## Comments
-- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
-
-## PHPDoc Blocks
-- Add useful array shape type definitions for arrays when appropriate.
-
-## Enums
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-
-=== laravel/core rules ===
-
-## Do Things the Laravel Way
-
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
-- If you're creating a generic PHP class, use `artisan make:class`.
-- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-
-### Database
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
-### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
-
-### APIs & Eloquent Resources
-- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-
-### Controllers & Validation
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-### Authentication & Authorization
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
-### URL Generation
-- When generating links to other pages, prefer named routes and the `route()` function.
-
-### Configuration
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
-### Testing
-- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
-- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
-- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-
-### Vite Error
-- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
-
-
-=== laravel/v12 rules ===
-
-## Laravel 12
-
-- Use the `search-docs` tool to get version specific documentation.
-- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-
-### Laravel 12 Structure
-- No middleware files in `app/Http/Middleware/`.
-- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
-- `bootstrap/providers.php` contains application specific service providers.
-- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
-- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
-
-### Database
-- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
-
-### Models
-- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-
-
-=== fluxui-free/core rules ===
-
-## Flux UI Free
-
-- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
-- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
-- You should use Flux UI components when available.
-- Fallback to standard Blade components if Flux is unavailable.
-- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
-- Flux UI components look like this:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== livewire/core rules ===
-
-## Livewire Core
-- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
-- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== livewire/v3 rules ===
-
-## Livewire 3
-
-### Key Changes From Livewire 2
-- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
-
-### New Directives
-- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
-
-### Alpine
-- Alpine is now included with Livewire, don't manually include Alpine.js.
-- Plugins included with Alpine: persist, intersect, collapse, and focus.
-
-### Lifecycle Hooks
-- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
-
-
-document.addEventListener('livewire:init', function () {
- Livewire.hook('request', ({ fail }) => {
- if (fail && fail.status === 419) {
- alert('Your session expired');
- }
- });
-
- Livewire.hook('message.failed', (message, component) => {
- console.error(message);
- });
-});
-
-
-
-=== volt/core rules ===
-
-## Livewire Volt
-
-- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
-- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
-- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
-- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
-)]))
-
-
-
-### Volt Class Based Component Example
-To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-declare(strict_types=1);
-
-use App\Models\{User, Product};
-use Livewire\Volt\Volt;
-
-test('product form creates product', function () {
- $user = User::factory()->create();
-
- Volt::test('pages.products.create')
- ->actingAs($user)
- ->set('form.name', 'Test Product')
- ->set('form.description', 'Test Description')
- ->set('form.price', 99.99)
- ->call('create')
- ->assertHasNoErrors();
-
- expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
-});
-
-
-
-### Common Patterns
-
-
-
- null, 'search' => '']);
-
-$products = computed(fn() => Product::when($this->search,
- fn($q) => $q->where('name', 'like', "%{$this->search}%")
-)->get());
-
-$edit = fn(Product $product) => $this->editing = $product->id;
-$delete = fn(Product $product) => $product->delete();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== pint/core rules ===
-
-## Laravel Pint Code Formatter
-
-- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
-
-
-=== pest/core rules ===
-
-## Pest
-
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-
-### Pest Tests
-- All tests must be written using Pest. Use `php artisan make:test --pest `.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `php artisan test`.
-- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== tailwindcss/core rules ===
-
-## Tailwind Core
-
-- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
-- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
-- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
-
-### Spacing
-- When listing items, use gap utilities for spacing, don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### Dark Mode
-- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
-
-=== tailwindcss/v4 rules ===
-
-## Tailwind 4
-
-- Always use Tailwind CSS v4 - do not use the deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
-
-
-
-
-### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
-- Opacity values are still numeric.
-
-| Deprecated | Replacement |
-|------------+--------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
-
-
-=== tests rules ===
-
-## Test Enforcement
-
-- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
-
\ No newline at end of file
diff --git a/.mcp.json b/.mcp.json
deleted file mode 100644
index ea30195..0000000
--- a/.mcp.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "mcpServers": {
- "laravel-boost": {
- "command": "php",
- "args": [
- "./artisan",
- "boost:mcp"
- ]
- }
- }
-}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 737877e..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,531 +0,0 @@
-
-=== foundation rules ===
-
-# Laravel Boost Guidelines
-
-The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
-
-## Foundational Context
-This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-
-- php - 8.3.24
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- larastan/larastan (LARASTAN) - v3
-- laravel/pint (PINT) - v1
-- pestphp/pest (PEST) - v3
-- tailwindcss (TAILWINDCSS) - v4
-
-
-## Conventions
-- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
-- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
-- Check for existing components to reuse before writing a new one.
-
-## Verification Scripts
-- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
-
-## Application Structure & Architecture
-- Stick to existing directory structure - don't create new base folders without approval.
-- Do not change the application's dependencies without approval.
-
-## Frontend Bundling
-- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
-
-## Replies
-- Be concise in your explanations - focus on what's important rather than explaining obvious details.
-
-## Documentation Files
-- You must only create documentation files if explicitly requested by the user.
-
-
-=== boost rules ===
-
-## Laravel Boost
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
-
-## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
-
-## URLs
-- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
-
-## Tinker / Debugging
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
-
-## Reading Browser Logs With the `browser-logs` Tool
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
-
-## Searching Documentation (Critically Important)
-- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
-- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
-- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
-
-### Available Search Syntax
-- You can and should pass multiple queries at once. The most relevant results will be returned first.
-
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
-
-
-=== php rules ===
-
-## PHP
-
-- Always use curly braces for control structures, even if it has one line.
-
-### Constructors
-- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters.
-
-### Type Declarations
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
-
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
-
-## Comments
-- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
-
-## PHPDoc Blocks
-- Add useful array shape type definitions for arrays when appropriate.
-
-## Enums
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-
-=== laravel/core rules ===
-
-## Do Things the Laravel Way
-
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
-- If you're creating a generic PHP class, use `artisan make:class`.
-- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-
-### Database
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
-### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
-
-### APIs & Eloquent Resources
-- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-
-### Controllers & Validation
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-### Authentication & Authorization
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
-### URL Generation
-- When generating links to other pages, prefer named routes and the `route()` function.
-
-### Configuration
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
-### Testing
-- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
-- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
-- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-
-### Vite Error
-- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
-
-
-=== laravel/v12 rules ===
-
-## Laravel 12
-
-- Use the `search-docs` tool to get version specific documentation.
-- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-
-### Laravel 12 Structure
-- No middleware files in `app/Http/Middleware/`.
-- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
-- `bootstrap/providers.php` contains application specific service providers.
-- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
-- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
-
-### Database
-- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
-
-### Models
-- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-
-
-=== fluxui-free/core rules ===
-
-## Flux UI Free
-
-- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
-- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
-- You should use Flux UI components when available.
-- Fallback to standard Blade components if Flux is unavailable.
-- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
-- Flux UI components look like this:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
-
-
-=== livewire/core rules ===
-
-## Livewire Core
-- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
-- Use the `php artisan make:livewire [Posts\\CreatePost]` artisan command to create new components
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== livewire/v3 rules ===
-
-## Livewire 3
-
-### Key Changes From Livewire 2
-- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
-
-### New Directives
-- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
-
-### Alpine
-- Alpine is now included with Livewire, don't manually include Alpine.js.
-- Plugins included with Alpine: persist, intersect, collapse, and focus.
-
-### Lifecycle Hooks
-- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
-
-
-document.addEventListener('livewire:init', function () {
- Livewire.hook('request', ({ fail }) => {
- if (fail && fail.status === 419) {
- alert('Your session expired');
- }
- });
-
- Livewire.hook('message.failed', (message, component) => {
- console.error(message);
- });
-});
-
-
-
-=== volt/core rules ===
-
-## Livewire Volt
-
-- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
-- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
-- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
-- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
-)]))
-
-
-
-### Volt Class Based Component Example
-To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-declare(strict_types=1);
-
-use App\Models\{User, Product};
-use Livewire\Volt\Volt;
-
-test('product form creates product', function () {
- $user = User::factory()->create();
-
- Volt::test('pages.products.create')
- ->actingAs($user)
- ->set('form.name', 'Test Product')
- ->set('form.description', 'Test Description')
- ->set('form.price', 99.99)
- ->call('create')
- ->assertHasNoErrors();
-
- expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
-});
-
-
-
-### Common Patterns
-
-
-
- null, 'search' => '']);
-
-$products = computed(fn() => Product::when($this->search,
- fn($q) => $q->where('name', 'like', "%{$this->search}%")
-)->get());
-
-$edit = fn(Product $product) => $this->editing = $product->id;
-$delete = fn(Product $product) => $product->delete();
-
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== pint/core rules ===
-
-## Laravel Pint Code Formatter
-
-- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
-
-
-=== pest/core rules ===
-
-## Pest
-
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-
-### Pest Tests
-- All tests must be written using Pest. Use `php artisan make:test --pest `.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `php artisan test`.
-- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== tailwindcss/core rules ===
-
-## Tailwind Core
-
-- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
-- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
-- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
-
-### Spacing
-- When listing items, use gap utilities for spacing, don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### Dark Mode
-- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
-
-=== tailwindcss/v4 rules ===
-
-## Tailwind 4
-
-- Always use Tailwind CSS v4 - do not use the deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
-
-
-
-
-### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
-- Opacity values are still numeric.
-
-| Deprecated | Replacement |
-|------------+--------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
-
-
-=== tests rules ===
-
-## Test Enforcement
-
-- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
-
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 6a9931d..2d761ed 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
########################
# Base Image
########################
-FROM bnussbau/serversideup-php:8.3-fpm-nginx-alpine-imagick-chromium AS base
+FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
@@ -12,9 +12,14 @@ ENV APP_VERSION=${APP_VERSION}
ENV AUTORUN_ENABLED="true"
+# Mark trmnl-liquid-cli as installed
+ENV TRMNL_LIQUID_ENABLED=1
+
# Switch to the root user so we can do root things
USER root
+COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
+
# Set the working directory
WORKDIR /var/www/html
@@ -48,6 +53,5 @@ FROM base AS production
# Copy the assets from the assets image
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build
COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules
-
# Drop back to the www-data user
USER www-data
diff --git a/README.md b/README.md
index 95ed5c8..acb0b5c 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,7 @@
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
-It enables you to manage TRMNL devices, generate screens dynamically, and can act as a proxy for the native cloud service (native plugins, recipes).
-
-If you are looking for a Laravel package designed to streamline the development of both public and private TRMNL plugins, check out [bnussbau/trmnl-laravel](https://github.com/bnussbau/laravel-trmnl).
+It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.


@@ -16,21 +14,32 @@ If you are looking for a Laravel package designed to streamline the development
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
-* 🖥️ Screen Generation – Supports Plugins (even Mashups), Recipes, API, Markup, or updates via Code.
- * Supported Devices / Apps: TRMNL, ESP32 with TRMNL firmware, [trmnl-android](https://github.com/usetrmnl/trmnl-android), [trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27), …
+* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
+ * Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
+ * Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
+ * Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes)
+ * Supported Devices
+ * TRMNL OG (1-bit & 2-bit)
+ * SeeedStudio TRMNL 7,5" (OG) DIY Kit
+ * Seeed Studio (XIAO 7.5" ePaper Panel)
+ * reTerminal E1001 Monochrome ePaper Display
+ * Custom ESP32 with TRMNL firmware
+ * E-Reader Devices
+ * KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
+ * Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
+ * Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
+ * Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
+ * Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
+ * Raspberry Pi (HDMI output) [trmnl-display](https://github.com/usetrmnl/trmnl-display)
* 🔄 TRMNL API Proxy – Can act as a proxy for the native cloud service (requires TRMNL Developer Edition).
* This enables a hybrid setup – for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day.
* 🌙 Dark Mode – Switch between light and dark mode.
* 🐳 Deployment – Dockerized setup for easier hosting (Dockerfile, docker-compose).
+* 💾 Flexible Database configuration – uses SQLite by default, also compatible with MySQL or PostgreSQL
* 🛠️ Devcontainer support for easier development.

-### 🎯 Target Audience
-
-This project is for developers who are looking for a self-hosted server for devices running the TRMNL firmware.
-It serves as a starter kit, giving you the flexibility to build and extend it however you like.
-
### Support ❤️
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
@@ -40,6 +49,8 @@ or
[](https://www.buymeacoffee.com/bnussbau)
+[GitHub Sponsors](https://github.com/sponsors/bnussbau/)
+
### Hosting
Run everywhere, where Docker is supported: Raspberry Pi, VPS, NAS, Container Cloud Service (Cloud Run, ...).
@@ -65,9 +76,12 @@ docker compose up -d
If you’re using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
It’s a quick way to get started without having to manually manage Docker setup.
-### PikaPods
+#### PikaPods
You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
+#### Umbrel
+Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
+
#### Other Hosting Options
Laravel Forge, or bare metal PHP server with Nginx or Apache is also supported.
@@ -110,6 +124,7 @@ php artisan db:seed --class=ExampleRecipesSeeder
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
+| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC |
#### Login
@@ -203,6 +218,12 @@ You can dynamically update screens by sending a POST request.
}
```
+### Releated Work
+* [bnussbau/laravel-trmnl-blade](https://github.com/bnussbau/laravel-trmnl-blade) – Blade Components on top of the TRMNL Design System
+* [bnussbau/trmnl-pipeline-php](https://github.com/bnussbau/trmnl-pipeline-php) – Browser Rendering and Image Conversion Pipeline with support for TRMNL Models API
+* [bnussbau/trmnl-recipe-catalog](https://github.com/bnussbau/trmnl-recipe-catalog) – A community-driven catalog of public repositories containing trmnlp-compatible recipes.
+
+
### 🤝 Contribution
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
diff --git a/app/Console/Commands/FetchDeviceModelsCommand.php b/app/Console/Commands/FetchDeviceModelsCommand.php
new file mode 100644
index 0000000..78dd02a
--- /dev/null
+++ b/app/Console/Commands/FetchDeviceModelsCommand.php
@@ -0,0 +1,46 @@
+info('Dispatching FetchDeviceModelsJob...');
+
+ try {
+ FetchDeviceModelsJob::dispatchSync();
+
+ $this->info('FetchDeviceModelsJob has been dispatched successfully.');
+
+ return self::SUCCESS;
+ } catch (Exception $e) {
+ $this->error('Failed to dispatch FetchDeviceModelsJob: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/FirmwareCheckCommand.php b/app/Console/Commands/FirmwareCheckCommand.php
index f407314..91922ba 100644
--- a/app/Console/Commands/FirmwareCheckCommand.php
+++ b/app/Console/Commands/FirmwareCheckCommand.php
@@ -23,7 +23,7 @@ class FirmwareCheckCommand extends Command
);
$latestFirmware = Firmware::getLatest();
- if ($latestFirmware) {
+ if ($latestFirmware instanceof Firmware) {
table(
rows: [
['Latest Version', $latestFirmware->version_tag],
diff --git a/app/Console/Commands/FirmwareUpdateCommand.php b/app/Console/Commands/FirmwareUpdateCommand.php
index 97d9d58..bd43786 100644
--- a/app/Console/Commands/FirmwareUpdateCommand.php
+++ b/app/Console/Commands/FirmwareUpdateCommand.php
@@ -42,15 +42,14 @@ class FirmwareUpdateCommand extends Command
label: 'Which devices should be updated?',
options: [
'all' => 'ALL Devices',
- ...Device::all()->mapWithKeys(function ($device) {
+ ...Device::all()->mapWithKeys(fn ($device): array =>
// without _ returns index
- return ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"];
- })->toArray(),
+ ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"])->toArray(),
],
scroll: 10
);
- if (empty($devices)) {
+ if ($devices === []) {
$this->error('No devices selected. Aborting.');
return;
@@ -59,9 +58,7 @@ class FirmwareUpdateCommand extends Command
if (in_array('all', $devices)) {
$devices = Device::pluck('id')->toArray();
} else {
- $devices = array_map(function ($selected) {
- return (int) str_replace('_', '', $selected);
- }, $devices);
+ $devices = array_map(fn ($selected): int => (int) str_replace('_', '', $selected), $devices);
}
foreach ($devices as $deviceId) {
diff --git a/app/Console/Commands/GenerateDefaultImagesCommand.php b/app/Console/Commands/GenerateDefaultImagesCommand.php
new file mode 100644
index 0000000..e2887df
--- /dev/null
+++ b/app/Console/Commands/GenerateDefaultImagesCommand.php
@@ -0,0 +1,201 @@
+info('Starting generation of default images for all device models...');
+
+ $deviceModels = DeviceModel::all();
+
+ if ($deviceModels->isEmpty()) {
+ $this->warn('No device models found in the database.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Found {$deviceModels->count()} device models to process.");
+
+ // Create the target directory
+ $targetDir = 'images/default-screens';
+ if (! Storage::disk('public')->exists($targetDir)) {
+ Storage::disk('public')->makeDirectory($targetDir);
+ $this->info("Created directory: {$targetDir}");
+ }
+
+ $successCount = 0;
+ $skipCount = 0;
+ $errorCount = 0;
+
+ foreach ($deviceModels as $deviceModel) {
+ $this->info("Processing device model: {$deviceModel->label} (ID: {$deviceModel->id})");
+
+ try {
+ // Process setup-logo
+ $setupResult = $this->transformImage('setup-logo', $deviceModel, $targetDir);
+ if ($setupResult) {
+ ++$successCount;
+ } else {
+ ++$skipCount;
+ }
+
+ // Process sleep
+ $sleepResult = $this->transformImage('sleep', $deviceModel, $targetDir);
+ if ($sleepResult) {
+ ++$successCount;
+ } else {
+ ++$skipCount;
+ }
+
+ } catch (Exception $e) {
+ $this->error("Error processing device model {$deviceModel->label}: ".$e->getMessage());
+ ++$errorCount;
+ }
+ }
+
+ $this->info("\nGeneration completed!");
+ $this->info("Successfully processed: {$successCount} images");
+ $this->info("Skipped (already exist): {$skipCount} images");
+ $this->info("Errors: {$errorCount} images");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Transform a single image for a device model using Blade templates
+ */
+ private function transformImage(string $imageType, DeviceModel $deviceModel, string $targetDir): bool
+ {
+ // Generate filename: {width}_{height}_{bit_depth}_{rotation}.{extension}
+ $extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
+ $filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}";
+ $targetPath = "{$targetDir}/{$imageType}_{$filename}";
+
+ // Check if target already exists and force is not set
+ if (Storage::disk('public')->exists($targetPath) && ! $this->option('force')) {
+ $this->line(" Skipping {$imageType} - already exists: {$filename}");
+
+ return false;
+ }
+
+ try {
+ // Create custom Browsershot instance if using AWS Lambda
+ $browsershotInstance = null;
+ if (config('app.puppeteer_mode') === 'sidecar-aws') {
+ $browsershotInstance = new BrowsershotLambda();
+ }
+
+ // Generate HTML from Blade template
+ $html = $this->generateHtmlFromTemplate($imageType, $deviceModel);
+ // dump($html);
+
+ $browserStage = new BrowserStage($browsershotInstance);
+ $browserStage->html($html);
+
+ // Set timezone from app config (no user context in this command)
+ $browserStage->timezone(config('app.timezone'));
+
+ $browserStage
+ ->width($deviceModel->width)
+ ->height($deviceModel->height);
+
+ $browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
+
+ if (config('app.puppeteer_docker')) {
+ $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
+ }
+
+ $outputPath = Storage::disk('public')->path($targetPath);
+
+ $imageStage = new ImageStage();
+ $imageStage->format($extension)
+ ->width($deviceModel->width)
+ ->height($deviceModel->height)
+ ->colors($deviceModel->colors)
+ ->bitDepth($deviceModel->bit_depth)
+ ->rotation($deviceModel->rotation)
+ // ->offsetX($deviceModel->offset_x)
+ // ->offsetY($deviceModel->offset_y)
+ ->outputPath($outputPath);
+
+ (new TrmnlPipeline())->pipe($browserStage)
+ ->pipe($imageStage)
+ ->process();
+
+ if (! file_exists($outputPath)) {
+ throw new RuntimeException('Image file was not created: '.$outputPath);
+ }
+
+ if (filesize($outputPath) === 0) {
+ throw new RuntimeException('Image file is empty: '.$outputPath);
+ }
+
+ $this->line(" ✓ Generated {$imageType}: {$filename}");
+
+ return true;
+
+ } catch (Exception $e) {
+ $this->error(" ✗ Failed to generate {$imageType} for {$deviceModel->label}: ".$e->getMessage());
+
+ return false;
+ }
+ }
+
+ /**
+ * Generate HTML from Blade template for the given image type and device model
+ */
+ private function generateHtmlFromTemplate(string $imageType, DeviceModel $deviceModel): string
+ {
+ // Map image type to template name
+ $templateName = match ($imageType) {
+ 'setup-logo' => 'default-screens.setup',
+ 'sleep' => 'default-screens.sleep',
+ default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
+ };
+
+ // Determine device properties from DeviceModel
+ $deviceVariant = $deviceModel->name ?? 'og';
+ $colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
+ $scaleLevel = $deviceModel->scale_level; // Use the accessor method
+ $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
+
+ // Render the Blade template
+ return view($templateName, [
+ 'noBleed' => false,
+ 'darkMode' => $darkMode,
+ 'deviceVariant' => $deviceVariant,
+ 'colorDepth' => $colorDepth,
+ 'scaleLevel' => $scaleLevel,
+ ])->render();
+ }
+}
diff --git a/app/Console/Commands/MashupCreateCommand.php b/app/Console/Commands/MashupCreateCommand.php
index d6f1378..7201274 100644
--- a/app/Console/Commands/MashupCreateCommand.php
+++ b/app/Console/Commands/MashupCreateCommand.php
@@ -9,9 +9,6 @@ use App\Models\Plugin;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
-use function Laravel\Prompts\select;
-use function Laravel\Prompts\text;
-
class MashupCreateCommand extends Command
{
/**
@@ -31,17 +28,17 @@ class MashupCreateCommand extends Command
/**
* Execute the console command.
*/
- public function handle()
+ public function handle(): int
{
// Select device
$device = $this->selectDevice();
- if (! $device) {
+ if (! $device instanceof Device) {
return 1;
}
// Select playlist
$playlist = $this->selectPlaylist($device);
- if (! $playlist) {
+ if (! $playlist instanceof Playlist) {
return 1;
}
@@ -88,9 +85,9 @@ class MashupCreateCommand extends Command
return null;
}
- $deviceId = select(
- label: 'Select a device',
- options: $devices->mapWithKeys(fn ($device) => [$device->id => $device->name])->toArray()
+ $deviceId = $this->choice(
+ 'Select a device',
+ $devices->mapWithKeys(fn ($device): array => [$device->id => $device->name])->toArray()
);
return $devices->firstWhere('id', $deviceId);
@@ -106,9 +103,9 @@ class MashupCreateCommand extends Command
return null;
}
- $playlistId = select(
- label: 'Select a playlist',
- options: $playlists->mapWithKeys(fn (Playlist $playlist) => [$playlist->id => $playlist->name])->toArray()
+ $playlistId = $this->choice(
+ 'Select a playlist',
+ $playlists->mapWithKeys(fn (Playlist $playlist): array => [$playlist->id => $playlist->name])->toArray()
);
return $playlists->firstWhere('id', $playlistId);
@@ -116,24 +113,29 @@ class MashupCreateCommand extends Command
protected function selectLayout(): ?string
{
- return select(
- label: 'Select a layout',
- options: PlaylistItem::getAvailableLayouts()
+ return $this->choice(
+ 'Select a layout',
+ PlaylistItem::getAvailableLayouts()
);
}
protected function getMashupName(): ?string
{
- return text(
- label: 'Enter a name for this mashup',
- required: true,
- default: 'Mashup',
- validate: fn (string $value) => match (true) {
- mb_strlen($value) < 1 => 'The name must be at least 2 characters.',
- mb_strlen($value) > 50 => 'The name must not exceed 50 characters.',
- default => null,
- }
- );
+ $name = $this->ask('Enter a name for this mashup', 'Mashup');
+
+ if (mb_strlen((string) $name) < 2) {
+ $this->error('The name must be at least 2 characters.');
+
+ return null;
+ }
+
+ if (mb_strlen((string) $name) > 50) {
+ $this->error('The name must not exceed 50 characters.');
+
+ return null;
+ }
+
+ return $name;
}
protected function selectPlugins(string $layout): Collection
@@ -148,7 +150,7 @@ class MashupCreateCommand extends Command
}
$selectedPlugins = collect();
- $availablePlugins = $plugins->mapWithKeys(fn ($plugin) => [$plugin->id => $plugin->name])->toArray();
+ $availablePlugins = $plugins->mapWithKeys(fn ($plugin): array => [$plugin->id => $plugin->name])->toArray();
for ($i = 0; $i < $requiredCount; ++$i) {
$position = match ($i) {
@@ -159,9 +161,9 @@ class MashupCreateCommand extends Command
default => ($i + 1).'th'
};
- $pluginId = select(
- label: "Select the $position plugin",
- options: $availablePlugins
+ $pluginId = $this->choice(
+ "Select the $position plugin",
+ $availablePlugins
);
$selectedPlugins->push($plugins->firstWhere('id', $pluginId));
diff --git a/app/Console/Commands/OidcTestCommand.php b/app/Console/Commands/OidcTestCommand.php
index 2ecfef2..81dff0b 100644
--- a/app/Console/Commands/OidcTestCommand.php
+++ b/app/Console/Commands/OidcTestCommand.php
@@ -2,7 +2,9 @@
namespace App\Console\Commands;
+use Exception;
use Illuminate\Console\Command;
+use InvalidArgumentException;
use Laravel\Socialite\Facades\Socialite;
class OidcTestCommand extends Command
@@ -24,27 +26,32 @@ class OidcTestCommand extends Command
/**
* Execute the console command.
*/
- public function handle()
+ public function handle(): int
{
$this->info('Testing OIDC Configuration...');
$this->newLine();
// Check if OIDC is enabled
$enabled = config('services.oidc.enabled');
- $this->line("OIDC Enabled: " . ($enabled ? '✅ Yes' : '❌ No'));
+ $this->line('OIDC Enabled: '.($enabled ? '✅ Yes' : '❌ No'));
// Check configuration values
$endpoint = config('services.oidc.endpoint');
$clientId = config('services.oidc.client_id');
$clientSecret = config('services.oidc.client_secret');
$redirect = config('services.oidc.redirect');
+ if (! $redirect) {
+ $redirect = config('app.url', 'http://localhost').'/auth/oidc/callback';
+ }
$scopes = config('services.oidc.scopes', []);
+ $defaultScopes = ['openid', 'profile', 'email'];
+ $effectiveScopes = empty($scopes) ? $defaultScopes : $scopes;
- $this->line("OIDC Endpoint: " . ($endpoint ? "✅ {$endpoint}" : '❌ Not set'));
- $this->line("Client ID: " . ($clientId ? "✅ {$clientId}" : '❌ Not set'));
- $this->line("Client Secret: " . ($clientSecret ? '✅ Set' : '❌ Not set'));
- $this->line("Redirect URL: " . ($redirect ? "✅ {$redirect}" : '❌ Not set'));
- $this->line("Scopes: " . (empty($scopes) ? '❌ Not set' : '✅ ' . implode(', ', $scopes)));
+ $this->line('OIDC Endpoint: '.($endpoint ? "✅ {$endpoint}" : '❌ Not set'));
+ $this->line('Client ID: '.($clientId ? "✅ {$clientId}" : '❌ Not set'));
+ $this->line('Client Secret: '.($clientSecret ? '✅ Set' : '❌ Not set'));
+ $this->line('Redirect URL: '.($redirect ? "✅ {$redirect}" : '❌ Not set'));
+ $this->line('Scopes: ✅ '.implode(', ', $effectiveScopes));
$this->newLine();
@@ -53,38 +60,45 @@ class OidcTestCommand extends Command
// Only test driver if we have basic configuration
if ($endpoint && $clientId && $clientSecret) {
$driver = Socialite::driver('oidc');
- $this->line("OIDC Driver: ✅ Successfully registered and accessible");
-
+ $this->line('OIDC Driver: ✅ Successfully registered and accessible');
+
if ($enabled) {
- $this->info("✅ OIDC is fully configured and ready to use!");
- $this->line("You can test the login flow at: /auth/oidc/redirect");
+ $this->info('✅ OIDC is fully configured and ready to use!');
+ $this->line('You can test the login flow at: /auth/oidc/redirect');
} else {
- $this->warn("⚠️ OIDC driver is working but OIDC_ENABLED is false.");
+ $this->warn('⚠️ OIDC driver is working but OIDC_ENABLED is false.');
}
} else {
- $this->line("OIDC Driver: ✅ Registered (configuration test skipped due to missing values)");
- $this->warn("⚠️ OIDC driver is registered but missing required configuration.");
- $this->line("Please set the following environment variables:");
- if (!$enabled) $this->line(" - OIDC_ENABLED=true");
- if (!$endpoint) {
- $this->line(" - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)");
- $this->line(" OR");
- $this->line(" - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)");
+ $this->line('OIDC Driver: ✅ Registered (configuration test skipped due to missing values)');
+ $this->warn('⚠️ OIDC driver is registered but missing required configuration.');
+ $this->line('Please set the following environment variables:');
+ if (! $enabled) {
+ $this->line(' - OIDC_ENABLED=true');
+ }
+ if (! $endpoint) {
+ $this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)');
+ $this->line(' OR');
+ $this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)');
+ }
+ if (! $clientId) {
+ $this->line(' - OIDC_CLIENT_ID=your-client-id');
+ }
+ if (! $clientSecret) {
+ $this->line(' - OIDC_CLIENT_SECRET=your-client-secret');
}
- if (!$clientId) $this->line(" - OIDC_CLIENT_ID=your-client-id");
- if (!$clientSecret) $this->line(" - OIDC_CLIENT_SECRET=your-client-secret");
}
- } catch (\InvalidArgumentException $e) {
+ } catch (InvalidArgumentException $e) {
if (str_contains($e->getMessage(), 'Driver [oidc] not supported')) {
- $this->error("❌ OIDC Driver registration failed: Driver not supported");
+ $this->error('❌ OIDC Driver registration failed: Driver not supported');
} else {
- $this->error("❌ OIDC Driver error: " . $e->getMessage());
+ $this->error('❌ OIDC Driver error: '.$e->getMessage());
}
- } catch (\Exception $e) {
- $this->warn("⚠️ OIDC Driver registered but configuration error: " . $e->getMessage());
+ } catch (Exception $e) {
+ $this->warn('⚠️ OIDC Driver registered but configuration error: '.$e->getMessage());
}
$this->newLine();
+
return Command::SUCCESS;
}
}
diff --git a/app/Console/Commands/ScreenGeneratorCommand.php b/app/Console/Commands/ScreenGeneratorCommand.php
index ac74fba..c0a2cc3 100644
--- a/app/Console/Commands/ScreenGeneratorCommand.php
+++ b/app/Console/Commands/ScreenGeneratorCommand.php
@@ -25,7 +25,7 @@ class ScreenGeneratorCommand extends Command
/**
* Execute the console command.
*/
- public function handle()
+ public function handle(): int
{
$deviceId = $this->argument('deviceId');
$view = $this->argument('view');
diff --git a/app/Enums/ImageFormat.php b/app/Enums/ImageFormat.php
index 75a7307..67e9b79 100644
--- a/app/Enums/ImageFormat.php
+++ b/app/Enums/ImageFormat.php
@@ -8,6 +8,7 @@ enum ImageFormat: string
case PNG_8BIT_GRAYSCALE = 'png_8bit_grayscale';
case BMP3_1BIT_SRGB = 'bmp3_1bit_srgb';
case PNG_8BIT_256C = 'png_8bit_256c';
+ case PNG_2BIT_4C = 'png_2bit_4c';
public function label(): string
{
@@ -16,6 +17,7 @@ enum ImageFormat: string
self::PNG_8BIT_GRAYSCALE => 'PNG 8-bit Grayscale Gray 2c',
self::BMP3_1BIT_SRGB => 'BMP3 1-bit sRGB 2c',
self::PNG_8BIT_256C => 'PNG 8-bit Grayscale Gray 256c',
+ self::PNG_2BIT_4C => 'PNG 2-bit Grayscale 4c',
};
}
}
diff --git a/app/Http/Controllers/Auth/OidcController.php b/app/Http/Controllers/Auth/OidcController.php
index 305dd49..f7847d9 100644
--- a/app/Http/Controllers/Auth/OidcController.php
+++ b/app/Http/Controllers/Auth/OidcController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
+use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
@@ -17,23 +18,25 @@ class OidcController extends Controller
*/
public function redirect()
{
- if (!config('services.oidc.enabled')) {
+ if (! config('services.oidc.enabled')) {
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
}
// Check if all required OIDC configuration is present
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
foreach ($requiredConfig as $key) {
- if (!config("services.oidc.{$key}")) {
+ if (! config("services.oidc.{$key}")) {
Log::error("OIDC configuration missing: {$key}");
+
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
}
}
try {
return Socialite::driver('oidc')->redirect();
- } catch (\Exception $e) {
- Log::error('OIDC redirect error: ' . $e->getMessage());
+ } catch (Exception $e) {
+ Log::error('OIDC redirect error: '.$e->getMessage());
+
return redirect()->route('login')->withErrors(['oidc' => 'Failed to initiate OIDC authentication.']);
}
}
@@ -43,32 +46,34 @@ class OidcController extends Controller
*/
public function callback(Request $request)
{
- if (!config('services.oidc.enabled')) {
+ if (! config('services.oidc.enabled')) {
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
}
// Check if all required OIDC configuration is present
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
foreach ($requiredConfig as $key) {
- if (!config("services.oidc.{$key}")) {
+ if (! config("services.oidc.{$key}")) {
Log::error("OIDC configuration missing: {$key}");
+
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
}
}
try {
$oidcUser = Socialite::driver('oidc')->user();
-
+
// Find or create the user
$user = $this->findOrCreateUser($oidcUser);
-
+
// Log the user in
Auth::login($user, true);
-
+
return redirect()->intended(route('dashboard', absolute: false));
-
- } catch (\Exception $e) {
- Log::error('OIDC callback error: ' . $e->getMessage());
+
+ } catch (Exception $e) {
+ Log::error('OIDC callback error: '.$e->getMessage());
+
return redirect()->route('login')->withErrors(['oidc' => 'Failed to authenticate with OIDC provider.']);
}
}
@@ -80,26 +85,28 @@ class OidcController extends Controller
{
// First, try to find user by OIDC subject ID
$user = User::where('oidc_sub', $oidcUser->getId())->first();
-
+
if ($user) {
// Update user information from OIDC
$user->update([
'name' => $oidcUser->getName() ?: $user->name,
'email' => $oidcUser->getEmail() ?: $user->email,
]);
+
return $user;
}
// If not found by OIDC sub, try to find by email
if ($oidcUser->getEmail()) {
$user = User::where('email', $oidcUser->getEmail())->first();
-
+
if ($user) {
// Link the existing user with OIDC
$user->update([
'oidc_sub' => $oidcUser->getId(),
'name' => $oidcUser->getName() ?: $user->name,
]);
+
return $user;
}
}
@@ -108,9 +115,9 @@ class OidcController extends Controller
return User::create([
'oidc_sub' => $oidcUser->getId(),
'name' => $oidcUser->getName() ?: 'OIDC User',
- 'email' => $oidcUser->getEmail() ?: $oidcUser->getId() . '@oidc.local',
+ 'email' => $oidcUser->getEmail() ?: $oidcUser->getId().'@oidc.local',
'password' => bcrypt(Str::random(32)), // Random password since we're using OIDC
'email_verified_at' => now(), // OIDC users are considered verified
]);
}
-}
\ No newline at end of file
+}
diff --git a/app/Jobs/CleanupDeviceLogsJob.php b/app/Jobs/CleanupDeviceLogsJob.php
index b49f507..d2f1dd9 100644
--- a/app/Jobs/CleanupDeviceLogsJob.php
+++ b/app/Jobs/CleanupDeviceLogsJob.php
@@ -18,7 +18,7 @@ class CleanupDeviceLogsJob implements ShouldQueue
*/
public function handle(): void
{
- Device::each(function ($device) {
+ Device::each(function ($device): void {
$keepIds = $device->logs()->latest('device_timestamp')->take(50)->pluck('id');
// Delete all other logs for this device
diff --git a/app/Jobs/FetchDeviceModelsJob.php b/app/Jobs/FetchDeviceModelsJob.php
new file mode 100644
index 0000000..475c5c7
--- /dev/null
+++ b/app/Jobs/FetchDeviceModelsJob.php
@@ -0,0 +1,247 @@
+processPalettes();
+
+ $response = Http::timeout(30)->get(self::API_URL);
+
+ if (! $response->successful()) {
+ Log::error('Failed to fetch device models from API', [
+ 'status' => $response->status(),
+ 'body' => $response->body(),
+ ]);
+
+ return;
+ }
+
+ $data = $response->json('data', []);
+
+ if (! is_array($data)) {
+ Log::error('Invalid response format from device models API', [
+ 'response' => $response->json(),
+ ]);
+
+ return;
+ }
+
+ $this->processDeviceModels($data);
+
+ Log::info('Successfully fetched and updated device models', [
+ 'count' => count($data),
+ ]);
+
+ } catch (Exception $e) {
+ Log::error('Exception occurred while fetching device models', [
+ 'message' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private function processDeviceModels(array $deviceModels): void
+ {
+ foreach ($deviceModels as $modelData) {
+ try {
+ $this->updateOrCreateDeviceModel($modelData);
+ } catch (Exception $e) {
+ Log::error('Failed to process device model', [
+ 'model_data' => $modelData,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Update or create a device model record.
+ */
+ private function updateOrCreateDeviceModel(array $modelData): void
+ {
+ $name = $modelData['name'] ?? null;
+
+ if (! $name) {
+ Log::warning('Device model data missing name field', [
+ 'model_data' => $modelData,
+ ]);
+
+ return;
+ }
+
+ $attributes = [
+ 'label' => $modelData['label'] ?? '',
+ 'description' => $modelData['description'] ?? '',
+ 'width' => $modelData['width'] ?? 0,
+ 'height' => $modelData['height'] ?? 0,
+ 'colors' => $modelData['colors'] ?? 0,
+ 'bit_depth' => $modelData['bit_depth'] ?? 0,
+ 'scale_factor' => $modelData['scale_factor'] ?? 1,
+ 'rotation' => $modelData['rotation'] ?? 0,
+ 'mime_type' => $modelData['mime_type'] ?? '',
+ '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 ece2808..ac23130 100644
--- a/app/Jobs/FetchProxyCloudResponses.php
+++ b/app/Jobs/FetchProxyCloudResponses.php
@@ -23,7 +23,7 @@ class FetchProxyCloudResponses implements ShouldQueue
*/
public function handle(): void
{
- Device::where('proxy_cloud', true)->each(function ($device) {
+ Device::where('proxy_cloud', true)->each(function ($device): void {
if (! $device->getNextPlaylistItem()) {
try {
$response = Http::withHeaders([
@@ -78,22 +78,30 @@ class FetchProxyCloudResponses implements ShouldQueue
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
if ($device->last_log_request) {
- Http::withHeaders([
- 'id' => $device->mac_address,
- 'access-token' => $device->api_key,
- 'width' => 800,
- 'height' => 480,
- 'rssi' => $device->last_rssi_level,
- 'battery_voltage' => $device->last_battery_voltage,
- 'refresh-rate' => $device->default_refresh_interval,
- 'fw-version' => $device->last_firmware_version,
- 'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
- 'user-agent' => 'ESP32HTTPClient',
- ])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
+ try {
+ Http::withHeaders([
+ 'id' => $device->mac_address,
+ 'access-token' => $device->api_key,
+ 'width' => 800,
+ 'height' => 480,
+ 'rssi' => $device->last_rssi_level,
+ 'battery_voltage' => $device->last_battery_voltage,
+ 'refresh-rate' => $device->default_refresh_interval,
+ 'fw-version' => $device->last_firmware_version,
+ 'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
+ 'user-agent' => 'ESP32HTTPClient',
+ ])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
- $device->update([
- 'last_log_request' => null,
- ]);
+ // Only clear the pending log request if the POST succeeded
+ $device->update([
+ 'last_log_request' => null,
+ ]);
+ } catch (Exception $e) {
+ // Do not fail the entire proxy fetch if the log upload fails
+ Log::error("Failed to upload device log for device: {$device->mac_address}", [
+ 'error' => $e->getMessage(),
+ ]);
+ }
}
} catch (Exception $e) {
diff --git a/app/Jobs/FirmwareDownloadJob.php b/app/Jobs/FirmwareDownloadJob.php
index 6b4fc36..dfc851d 100644
--- a/app/Jobs/FirmwareDownloadJob.php
+++ b/app/Jobs/FirmwareDownloadJob.php
@@ -18,12 +18,7 @@ class FirmwareDownloadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- private Firmware $firmware;
-
- public function __construct(Firmware $firmware)
- {
- $this->firmware = $firmware;
- }
+ public function __construct(private Firmware $firmware) {}
public function handle(): void
{
@@ -33,16 +28,25 @@ class FirmwareDownloadJob implements ShouldQueue
try {
$filename = "FW{$this->firmware->version_tag}.bin";
- Http::sink(storage_path("app/public/firmwares/$filename"))
- ->get($this->firmware->url);
+ $response = Http::get($this->firmware->url);
+ if (! $response->successful()) {
+ throw new Exception('HTTP request failed with status: '.$response->status());
+ }
+
+ // Save the response content to file
+ Storage::disk('public')->put("firmwares/$filename", $response->body());
+
+ // Only update storage location if download was successful
$this->firmware->update([
'storage_location' => "firmwares/$filename",
]);
} catch (ConnectionException $e) {
Log::error('Firmware download failed: '.$e->getMessage());
+ // Don't update storage_location on failure
} catch (Exception $e) {
Log::error('An unexpected error occurred: '.$e->getMessage());
+ // Don't update storage_location on failure
}
}
}
diff --git a/app/Jobs/FirmwarePollJob.php b/app/Jobs/FirmwarePollJob.php
index 7110b9c..c1a2267 100644
--- a/app/Jobs/FirmwarePollJob.php
+++ b/app/Jobs/FirmwarePollJob.php
@@ -17,12 +17,7 @@ class FirmwarePollJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- private bool $download;
-
- public function __construct(bool $download = false)
- {
- $this->download = $download;
- }
+ public function __construct(private bool $download = false) {}
public function handle(): void
{
diff --git a/app/Jobs/NotifyDeviceBatteryLowJob.php b/app/Jobs/NotifyDeviceBatteryLowJob.php
index 2508365..9b1001b 100644
--- a/app/Jobs/NotifyDeviceBatteryLowJob.php
+++ b/app/Jobs/NotifyDeviceBatteryLowJob.php
@@ -15,8 +15,6 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
- public function __construct() {}
-
public function handle(): void
{
$devices = Device::all();
@@ -32,9 +30,11 @@ class NotifyDeviceBatteryLowJob implements ShouldQueue
continue;
}
-
// Skip if battery is not low or notification was already sent
- if ($batteryPercent > $batteryThreshold || $device->battery_notification_sent) {
+ if ($batteryPercent > $batteryThreshold) {
+ continue;
+ }
+ if ($device->battery_notification_sent) {
continue;
}
diff --git a/app/Liquid/FileSystems/InlineTemplatesFileSystem.php b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
new file mode 100644
index 0000000..dbde888
--- /dev/null
+++ b/app/Liquid/FileSystems/InlineTemplatesFileSystem.php
@@ -0,0 +1,62 @@
+
+ */
+ protected array $templates = [];
+
+ /**
+ * Register a template with the given name and content
+ */
+ public function register(string $name, string $content): void
+ {
+ $this->templates[$name] = $content;
+ }
+
+ /**
+ * Check if a template exists
+ */
+ public function hasTemplate(string $templateName): bool
+ {
+ return isset($this->templates[$templateName]);
+ }
+
+ /**
+ * Get all registered template names
+ *
+ * @return array
+ */
+ public function getTemplateNames(): array
+ {
+ return array_keys($this->templates);
+ }
+
+ /**
+ * Clear all registered templates
+ */
+ public function clear(): void
+ {
+ $this->templates = [];
+ }
+
+ public function readTemplateFile(string $templateName): string
+ {
+ if (! isset($this->templates[$templateName])) {
+ throw new InvalidArgumentException("Template '{$templateName}' not found in inline templates");
+ }
+
+ return $this->templates[$templateName];
+ }
+}
diff --git a/app/Liquid/Filters/Data.php b/app/Liquid/Filters/Data.php
index 5b1f92f..2387ac5 100644
--- a/app/Liquid/Filters/Data.php
+++ b/app/Liquid/Filters/Data.php
@@ -2,6 +2,7 @@
namespace App\Liquid\Filters;
+use App\Liquid\Utils\ExpressionUtils;
use Keepsuit\Liquid\Filters\FiltersProvider;
/**
@@ -19,4 +20,117 @@ class Data extends FiltersProvider
{
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+
+ /**
+ * Find an object in a collection by a specific key-value pair
+ *
+ * @param array $collection The collection to search in
+ * @param string $key The key to search for
+ * @param mixed $value The value to match
+ * @param mixed $fallback Optional fallback value if no match is found
+ * @return mixed The matching object or fallback value
+ */
+ public function find_by(array $collection, string $key, mixed $value, mixed $fallback = null): mixed
+ {
+ foreach ($collection as $item) {
+ if (is_array($item) && isset($item[$key]) && $item[$key] === $value) {
+ return $item;
+ }
+ }
+
+ return $fallback;
+ }
+
+ /**
+ * Group a collection by a specific key
+ *
+ * @param array $collection The collection to group
+ * @param string $key The key to group by
+ * @return array The grouped collection
+ */
+ public function group_by(array $collection, string $key): array
+ {
+ $grouped = [];
+
+ foreach ($collection as $item) {
+ if (is_array($item) && array_key_exists($key, $item)) {
+ $groupKey = $item[$key];
+ if (! isset($grouped[$groupKey])) {
+ $grouped[$groupKey] = [];
+ }
+ $grouped[$groupKey][] = $item;
+ }
+ }
+
+ return $grouped;
+ }
+
+ /**
+ * Return a random element from an array
+ *
+ * @param array $array The array to sample from
+ * @return mixed A random element from the array
+ */
+ public function sample(array $array): mixed
+ {
+ if ($array === []) {
+ return null;
+ }
+
+ return $array[array_rand($array)];
+ }
+
+ /**
+ * Parse a JSON string into a PHP value
+ *
+ * @param string $json The JSON string to parse
+ * @return mixed The parsed JSON value
+ */
+ public function parse_json(string $json): mixed
+ {
+ return json_decode($json, true);
+ }
+
+ /**
+ * Filter a collection using an expression
+ *
+ * @param mixed $input The collection to filter
+ * @param string $variable The variable name to use in the expression
+ * @param string $expression The expression to evaluate
+ * @return array The filtered collection
+ */
+ public function where_exp(mixed $input, string $variable, string $expression): array
+ {
+ // Return input as-is if it's not an array or doesn't have values method
+ if (! is_array($input)) {
+ return is_string($input) ? [$input] : [];
+ }
+
+ // Convert hash to array of values if needed
+ if (ExpressionUtils::isAssociativeArray($input)) {
+ $input = array_values($input);
+ }
+
+ $condition = ExpressionUtils::parseCondition($expression);
+ $result = [];
+
+ foreach ($input as $object) {
+ if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) {
+ $result[] = $object;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Convert array of strings to integers
+ *
+ * @param array $input Array of string numbers
+ * @return array Array of integers
+ */
+ public function map_to_i(array $input): array
+ {
+ return array_map(intval(...), $input);
+ }
}
diff --git a/app/Liquid/Filters/Date.php b/app/Liquid/Filters/Date.php
new file mode 100644
index 0000000..6bc81fc
--- /dev/null
+++ b/app/Liquid/Filters/Date.php
@@ -0,0 +1,55 @@
+subDays($days)->toDateString();
+ }
+
+ /**
+ * Format a date string with ordinal day (1st, 2nd, 3rd, etc.)
+ *
+ * @param string $dateStr The date string to parse
+ * @param string $strftimeExp The strftime format string with <> placeholder
+ * @return string The formatted date with ordinal day
+ */
+ public function ordinalize(string $dateStr, string $strftimeExp): string
+ {
+ $date = Carbon::parse($dateStr);
+ $ordinalDay = $date->ordinal('day');
+
+ // Convert strftime format to PHP date format
+ $phpFormat = ExpressionUtils::strftimeToPhpFormat($strftimeExp);
+
+ // Split the format string by the ordinal day placeholder
+ $parts = explode('<>', $phpFormat);
+
+ if (count($parts) === 2) {
+ $before = $date->format($parts[0]);
+ $after = $date->format($parts[1]);
+
+ return $before.$ordinalDay.$after;
+ }
+
+ // Fallback: if no placeholder found, just format normally
+ return $date->format($phpFormat);
+ }
+}
diff --git a/app/Liquid/Filters/Numbers.php b/app/Liquid/Filters/Numbers.php
index 53d1973..0e31de1 100644
--- a/app/Liquid/Filters/Numbers.php
+++ b/app/Liquid/Filters/Numbers.php
@@ -40,15 +40,11 @@ class Numbers extends FiltersProvider
$currency = 'GBP';
}
- if ($delimiter === '.' && $separator === ',') {
- $locale = 'de';
- } else {
- $locale = 'en';
- }
+ $locale = $delimiter === '.' && $separator === ',' ? 'de' : 'en';
// 2 decimal places for floats, 0 for integers
$decimal = is_float($value + 0) ? 2 : 0;
- return Number::currency($value, in: $currency, precision: $decimal, locale: $locale);
+ return Number::currency($value, in: $currency, locale: $locale, precision: $decimal);
}
}
diff --git a/app/Liquid/Filters/StandardFilters.php b/app/Liquid/Filters/StandardFilters.php
new file mode 100644
index 0000000..4db86a0
--- /dev/null
+++ b/app/Liquid/Filters/StandardFilters.php
@@ -0,0 +1,20 @@
+size($size);
+
+ // Set error correction level if provided
+ if ($errorCorrection !== null) {
+ $qrCode->errorCorrection($errorCorrection);
+ }
+
+ $svg = (string) $qrCode->generate($text);
+
+ // Add class="qr-code" to the SVG element
+ // The SVG may start with and then