Compare commits
154 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f6237265 | ||
|
|
c2c553461f | ||
|
|
fceacfe4b3 | ||
|
|
3032c09778 | ||
|
|
f1903bcbe8 | ||
|
|
621c108e78 | ||
|
|
131d99a2e3 | ||
|
|
7d1e74183d | ||
|
|
3f98a70ad9 | ||
|
|
0d6079db8b | ||
|
|
a86315c5c7 | ||
|
|
887c4d130b | ||
|
|
74e9e1eba3 | ||
|
|
53d4a8399f | ||
|
|
043f683db7 | ||
|
|
36e1ad8441 | ||
|
|
a06a0879ff | ||
|
|
ddce3947c6 | ||
|
|
4bc42cc1d2 | ||
|
|
94d5fca879 | ||
|
|
dc676327c2 | ||
|
|
e3bb9ad4e2 | ||
|
|
e176f2828e | ||
|
|
164a990dfe | ||
|
|
6d02415b7d | ||
|
|
3def60ae3e | ||
|
|
809965e81c | ||
|
|
b855ccffcb | ||
|
|
32dd4c3d08 | ||
|
|
a3f792944c | ||
|
|
3e670d37c0 | ||
|
|
46e792bc6d | ||
|
|
9019561bb3 | ||
|
|
838b4fd33b | ||
|
|
4451361f15 | ||
|
|
265972ac24 | ||
|
|
7f97114f6e | ||
|
|
3250bb0402 | ||
|
|
50853728bc | ||
|
|
3cdc267809 | ||
|
|
1298814521 | ||
|
|
a5cb38421e | ||
|
|
e6d66af298 | ||
|
|
d4b5cf99d5 | ||
|
|
d81c1b99f1 | ||
|
|
0b2b5bf25f | ||
|
|
f1a9103f0d | ||
|
|
d49a2d4f6c | ||
|
|
be2bb637c9 | ||
|
|
f3538048d4 | ||
|
|
a7963947f8 | ||
|
|
b1467204f8 | ||
|
|
fb9469d9cd | ||
|
|
b6faa2f232 | ||
|
|
60f2a38169 | ||
|
|
838db288e7 | ||
|
|
8776c668b4 | ||
|
|
1096118e03 | ||
|
|
b10bbca774 | ||
|
|
0322ec899e | ||
|
|
7c8e55588a | ||
|
|
dac8064938 | ||
|
|
fd41e77e7d | ||
|
|
568bd69fea | ||
|
|
61b9ff56e0 | ||
|
|
73f0fd26c2 | ||
|
|
7014250ac5 | ||
|
|
c157dcf3b6 | ||
|
|
742fd86c77 | ||
|
|
7489d85592 | ||
|
|
22a24383b2 | ||
|
|
468e8a130d | ||
|
|
346f04a7af | ||
|
|
31a73ccc6e | ||
|
|
042654993a | ||
|
|
6c438ff4d4 | ||
|
|
b7ce0b6152 | ||
|
|
cdf477e2ed | ||
|
|
e63953dc13 | ||
|
|
a8f3232ccc | ||
|
|
41baff51a6 | ||
|
|
f0f6b28107 | ||
|
|
e53c584eed | ||
|
|
1ccaa8382b | ||
|
|
36f783ac60 | ||
|
|
dd4237360c | ||
|
|
ef9cb81edb | ||
|
|
10b53c3772 | ||
|
|
52dfe92054 | ||
|
|
882cbff7fe | ||
|
|
80e2e8058a | ||
|
|
38e1b6f2a6 | ||
|
|
315fbac261 | ||
|
|
5abc452770 | ||
|
|
4de32e9d47 | ||
|
|
aa46dff00b | ||
|
|
311236a70d | ||
|
|
5e0d0ad73f | ||
|
|
f6897fdfc7 | ||
|
|
04ae695a14 | ||
|
|
a7e76f3c07 | ||
|
|
627d9ad09b | ||
|
|
583d8b2440 | ||
|
|
b18d561361 | ||
|
|
4c65c015b9 | ||
|
|
58e1fc32a4 | ||
|
|
74a65d6daf | ||
|
|
8aea83703c | ||
|
|
161200df44 | ||
|
|
23a7a217db | ||
|
|
c8f6dd3bec | ||
|
|
c1786dfb6d | ||
|
|
91e222f7a6 | ||
|
|
203584107f | ||
|
|
56548a96cb | ||
|
|
e812f56c11 | ||
|
|
50318b8b9d | ||
|
|
93dacb0baf | ||
|
|
4af4bfe14a | ||
|
|
96e0223f2f | ||
|
|
6f7efd9e36 | ||
|
|
3e5ba47a12 | ||
|
|
6ae3e023d4 | ||
|
|
e443539357 | ||
|
|
b4b6286172 | ||
|
|
c67a182cf2 | ||
|
|
a1a57014b6 | ||
|
|
42b515e322 | ||
|
|
4f251bf37e | ||
|
|
d8f47eb9c2 | ||
|
|
39ac9f0ad2 | ||
|
|
8958e65ec2 | ||
|
|
2d76afee6f | ||
|
|
00fc526371 | ||
|
|
b3b251bae2 | ||
|
|
0c5041a8ca | ||
|
|
e9037ef5d7 | ||
|
|
ee9f21a83d | ||
|
|
19a8bb18cc | ||
|
|
b7bcaf6feb | ||
|
|
85e887f8a5 | ||
|
|
8791a5154e | ||
|
|
29d1838690 | ||
|
|
97e6beaee4 | ||
|
|
cc4aa0560c | ||
|
|
93406b83a5 | ||
|
|
88e10101b8 | ||
|
|
e65473f932 | ||
|
|
12c82e02d7 | ||
|
|
f20977a822 | ||
|
|
425dbf6b3f | ||
|
|
495bbe7b7e | ||
|
|
ec704d8d83 | ||
|
|
38e77eaeb6 |
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"./artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,581 +0,0 @@
|
|||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.12
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- livewire/volt (VOLT) - v1
|
||||
- larastan/larastan (LARASTAN) - v3
|
||||
- laravel/pint (PINT) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== fluxui-free/core rules ===
|
||||
|
||||
## Flux UI Free
|
||||
|
||||
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
|
||||
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
|
||||
- You should use Flux UI components when available.
|
||||
- Fallback to standard Blade components if Flux is unavailable.
|
||||
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
|
||||
- Flux UI components look like this:
|
||||
|
||||
<code-snippet name="Flux UI Component Usage Example" lang="blade">
|
||||
<flux:button variant="primary"/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Available Components
|
||||
This is correct as of Boost installation, but there may be additional components within the codebase.
|
||||
|
||||
<available-flux-components>
|
||||
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
|
||||
</available-flux-components>
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== volt/core rules ===
|
||||
|
||||
## Livewire Volt
|
||||
|
||||
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
|
||||
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
|
||||
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
|
||||
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
|
||||
)]))
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Volt Class Based Component Example
|
||||
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
|
||||
|
||||
|
||||
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public $count = 0;
|
||||
|
||||
public function increment()
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
} ?>
|
||||
|
||||
<div>
|
||||
<h1>{{ $count }}</h1>
|
||||
<button wire:click="increment">+</button>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Testing Volt & Volt Components
|
||||
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
|
||||
|
||||
<code-snippet name="Livewire Test Example" lang="php">
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('counter increments', function () {
|
||||
Volt::test('counter')
|
||||
->assertSee('Count: 0')
|
||||
->call('increment')
|
||||
->assertSee('Count: 1');
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Volt Component Test Using Pest" lang="php">
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\{User, Product};
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('product form creates product', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::test('pages.products.create')
|
||||
->actingAs($user)
|
||||
->set('form.name', 'Test Product')
|
||||
->set('form.description', 'Test Description')
|
||||
->set('form.price', 99.99)
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Common Patterns
|
||||
|
||||
|
||||
<code-snippet name="CRUD With Volt" lang="php">
|
||||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state(['editing' => null, 'search' => '']);
|
||||
|
||||
$products = computed(fn() => Product::when($this->search,
|
||||
fn($q) => $q->where('name', 'like', "%{$this->search}%")
|
||||
)->get());
|
||||
|
||||
$edit = fn(Product $product) => $this->editing = $product->id;
|
||||
$delete = fn(Product $product) => $product->delete();
|
||||
|
||||
?>
|
||||
|
||||
<!-- HTML / UI Here -->
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Real-Time Search With Volt" lang="php">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Loading States With Volt" lang="php">
|
||||
<flux:button wire:click="save" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove>Save</span>
|
||||
<span wire:loading>Saving...</span>
|
||||
</flux:button>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
|
|
@ -9,7 +9,8 @@ RUN apk add --no-cache composer
|
|||
# Add Chromium and Image Magick for puppeteer.
|
||||
RUN apk add --no-cache \
|
||||
imagemagick-dev \
|
||||
chromium
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
|
@ -19,7 +20,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
|
|||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
# Composer uses its php binary, but we want it to use the container's one
|
||||
RUN rm -f /usr/bin/php84
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ RUN apk add --no-cache \
|
|||
nodejs \
|
||||
npm \
|
||||
imagemagick-dev \
|
||||
chromium
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
|
@ -24,7 +25,7 @@ RUN chmod 777 /usr/src/php/ext/imagick
|
|||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
RUN rm -f /usr/bin/php84
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||
|
|
|
|||
578
.github/copilot-instructions.md
vendored
|
|
@ -1,578 +0,0 @@
|
|||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.12
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- livewire/volt (VOLT) - v1
|
||||
- larastan/larastan (LARASTAN) - v3
|
||||
- laravel/pint (PINT) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== fluxui-free/core rules ===
|
||||
|
||||
## Flux UI Free
|
||||
|
||||
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
|
||||
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
|
||||
- You should use Flux UI components when available.
|
||||
- Fallback to standard Blade components if Flux is unavailable.
|
||||
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
|
||||
- Flux UI components look like this:
|
||||
|
||||
<code-snippet name="Flux UI Component Usage Example" lang="blade">
|
||||
<flux:button variant="primary"/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Available Components
|
||||
This is correct as of Boost installation, but there may be additional components within the codebase.
|
||||
|
||||
<available-flux-components>
|
||||
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
|
||||
</available-flux-components>
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== volt/core rules ===
|
||||
|
||||
## Livewire Volt
|
||||
|
||||
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
|
||||
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
|
||||
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
|
||||
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
|
||||
)]))
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Volt Class Based Component Example
|
||||
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
|
||||
|
||||
|
||||
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public $count = 0;
|
||||
|
||||
public function increment()
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
} ?>
|
||||
|
||||
<div>
|
||||
<h1>{{ $count }}</h1>
|
||||
<button wire:click="increment">+</button>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Testing Volt & Volt Components
|
||||
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
|
||||
|
||||
<code-snippet name="Livewire Test Example" lang="php">
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('counter increments', function () {
|
||||
Volt::test('counter')
|
||||
->assertSee('Count: 0')
|
||||
->call('increment')
|
||||
->assertSee('Count: 1');
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Volt Component Test Using Pest" lang="php">
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\{User, Product};
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('product form creates product', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::test('pages.products.create')
|
||||
->actingAs($user)
|
||||
->set('form.name', 'Test Product')
|
||||
->set('form.description', 'Test Description')
|
||||
->set('form.price', 99.99)
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Common Patterns
|
||||
|
||||
|
||||
<code-snippet name="CRUD With Volt" lang="php">
|
||||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state(['editing' => null, 'search' => '']);
|
||||
|
||||
$products = computed(fn() => Product::when($this->search,
|
||||
fn($q) => $q->where('name', 'like', "%{$this->search}%")
|
||||
)->get());
|
||||
|
||||
$edit = fn(Product $product) => $this->editing = $product->id;
|
||||
$delete = fn(Product $product) => $product->delete();
|
||||
|
||||
?>
|
||||
|
||||
<!-- HTML / UI Here -->
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Real-Time Search With Volt" lang="php">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Loading States With Volt" lang="php">
|
||||
<flux:button wire:click="save" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove>Save</span>
|
||||
<span wire:loading>Saving...</span>
|
||||
</flux:button>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
3
.github/workflows/docker-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.gitignore
vendored
|
|
@ -23,3 +23,14 @@ yarn-error.log
|
|||
/.zed
|
||||
/database/seeders/PersonalDeviceSeeder.php
|
||||
/.junie/mcp/mcp.json
|
||||
/.cursor/mcp.json
|
||||
/.cursor/rules/laravel-boost.mdc
|
||||
/.github/copilot-instructions.md
|
||||
/.junie/guidelines.md
|
||||
/CLAUDE.md
|
||||
/.mcp.json
|
||||
/.ai
|
||||
.DS_Store
|
||||
/boost.json
|
||||
/.gemini
|
||||
/GEMINI.md
|
||||
|
|
|
|||
|
|
@ -1,578 +0,0 @@
|
|||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.12
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- livewire/volt (VOLT) - v1
|
||||
- larastan/larastan (LARASTAN) - v3
|
||||
- laravel/pint (PINT) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== fluxui-free/core rules ===
|
||||
|
||||
## Flux UI Free
|
||||
|
||||
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
|
||||
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
|
||||
- You should use Flux UI components when available.
|
||||
- Fallback to standard Blade components if Flux is unavailable.
|
||||
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
|
||||
- Flux UI components look like this:
|
||||
|
||||
<code-snippet name="Flux UI Component Usage Example" lang="blade">
|
||||
<flux:button variant="primary"/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Available Components
|
||||
This is correct as of Boost installation, but there may be additional components within the codebase.
|
||||
|
||||
<available-flux-components>
|
||||
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
|
||||
</available-flux-components>
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== volt/core rules ===
|
||||
|
||||
## Livewire Volt
|
||||
|
||||
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
|
||||
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
|
||||
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
|
||||
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
|
||||
)]))
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Volt Class Based Component Example
|
||||
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
|
||||
|
||||
|
||||
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public $count = 0;
|
||||
|
||||
public function increment()
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
} ?>
|
||||
|
||||
<div>
|
||||
<h1>{{ $count }}</h1>
|
||||
<button wire:click="increment">+</button>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Testing Volt & Volt Components
|
||||
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
|
||||
|
||||
<code-snippet name="Livewire Test Example" lang="php">
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('counter increments', function () {
|
||||
Volt::test('counter')
|
||||
->assertSee('Count: 0')
|
||||
->call('increment')
|
||||
->assertSee('Count: 1');
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Volt Component Test Using Pest" lang="php">
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\{User, Product};
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('product form creates product', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::test('pages.products.create')
|
||||
->actingAs($user)
|
||||
->set('form.name', 'Test Product')
|
||||
->set('form.description', 'Test Description')
|
||||
->set('form.price', 99.99)
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Common Patterns
|
||||
|
||||
|
||||
<code-snippet name="CRUD With Volt" lang="php">
|
||||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state(['editing' => null, 'search' => '']);
|
||||
|
||||
$products = computed(fn() => Product::when($this->search,
|
||||
fn($q) => $q->where('name', 'like', "%{$this->search}%")
|
||||
)->get());
|
||||
|
||||
$edit = fn(Product $product) => $this->editing = $product->id;
|
||||
$delete = fn(Product $product) => $product->delete();
|
||||
|
||||
?>
|
||||
|
||||
<!-- HTML / UI Here -->
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Real-Time Search With Volt" lang="php">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Loading States With Volt" lang="php">
|
||||
<flux:button wire:click="save" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove>Save</span>
|
||||
<span wire:loading>Saving...</span>
|
||||
</flux:button>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
11
.mcp.json
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"./artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
578
CLAUDE.md
|
|
@ -1,578 +0,0 @@
|
|||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
# Laravel Boost Guidelines
|
||||
|
||||
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
|
||||
|
||||
## Foundational Context
|
||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
||||
|
||||
- php - 8.4.12
|
||||
- laravel/framework (LARAVEL) - v12
|
||||
- laravel/prompts (PROMPTS) - v0
|
||||
- livewire/flux (FLUXUI_FREE) - v2
|
||||
- livewire/livewire (LIVEWIRE) - v3
|
||||
- livewire/volt (VOLT) - v1
|
||||
- larastan/larastan (LARASTAN) - v3
|
||||
- laravel/pint (PINT) - v1
|
||||
- pestphp/pest (PEST) - v4
|
||||
- tailwindcss (TAILWINDCSS) - v4
|
||||
|
||||
|
||||
## Conventions
|
||||
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
|
||||
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||
- Check for existing components to reuse before writing a new one.
|
||||
|
||||
## Verification Scripts
|
||||
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
|
||||
|
||||
## Application Structure & Architecture
|
||||
- Stick to existing directory structure - don't create new base folders without approval.
|
||||
- Do not change the application's dependencies without approval.
|
||||
|
||||
## Frontend Bundling
|
||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||
|
||||
## Replies
|
||||
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
|
||||
|
||||
## Documentation Files
|
||||
- You must only create documentation files if explicitly requested by the user.
|
||||
|
||||
|
||||
=== boost rules ===
|
||||
|
||||
## Laravel Boost
|
||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||
|
||||
## Artisan
|
||||
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
|
||||
|
||||
## URLs
|
||||
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
|
||||
|
||||
## Tinker / Debugging
|
||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||
- Use the `database-query` tool when you only need to read from the database.
|
||||
|
||||
## Reading Browser Logs With the `browser-logs` Tool
|
||||
- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
|
||||
- Only recent browser logs will be useful - ignore old logs.
|
||||
|
||||
## Searching Documentation (Critically Important)
|
||||
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
|
||||
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
|
||||
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
|
||||
- Search the documentation before making code changes to ensure we are taking the correct approach.
|
||||
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
|
||||
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
|
||||
|
||||
### Available Search Syntax
|
||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
||||
|
||||
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
|
||||
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
|
||||
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
|
||||
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
|
||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
||||
|
||||
|
||||
=== php rules ===
|
||||
|
||||
## PHP
|
||||
|
||||
- Always use curly braces for control structures, even if it has one line.
|
||||
|
||||
### Constructors
|
||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||
- Do not allow empty `__construct()` methods with zero parameters.
|
||||
|
||||
### Type Declarations
|
||||
- Always use explicit return type declarations for methods and functions.
|
||||
- Use appropriate PHP type hints for method parameters.
|
||||
|
||||
<code-snippet name="Explicit Return Types and Method Params" lang="php">
|
||||
protected function isAccessible(User $user, ?string $path = null): bool
|
||||
{
|
||||
...
|
||||
}
|
||||
</code-snippet>
|
||||
|
||||
## Comments
|
||||
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
|
||||
|
||||
## PHPDoc Blocks
|
||||
- Add useful array shape type definitions for arrays when appropriate.
|
||||
|
||||
## Enums
|
||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
||||
|
||||
|
||||
=== laravel/core rules ===
|
||||
|
||||
## Do Things the Laravel Way
|
||||
|
||||
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
|
||||
- If you're creating a generic PHP class, use `artisan make:class`.
|
||||
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
|
||||
|
||||
### Database
|
||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
||||
- Use Eloquent models and relationships before suggesting raw database queries
|
||||
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
|
||||
- Generate code that prevents N+1 query problems by using eager loading.
|
||||
- Use Laravel's query builder for very complex database operations.
|
||||
|
||||
### Model Creation
|
||||
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
|
||||
|
||||
### APIs & Eloquent Resources
|
||||
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
|
||||
|
||||
### Controllers & Validation
|
||||
- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
|
||||
- Check sibling Form Requests to see if the application uses array or string based validation rules.
|
||||
|
||||
### Queues
|
||||
- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
|
||||
|
||||
### Authentication & Authorization
|
||||
- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
|
||||
|
||||
### URL Generation
|
||||
- When generating links to other pages, prefer named routes and the `route()` function.
|
||||
|
||||
### Configuration
|
||||
- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
|
||||
|
||||
### Testing
|
||||
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
|
||||
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
|
||||
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
|
||||
|
||||
### Vite Error
|
||||
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
|
||||
|
||||
|
||||
=== laravel/v12 rules ===
|
||||
|
||||
## Laravel 12
|
||||
|
||||
- Use the `search-docs` tool to get version specific documentation.
|
||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||
|
||||
### Laravel 12 Structure
|
||||
- No middleware files in `app/Http/Middleware/`.
|
||||
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
|
||||
- `bootstrap/providers.php` contains application specific service providers.
|
||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
||||
- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||
|
||||
### Database
|
||||
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
|
||||
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||
|
||||
### Models
|
||||
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
|
||||
|
||||
|
||||
=== fluxui-free/core rules ===
|
||||
|
||||
## Flux UI Free
|
||||
|
||||
- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
|
||||
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
|
||||
- You should use Flux UI components when available.
|
||||
- Fallback to standard Blade components if Flux is unavailable.
|
||||
- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
|
||||
- Flux UI components look like this:
|
||||
|
||||
<code-snippet name="Flux UI Component Usage Example" lang="blade">
|
||||
<flux:button variant="primary"/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Available Components
|
||||
This is correct as of Boost installation, but there may be additional components within the codebase.
|
||||
|
||||
<available-flux-components>
|
||||
avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
|
||||
</available-flux-components>
|
||||
|
||||
|
||||
=== livewire/core rules ===
|
||||
|
||||
## Livewire Core
|
||||
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
|
||||
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
|
||||
- State should live on the server, with the UI reflecting it.
|
||||
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
|
||||
|
||||
## Livewire Best Practices
|
||||
- Livewire components require a single root element.
|
||||
- Use `wire:loading` and `wire:dirty` for delightful loading states.
|
||||
- Add `wire:key` in loops:
|
||||
|
||||
```blade
|
||||
@foreach ($items as $item)
|
||||
<div wire:key="item-{{ $item->id }}">
|
||||
{{ $item->name }}
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
|
||||
|
||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
||||
public function mount(User $user) { $this->user = $user; }
|
||||
public function updatedSearch() { $this->resetPage(); }
|
||||
</code-snippet>
|
||||
|
||||
|
||||
## Testing Livewire
|
||||
|
||||
<code-snippet name="Example Livewire component test" lang="php">
|
||||
Livewire::test(Counter::class)
|
||||
->assertSet('count', 0)
|
||||
->call('increment')
|
||||
->assertSet('count', 1)
|
||||
->assertSee(1)
|
||||
->assertStatus(200);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
||||
$this->get('/posts/create')
|
||||
->assertSeeLivewire(CreatePost::class);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== livewire/v3 rules ===
|
||||
|
||||
## Livewire 3
|
||||
|
||||
### Key Changes From Livewire 2
|
||||
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
|
||||
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
|
||||
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||
- Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
|
||||
|
||||
### New Directives
|
||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||
|
||||
### Alpine
|
||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||
|
||||
### Lifecycle Hooks
|
||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||
|
||||
<code-snippet name="livewire:load example" lang="js">
|
||||
document.addEventListener('livewire:init', function () {
|
||||
Livewire.hook('request', ({ fail }) => {
|
||||
if (fail && fail.status === 419) {
|
||||
alert('Your session expired');
|
||||
}
|
||||
});
|
||||
|
||||
Livewire.hook('message.failed', (message, component) => {
|
||||
console.error(message);
|
||||
});
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== volt/core rules ===
|
||||
|
||||
## Livewire Volt
|
||||
|
||||
- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
|
||||
- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
|
||||
- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
|
||||
- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@livewire("volt-anonymous-fragment-eyJuYW1lIjoidm9sdC1hbm9ueW1vdXMtZnJhZ21lbnQtYmQ5YWJiNTE3YWMyMTgwOTA1ZmUxMzAxODk0MGJiZmIiLCJwYXRoIjoic3RvcmFnZVwvZnJhbWV3b3JrXC92aWV3c1wvMTUxYWRjZWRjMzBhMzllOWIxNzQ0ZDRiMWRjY2FjYWIuYmxhZGUucGhwIn0=", Livewire\Volt\Precompilers\ExtractFragments::componentArguments([...get_defined_vars(), ...array (
|
||||
)]))
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Volt Class Based Component Example
|
||||
To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
|
||||
|
||||
|
||||
<code-snippet name="Volt Class-based Volt Component Example" lang="php">
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
public $count = 0;
|
||||
|
||||
public function increment()
|
||||
{
|
||||
$this->count++;
|
||||
}
|
||||
} ?>
|
||||
|
||||
<div>
|
||||
<h1>{{ $count }}</h1>
|
||||
<button wire:click="increment">+</button>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Testing Volt & Volt Components
|
||||
- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
|
||||
|
||||
<code-snippet name="Livewire Test Example" lang="php">
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('counter increments', function () {
|
||||
Volt::test('counter')
|
||||
->assertSee('Count: 0')
|
||||
->call('increment')
|
||||
->assertSee('Count: 1');
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
<code-snippet name="Volt Component Test Using Pest" lang="php">
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\{User, Product};
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('product form creates product', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Volt::test('pages.products.create')
|
||||
->actingAs($user)
|
||||
->set('form.name', 'Test Product')
|
||||
->set('form.description', 'Test Description')
|
||||
->set('form.price', 99.99)
|
||||
->call('create')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Common Patterns
|
||||
|
||||
|
||||
<code-snippet name="CRUD With Volt" lang="php">
|
||||
<?php
|
||||
|
||||
use App\Models\Product;
|
||||
use function Livewire\Volt\{state, computed};
|
||||
|
||||
state(['editing' => null, 'search' => '']);
|
||||
|
||||
$products = computed(fn() => Product::when($this->search,
|
||||
fn($q) => $q->where('name', 'like', "%{$this->search}%")
|
||||
)->get());
|
||||
|
||||
$edit = fn(Product $product) => $this->editing = $product->id;
|
||||
$delete = fn(Product $product) => $product->delete();
|
||||
|
||||
?>
|
||||
|
||||
<!-- HTML / UI Here -->
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Real-Time Search With Volt" lang="php">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Loading States With Volt" lang="php">
|
||||
<flux:button wire:click="save" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove>Save</span>
|
||||
<span wire:loading>Saving...</span>
|
||||
</flux:button>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pint/core rules ===
|
||||
|
||||
## Laravel Pint Code Formatter
|
||||
|
||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
||||
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||
|
||||
|
||||
=== pest/core rules ===
|
||||
|
||||
## Pest
|
||||
|
||||
### Testing
|
||||
- If you need to verify a feature is working, write or update a Unit / Feature test.
|
||||
|
||||
### Pest Tests
|
||||
- All tests must be written using Pest. Use `php artisan make:test --pest <name>`.
|
||||
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
|
||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||
- Tests live in the `tests/Feature` and `tests/Unit` directories.
|
||||
- Pest tests look and behave like this:
|
||||
<code-snippet name="Basic Pest Test Example" lang="php">
|
||||
it('is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Running Tests
|
||||
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
|
||||
- To run all tests: `php artisan test`.
|
||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
||||
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
|
||||
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
|
||||
|
||||
### Pest Assertions
|
||||
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
|
||||
<code-snippet name="Pest Example Asserting postJson Response" lang="php">
|
||||
it('returns all', function () {
|
||||
$response = $this->postJson('/api/docs', []);
|
||||
|
||||
$response->assertSuccessful();
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
### Mocking
|
||||
- Mocking can be very helpful when appropriate.
|
||||
- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
|
||||
- You can also create partial mocks using the same import or self method.
|
||||
|
||||
### Datasets
|
||||
- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
|
||||
|
||||
<code-snippet name="Pest Dataset Example" lang="php">
|
||||
it('has emails', function (string $email) {
|
||||
expect($email)->not->toBeEmpty();
|
||||
})->with([
|
||||
'james' => 'james@laravel.com',
|
||||
'taylor' => 'taylor@laravel.com',
|
||||
]);
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== pest/v4 rules ===
|
||||
|
||||
## Pest 4
|
||||
|
||||
- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
|
||||
- Browser testing is incredibly powerful and useful for this project.
|
||||
- Browser tests should live in `tests/Browser/`.
|
||||
- Use the `search-docs` tool for detailed guidance on utilizing these features.
|
||||
|
||||
### Browser Testing
|
||||
- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
|
||||
- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
|
||||
- If requested, test on multiple browsers (Chrome, Firefox, Safari).
|
||||
- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
|
||||
- Switch color schemes (light/dark mode) when appropriate.
|
||||
- Take screenshots or pause tests for debugging when appropriate.
|
||||
|
||||
### Example Tests
|
||||
|
||||
<code-snippet name="Pest Browser Test Example" lang="php">
|
||||
it('may reset the password', function () {
|
||||
Notification::fake();
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = visit('/sign-in'); // Visit on a real browser...
|
||||
|
||||
$page->assertSee('Sign In')
|
||||
->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
|
||||
->click('Forgot Password?')
|
||||
->fill('email', 'nuno@laravel.com')
|
||||
->click('Send Reset Link')
|
||||
->assertSee('We have emailed your password reset link!')
|
||||
|
||||
Notification::assertSent(ResetPassword::class);
|
||||
});
|
||||
</code-snippet>
|
||||
|
||||
|
||||
|
||||
<code-snippet name="Pest Smoke Testing Example" lang="php">
|
||||
$pages = visit(['/', '/about', '/contact']);
|
||||
|
||||
$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
|
||||
</code-snippet>
|
||||
|
||||
|
||||
=== tailwindcss/core rules ===
|
||||
|
||||
## Tailwind Core
|
||||
|
||||
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
|
||||
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
|
||||
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
|
||||
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||
|
||||
### Spacing
|
||||
- When listing items, use gap utilities for spacing, don't use margins.
|
||||
|
||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||
<div class="flex gap-8">
|
||||
<div>Superior</div>
|
||||
<div>Michigan</div>
|
||||
<div>Erie</div>
|
||||
</div>
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Dark Mode
|
||||
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
|
||||
|
||||
|
||||
=== tailwindcss/v4 rules ===
|
||||
|
||||
## Tailwind 4
|
||||
|
||||
- Always use Tailwind CSS v4 - do not use the deprecated utilities.
|
||||
- `corePlugins` is not supported in Tailwind v4.
|
||||
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||
|
||||
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff"
|
||||
- @tailwind base;
|
||||
- @tailwind components;
|
||||
- @tailwind utilities;
|
||||
+ @import "tailwindcss";
|
||||
</code-snippet>
|
||||
|
||||
|
||||
### Replaced Utilities
|
||||
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
|
||||
- Opacity values are still numeric.
|
||||
|
||||
| Deprecated | Replacement |
|
||||
|------------+--------------|
|
||||
| bg-opacity-* | bg-black/* |
|
||||
| text-opacity-* | text-black/* |
|
||||
| border-opacity-* | border-black/* |
|
||||
| divide-opacity-* | divide-black/* |
|
||||
| ring-opacity-* | ring-black/* |
|
||||
| placeholder-opacity-* | placeholder-black/* |
|
||||
| flex-shrink-* | shrink-* |
|
||||
| flex-grow-* | grow-* |
|
||||
| overflow-ellipsis | text-ellipsis |
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
|
||||
|
||||
=== tests rules ===
|
||||
|
||||
## Test Enforcement
|
||||
|
||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
|
||||
</laravel-boost-guidelines>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
########################
|
||||
# Base Image
|
||||
########################
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium AS base
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
|
||||
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
||||
|
|
@ -12,9 +12,14 @@ ENV APP_VERSION=${APP_VERSION}
|
|||
|
||||
ENV AUTORUN_ENABLED="true"
|
||||
|
||||
# Mark trmnl-liquid-cli as installed
|
||||
ENV TRMNL_LIQUID_ENABLED=1
|
||||
|
||||
# Switch to the root user so we can do root things
|
||||
USER root
|
||||
|
||||
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
|
|
@ -48,6 +53,5 @@ FROM base AS production
|
|||
# Copy the assets from the assets image
|
||||
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build
|
||||
COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules
|
||||
|
||||
# Drop back to the www-data user
|
||||
USER www-data
|
||||
|
|
|
|||
41
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).
|
||||
|
||||
|
|
@ -39,6 +48,7 @@ Support the development of this package by purchasing a TRMNL device through the
|
|||
or
|
||||
|
||||
[](https://www.buymeacoffee.com/bnussbau)
|
||||
|
||||
[GitHub Sponsors](https://github.com/sponsors/bnussbau/)
|
||||
|
||||
### Hosting
|
||||
|
|
@ -66,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.
|
||||
|
||||
|
|
@ -205,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class FirmwareCheckCommand extends Command
|
|||
);
|
||||
|
||||
$latestFirmware = Firmware::getLatest();
|
||||
if ($latestFirmware) {
|
||||
if ($latestFirmware instanceof Firmware) {
|
||||
table(
|
||||
rows: [
|
||||
['Latest Version', $latestFirmware->version_tag],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
201
app/Console/Commands/GenerateDefaultImagesCommand.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
|
||||
use Bnussbau\TrmnlPipeline\Stages\ImageStage;
|
||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
class GenerateDefaultImagesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'images:generate-defaults {--force : Force regeneration of existing images}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate default images (setup-logo and sleep) for all device models from Blade templates';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class OidcTestCommand extends Command
|
|||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Testing OIDC Configuration...');
|
||||
$this->newLine();
|
||||
|
|
@ -40,13 +40,18 @@ class OidcTestCommand extends Command
|
|||
$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('Scopes: ✅ '.implode(', ', $effectiveScopes));
|
||||
|
||||
$this->newLine();
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\DevicePalette;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -20,6 +21,8 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
|||
|
||||
private const API_URL = 'https://usetrmnl.com/api/models';
|
||||
|
||||
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
|
|
@ -34,6 +37,8 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
|||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$this->processPalettes();
|
||||
|
||||
$response = Http::timeout(30)->get(self::API_URL);
|
||||
|
||||
if (! $response->successful()) {
|
||||
|
|
@ -69,6 +74,86 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process palettes from API and update/create records.
|
||||
*/
|
||||
private function processPalettes(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(30)->get(self::PALETTES_API_URL);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Failed to fetch palettes from API', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
if (! is_array($data)) {
|
||||
Log::error('Invalid response format from palettes API', [
|
||||
'response' => $response->json(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data as $paletteData) {
|
||||
try {
|
||||
$this->updateOrCreatePalette($paletteData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to process palette', [
|
||||
'palette_data' => $paletteData,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('Successfully fetched and updated palettes', [
|
||||
'count' => count($data),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Exception occurred while fetching palettes', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a palette record.
|
||||
*/
|
||||
private function updateOrCreatePalette(array $paletteData): void
|
||||
{
|
||||
$name = $paletteData['id'] ?? null;
|
||||
|
||||
if (! $name) {
|
||||
Log::warning('Palette data missing id field', [
|
||||
'palette_data' => $paletteData,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'name' => $name,
|
||||
'description' => $paletteData['name'] ?? '',
|
||||
'grays' => $paletteData['grays'] ?? 2,
|
||||
'colors' => $paletteData['colors'] ?? null,
|
||||
'framework_class' => $paletteData['framework_class'] ?? '',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
DevicePalette::updateOrCreate(
|
||||
['name' => $name],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the device models data and update/create records.
|
||||
*/
|
||||
|
|
@ -114,12 +199,49 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
|||
'offset_x' => $modelData['offset_x'] ?? 0,
|
||||
'offset_y' => $modelData['offset_y'] ?? 0,
|
||||
'published_at' => $modelData['published_at'] ?? null,
|
||||
'kind' => $modelData['kind'] ?? null,
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
// Set palette_id to the first palette from the model's palettes array
|
||||
$firstPaletteId = $this->getFirstPaletteId($modelData);
|
||||
if ($firstPaletteId) {
|
||||
$attributes['palette_id'] = $firstPaletteId;
|
||||
}
|
||||
|
||||
DeviceModel::updateOrCreate(
|
||||
['name' => $name],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first palette ID from model data.
|
||||
*/
|
||||
private function getFirstPaletteId(array $modelData): ?int
|
||||
{
|
||||
$paletteName = null;
|
||||
|
||||
// Check for palette_ids array
|
||||
if (isset($modelData['palette_ids']) && is_array($modelData['palette_ids']) && $modelData['palette_ids'] !== []) {
|
||||
$paletteName = $modelData['palette_ids'][0];
|
||||
}
|
||||
|
||||
// Check for palettes array (array of objects with id)
|
||||
if (! $paletteName && isset($modelData['palettes']) && is_array($modelData['palettes']) && $modelData['palettes'] !== []) {
|
||||
$firstPalette = $modelData['palettes'][0];
|
||||
if (is_array($firstPalette) && isset($firstPalette['id'])) {
|
||||
$paletteName = $firstPalette['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (! $paletteName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look up palette by name to get the integer ID
|
||||
$palette = DevicePalette::where('name', $paletteName)->first();
|
||||
|
||||
return $palette?->id;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use App\Liquid\Utils\ExpressionUtils;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
|
|
@ -72,7 +73,7 @@ class Data extends FiltersProvider
|
|||
*/
|
||||
public function sample(array $array): mixed
|
||||
{
|
||||
if (empty($array)) {
|
||||
if ($array === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -89,4 +90,47 @@ class Data extends FiltersProvider
|
|||
{
|
||||
return json_decode($json, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a collection using an expression
|
||||
*
|
||||
* @param mixed $input The collection to filter
|
||||
* @param string $variable The variable name to use in the expression
|
||||
* @param string $expression The expression to evaluate
|
||||
* @return array The filtered collection
|
||||
*/
|
||||
public function where_exp(mixed $input, string $variable, string $expression): array
|
||||
{
|
||||
// Return input as-is if it's not an array or doesn't have values method
|
||||
if (! is_array($input)) {
|
||||
return is_string($input) ? [$input] : [];
|
||||
}
|
||||
|
||||
// Convert hash to array of values if needed
|
||||
if (ExpressionUtils::isAssociativeArray($input)) {
|
||||
$input = array_values($input);
|
||||
}
|
||||
|
||||
$condition = ExpressionUtils::parseCondition($expression);
|
||||
$result = [];
|
||||
|
||||
foreach ($input as $object) {
|
||||
if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) {
|
||||
$result[] = $object;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of strings to integers
|
||||
*
|
||||
* @param array $input Array of string numbers
|
||||
* @return array Array of integers
|
||||
*/
|
||||
public function map_to_i(array $input): array
|
||||
{
|
||||
return array_map(intval(...), $input);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use App\Liquid\Utils\ExpressionUtils;
|
||||
use Carbon\Carbon;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
|
|
@ -22,4 +23,33 @@ class Date extends FiltersProvider
|
|||
|
||||
return Carbon::now()->subDays($days)->toDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string with ordinal day (1st, 2nd, 3rd, etc.)
|
||||
*
|
||||
* @param string $dateStr The date string to parse
|
||||
* @param string $strftimeExp The strftime format string with <<ordinal_day>> placeholder
|
||||
* @return string The formatted date with ordinal day
|
||||
*/
|
||||
public function ordinalize(string $dateStr, string $strftimeExp): string
|
||||
{
|
||||
$date = Carbon::parse($dateStr);
|
||||
$ordinalDay = $date->ordinal('day');
|
||||
|
||||
// Convert strftime format to PHP date format
|
||||
$phpFormat = ExpressionUtils::strftimeToPhpFormat($strftimeExp);
|
||||
|
||||
// Split the format string by the ordinal day placeholder
|
||||
$parts = explode('<<ordinal_day>>', $phpFormat);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
$before = $date->format($parts[0]);
|
||||
$after = $date->format($parts[1]);
|
||||
|
||||
return $before.$ordinalDay.$after;
|
||||
}
|
||||
|
||||
// Fallback: if no placeholder found, just format normally
|
||||
return $date->format($phpFormat);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ class Uniqueness extends FiltersProvider
|
|||
$randomString = '';
|
||||
|
||||
for ($i = 0; $i < $length; ++$i) {
|
||||
$randomString .= $characters[rand(0, mb_strlen($characters) - 1)];
|
||||
$randomString .= $characters[random_int(0, mb_strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
return $randomString;
|
||||
|
|
|
|||
210
app/Liquid/Utils/ExpressionUtils.php
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Utils;
|
||||
|
||||
/**
|
||||
* Utility class for parsing and evaluating expressions in Liquid filters
|
||||
*/
|
||||
class ExpressionUtils
|
||||
{
|
||||
/**
|
||||
* Check if an array is associative
|
||||
*/
|
||||
public static function isAssociativeArray(array $array): bool
|
||||
{
|
||||
if ($array === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_keys($array) !== range(0, count($array) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a condition expression into a structured format
|
||||
*/
|
||||
public static function parseCondition(string $expression): array
|
||||
{
|
||||
$expression = mb_trim($expression);
|
||||
|
||||
// Handle logical operators (and, or)
|
||||
if (str_contains($expression, ' and ')) {
|
||||
$parts = explode(' and ', $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'and',
|
||||
'left' => self::parseCondition(mb_trim($parts[0])),
|
||||
'right' => self::parseCondition(mb_trim($parts[1])),
|
||||
];
|
||||
}
|
||||
|
||||
if (str_contains($expression, ' or ')) {
|
||||
$parts = explode(' or ', $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'or',
|
||||
'left' => self::parseCondition(mb_trim($parts[0])),
|
||||
'right' => self::parseCondition(mb_trim($parts[1])),
|
||||
];
|
||||
}
|
||||
|
||||
// Handle comparison operators
|
||||
$operators = ['>=', '<=', '!=', '==', '>', '<', '='];
|
||||
|
||||
foreach ($operators as $operator) {
|
||||
if (str_contains($expression, $operator)) {
|
||||
$parts = explode($operator, $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'comparison',
|
||||
'left' => mb_trim($parts[0]),
|
||||
'operator' => $operator === '=' ? '==' : $operator,
|
||||
'right' => mb_trim($parts[1]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// If no operator found, treat as a simple expression
|
||||
return [
|
||||
'type' => 'simple',
|
||||
'expression' => $expression,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition against an object
|
||||
*/
|
||||
public static function evaluateCondition(array $condition, string $variable, mixed $object): bool
|
||||
{
|
||||
switch ($condition['type']) {
|
||||
case 'and':
|
||||
return self::evaluateCondition($condition['left'], $variable, $object) &&
|
||||
self::evaluateCondition($condition['right'], $variable, $object);
|
||||
|
||||
case 'or':
|
||||
if (self::evaluateCondition($condition['left'], $variable, $object)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::evaluateCondition($condition['right'], $variable, $object);
|
||||
|
||||
case 'comparison':
|
||||
$leftValue = self::resolveValue($condition['left'], $variable, $object);
|
||||
$rightValue = self::resolveValue($condition['right'], $variable, $object);
|
||||
|
||||
return match ($condition['operator']) {
|
||||
'==' => $leftValue === $rightValue,
|
||||
'!=' => $leftValue !== $rightValue,
|
||||
'>' => $leftValue > $rightValue,
|
||||
'<' => $leftValue < $rightValue,
|
||||
'>=' => $leftValue >= $rightValue,
|
||||
'<=' => $leftValue <= $rightValue,
|
||||
default => false,
|
||||
};
|
||||
|
||||
case 'simple':
|
||||
$value = self::resolveValue($condition['expression'], $variable, $object);
|
||||
|
||||
return (bool) $value;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a value from an expression, variable, or literal
|
||||
*/
|
||||
public static function resolveValue(string $expression, string $variable, mixed $object): mixed
|
||||
{
|
||||
$expression = mb_trim($expression);
|
||||
|
||||
// If it's the variable name, return the object
|
||||
if ($expression === $variable) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
// If it's a property access (e.g., "n.age"), resolve it
|
||||
if (str_starts_with($expression, $variable.'.')) {
|
||||
$property = mb_substr($expression, mb_strlen($variable) + 1);
|
||||
if (is_array($object) && array_key_exists($property, $object)) {
|
||||
return $object[$property];
|
||||
}
|
||||
if (is_object($object) && property_exists($object, $property)) {
|
||||
return $object->$property;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse as a number
|
||||
if (is_numeric($expression)) {
|
||||
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
|
||||
}
|
||||
|
||||
// Try to parse as boolean
|
||||
if (in_array(mb_strtolower($expression), ['true', 'false'])) {
|
||||
return mb_strtolower($expression) === 'true';
|
||||
}
|
||||
|
||||
// Try to parse as null
|
||||
if (mb_strtolower($expression) === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return as string (remove quotes if present)
|
||||
if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) ||
|
||||
(str_starts_with($expression, "'") && str_ends_with($expression, "'"))) {
|
||||
return mb_substr($expression, 1, -1);
|
||||
}
|
||||
|
||||
return $expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert strftime format string to PHP date format string
|
||||
*
|
||||
* @param string $strftimeFormat The strftime format string
|
||||
* @return string The PHP date format string
|
||||
*/
|
||||
public static function strftimeToPhpFormat(string $strftimeFormat): string
|
||||
{
|
||||
$conversions = [
|
||||
// Special Ruby format cases
|
||||
'%N' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
|
||||
'%u' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
|
||||
'%-m' => 'n', // Month without leading zero (Ruby) -> month without leading zero (PHP)
|
||||
'%-d' => 'j', // Day without leading zero (Ruby) -> day without leading zero (PHP)
|
||||
'%-H' => 'G', // Hour without leading zero (Ruby) -> hour without leading zero (PHP)
|
||||
'%-I' => 'g', // Hour 12h without leading zero (Ruby) -> hour 12h without leading zero (PHP)
|
||||
'%-M' => 'i', // Minute without leading zero (Ruby) -> minute without leading zero (PHP)
|
||||
'%-S' => 's', // Second without leading zero (Ruby) -> second without leading zero (PHP)
|
||||
'%z' => 'O', // Timezone offset (Ruby) -> timezone offset (PHP)
|
||||
'%Z' => 'T', // Timezone name (Ruby) -> timezone name (PHP)
|
||||
|
||||
// Standard strftime conversions
|
||||
'%A' => 'l', // Full weekday name
|
||||
'%a' => 'D', // Abbreviated weekday name
|
||||
'%B' => 'F', // Full month name
|
||||
'%b' => 'M', // Abbreviated month name
|
||||
'%Y' => 'Y', // Full year (4 digits)
|
||||
'%y' => 'y', // Year without century (2 digits)
|
||||
'%m' => 'm', // Month as decimal number (01-12)
|
||||
'%d' => 'd', // Day of month as decimal number (01-31)
|
||||
'%H' => 'H', // Hour in 24-hour format (00-23)
|
||||
'%I' => 'h', // Hour in 12-hour format (01-12)
|
||||
'%M' => 'i', // Minute as decimal number (00-59)
|
||||
'%S' => 's', // Second as decimal number (00-59)
|
||||
'%p' => 'A', // AM/PM
|
||||
'%P' => 'a', // am/pm
|
||||
'%j' => 'z', // Day of year as decimal number (001-366)
|
||||
'%w' => 'w', // Weekday as decimal number (0-6, Sunday is 0)
|
||||
'%U' => 'W', // Week number of year (00-53, Sunday is first day)
|
||||
'%W' => 'W', // Week number of year (00-53, Monday is first day)
|
||||
'%c' => 'D M j H:i:s Y', // Date and time representation
|
||||
'%x' => 'm/d/Y', // Date representation
|
||||
'%X' => 'H:i:s', // Time representation
|
||||
];
|
||||
|
||||
return str_replace(array_keys($conversions), array_values($conversions), $strftimeFormat);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,14 +10,14 @@ class DeviceAutoJoin extends Component
|
|||
|
||||
public bool $isFirstUser = false;
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->deviceAutojoin = auth()->user()->assign_new_devices;
|
||||
$this->isFirstUser = auth()->user()->id === 1;
|
||||
|
||||
}
|
||||
|
||||
public function updating($name, $value)
|
||||
public function updating($name, $value): void
|
||||
{
|
||||
$this->validate([
|
||||
'deviceAutojoin' => 'boolean',
|
||||
|
|
@ -30,7 +30,7 @@ class DeviceAutoJoin extends Component
|
|||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
|
||||
{
|
||||
return view('livewire.actions.device-auto-join');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class Logout
|
|||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke()
|
||||
public function __invoke(): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use Livewire\Component;
|
|||
|
||||
class DeviceDashboard extends Component
|
||||
{
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
|
||||
{
|
||||
return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,24 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* @property-read DeviceModel|null $deviceModel
|
||||
* @property-read DevicePalette|null $palette
|
||||
*/
|
||||
class Device extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Set the MAC address attribute, normalizing to uppercase.
|
||||
*/
|
||||
public function setMacAddressAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null;
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'battery_notification_sent' => 'boolean',
|
||||
'proxy_cloud' => 'boolean',
|
||||
|
|
@ -32,7 +44,7 @@ class Device extends Model
|
|||
'pause_until' => 'datetime',
|
||||
];
|
||||
|
||||
public function getBatteryPercentAttribute()
|
||||
public function getBatteryPercentAttribute(): int|float
|
||||
{
|
||||
$volts = $this->last_battery_voltage;
|
||||
|
||||
|
|
@ -80,7 +92,7 @@ class Device extends Model
|
|||
return round($voltage, 2);
|
||||
}
|
||||
|
||||
public function getWifiStrengthAttribute()
|
||||
public function getWifiStrengthAttribute(): int
|
||||
{
|
||||
$rssi = $this->last_rssi_level;
|
||||
if ($rssi >= 0) {
|
||||
|
|
@ -103,11 +115,7 @@ class Device extends Model
|
|||
return true;
|
||||
}
|
||||
|
||||
if ($this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware']) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware'];
|
||||
}
|
||||
|
||||
public function getFirmwareUrlAttribute(): ?string
|
||||
|
|
@ -188,6 +196,35 @@ class Device extends Model
|
|||
return $this->belongsTo(DeviceModel::class);
|
||||
}
|
||||
|
||||
public function palette(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color depth string (e.g., "4bit") for the associated device model.
|
||||
*/
|
||||
public function colorDepth(): ?string
|
||||
{
|
||||
return $this->deviceModel?->color_depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scale level (e.g., large/xlarge/xxlarge) for the associated device model.
|
||||
*/
|
||||
public function scaleLevel(): ?string
|
||||
{
|
||||
return $this->deviceModel?->scale_level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device variant name, defaulting to 'og' if not available.
|
||||
*/
|
||||
public function deviceVariant(): string
|
||||
{
|
||||
return $this->deviceModel->name ?? 'og';
|
||||
}
|
||||
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceLog::class);
|
||||
|
|
@ -204,7 +241,7 @@ class Device extends Model
|
|||
return false;
|
||||
}
|
||||
|
||||
$now = $now ? Carbon::instance($now) : now();
|
||||
$now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
|
||||
|
||||
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
||||
return $this->sleep_mode_from < $this->sleep_mode_to
|
||||
|
|
@ -218,7 +255,7 @@ class Device extends Model
|
|||
return null;
|
||||
}
|
||||
|
||||
$now = $now ? Carbon::instance($now) : now();
|
||||
$now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
|
||||
$from = $this->sleep_mode_from;
|
||||
$to = $this->sleep_mode_to;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@ namespace App\Models;
|
|||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property-read DevicePalette|null $palette
|
||||
*/
|
||||
final class DeviceModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
|
@ -24,4 +28,51 @@ final class DeviceModel extends Model
|
|||
'offset_y' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getColorDepthAttribute(): ?string
|
||||
{
|
||||
if (! $this->bit_depth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->bit_depth === 3) {
|
||||
return '2bit';
|
||||
}
|
||||
|
||||
// if higher than 4 return 4bit
|
||||
if ($this->bit_depth > 4) {
|
||||
return '4bit';
|
||||
}
|
||||
|
||||
return $this->bit_depth.'bit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scale level based on the device width.
|
||||
*/
|
||||
public function getScaleLevelAttribute(): ?string
|
||||
{
|
||||
if (! $this->width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->width > 800 && $this->width <= 1000) {
|
||||
return 'large';
|
||||
}
|
||||
|
||||
if ($this->width > 1000 && $this->width <= 1400) {
|
||||
return 'xlarge';
|
||||
}
|
||||
|
||||
if ($this->width > 1400) {
|
||||
return 'xxlarge';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function palette(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
app/Models/DevicePalette.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property array|null $colors
|
||||
*/
|
||||
final class DevicePalette extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'grays' => 'integer',
|
||||
'colors' => 'array',
|
||||
];
|
||||
}
|
||||
|
|
@ -37,26 +37,33 @@ class Playlist extends Model
|
|||
return false;
|
||||
}
|
||||
|
||||
// Check weekday
|
||||
if ($this->weekdays !== null) {
|
||||
if (! in_array(now()->dayOfWeek, $this->weekdays)) {
|
||||
return false;
|
||||
}
|
||||
// Get user's timezone or fall back to app timezone
|
||||
$timezone = $this->device->user->timezone ?? config('app.timezone');
|
||||
$now = now($timezone);
|
||||
|
||||
// Check weekday (using timezone-aware time)
|
||||
if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->active_from !== null && $this->active_until !== null) {
|
||||
$now = now();
|
||||
// Create timezone-aware datetime objects for active_from and active_until
|
||||
$activeFrom = $now->copy()
|
||||
->setTimeFrom($this->active_from)
|
||||
->timezone($timezone);
|
||||
|
||||
$activeUntil = $now->copy()
|
||||
->setTimeFrom($this->active_until)
|
||||
->timezone($timezone);
|
||||
|
||||
// Handle time ranges that span across midnight
|
||||
if ($this->active_from > $this->active_until) {
|
||||
if ($activeFrom > $activeUntil) {
|
||||
// Time range spans midnight (e.g., 09:01 to 03:58)
|
||||
if ($now >= $this->active_from || $now <= $this->active_until) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if ($now >= $this->active_from && $now <= $this->active_until) {
|
||||
if ($now >= $activeFrom || $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
} elseif ($now >= $activeFrom && $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -135,10 +135,13 @@ class PlaylistItem extends Model
|
|||
/**
|
||||
* Render all plugins with appropriate layout
|
||||
*/
|
||||
public function render(): string
|
||||
public function render(?Device $device = null): string
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $this->plugin instanceof Plugin
|
||||
? $this->plugin->render('full', false)
|
||||
: throw new Exception('Invalid plugin instance'),
|
||||
|
|
@ -150,9 +153,7 @@ class PlaylistItem extends Model
|
|||
$plugins = Plugin::whereIn('id', $pluginIds)->get();
|
||||
|
||||
// Sort the collection to match plugin_ids order
|
||||
$plugins = $plugins->sortBy(function ($plugin) use ($pluginIds) {
|
||||
return array_search($plugin->id, $pluginIds);
|
||||
})->values();
|
||||
$plugins = $plugins->sortBy(fn ($plugin): int|string|false => array_search($plugin->id, $pluginIds))->values();
|
||||
|
||||
foreach ($plugins as $index => $plugin) {
|
||||
$size = $this->getLayoutSize($index);
|
||||
|
|
@ -160,6 +161,9 @@ class PlaylistItem extends Model
|
|||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'mashupLayout' => $this->getMashupLayoutType(),
|
||||
'slot' => implode('', $pluginMarkups),
|
||||
])->render();
|
||||
|
|
|
|||
|
|
@ -11,14 +11,20 @@ use App\Liquid\Filters\StandardFilters;
|
|||
use App\Liquid\Filters\StringMarkup;
|
||||
use App\Liquid\Filters\Uniqueness;
|
||||
use App\Liquid\Tags\TemplateTag;
|
||||
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||
use App\Services\PluginImportService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||
|
|
@ -36,17 +42,34 @@ class Plugin extends Model
|
|||
'markup_language' => 'string',
|
||||
'configuration' => 'json',
|
||||
'configuration_template' => 'json',
|
||||
'no_bleed' => 'boolean',
|
||||
'dark_mode' => 'boolean',
|
||||
'preferred_renderer' => 'string',
|
||||
'plugin_type' => 'string',
|
||||
'alias' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
static::creating(function ($model): void {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = Str::uuid();
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($model): void {
|
||||
// Reset image cache when markup changes
|
||||
if ($model->isDirty('render_markup')) {
|
||||
$model->current_image = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize configuration template on save
|
||||
static::saving(function ($model): void {
|
||||
$model->sanitizeTemplate();
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
|
|
@ -54,6 +77,25 @@ class Plugin extends Model
|
|||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
|
||||
protected function sanitizeTemplate(): void
|
||||
{
|
||||
$template = $this->configuration_template;
|
||||
|
||||
if (isset($template['custom_fields']) && is_array($template['custom_fields'])) {
|
||||
foreach ($template['custom_fields'] as &$field) {
|
||||
if (isset($field['description'])) {
|
||||
$field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']);
|
||||
}
|
||||
if (isset($field['help_text'])) {
|
||||
$field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->configuration_template = $template;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasMissingRequiredConfigurationFields(): bool
|
||||
{
|
||||
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
||||
|
|
@ -83,7 +125,7 @@ class Plugin extends Model
|
|||
$currentValue = $this->configuration[$fieldKey] ?? null;
|
||||
|
||||
// If the field has a default value and no current value is set, it's not missing
|
||||
if (($currentValue === null || $currentValue === '' || (is_array($currentValue) && empty($currentValue))) && ! isset($field['default'])) {
|
||||
if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) {
|
||||
return true; // Found a required field that is not set and has no default
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +136,11 @@ class Plugin extends Model
|
|||
|
||||
public function isDataStale(): bool
|
||||
{
|
||||
// Image webhook plugins don't use data staleness - images are pushed directly
|
||||
if ($this->plugin_type === 'image_webhook') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->data_strategy === 'webhook') {
|
||||
// Treat as stale if any webhook event has occurred in the past hour
|
||||
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
||||
|
|
@ -107,108 +154,88 @@ class Plugin extends Model
|
|||
|
||||
public function updateDataPayload(): void
|
||||
{
|
||||
if ($this->data_strategy === 'polling' && $this->polling_url) {
|
||||
|
||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||
|
||||
if ($this->polling_header) {
|
||||
// Resolve Liquid variables in the polling header
|
||||
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
||||
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
||||
foreach ($headerLines as $line) {
|
||||
$parts = explode(':', $line, 2);
|
||||
if (count($parts) === 2) {
|
||||
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split URLs by newline and filter out empty lines
|
||||
$urls = array_filter(
|
||||
array_map('trim', explode("\n", $this->polling_url)),
|
||||
fn ($url) => ! empty($url)
|
||||
);
|
||||
|
||||
// If only one URL, use the original logic without nesting
|
||||
if (count($urls) === 1) {
|
||||
$url = reset($urls);
|
||||
$httpRequest = Http::withHeaders($headers);
|
||||
|
||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||
// Resolve Liquid variables in the polling body
|
||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||
}
|
||||
|
||||
// Resolve Liquid variables in the polling URL
|
||||
$resolvedUrl = $this->resolveLiquidVariables($url);
|
||||
|
||||
try {
|
||||
// Make the request based on the verb
|
||||
if ($this->polling_verb === 'post') {
|
||||
$response = $httpRequest->post($resolvedUrl)->json();
|
||||
} else {
|
||||
$response = $httpRequest->get($resolvedUrl)->json();
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'data_payload' => $response,
|
||||
'data_payload_updated_at' => now(),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
|
||||
$this->update([
|
||||
'data_payload' => ['error' => 'Failed to fetch data'],
|
||||
'data_payload_updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple URLs - use nested response logic
|
||||
$combinedResponse = [];
|
||||
|
||||
foreach ($urls as $index => $url) {
|
||||
$httpRequest = Http::withHeaders($headers);
|
||||
|
||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||
// Resolve Liquid variables in the polling body
|
||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||
}
|
||||
|
||||
// Resolve Liquid variables in the polling URL
|
||||
$resolvedUrl = $this->resolveLiquidVariables($url);
|
||||
|
||||
try {
|
||||
// Make the request based on the verb
|
||||
if ($this->polling_verb === 'post') {
|
||||
$response = $httpRequest->post($resolvedUrl)->json();
|
||||
} else {
|
||||
$response = $httpRequest->get($resolvedUrl)->json();
|
||||
}
|
||||
|
||||
// Check if response is an array at root level
|
||||
if (is_array($response) && array_keys($response) === range(0, count($response) - 1)) {
|
||||
// Response is a sequential array, nest under .data
|
||||
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
||||
} else {
|
||||
// Response is an object or associative array, keep as is
|
||||
$combinedResponse["IDX_{$index}"] = $response;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Log error and continue with other URLs
|
||||
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
|
||||
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'data_payload' => $combinedResponse,
|
||||
'data_payload_updated_at' => now(),
|
||||
]);
|
||||
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
||||
return;
|
||||
}
|
||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||
|
||||
// resolve headers
|
||||
if ($this->polling_header) {
|
||||
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
||||
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
||||
foreach ($headerLines as $line) {
|
||||
$parts = explode(':', $line, 2);
|
||||
if (count($parts) === 2) {
|
||||
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolve and clean URLs
|
||||
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
||||
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
|
||||
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
||||
fn ($url): bool => filled($url)
|
||||
));
|
||||
|
||||
$combinedResponse = [];
|
||||
|
||||
// Loop through all URLs (Handles 1 or many)
|
||||
foreach ($urls as $index => $url) {
|
||||
$httpRequest = Http::withHeaders($headers);
|
||||
|
||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||
}
|
||||
|
||||
try {
|
||||
$httpResponse = ($this->polling_verb === 'post')
|
||||
? $httpRequest->post($url)
|
||||
: $httpRequest->get($url);
|
||||
|
||||
$response = $this->parseResponse($httpResponse);
|
||||
|
||||
// Nest if it's a sequential array
|
||||
if (array_keys($response) === range(0, count($response) - 1)) {
|
||||
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
||||
} else {
|
||||
$combinedResponse["IDX_{$index}"] = $response;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
|
||||
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
||||
}
|
||||
}
|
||||
|
||||
// unwrap IDX_0 if only one URL
|
||||
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
|
||||
|
||||
$this->update([
|
||||
'data_payload' => $finalPayload,
|
||||
'data_payload_updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseResponse(Response $httpResponse): array
|
||||
{
|
||||
$parsers = app(ResponseParserRegistry::class)->getParsers();
|
||||
|
||||
foreach ($parsers as $parser) {
|
||||
$parserName = class_basename($parser);
|
||||
|
||||
try {
|
||||
$result = $parser->parse($httpResponse);
|
||||
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ['error' => 'Failed to parse response'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,15 +243,15 @@ class Plugin extends Model
|
|||
*/
|
||||
private function applyLiquidReplacements(string $template): string
|
||||
{
|
||||
$replacements = [
|
||||
'date: "%N"' => 'date: "u"',
|
||||
'date: "%u"' => 'date: "u"',
|
||||
'%-m/%-d/%Y' => 'm/d/Y',
|
||||
];
|
||||
|
||||
$replacements = [];
|
||||
|
||||
// Apply basic replacements
|
||||
$template = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
|
||||
// Convert Ruby/strftime date formats to PHP date formats
|
||||
$template = $this->convertDateFormats($template);
|
||||
|
||||
// Convert {% render "template" with %} syntax to {% render "template", %} syntax
|
||||
$template = preg_replace(
|
||||
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
|
||||
|
|
@ -236,8 +263,8 @@ class Plugin extends Model
|
|||
// This handles: {% for item in collection | filter: "key", "value" %}
|
||||
// Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %}
|
||||
$template = preg_replace_callback(
|
||||
'/{%\s*for\s+(\w+)\s+in\s+([^|]+)\s*\|\s*([^}]+)%}/',
|
||||
function ($matches) {
|
||||
'/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/',
|
||||
function (array $matches): string {
|
||||
$variableName = mb_trim($matches[1]);
|
||||
$collection = mb_trim($matches[2]);
|
||||
$filter = mb_trim($matches[3]);
|
||||
|
|
@ -245,25 +272,88 @@ class Plugin extends Model
|
|||
|
||||
return "{% assign {$tempVarName} = {$collection} | {$filter} %}{% for {$variableName} in {$tempVarName} %}";
|
||||
},
|
||||
$template
|
||||
(string) $template
|
||||
);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Ruby/strftime date formats to PHP date formats in Liquid templates
|
||||
*/
|
||||
private function convertDateFormats(string $template): string
|
||||
{
|
||||
// Handle date filter formats: date: "format" or date: 'format'
|
||||
$template = preg_replace_callback(
|
||||
'/date:\s*(["\'])([^"\']+)\1/',
|
||||
function (array $matches): string {
|
||||
$quote = $matches[1];
|
||||
$format = $matches[2];
|
||||
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
||||
|
||||
return 'date: '.$quote.$convertedFormat.$quote;
|
||||
},
|
||||
$template
|
||||
);
|
||||
|
||||
// Handle l_date filter formats: l_date: "format" or l_date: 'format'
|
||||
$template = preg_replace_callback(
|
||||
'/l_date:\s*(["\'])([^"\']+)\1/',
|
||||
function (array $matches): string {
|
||||
$quote = $matches[1];
|
||||
$format = $matches[2];
|
||||
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
||||
|
||||
return 'l_date: '.$quote.$convertedFormat.$quote;
|
||||
},
|
||||
(string) $template
|
||||
);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template contains a Liquid for loop pattern
|
||||
*
|
||||
* @param string $template The template string to check
|
||||
* @return bool True if the template contains a for loop pattern
|
||||
*/
|
||||
private function containsLiquidForLoop(string $template): bool
|
||||
{
|
||||
return preg_match('/{%-?\s*for\s+/i', $template) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Liquid variables in a template string using the Liquid template engine
|
||||
*
|
||||
* Uses the external trmnl-liquid renderer when:
|
||||
* - preferred_renderer is 'trmnl-liquid'
|
||||
* - External renderer is enabled in config
|
||||
* - Template contains a Liquid for loop pattern
|
||||
*
|
||||
* Otherwise uses the internal PHP-based Liquid renderer.
|
||||
*
|
||||
* @param string $template The template string containing Liquid variables
|
||||
* @return string The resolved template with variables replaced with their values
|
||||
*
|
||||
* @throws LiquidException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resolveLiquidVariables(string $template): string
|
||||
{
|
||||
// Get configuration variables - make them available at root level
|
||||
$variables = $this->configuration ?? [];
|
||||
|
||||
// Check if external renderer should be used
|
||||
$useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid'
|
||||
&& config('services.trmnl.liquid_enabled')
|
||||
&& $this->containsLiquidForLoop($template);
|
||||
|
||||
if ($useExternalRenderer) {
|
||||
// Use external Ruby liquid renderer
|
||||
return $this->renderWithExternalLiquidRenderer($template, $variables);
|
||||
}
|
||||
|
||||
// Use the Liquid template engine to resolve variables
|
||||
$environment = App::make('liquid.environment');
|
||||
$environment->filterRegistry->register(StandardFilters::class);
|
||||
|
|
@ -273,67 +363,135 @@ class Plugin extends Model
|
|||
return $liquidTemplate->render($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render template using external Ruby liquid renderer
|
||||
*
|
||||
* @param string $template The liquid template string
|
||||
* @param array $context The render context data
|
||||
* @return string The rendered HTML
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function renderWithExternalLiquidRenderer(string $template, array $context): string
|
||||
{
|
||||
$liquidPath = config('services.trmnl.liquid_path');
|
||||
|
||||
if (empty($liquidPath)) {
|
||||
throw new Exception('External liquid renderer path is not configured');
|
||||
}
|
||||
|
||||
// HTML encode the template
|
||||
$encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Encode context as JSON
|
||||
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($jsonContext === false) {
|
||||
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg());
|
||||
}
|
||||
|
||||
// Validate argument sizes
|
||||
app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath);
|
||||
|
||||
// Execute the external renderer
|
||||
$process = Process::run([
|
||||
$liquidPath,
|
||||
'--template',
|
||||
$encodedTemplate,
|
||||
'--context',
|
||||
$jsonContext,
|
||||
]);
|
||||
|
||||
if (! $process->successful()) {
|
||||
$errorOutput = $process->errorOutput() ?: $process->output();
|
||||
throw new Exception('External liquid renderer failed: '.$errorOutput);
|
||||
}
|
||||
|
||||
return $process->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the plugin's markup
|
||||
*
|
||||
* @throws LiquidException
|
||||
*/
|
||||
public function render(string $size = 'full', bool $standalone = true): string
|
||||
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
|
||||
{
|
||||
if ($this->plugin_type !== 'recipe') {
|
||||
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
|
||||
}
|
||||
|
||||
if ($this->render_markup) {
|
||||
$renderedContent = '';
|
||||
|
||||
if ($this->markup_language === 'liquid') {
|
||||
// Create a custom environment with inline templates support
|
||||
$inlineFileSystem = new InlineTemplatesFileSystem();
|
||||
$environment = new \Keepsuit\Liquid\Environment(
|
||||
fileSystem: $inlineFileSystem,
|
||||
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
|
||||
);
|
||||
// Get timezone from user or fall back to app timezone
|
||||
$timezone = $this->user->timezone ?? config('app.timezone');
|
||||
|
||||
// Register all custom filters
|
||||
$environment->filterRegistry->register(Data::class);
|
||||
$environment->filterRegistry->register(Date::class);
|
||||
$environment->filterRegistry->register(Localization::class);
|
||||
$environment->filterRegistry->register(Numbers::class);
|
||||
$environment->filterRegistry->register(StringMarkup::class);
|
||||
$environment->filterRegistry->register(Uniqueness::class);
|
||||
// Calculate UTC offset in seconds
|
||||
$utcOffset = (string) Carbon::now($timezone)->getOffset();
|
||||
|
||||
// Register the template tag for inline templates
|
||||
$environment->tagRegistry->register(TemplateTag::class);
|
||||
|
||||
// Apply Liquid replacements (including 'with' syntax conversion)
|
||||
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
||||
|
||||
$template = $environment->parseString($processedMarkup);
|
||||
$context = $environment->newRenderContext(
|
||||
data: [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
...(is_array($this->data_payload) ? $this->data_payload : []),
|
||||
'trmnl' => [
|
||||
'user' => [
|
||||
'utc_offset' => '0',
|
||||
'name' => $this->user->name ?? 'Unknown User',
|
||||
'locale' => 'en',
|
||||
'time_zone_iana' => config('app.timezone'),
|
||||
],
|
||||
'plugin_settings' => [
|
||||
'instance_name' => $this->name,
|
||||
'strategy' => $this->data_strategy,
|
||||
'dark_mode' => 'no',
|
||||
'no_screen_padding' => 'no',
|
||||
'polling_headers' => $this->polling_header,
|
||||
'polling_url' => $this->polling_url,
|
||||
'custom_fields_values' => [
|
||||
...(is_array($this->configuration) ? $this->configuration : []),
|
||||
],
|
||||
// Build render context
|
||||
$context = [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
...(is_array($this->data_payload) ? $this->data_payload : []),
|
||||
'trmnl' => [
|
||||
'system' => [
|
||||
'timestamp_utc' => now()->utc()->timestamp,
|
||||
],
|
||||
'user' => [
|
||||
'utc_offset' => $utcOffset,
|
||||
'name' => $this->user->name ?? 'Unknown User',
|
||||
'locale' => 'en',
|
||||
'time_zone_iana' => $timezone,
|
||||
],
|
||||
'plugin_settings' => [
|
||||
'instance_name' => $this->name,
|
||||
'strategy' => $this->data_strategy,
|
||||
'dark_mode' => $this->dark_mode ? 'yes' : 'no',
|
||||
'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
|
||||
'polling_headers' => $this->polling_header,
|
||||
'polling_url' => $this->polling_url,
|
||||
'custom_fields_values' => [
|
||||
...(is_array($this->configuration) ? $this->configuration : []),
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
$renderedContent = $template->render($context);
|
||||
],
|
||||
];
|
||||
|
||||
// Check if external renderer should be used
|
||||
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
||||
// Use external Ruby renderer - pass raw template without preprocessing
|
||||
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
|
||||
} else {
|
||||
// Use PHP keepsuit/liquid renderer
|
||||
// Create a custom environment with inline templates support
|
||||
$inlineFileSystem = new InlineTemplatesFileSystem();
|
||||
$environment = new \Keepsuit\Liquid\Environment(
|
||||
fileSystem: $inlineFileSystem,
|
||||
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
|
||||
);
|
||||
|
||||
// Register all custom filters
|
||||
$environment->filterRegistry->register(Data::class);
|
||||
$environment->filterRegistry->register(Date::class);
|
||||
$environment->filterRegistry->register(Localization::class);
|
||||
$environment->filterRegistry->register(Numbers::class);
|
||||
$environment->filterRegistry->register(StringMarkup::class);
|
||||
$environment->filterRegistry->register(Uniqueness::class);
|
||||
|
||||
// Register the template tag for inline templates
|
||||
$environment->tagRegistry->register(TemplateTag::class);
|
||||
|
||||
// Apply Liquid replacements (including 'with' syntax conversion)
|
||||
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
||||
|
||||
$template = $environment->parseString($processedMarkup);
|
||||
$liquidContext = $environment->newRenderContext(data: $context);
|
||||
$renderedContent = $template->render($liquidContext);
|
||||
}
|
||||
} else {
|
||||
$renderedContent = Blade::render($this->render_markup, [
|
||||
'size' => $size,
|
||||
|
|
@ -343,9 +501,26 @@ class Plugin extends Model
|
|||
}
|
||||
|
||||
if ($standalone) {
|
||||
return view('trmnl-layouts.single', [
|
||||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
|
||||
}
|
||||
|
||||
return $renderedContent;
|
||||
|
|
@ -353,12 +528,30 @@ class Plugin extends Model
|
|||
|
||||
if ($this->render_markup_view) {
|
||||
if ($standalone) {
|
||||
return view('trmnl-layouts.single', [
|
||||
'slot' => view($this->render_markup_view, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
])->render(),
|
||||
$renderedView = view($this->render_markup_view, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
])->render();
|
||||
|
||||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
||||
|
|
@ -380,4 +573,70 @@ class Plugin extends Model
|
|||
{
|
||||
return $this->configuration[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getPreviewMashupLayoutForSize(string $size): string
|
||||
{
|
||||
return match ($size) {
|
||||
'half_vertical' => '1Lx1R',
|
||||
'quadrant' => '2x2',
|
||||
default => '1Tx1B',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the plugin, copying all attributes and handling render_markup_view
|
||||
*
|
||||
* @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id.
|
||||
* @return Plugin The newly created duplicate plugin
|
||||
*/
|
||||
public function duplicate(?int $userId = null): self
|
||||
{
|
||||
// Get all attributes except id and uuid
|
||||
// Use toArray() to get cast values (respects JSON casts)
|
||||
$attributes = $this->toArray();
|
||||
unset($attributes['id'], $attributes['uuid']);
|
||||
|
||||
// Handle render_markup_view - copy file content to render_markup
|
||||
if ($this->render_markup_view) {
|
||||
try {
|
||||
$basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view));
|
||||
$paths = [
|
||||
$basePath.'.blade.php',
|
||||
$basePath.'.liquid',
|
||||
];
|
||||
|
||||
$fileContent = null;
|
||||
$markupLanguage = null;
|
||||
foreach ($paths as $path) {
|
||||
if (file_exists($path)) {
|
||||
$fileContent = file_get_contents($path);
|
||||
// Determine markup language based on file extension
|
||||
$markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileContent !== null) {
|
||||
$attributes['render_markup'] = $fileContent;
|
||||
$attributes['markup_language'] = $markupLanguage;
|
||||
$attributes['render_markup_view'] = null;
|
||||
} else {
|
||||
// File doesn't exist, remove the view reference
|
||||
$attributes['render_markup_view'] = null;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// If file reading fails, remove the view reference
|
||||
$attributes['render_markup_view'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Append " (Copy)" to the name
|
||||
$attributes['name'] = $this->name.' (Copy)';
|
||||
|
||||
// Set user_id - use provided userId or fall back to original plugin's user_id
|
||||
$attributes['user_id'] = $userId ?? $this->user_id;
|
||||
|
||||
// Create and return the new plugin
|
||||
return self::create($attributes);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ class User extends Authenticatable // implements MustVerifyEmail
|
|||
'assign_new_devices',
|
||||
'assign_new_device_id',
|
||||
'oidc_sub',
|
||||
'timezone',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,15 +13,10 @@ class BatteryLow extends Notification
|
|||
{
|
||||
use Queueable;
|
||||
|
||||
private Device $device;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(Device $device)
|
||||
{
|
||||
$this->device = $device;
|
||||
}
|
||||
public function __construct(private Device $device) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
|
|
@ -41,7 +36,7 @@ class BatteryLow extends Notification
|
|||
return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]);
|
||||
}
|
||||
|
||||
public function toWebhook(object $notifiable)
|
||||
public function toWebhook(object $notifiable): WebhookMessage
|
||||
{
|
||||
return WebhookMessage::create()
|
||||
->data([
|
||||
|
|
|
|||
|
|
@ -11,13 +11,7 @@ use Illuminate\Support\Arr;
|
|||
|
||||
class WebhookChannel extends Notification
|
||||
{
|
||||
/** @var Client */
|
||||
protected $client;
|
||||
|
||||
public function __construct(Client $client)
|
||||
{
|
||||
$this->client = $client;
|
||||
}
|
||||
public function __construct(protected Client $client) {}
|
||||
|
||||
/**
|
||||
* Send the given notification.
|
||||
|
|
|
|||
|
|
@ -13,13 +13,6 @@ final class WebhookMessage extends Notification
|
|||
*/
|
||||
private $query;
|
||||
|
||||
/**
|
||||
* The POST data of the Webhook request.
|
||||
*
|
||||
* @var mixed
|
||||
*/
|
||||
private $data;
|
||||
|
||||
/**
|
||||
* The headers to send with the request.
|
||||
*
|
||||
|
|
@ -36,9 +29,8 @@ final class WebhookMessage extends Notification
|
|||
|
||||
/**
|
||||
* @param mixed $data
|
||||
* @return static
|
||||
*/
|
||||
public static function create($data = '')
|
||||
public static function create($data = ''): self
|
||||
{
|
||||
return new self($data);
|
||||
}
|
||||
|
|
@ -46,10 +38,12 @@ final class WebhookMessage extends Notification
|
|||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
public function __construct($data = '')
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
public function __construct(
|
||||
/**
|
||||
* The POST data of the Webhook request.
|
||||
*/
|
||||
private $data = ''
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set the Webhook parameters to be URL encoded.
|
||||
|
|
@ -57,7 +51,7 @@ final class WebhookMessage extends Notification
|
|||
* @param mixed $query
|
||||
* @return $this
|
||||
*/
|
||||
public function query($query)
|
||||
public function query($query): self
|
||||
{
|
||||
$this->query = $query;
|
||||
|
||||
|
|
@ -70,7 +64,7 @@ final class WebhookMessage extends Notification
|
|||
* @param mixed $data
|
||||
* @return $this
|
||||
*/
|
||||
public function data($data)
|
||||
public function data($data): self
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
|
|
@ -84,7 +78,7 @@ final class WebhookMessage extends Notification
|
|||
* @param string $value
|
||||
* @return $this
|
||||
*/
|
||||
public function header($name, $value)
|
||||
public function header($name, $value): self
|
||||
{
|
||||
$this->headers[$name] = $value;
|
||||
|
||||
|
|
@ -97,7 +91,7 @@ final class WebhookMessage extends Notification
|
|||
* @param string $userAgent
|
||||
* @return $this
|
||||
*/
|
||||
public function userAgent($userAgent)
|
||||
public function userAgent($userAgent): self
|
||||
{
|
||||
$this->headers['User-Agent'] = $userAgent;
|
||||
|
||||
|
|
@ -109,17 +103,14 @@ final class WebhookMessage extends Notification
|
|||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function verify($value = true)
|
||||
public function verify($value = true): self
|
||||
{
|
||||
$this->verify = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function toArray()
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'query' => $this->query,
|
||||
|
|
|
|||
|
|
@ -33,17 +33,19 @@ class AppServiceProvider extends ServiceProvider
|
|||
|
||||
$http = clone $this;
|
||||
$http->server->set('HTTPS', 'off');
|
||||
if (URL::hasValidSignature($https, $absolute, $ignoreQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return URL::hasValidSignature($https, $absolute, $ignoreQuery)
|
||||
|| URL::hasValidSignature($http, $absolute, $ignoreQuery);
|
||||
return URL::hasValidSignature($http, $absolute, $ignoreQuery);
|
||||
});
|
||||
|
||||
// Register OIDC provider with Socialite
|
||||
Socialite::extend('oidc', function ($app) {
|
||||
$config = $app['config']['services.oidc'] ?? [];
|
||||
Socialite::extend('oidc', function (\Illuminate\Contracts\Foundation\Application $app): OidcProvider {
|
||||
$config = $app->make('config')->get('services.oidc', []);
|
||||
|
||||
return new OidcProvider(
|
||||
$app['request'],
|
||||
$app->make(Request::class),
|
||||
$config['client_id'] ?? null,
|
||||
$config['client_secret'] ?? null,
|
||||
$config['redirect'] ?? null,
|
||||
|
|
|
|||
|
|
@ -6,65 +6,33 @@ use App\Enums\ImageFormat;
|
|||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\Plugin;
|
||||
use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
|
||||
use Bnussbau\TrmnlPipeline\Stages\ImageStage;
|
||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Imagick;
|
||||
use ImagickException;
|
||||
use ImagickPixel;
|
||||
use Log;
|
||||
use InvalidArgumentException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RuntimeException;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
use function config;
|
||||
use function file_exists;
|
||||
use function filesize;
|
||||
|
||||
class ImageGenerationService
|
||||
{
|
||||
public static function generateImage(string $markup, $deviceId): string
|
||||
{
|
||||
$device = Device::with('deviceModel')->find($deviceId);
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
|
||||
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
|
||||
|
||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
$imageSettings = self::getImageSettings($device);
|
||||
|
||||
// Generate PNG
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
try {
|
||||
$browsershot = BrowsershotLambda::html($markup)
|
||||
->windowSize(800, 480);
|
||||
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browsershot->waitUntilNetworkIdle();
|
||||
}
|
||||
|
||||
$browsershot->save($pngPath);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate PNG: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
$browsershot = Browsershot::html($markup)
|
||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []);
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browsershot->waitUntilNetworkIdle();
|
||||
}
|
||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||
$browsershot->windowSize($imageSettings['width'], $imageSettings['height']);
|
||||
} else {
|
||||
$browsershot->windowSize(800, 480);
|
||||
}
|
||||
$browsershot->save($pngPath);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate PNG: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert image based on DeviceModel settings or fallback to device settings
|
||||
self::convertImage($pngPath, $bmpPath, $imageSettings);
|
||||
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
|
||||
$uuid = self::generateImageFromModel(
|
||||
markup: $markup,
|
||||
deviceModel: $device->deviceModel,
|
||||
user: $device->user,
|
||||
palette: $device->palette ?? $device->deviceModel?->palette,
|
||||
device: $device
|
||||
);
|
||||
|
||||
$device->update(['current_screen_image' => $uuid]);
|
||||
Log::info("Device $device->id: updated with new image: $uuid");
|
||||
|
|
@ -72,6 +40,116 @@ class ImageGenerationService
|
|||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image from markup using a DeviceModel
|
||||
*
|
||||
* @param string $markup The HTML markup to render
|
||||
* @param DeviceModel|null $deviceModel The device model to use for image generation
|
||||
* @param \App\Models\User|null $user Optional user for timezone settings
|
||||
* @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
|
||||
* @param Device|null $device Optional device for legacy devices without DeviceModel
|
||||
* @return string The UUID of the generated image
|
||||
*/
|
||||
public static function generateImageFromModel(
|
||||
string $markup,
|
||||
?DeviceModel $deviceModel = null,
|
||||
?\App\Models\User $user = null,
|
||||
?\App\Models\DevicePalette $palette = null,
|
||||
?Device $device = null
|
||||
): string {
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
|
||||
try {
|
||||
// Get image generation settings from DeviceModel or Device (for legacy devices)
|
||||
$imageSettings = $deviceModel
|
||||
? self::getImageSettingsFromModel($deviceModel)
|
||||
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
|
||||
|
||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($markup);
|
||||
|
||||
// Set timezone from user or fall back to app timezone
|
||||
$timezone = $user?->timezone ?? config('app.timezone');
|
||||
$browserStage->timezone($timezone);
|
||||
|
||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||
$browserStage
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height']);
|
||||
} else {
|
||||
// default behaviour for Framework v1
|
||||
$browserStage->useDefaultDimensions();
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
// Get palette from parameter or fallback to device model's default palette
|
||||
$colorPalette = null;
|
||||
if ($palette && $palette->colors) {
|
||||
$colorPalette = $palette->colors;
|
||||
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
|
||||
$colorPalette = $deviceModel->palette->colors;
|
||||
}
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($fileExtension)
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height'])
|
||||
->colors($imageSettings['colors'])
|
||||
->bitDepth($imageSettings['bit_depth'])
|
||||
->rotation($imageSettings['rotation'])
|
||||
->offsetX($imageSettings['offset_x'])
|
||||
->offsetY($imageSettings['offset_y'])
|
||||
->outputPath($outputPath);
|
||||
|
||||
// Apply color palette if available
|
||||
if ($colorPalette) {
|
||||
$imageStage->colormap($colorPalette);
|
||||
}
|
||||
|
||||
// Apply dithering if requested by markup
|
||||
$shouldDither = self::markupContainsDitherImage($markup);
|
||||
if ($shouldDither) {
|
||||
$imageStage->dither();
|
||||
}
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
Log::info("Generated image: $uuid");
|
||||
|
||||
return $uuid;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate image: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate image: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
*/
|
||||
|
|
@ -79,36 +157,63 @@ class ImageGenerationService
|
|||
{
|
||||
// If device has a DeviceModel, use its settings
|
||||
if ($device->deviceModel) {
|
||||
/** @var DeviceModel $model */
|
||||
$model = $device->deviceModel;
|
||||
return self::getImageSettingsFromModel($device->deviceModel);
|
||||
}
|
||||
|
||||
// Fallback to device settings
|
||||
$imageFormat = $device->image_format ?? ImageFormat::AUTO->value;
|
||||
$mimeType = self::getMimeTypeFromImageFormat($imageFormat);
|
||||
$colors = self::getColorsFromImageFormat($imageFormat);
|
||||
$bitDepth = self::getBitDepthFromImageFormat($imageFormat);
|
||||
|
||||
return [
|
||||
'width' => $device->width ?? 800,
|
||||
'height' => $device->height ?? 480,
|
||||
'colors' => $colors,
|
||||
'bit_depth' => $bitDepth,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => $device->rotate ?? 0,
|
||||
'mime_type' => $mimeType,
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'image_format' => $imageFormat,
|
||||
'use_model_settings' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image generation settings from a DeviceModel
|
||||
*/
|
||||
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
|
||||
{
|
||||
if ($deviceModel) {
|
||||
return [
|
||||
'width' => $model->width,
|
||||
'height' => $model->height,
|
||||
'colors' => $model->colors,
|
||||
'bit_depth' => $model->bit_depth,
|
||||
'scale_factor' => $model->scale_factor,
|
||||
'rotation' => $model->rotation,
|
||||
'mime_type' => $model->mime_type,
|
||||
'offset_x' => $model->offset_x,
|
||||
'offset_y' => $model->offset_y,
|
||||
'image_format' => self::determineImageFormatFromModel($model),
|
||||
'width' => $deviceModel->width,
|
||||
'height' => $deviceModel->height,
|
||||
'colors' => $deviceModel->colors,
|
||||
'bit_depth' => $deviceModel->bit_depth,
|
||||
'scale_factor' => $deviceModel->scale_factor,
|
||||
'rotation' => $deviceModel->rotation,
|
||||
'mime_type' => $deviceModel->mime_type,
|
||||
'offset_x' => $deviceModel->offset_x,
|
||||
'offset_y' => $deviceModel->offset_y,
|
||||
'image_format' => self::determineImageFormatFromModel($deviceModel),
|
||||
'use_model_settings' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback to device settings
|
||||
// Default settings if no device model provided
|
||||
return [
|
||||
'width' => $device->width ?? 800,
|
||||
'height' => $device->height ?? 480,
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => $device->rotate ?? 0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'image_format' => $device->image_format,
|
||||
'image_format' => ImageFormat::AUTO->value,
|
||||
'use_model_settings' => false,
|
||||
];
|
||||
}
|
||||
|
|
@ -137,197 +242,73 @@ class ImageGenerationService
|
|||
}
|
||||
|
||||
/**
|
||||
* Convert image based on the provided settings
|
||||
* Get MIME type from ImageFormat
|
||||
*/
|
||||
private static function convertImage(string $pngPath, string $bmpPath, array $settings): void
|
||||
private static function getMimeTypeFromImageFormat(string $imageFormat): string
|
||||
{
|
||||
$imageFormat = $settings['image_format'];
|
||||
$useModelSettings = $settings['use_model_settings'] ?? false;
|
||||
|
||||
if ($useModelSettings) {
|
||||
// Use DeviceModel-specific conversion
|
||||
self::convertUsingModelSettings($pngPath, $bmpPath, $settings);
|
||||
} else {
|
||||
// Use legacy device-specific conversion
|
||||
self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings);
|
||||
}
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value => 'image/bmp',
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value,
|
||||
ImageFormat::PNG_8BIT_256C->value,
|
||||
ImageFormat::PNG_2BIT_4C->value => 'image/png',
|
||||
ImageFormat::AUTO->value => 'image/png', // Default for AUTO
|
||||
default => 'image/png',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image using DeviceModel settings
|
||||
* Get colors from ImageFormat
|
||||
*/
|
||||
private static function convertUsingModelSettings(string $pngPath, string $bmpPath, array $settings): void
|
||||
private static function getColorsFromImageFormat(string $imageFormat): int
|
||||
{
|
||||
try {
|
||||
$imagick = new Imagick($pngPath);
|
||||
|
||||
// Apply scale factor if needed
|
||||
if ($settings['scale_factor'] !== 1.0) {
|
||||
$newWidth = (int) ($settings['width'] * $settings['scale_factor']);
|
||||
$newHeight = (int) ($settings['height'] * $settings['scale_factor']);
|
||||
$imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1, true);
|
||||
} else {
|
||||
// Resize to model dimensions if different from generated size
|
||||
if ($imagick->getImageWidth() !== $settings['width'] || $imagick->getImageHeight() !== $settings['height']) {
|
||||
$imagick->resizeImage($settings['width'], $settings['height'], Imagick::FILTER_LANCZOS, 1, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
if ($settings['rotation'] !== 0) {
|
||||
$imagick->rotateImage(new ImagickPixel('black'), $settings['rotation']);
|
||||
}
|
||||
|
||||
// Apply offset if specified
|
||||
if ($settings['offset_x'] !== 0 || $settings['offset_y'] !== 0) {
|
||||
$imagick->rollImage($settings['offset_x'], $settings['offset_y']);
|
||||
}
|
||||
|
||||
// Handle special case for 4-color, 2-bit PNG
|
||||
if ($settings['colors'] === 4 && $settings['bit_depth'] === 2 && $settings['mime_type'] === 'image/png') {
|
||||
self::convertTo4Color2BitPng($imagick, $settings['width'], $settings['height']);
|
||||
} else {
|
||||
// Set image type and color depth based on model settings
|
||||
$imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
|
||||
|
||||
if ($settings['bit_depth'] === 1) {
|
||||
$imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth(1);
|
||||
} else {
|
||||
$imagick->quantizeImage($settings['colors'], Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth($settings['bit_depth']);
|
||||
}
|
||||
}
|
||||
|
||||
$imagick->stripImage();
|
||||
|
||||
// Save in the appropriate format
|
||||
if ($settings['mime_type'] === 'image/bmp') {
|
||||
$imagick->setFormat('BMP3');
|
||||
$imagick->writeImage($bmpPath);
|
||||
} else {
|
||||
$imagick->setFormat('png');
|
||||
$imagick->writeImage($pngPath);
|
||||
}
|
||||
|
||||
$imagick->clear();
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image using model settings: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value => 2,
|
||||
ImageFormat::PNG_8BIT_256C->value => 256,
|
||||
ImageFormat::PNG_2BIT_4C->value => 4,
|
||||
ImageFormat::AUTO->value => 2, // Default for AUTO
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image to 4-color, 2-bit PNG using custom colormap and dithering
|
||||
* Get bit depth from ImageFormat
|
||||
*/
|
||||
private static function convertTo4Color2BitPng(Imagick $imagick, int $width, int $height): void
|
||||
private static function getBitDepthFromImageFormat(string $imageFormat): int
|
||||
{
|
||||
// Step 1: Create 4-color grayscale colormap in memory
|
||||
$colors = ['#000000', '#555555', '#aaaaaa', '#ffffff'];
|
||||
$colormap = new Imagick();
|
||||
|
||||
foreach ($colors as $color) {
|
||||
$swatch = new Imagick();
|
||||
$swatch->newImage(1, 1, new ImagickPixel($color));
|
||||
$swatch->setImageFormat('png');
|
||||
$colormap->addImage($swatch);
|
||||
}
|
||||
|
||||
$colormap = $colormap->appendImages(true); // horizontal
|
||||
$colormap->setType(Imagick::IMGTYPE_PALETTE);
|
||||
$colormap->setImageFormat('png');
|
||||
|
||||
// Step 2: Resize to target dimensions without keeping aspect ratio
|
||||
$imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, false);
|
||||
|
||||
// Step 3: Apply Floyd–Steinberg dithering
|
||||
$imagick->setOption('dither', 'FloydSteinberg');
|
||||
|
||||
// Step 4: Remap to our 4-color colormap
|
||||
// $imagick->remapImage($colormap, Imagick::DITHERMETHOD_FLOYDSTEINBERG);
|
||||
|
||||
// Step 5: Force 2-bit grayscale PNG
|
||||
$imagick->setImageFormat('png');
|
||||
$imagick->setImageDepth(2);
|
||||
$imagick->setType(Imagick::IMGTYPE_GRAYSCALE);
|
||||
|
||||
// Cleanup colormap
|
||||
$colormap->clear();
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value => 1,
|
||||
ImageFormat::PNG_8BIT_256C->value => 8,
|
||||
ImageFormat::PNG_2BIT_4C->value => 2,
|
||||
ImageFormat::AUTO->value => 1, // Default for AUTO
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image using legacy device settings
|
||||
* Detect whether the provided HTML markup contains an <img> tag with class "image-dither".
|
||||
*/
|
||||
private static function convertUsingLegacySettings(string $pngPath, string $bmpPath, string $imageFormat, array $settings): void
|
||||
private static function markupContainsDitherImage(string $markup): bool
|
||||
{
|
||||
switch ($imageFormat) {
|
||||
case ImageFormat::BMP3_1BIT_SRGB->value:
|
||||
try {
|
||||
self::convertToBmpImageMagick($pngPath, $bmpPath);
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
break;
|
||||
case ImageFormat::PNG_8BIT_GRAYSCALE->value:
|
||||
case ImageFormat::PNG_8BIT_256C->value:
|
||||
try {
|
||||
self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation'], quantize: $imageFormat === ImageFormat::PNG_8BIT_GRAYSCALE->value);
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
break;
|
||||
case ImageFormat::AUTO->value:
|
||||
default:
|
||||
// For AUTO format, we need to check if this is a legacy device
|
||||
// This would require checking if the device has a firmware version
|
||||
// For now, we'll use the device's current logic
|
||||
try {
|
||||
self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation']);
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImagickException
|
||||
*/
|
||||
private static function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
|
||||
{
|
||||
$imagick = new Imagick($pngPath);
|
||||
$imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
|
||||
$imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth(1);
|
||||
$imagick->stripImage();
|
||||
$imagick->setFormat('BMP3');
|
||||
$imagick->writeImage($bmpPath);
|
||||
$imagick->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImagickException
|
||||
*/
|
||||
private static function convertToPngImageMagick(string $pngPath, ?int $width, ?int $height, ?int $rotate, $quantize = true): void
|
||||
{
|
||||
$imagick = new Imagick($pngPath);
|
||||
if ($width !== 800 || $height !== 480) {
|
||||
$imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, true);
|
||||
}
|
||||
if ($rotate !== null && $rotate !== 0) {
|
||||
$imagick->rotateImage(new ImagickPixel('black'), $rotate);
|
||||
if (mb_trim($markup) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
|
||||
$imagick->setOption('dither', 'FloydSteinberg');
|
||||
|
||||
if ($quantize) {
|
||||
$imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
// Find <img ... class="..."> (or with single quotes) and inspect class tokens
|
||||
$imgWithClassPattern = '/<img\b[^>]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i';
|
||||
if (! preg_match_all($imgWithClassPattern, $markup, $matches)) {
|
||||
return false;
|
||||
}
|
||||
$imagick->setImageDepth(8);
|
||||
$imagick->stripImage();
|
||||
|
||||
$imagick->setFormat('png');
|
||||
$imagick->writeImage($pngPath);
|
||||
$imagick->clear();
|
||||
foreach ($matches[2] as $classValue) {
|
||||
// Look for class token 'image-dither' or 'image--dither'
|
||||
if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function cleanupFolder(): void
|
||||
|
|
@ -353,16 +334,20 @@ class ImageGenerationService
|
|||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||
{
|
||||
if ($plugin?->id) {
|
||||
// Image webhook plugins have finalized images that shouldn't be reset
|
||||
if ($plugin->plugin_type === 'image_webhook') {
|
||||
return;
|
||||
}
|
||||
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
||||
$hasCustomDimensions = Device::query()
|
||||
->where(function ($query) {
|
||||
->where(function ($query): void {
|
||||
$query->where('width', '!=', 800)
|
||||
->orWhere('height', '!=', 480)
|
||||
->orWhere('rotate', '!=', 0);
|
||||
})
|
||||
->orWhereHas('deviceModel', function ($query) {
|
||||
->orWhereHas('deviceModel', function ($query): void {
|
||||
// Only allow caching if all device models have standard dimensions (800x480, rotation=0)
|
||||
$query->where(function ($subQuery) {
|
||||
$query->where(function ($subQuery): void {
|
||||
$subQuery->where('width', '!=', 800)
|
||||
->orWhere('height', '!=', 480)
|
||||
->orWhere('rotation', '!=', 0);
|
||||
|
|
@ -377,4 +362,180 @@ class ImageGenerationService
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device-specific default image path for setup or sleep mode
|
||||
*/
|
||||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If device has a DeviceModel, try to find device-specific image
|
||||
if ($device->deviceModel) {
|
||||
$model = $device->deviceModel;
|
||||
$extension = $model->mime_type === 'image/bmp' ? 'bmp' : 'png';
|
||||
$filename = "{$model->width}_{$model->height}_{$model->bit_depth}_{$model->rotation}.{$extension}";
|
||||
$deviceSpecificPath = "images/default-screens/{$imageType}_{$filename}";
|
||||
|
||||
if (Storage::disk('public')->exists($deviceSpecificPath)) {
|
||||
return $deviceSpecificPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original hardcoded images
|
||||
$fallbackPath = "images/{$imageType}.bmp";
|
||||
if (Storage::disk('public')->exists($fallbackPath)) {
|
||||
return $fallbackPath;
|
||||
}
|
||||
|
||||
// Try PNG fallback
|
||||
$fallbackPathPng = "images/{$imageType}.png";
|
||||
if (Storage::disk('public')->exists($fallbackPathPng)) {
|
||||
return $fallbackPathPng;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a default screen image from Blade template
|
||||
*/
|
||||
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||
}
|
||||
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
|
||||
try {
|
||||
// Load device with relationships
|
||||
$device->load(['palette', 'deviceModel.palette', 'user']);
|
||||
|
||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
$imageSettings = self::getImageSettings($device);
|
||||
|
||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||
|
||||
// Generate HTML from Blade template
|
||||
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
||||
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($html);
|
||||
|
||||
// Set timezone from user or fall back to app timezone
|
||||
$timezone = $device->user->timezone ?? config('app.timezone');
|
||||
$browserStage->timezone($timezone);
|
||||
|
||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||
$browserStage
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height']);
|
||||
} else {
|
||||
$browserStage->useDefaultDimensions();
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
// Get palette from device or fallback to device model's default palette
|
||||
$palette = $device->palette ?? $device->deviceModel?->palette;
|
||||
$colorPalette = null;
|
||||
|
||||
if ($palette && $palette->colors) {
|
||||
$colorPalette = $palette->colors;
|
||||
}
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($fileExtension)
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height'])
|
||||
->colors($imageSettings['colors'])
|
||||
->bitDepth($imageSettings['bit_depth'])
|
||||
->rotation($imageSettings['rotation'])
|
||||
->offsetX($imageSettings['offset_x'])
|
||||
->offsetY($imageSettings['offset_y'])
|
||||
->outputPath($outputPath);
|
||||
|
||||
// Apply color palette if available
|
||||
if ($colorPalette) {
|
||||
$imageStage->colormap($colorPalette);
|
||||
}
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
Log::info("Device $device->id: generated default screen image: $uuid for type: $imageType");
|
||||
|
||||
return $uuid;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate default screen image: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate default screen image: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML from Blade template for default screens
|
||||
*/
|
||||
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Map image type to template name
|
||||
$templateName = match ($imageType) {
|
||||
'setup-logo' => 'default-screens.setup',
|
||||
'sleep' => 'default-screens.sleep',
|
||||
'error' => 'default-screens.error',
|
||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||
};
|
||||
|
||||
// Determine device properties from DeviceModel or device settings
|
||||
$deviceVariant = $device->deviceVariant();
|
||||
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
|
||||
$colorDepth = $device->colorDepth() ?? '1bit';
|
||||
$scaleLevel = $device->scaleLevel();
|
||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||
|
||||
// Build view data
|
||||
$viewData = [
|
||||
'noBleed' => false,
|
||||
'darkMode' => $darkMode,
|
||||
'deviceVariant' => $deviceVariant,
|
||||
'deviceOrientation' => $deviceOrientation,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
];
|
||||
|
||||
// Add plugin name for error screens
|
||||
if ($imageType === 'error' && $pluginName !== null) {
|
||||
$viewData['pluginName'] = $pluginName;
|
||||
}
|
||||
|
||||
// Render the Blade template
|
||||
return view($templateName, $viewData)->render();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
|
|||
/**
|
||||
* Create a new provider instance.
|
||||
*/
|
||||
public function __construct($request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = [])
|
||||
public function __construct(\Illuminate\Http\Request $request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = [])
|
||||
{
|
||||
parent::__construct($request, $clientId, $clientSecret, $redirectUrl, $guzzle);
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
|
|||
}
|
||||
|
||||
// Handle both full well-known URL and base URL
|
||||
if (str_ends_with($endpoint, '/.well-known/openid-configuration')) {
|
||||
if (str_ends_with((string) $endpoint, '/.well-known/openid-configuration')) {
|
||||
$this->baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint);
|
||||
} else {
|
||||
$this->baseUrl = mb_rtrim($endpoint, '/');
|
||||
|
|
@ -60,7 +60,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
|
|||
{
|
||||
try {
|
||||
$url = $this->baseUrl.'/.well-known/openid-configuration';
|
||||
$client = new Client();
|
||||
$client = app(Client::class);
|
||||
$response = $client->get($url);
|
||||
$this->oidcConfig = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
|
|||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to load OIDC configuration: '.$e->getMessage());
|
||||
throw new Exception('Failed to load OIDC configuration: '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ class OidcProvider extends AbstractProvider implements ProviderInterface
|
|||
/**
|
||||
* Map the raw user array to a Socialite User instance.
|
||||
*/
|
||||
protected function mapUserToObject(array $user)
|
||||
public function mapUserToObject(array $user)
|
||||
{
|
||||
return (new User)->setRaw($user)->map([
|
||||
'id' => $user['sub'],
|
||||
|
|
|
|||
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use om\IcalParser;
|
||||
|
||||
class IcalResponseParser implements ResponseParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IcalParser $parser = new IcalParser(),
|
||||
) {}
|
||||
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
$body = $response->body();
|
||||
|
||||
if (! $this->isIcalResponse($contentType, $body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->parser->parseString($body);
|
||||
|
||||
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
|
||||
$windowStart = now()->subDays(7);
|
||||
$windowEnd = now()->addDays(30);
|
||||
|
||||
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
|
||||
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
|
||||
|
||||
if (! $startDate instanceof Carbon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $startDate->between($windowStart, $windowEnd, true);
|
||||
}));
|
||||
|
||||
$normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents);
|
||||
|
||||
return ['ical' => $normalizedEvents];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse iCal response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse iCal response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function isIcalResponse(?string $contentType, string $body): bool
|
||||
{
|
||||
$normalizedContentType = $contentType ? mb_strtolower($contentType) : '';
|
||||
|
||||
if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($body, 'BEGIN:VCALENDAR');
|
||||
}
|
||||
|
||||
private function asCarbon(DateTimeInterface|string|null $value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse date value: '.$exception->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeIcalEvent(array $event): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($event as $key => $value) {
|
||||
$normalized[$key] = $this->normalizeIcalValue($value);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeIcalValue(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value)->toAtomString();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map($this->normalizeIcalValue(...), $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JsonOrTextResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): array
|
||||
{
|
||||
try {
|
||||
$json = $response->json();
|
||||
if ($json !== null) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
return ['data' => $response->body()];
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to parse JSON response: '.$e->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse JSON response'];
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
interface ResponseParser
|
||||
{
|
||||
/**
|
||||
* Attempt to parse the given response.
|
||||
*
|
||||
* Return null when the parser is not applicable so other parsers can run.
|
||||
*/
|
||||
public function parse(Response $response): ?array;
|
||||
}
|
||||
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
class ResponseParserRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<int, ResponseParser>
|
||||
*/
|
||||
private readonly array $parsers;
|
||||
|
||||
/**
|
||||
* @param array<int, ResponseParser> $parsers
|
||||
*/
|
||||
public function __construct(array $parsers = [])
|
||||
{
|
||||
$this->parsers = $parsers ?: [
|
||||
new XmlResponseParser(),
|
||||
new IcalResponseParser(),
|
||||
new JsonOrTextResponseParser(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ResponseParser>
|
||||
*/
|
||||
public function getParsers(): array
|
||||
{
|
||||
return $this->parsers;
|
||||
}
|
||||
}
|
||||
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class XmlResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
|
||||
if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($response->body());
|
||||
if ($xml === false) {
|
||||
throw new Exception('Invalid XML content');
|
||||
}
|
||||
|
||||
return ['rss' => $this->xmlToArray($xml)];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse XML response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function xmlToArray(SimpleXMLElement $xml): array
|
||||
{
|
||||
$array = (array) $xml;
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if ($value instanceof SimpleXMLElement) {
|
||||
$array[$key] = $this->xmlToArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,44 +47,34 @@ class PluginExportService
|
|||
$tempDirName = 'temp/'.uniqid('plugin_export_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
|
||||
try {
|
||||
// Generate settings.yml content
|
||||
$settings = $this->generateSettingsYaml($plugin);
|
||||
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
File::put($tempDir.'/settings.yml', $settingsYaml);
|
||||
|
||||
// Generate full template content
|
||||
$fullTemplate = $this->generateFullTemplate($plugin);
|
||||
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
||||
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
||||
|
||||
// Generate shared.liquid if needed (for liquid templates)
|
||||
if ($plugin->markup_language === 'liquid') {
|
||||
$sharedTemplate = $this->generateSharedTemplate($plugin);
|
||||
if ($sharedTemplate) {
|
||||
File::put($tempDir.'/shared.liquid', $sharedTemplate);
|
||||
}
|
||||
// Generate settings.yml content
|
||||
$settings = $this->generateSettingsYaml($plugin);
|
||||
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
File::put($tempDir.'/settings.yml', $settingsYaml);
|
||||
// Generate full template content
|
||||
$fullTemplate = $this->generateFullTemplate($plugin);
|
||||
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
||||
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
||||
// Generate shared.liquid if needed (for liquid templates)
|
||||
if ($plugin->markup_language === 'liquid') {
|
||||
$sharedTemplate = $this->generateSharedTemplate();
|
||||
/** @phpstan-ignore-next-line */
|
||||
if ($sharedTemplate) {
|
||||
File::put($tempDir.'/shared.liquid', $sharedTemplate);
|
||||
}
|
||||
|
||||
// Create ZIP file
|
||||
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Could not create ZIP file.');
|
||||
}
|
||||
|
||||
// Add files directly to ZIP root
|
||||
$this->addDirectoryToZip($zip, $tempDir, '');
|
||||
$zip->close();
|
||||
|
||||
// Return the ZIP file as a download response
|
||||
return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip');
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw $e;
|
||||
}
|
||||
// Create ZIP file
|
||||
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Could not create ZIP file.');
|
||||
}
|
||||
// Add files directly to ZIP root
|
||||
$this->addDirectoryToZip($zip, $tempDir, '');
|
||||
$zip->close();
|
||||
|
||||
// Return the ZIP file as a download response
|
||||
return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -150,7 +140,7 @@ class PluginExportService
|
|||
/**
|
||||
* Generate the shared template content (for liquid templates)
|
||||
*/
|
||||
private function generateSharedTemplate(Plugin $plugin)
|
||||
private function generateSharedTemplate(): null
|
||||
{
|
||||
// For now, we don't have a way to store shared templates separately
|
||||
// TODO - add support for shared templates
|
||||
|
|
@ -170,14 +160,10 @@ class PluginExportService
|
|||
foreach ($files as $file) {
|
||||
if (! $file->isDir()) {
|
||||
$filePath = $file->getRealPath();
|
||||
$fileName = basename($filePath);
|
||||
$fileName = basename((string) $filePath);
|
||||
|
||||
// For root directory, just use the filename
|
||||
if ($zipPath === '') {
|
||||
$relativePath = $fileName;
|
||||
} else {
|
||||
$relativePath = $zipPath.'/'.mb_substr($filePath, mb_strlen($dirPath) + 1);
|
||||
}
|
||||
$relativePath = $zipPath === '' ? $fileName : $zipPath.'/'.mb_substr((string) $filePath, mb_strlen($dirPath) + 1);
|
||||
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,16 +17,45 @@ use ZipArchive;
|
|||
|
||||
class PluginImportService
|
||||
{
|
||||
/**
|
||||
* Validate YAML settings
|
||||
*
|
||||
* @param array $settings The parsed YAML settings
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function validateYAML(array $settings): void
|
||||
{
|
||||
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['field_type']) && $field['field_type'] === 'multi_string') {
|
||||
|
||||
if (isset($field['default']) && str_contains($field['default'], ',')) {
|
||||
throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas.");
|
||||
}
|
||||
|
||||
if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) {
|
||||
throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas.");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a plugin from a ZIP file
|
||||
*
|
||||
* @param UploadedFile $zipFile The uploaded ZIP file
|
||||
* @param User $user The user importing the plugin
|
||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
||||
* @return Plugin The created plugin instance
|
||||
*
|
||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||
*/
|
||||
public function importFromZip(UploadedFile $zipFile, User $user): Plugin
|
||||
public function importFromZip(UploadedFile $zipFile, User $user, ?string $zipEntryPath = null): Plugin
|
||||
{
|
||||
// Create a temporary directory using Laravel's temporary directory helper
|
||||
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
|
||||
|
|
@ -46,33 +75,55 @@ class PluginImportService
|
|||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir);
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||
|
||||
// Validate that we found the required files
|
||||
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php
|
||||
if (! $filePaths['settingsYamlPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||
}
|
||||
|
||||
// Validate that we have at least one template file
|
||||
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||
}
|
||||
|
||||
// Parse settings.yml
|
||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||
$settings = Yaml::parse($settingsYaml);
|
||||
$this->validateYAML($settings);
|
||||
|
||||
// Read full.liquid content
|
||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
||||
|
||||
// Prepend shared.liquid content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
// Determine which template file to use and read its content
|
||||
$templatePath = null;
|
||||
$markupLanguage = 'blade';
|
||||
if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
|
||||
|
||||
if ($filePaths['fullLiquidPath']) {
|
||||
$templatePath = $filePaths['fullLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
|
||||
// Prepend shared.liquid or shared.blade.php content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
}
|
||||
} elseif ($filePaths['sharedLiquidPath']) {
|
||||
$templatePath = $filePaths['sharedLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
} elseif ($filePaths['sharedBladePath']) {
|
||||
$templatePath = $filePaths['sharedBladePath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'blade';
|
||||
}
|
||||
|
||||
// Ensure custom_fields is properly formatted
|
||||
|
|
@ -80,6 +131,9 @@ class PluginImportService
|
|||
$settings['custom_fields'] = [];
|
||||
}
|
||||
|
||||
// Normalize options in custom_fields (convert non-named values to named values)
|
||||
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
|
||||
|
||||
// Create configuration template with the custom fields
|
||||
$configurationTemplate = [
|
||||
'custom_fields' => $settings['custom_fields'],
|
||||
|
|
@ -138,11 +192,15 @@ class PluginImportService
|
|||
*
|
||||
* @param string $zipUrl The URL to the ZIP file
|
||||
* @param User $user The user importing the plugin
|
||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
||||
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
|
||||
* @param string|null $iconUrl Optional icon URL to set on the plugin
|
||||
* @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists
|
||||
* @return Plugin The created plugin instance
|
||||
*
|
||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||
*/
|
||||
public function importFromUrl(string $zipUrl, User $user): Plugin
|
||||
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
|
||||
{
|
||||
// Download the ZIP file
|
||||
$response = Http::timeout(60)->get($zipUrl);
|
||||
|
|
@ -170,33 +228,55 @@ class PluginImportService
|
|||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir);
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||
|
||||
// Validate that we found the required files
|
||||
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
|
||||
if (! $filePaths['settingsYamlPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||
}
|
||||
|
||||
// Validate that we have at least one template file
|
||||
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||
}
|
||||
|
||||
// Parse settings.yml
|
||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||
$settings = Yaml::parse($settingsYaml);
|
||||
$this->validateYAML($settings);
|
||||
|
||||
// Read full.liquid content
|
||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
||||
|
||||
// Prepend shared.liquid content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
// Determine which template file to use and read its content
|
||||
$templatePath = null;
|
||||
$markupLanguage = 'blade';
|
||||
if (pathinfo($filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
|
||||
|
||||
if ($filePaths['fullLiquidPath']) {
|
||||
$templatePath = $filePaths['fullLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
|
||||
// Prepend shared.liquid or shared.blade.php content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
}
|
||||
} elseif ($filePaths['sharedLiquidPath']) {
|
||||
$templatePath = $filePaths['sharedLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
} elseif ($filePaths['sharedBladePath']) {
|
||||
$templatePath = $filePaths['sharedBladePath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'blade';
|
||||
}
|
||||
|
||||
// Ensure custom_fields is properly formatted
|
||||
|
|
@ -204,22 +284,34 @@ class PluginImportService
|
|||
$settings['custom_fields'] = [];
|
||||
}
|
||||
|
||||
// Normalize options in custom_fields (convert non-named values to named values)
|
||||
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
|
||||
|
||||
// Create configuration template with the custom fields
|
||||
$configurationTemplate = [
|
||||
'custom_fields' => $settings['custom_fields'],
|
||||
];
|
||||
|
||||
$plugin_updated = isset($settings['id'])
|
||||
// Determine the trmnlp_id to use
|
||||
$trmnlpId = $settings['id'] ?? Uuid::v7();
|
||||
|
||||
// If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID
|
||||
if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) {
|
||||
$trmnlpId = Uuid::v7();
|
||||
}
|
||||
|
||||
$plugin_updated = ! $allowDuplicate && isset($settings['id'])
|
||||
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||
|
||||
// Create a new plugin
|
||||
$plugin = Plugin::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||
'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
'trmnlp_id' => $trmnlpId,
|
||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||
'polling_url' => $settings['polling_url'] ?? null,
|
||||
|
|
@ -232,6 +324,8 @@ class PluginImportService
|
|||
'render_markup' => $fullLiquid,
|
||||
'configuration_template' => $configurationTemplate,
|
||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||
'preferred_renderer' => $preferredRenderer,
|
||||
'icon_url' => $iconUrl,
|
||||
]);
|
||||
|
||||
if (! $plugin_updated) {
|
||||
|
|
@ -257,11 +351,62 @@ class PluginImportService
|
|||
}
|
||||
}
|
||||
|
||||
private function findRequiredFiles(string $tempDir): array
|
||||
private function findRequiredFiles(string $tempDir, ?string $zipEntryPath = null): array
|
||||
{
|
||||
$settingsYamlPath = null;
|
||||
$fullLiquidPath = null;
|
||||
$sharedLiquidPath = null;
|
||||
$sharedBladePath = null;
|
||||
|
||||
// If zipEntryPath is specified, look for files in that specific directory first
|
||||
if ($zipEntryPath) {
|
||||
$targetDir = $tempDir.'/'.$zipEntryPath;
|
||||
if (File::exists($targetDir)) {
|
||||
// Check if files are directly in the target directory
|
||||
if (File::exists($targetDir.'/settings.yml')) {
|
||||
$settingsYamlPath = $targetDir.'/settings.yml';
|
||||
|
||||
if (File::exists($targetDir.'/full.liquid')) {
|
||||
$fullLiquidPath = $targetDir.'/full.liquid';
|
||||
} elseif (File::exists($targetDir.'/full.blade.php')) {
|
||||
$fullLiquidPath = $targetDir.'/full.blade.php';
|
||||
}
|
||||
|
||||
if (File::exists($targetDir.'/shared.liquid')) {
|
||||
$sharedLiquidPath = $targetDir.'/shared.liquid';
|
||||
} elseif (File::exists($targetDir.'/shared.blade.php')) {
|
||||
$sharedBladePath = $targetDir.'/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if files are in src subdirectory of target directory
|
||||
if (! $settingsYamlPath && File::exists($targetDir.'/src/settings.yml')) {
|
||||
$settingsYamlPath = $targetDir.'/src/settings.yml';
|
||||
|
||||
if (File::exists($targetDir.'/src/full.liquid')) {
|
||||
$fullLiquidPath = $targetDir.'/src/full.liquid';
|
||||
} elseif (File::exists($targetDir.'/src/full.blade.php')) {
|
||||
$fullLiquidPath = $targetDir.'/src/full.blade.php';
|
||||
}
|
||||
|
||||
if (File::exists($targetDir.'/src/shared.liquid')) {
|
||||
$sharedLiquidPath = $targetDir.'/src/shared.liquid';
|
||||
} elseif (File::exists($targetDir.'/src/shared.blade.php')) {
|
||||
$sharedBladePath = $targetDir.'/src/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the required files in the target directory, return them
|
||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||
return [
|
||||
'settingsYamlPath' => $settingsYamlPath,
|
||||
'fullLiquidPath' => $fullLiquidPath,
|
||||
'sharedLiquidPath' => $sharedLiquidPath,
|
||||
'sharedBladePath' => $sharedBladePath,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First, check if files are directly in the src folder
|
||||
if (File::exists($tempDir.'/src/settings.yml')) {
|
||||
|
|
@ -274,9 +419,11 @@ class PluginImportService
|
|||
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
||||
}
|
||||
|
||||
// Check for shared.liquid in the same directory
|
||||
// Check for shared.liquid or shared.blade.php in the same directory
|
||||
if (File::exists($tempDir.'/src/shared.liquid')) {
|
||||
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
||||
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
||||
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
||||
}
|
||||
} else {
|
||||
// Search for the files in the extracted directory structure
|
||||
|
|
@ -293,19 +440,26 @@ class PluginImportService
|
|||
$fullLiquidPath = $filepath;
|
||||
} elseif ($filename === 'shared.liquid') {
|
||||
$sharedLiquidPath = $filepath;
|
||||
} elseif ($filename === 'shared.blade.php') {
|
||||
$sharedBladePath = $filepath;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found both required files, break the loop
|
||||
if ($settingsYamlPath && $fullLiquidPath) {
|
||||
break;
|
||||
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
|
||||
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
|
||||
$fullLiquidDir = dirname((string) $fullLiquidPath);
|
||||
if (File::exists($fullLiquidDir.'/shared.liquid')) {
|
||||
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
|
||||
} elseif (File::exists($fullLiquidDir.'/shared.blade.php')) {
|
||||
$sharedBladePath = $fullLiquidDir.'/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the files but they're not in the src folder,
|
||||
// check if they're in the root of the ZIP or in a subfolder
|
||||
if ($settingsYamlPath && $fullLiquidPath) {
|
||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||
// If the files are in the root of the ZIP, create a src folder and move them there
|
||||
$srcDir = dirname($settingsYamlPath);
|
||||
$srcDir = dirname((string) $settingsYamlPath);
|
||||
|
||||
// If the parent directory is not named 'src', create a src directory
|
||||
if (basename($srcDir) !== 'src') {
|
||||
|
|
@ -314,17 +468,25 @@ class PluginImportService
|
|||
|
||||
// Copy the files to the src directory
|
||||
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
||||
File::copy($fullLiquidPath, $newSrcDir.'/full.liquid');
|
||||
|
||||
// Copy shared.liquid if it exists
|
||||
// Copy full.liquid or full.blade.php if it exists
|
||||
if ($fullLiquidPath) {
|
||||
$extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION);
|
||||
File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension);
|
||||
$fullLiquidPath = $newSrcDir.'/full.'.$extension;
|
||||
}
|
||||
|
||||
// Copy shared.liquid or shared.blade.php if it exists
|
||||
if ($sharedLiquidPath) {
|
||||
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
||||
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
|
||||
} elseif ($sharedBladePath) {
|
||||
File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php');
|
||||
$sharedBladePath = $newSrcDir.'/shared.blade.php';
|
||||
}
|
||||
|
||||
// Update the paths
|
||||
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||
$fullLiquidPath = $newSrcDir.'/full.liquid';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -333,6 +495,104 @@ class PluginImportService
|
|||
'settingsYamlPath' => $settingsYamlPath,
|
||||
'fullLiquidPath' => $fullLiquidPath,
|
||||
'sharedLiquidPath' => $sharedLiquidPath,
|
||||
'sharedBladePath' => $sharedBladePath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize options in custom_fields by converting non-named values to named values
|
||||
* This ensures that options like ["true", "false"] become [["true" => "true"], ["false" => "false"]]
|
||||
*
|
||||
* @param array $customFields The custom_fields array from settings
|
||||
* @return array The normalized custom_fields array
|
||||
*/
|
||||
private function normalizeCustomFieldsOptions(array $customFields): array
|
||||
{
|
||||
foreach ($customFields as &$field) {
|
||||
// Only process select fields with options
|
||||
if (isset($field['field_type']) && $field['field_type'] === 'select' && isset($field['options']) && is_array($field['options'])) {
|
||||
$normalizedOptions = [];
|
||||
foreach ($field['options'] as $option) {
|
||||
// If option is already a named value (array with key-value pair), keep it as is
|
||||
if (is_array($option)) {
|
||||
$normalizedOptions[] = $option;
|
||||
} else {
|
||||
// Convert non-named value to named value
|
||||
// Convert boolean to string, use lowercase for label
|
||||
$value = is_bool($option) ? ($option ? 'true' : 'false') : (string) $option;
|
||||
$normalizedOptions[] = [$value => $value];
|
||||
}
|
||||
}
|
||||
$field['options'] = $normalizedOptions;
|
||||
|
||||
// Normalize default value to match normalized option values
|
||||
if (isset($field['default'])) {
|
||||
$default = $field['default'];
|
||||
// If default is boolean, convert to string to match normalized options
|
||||
if (is_bool($default)) {
|
||||
$field['default'] = $default ? 'true' : 'false';
|
||||
} else {
|
||||
// Convert to string to ensure consistency
|
||||
$field['default'] = (string) $default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $customFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that template and context are within command-line argument limits
|
||||
*
|
||||
* @param string $template The liquid template string
|
||||
* @param string $jsonContext The JSON-encoded context
|
||||
* @param string $liquidPath The path to the liquid renderer executable
|
||||
*
|
||||
* @throws Exception If the template or context exceeds argument limits
|
||||
*/
|
||||
public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void
|
||||
{
|
||||
// MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments
|
||||
// ARG_MAX is the total size of all arguments (typically 2MB on modern systems)
|
||||
$maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit
|
||||
$maxTotalArgLength = $this->getMaxArgumentLength();
|
||||
|
||||
// Check individual argument sizes (template and context are the largest)
|
||||
if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) {
|
||||
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
|
||||
}
|
||||
|
||||
// Calculate total size of all arguments (path + flags + template + context)
|
||||
// Add overhead for path, flags, and separators (conservative estimate: ~200 bytes)
|
||||
$totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template)
|
||||
+ mb_strlen('--context') + mb_strlen($jsonContext) + 200;
|
||||
|
||||
if ($totalArgSize > $maxTotalArgLength) {
|
||||
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum argument length for command-line arguments
|
||||
*
|
||||
* @return int Maximum argument length in bytes
|
||||
*/
|
||||
private function getMaxArgumentLength(): int
|
||||
{
|
||||
// Try to get ARG_MAX from system using getconf
|
||||
$argMax = null;
|
||||
if (function_exists('shell_exec')) {
|
||||
$result = @shell_exec('getconf ARG_MAX 2>/dev/null');
|
||||
if ($result !== null && is_numeric(mb_trim($result))) {
|
||||
$argMax = (int) mb_trim($result);
|
||||
}
|
||||
}
|
||||
|
||||
// Use conservative fallback if ARG_MAX cannot be determined
|
||||
// ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB)
|
||||
// We use 200KB as a conservative limit that works on both systems
|
||||
// Note: ARG_MAX includes environment variables, so we leave headroom
|
||||
return $argMax !== null ? min($argMax, 204800) : 204800;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,17 +4,19 @@
|
|||
"type": "project",
|
||||
"description": "TRMNL Server Implementation (BYOS) for Laravel",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework",
|
||||
"trmnl"
|
||||
"trmnl",
|
||||
"trmnl-server",
|
||||
"trmnl-byos",
|
||||
"laravel"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-imagick": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-zip": "*",
|
||||
"bnussbau/laravel-trmnl-blade": "1.2.*",
|
||||
"intervention/image": "^3.11",
|
||||
"bnussbau/laravel-trmnl-blade": "2.1.*",
|
||||
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
||||
"keepsuit/laravel-liquid": "^0.5.2",
|
||||
"laravel/framework": "^12.1",
|
||||
"laravel/sanctum": "^4.0",
|
||||
|
|
@ -22,7 +24,9 @@
|
|||
"laravel/tinker": "^2.10.1",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/volt": "^1.7",
|
||||
"om/icalparser": "^3.2",
|
||||
"spatie/browsershot": "^5.0",
|
||||
"stevebauman/purify": "^6.3",
|
||||
"symfony/yaml": "^7.3",
|
||||
"wnx/sidecar-browsershot": "^2.6"
|
||||
},
|
||||
|
|
@ -37,7 +41,8 @@
|
|||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.0",
|
||||
"pestphp/pest-plugin-drift": "^4.0",
|
||||
"pestphp/pest-plugin-laravel": "^4.0"
|
||||
"pestphp/pest-plugin-laravel": "^4.0",
|
||||
"rector/rector": "^2.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
@ -73,7 +78,10 @@
|
|||
],
|
||||
"test": "vendor/bin/pest",
|
||||
"test-coverage": "vendor/bin/pest --coverage",
|
||||
"format": "vendor/bin/pint"
|
||||
"format": "vendor/bin/pint",
|
||||
"analyse": "vendor/bin/phpstan analyse",
|
||||
"analyze": "vendor/bin/phpstan analyse",
|
||||
"rector": "vendor/bin/rector process"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
|
|
|
|||
2261
composer.lock
generated
|
|
@ -130,7 +130,7 @@ return [
|
|||
'force_https' => env('FORCE_HTTPS', false),
|
||||
'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
|
||||
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
|
||||
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false),
|
||||
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true),
|
||||
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
|
||||
|
||||
'notifications' => [
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ return [
|
|||
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
|
||||
'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false),
|
||||
'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices
|
||||
'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false),
|
||||
'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'),
|
||||
],
|
||||
|
||||
'webhook' => [
|
||||
|
|
|
|||
6
config/trustedproxy.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Comma‑separated list from .env, e.g. "10.0.0.0/8,172.16.0.0/12" or '*'
|
||||
'proxies' => ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)),
|
||||
];
|
||||
38
database/factories/DevicePaletteFactory.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DevicePalette;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DevicePalette>
|
||||
*/
|
||||
class DevicePaletteFactory extends Factory
|
||||
{
|
||||
protected $model = DevicePalette::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'test-'.$this->faker->unique()->slug(),
|
||||
'name' => $this->faker->words(3, true),
|
||||
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
||||
'colors' => $this->faker->optional()->passthrough([
|
||||
'#FF0000',
|
||||
'#00FF00',
|
||||
'#0000FF',
|
||||
'#FFFF00',
|
||||
'#000000',
|
||||
'#FFFFFF',
|
||||
]),
|
||||
'framework_class' => null,
|
||||
'source' => 'api',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -29,8 +29,24 @@ class PluginFactory extends Factory
|
|||
'icon_url' => null,
|
||||
'flux_icon_name' => null,
|
||||
'author_name' => $this->faker->name(),
|
||||
'plugin_type' => 'recipe',
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the plugin is an image webhook plugin.
|
||||
*/
|
||||
public function imageWebhook(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'plugin_type' => 'image_webhook',
|
||||
'data_strategy' => 'static',
|
||||
'data_stale_minutes' => 60,
|
||||
'polling_url' => null,
|
||||
'polling_verb' => 'get',
|
||||
'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ return new class extends Migration
|
|||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropUnique(['oidc_sub']);
|
||||
$table->dropColumn('oidc_sub');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('plugins', 'no_bleed')) {
|
||||
$table->boolean('no_bleed')->default(false)->after('configuration_template');
|
||||
}
|
||||
if (! Schema::hasColumn('plugins', 'dark_mode')) {
|
||||
$table->boolean('dark_mode')->default(false)->after('no_bleed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('plugins', 'dark_mode')) {
|
||||
$table->dropColumn('dark_mode');
|
||||
}
|
||||
if (Schema::hasColumn('plugins', 'no_bleed')) {
|
||||
$table->dropColumn('no_bleed');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->string('preferred_renderer')->nullable()->after('markup_language');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('preferred_renderer');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('device_palettes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('description')->nullable();
|
||||
$table->integer('grays');
|
||||
$table->json('colors')->nullable();
|
||||
$table->string('framework_class')->default('');
|
||||
$table->string('source')->default('api');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('device_palettes');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('device_models', function (Blueprint $table) {
|
||||
$table->foreignId('palette_id')->nullable()->after('source')->constrained('device_palettes')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('device_models', function (Blueprint $table) {
|
||||
$table->dropForeign(['palette_id']);
|
||||
$table->dropColumn('palette_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->foreignId('palette_id')->nullable()->constrained('device_palettes')->onDelete('set null');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropForeign(['palette_id']);
|
||||
$table->dropColumn('palette_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Seed palettes from hardcoded data
|
||||
// name = identifier, description = human-readable name
|
||||
$palettes = [
|
||||
[
|
||||
'name' => 'bw',
|
||||
'description' => 'Black & White',
|
||||
'grays' => 2,
|
||||
'colors' => null,
|
||||
'framework_class' => 'screen--1bit',
|
||||
'source' => 'api',
|
||||
],
|
||||
[
|
||||
'name' => 'gray-4',
|
||||
'description' => '4 Grays',
|
||||
'grays' => 4,
|
||||
'colors' => null,
|
||||
'framework_class' => 'screen--2bit',
|
||||
'source' => 'api',
|
||||
],
|
||||
[
|
||||
'name' => 'gray-16',
|
||||
'description' => '16 Grays',
|
||||
'grays' => 16,
|
||||
'colors' => null,
|
||||
'framework_class' => 'screen--4bit',
|
||||
'source' => 'api',
|
||||
],
|
||||
[
|
||||
'name' => 'gray-256',
|
||||
'description' => '256 Grays',
|
||||
'grays' => 256,
|
||||
'colors' => null,
|
||||
'framework_class' => 'screen--4bit',
|
||||
'source' => 'api',
|
||||
],
|
||||
[
|
||||
'name' => 'color-6a',
|
||||
'description' => '6 Colors',
|
||||
'grays' => 2,
|
||||
'colors' => json_encode(['#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#000000', '#FFFFFF']),
|
||||
'framework_class' => '',
|
||||
'source' => 'api',
|
||||
],
|
||||
[
|
||||
'name' => 'color-7a',
|
||||
'description' => '7 Colors',
|
||||
'grays' => 2,
|
||||
'colors' => json_encode(['#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FFA500']),
|
||||
'framework_class' => '',
|
||||
'source' => 'api',
|
||||
],
|
||||
];
|
||||
|
||||
$now = now();
|
||||
$paletteIdMap = [];
|
||||
|
||||
foreach ($palettes as $paletteData) {
|
||||
$paletteName = $paletteData['name'];
|
||||
$paletteData['created_at'] = $now;
|
||||
$paletteData['updated_at'] = $now;
|
||||
|
||||
DB::table('device_palettes')->updateOrInsert(
|
||||
['name' => $paletteName],
|
||||
$paletteData
|
||||
);
|
||||
|
||||
// Get the ID of the palette (either newly created or existing)
|
||||
$paletteRecord = DB::table('device_palettes')->where('name', $paletteName)->first();
|
||||
$paletteIdMap[$paletteName] = $paletteRecord->id;
|
||||
}
|
||||
|
||||
// Set default palette_id on DeviceModel based on first palette_ids entry
|
||||
$models = [
|
||||
['name' => 'og_png', 'palette_name' => 'bw'],
|
||||
['name' => 'og_plus', 'palette_name' => 'gray-4'],
|
||||
['name' => 'amazon_kindle_2024', 'palette_name' => 'gray-256'],
|
||||
['name' => 'amazon_kindle_paperwhite_6th_gen', 'palette_name' => 'gray-256'],
|
||||
['name' => 'amazon_kindle_paperwhite_7th_gen', 'palette_name' => 'gray-256'],
|
||||
['name' => 'inkplate_10', 'palette_name' => 'gray-4'],
|
||||
['name' => 'amazon_kindle_7', 'palette_name' => 'gray-256'],
|
||||
['name' => 'inky_impression_7_3', 'palette_name' => 'color-7a'],
|
||||
['name' => 'kobo_libra_2', 'palette_name' => 'gray-16'],
|
||||
['name' => 'amazon_kindle_oasis_2', 'palette_name' => 'gray-256'],
|
||||
['name' => 'kobo_aura_one', 'palette_name' => 'gray-16'],
|
||||
['name' => 'kobo_aura_hd', 'palette_name' => 'gray-16'],
|
||||
['name' => 'inky_impression_13_3', 'palette_name' => 'color-6a'],
|
||||
['name' => 'm5_paper_s3', 'palette_name' => 'gray-16'],
|
||||
['name' => 'amazon_kindle_scribe', 'palette_name' => 'gray-256'],
|
||||
['name' => 'seeed_e1001', 'palette_name' => 'gray-4'],
|
||||
['name' => 'seeed_e1002', 'palette_name' => 'gray-4'],
|
||||
['name' => 'waveshare_4_26', 'palette_name' => 'gray-4'],
|
||||
['name' => 'waveshare_7_5_bw', 'palette_name' => 'bw'],
|
||||
];
|
||||
|
||||
foreach ($models as $modelData) {
|
||||
$deviceModel = DeviceModel::where('name', $modelData['name'])->first();
|
||||
if ($deviceModel && ! $deviceModel->palette_id && isset($paletteIdMap[$modelData['palette_name']])) {
|
||||
$deviceModel->update(['palette_id' => $paletteIdMap[$modelData['palette_name']]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Remove palette_id from device models but keep palettes
|
||||
DeviceModel::query()->update(['palette_id' => null]);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('timezone')->nullable()->after('oidc_sub');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('timezone');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table): void {
|
||||
$table->string('plugin_type')->default('recipe')->after('uuid');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table): void {
|
||||
$table->dropColumn('plugin_type');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('device_models', function (Blueprint $table) {
|
||||
$table->string('kind')->nullable()->index();
|
||||
});
|
||||
|
||||
// Set existing og_png and og_plus to kind "trmnl"
|
||||
DeviceModel::whereIn('name', ['og_png', 'og_plus'])->update(['kind' => 'trmnl']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('device_models', function (Blueprint $table) {
|
||||
$table->dropIndex(['kind']);
|
||||
$table->dropColumn('kind');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Find and handle duplicate (user_id, trmnlp_id) combinations
|
||||
$duplicates = Plugin::query()
|
||||
->selectRaw('user_id, trmnlp_id, COUNT(*) as duplicate_count')
|
||||
->whereNotNull('trmnlp_id')
|
||||
->groupBy('user_id', 'trmnlp_id')
|
||||
->havingRaw('COUNT(*) > ?', [1])
|
||||
->get();
|
||||
|
||||
// For each duplicate combination, keep the first one (by id) and set others to null
|
||||
foreach ($duplicates as $duplicate) {
|
||||
$plugins = Plugin::query()
|
||||
->where('user_id', $duplicate->user_id)
|
||||
->where('trmnlp_id', $duplicate->trmnlp_id)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
// Keep the first one, set the rest to null
|
||||
$keepFirst = true;
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($keepFirst) {
|
||||
$keepFirst = false;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$plugin->update(['trmnlp_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->unique(['user_id', 'trmnlp_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropUnique(['user_id', 'trmnlp_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->boolean('alias')->default(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('alias');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -13,8 +13,8 @@ class ExampleRecipesSeeder extends Seeder
|
|||
public function run($user_id = 1): void
|
||||
{
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec'],
|
||||
[
|
||||
'uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec',
|
||||
'name' => 'ÖBB Departures',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
|
|
@ -32,8 +32,8 @@ class ExampleRecipesSeeder extends Seeder
|
|||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => '3b046eda-34e9-4232-b935-c33b989a284b'],
|
||||
[
|
||||
'uuid' => '3b046eda-34e9-4232-b935-c33b989a284b',
|
||||
'name' => 'Weather',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
|
|
@ -51,8 +51,8 @@ class ExampleRecipesSeeder extends Seeder
|
|||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54'],
|
||||
[
|
||||
'uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54',
|
||||
'name' => 'Zen Quotes',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
|
|
@ -70,8 +70,8 @@ class ExampleRecipesSeeder extends Seeder
|
|||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f'],
|
||||
[
|
||||
'uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f',
|
||||
'name' => 'This Day in History',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
|
|
@ -89,8 +89,8 @@ class ExampleRecipesSeeder extends Seeder
|
|||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d'],
|
||||
[
|
||||
'uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d',
|
||||
'name' => 'Home Assistant',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
|
|
@ -108,8 +108,8 @@ class ExampleRecipesSeeder extends Seeder
|
|||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80'],
|
||||
[
|
||||
'uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80',
|
||||
'name' => 'Sunrise/Sunset',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
|
|
@ -127,8 +127,8 @@ class ExampleRecipesSeeder extends Seeder
|
|||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe'],
|
||||
[
|
||||
'uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe',
|
||||
'name' => 'Pollen Forecast',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
|
|
@ -144,5 +144,42 @@ class ExampleRecipesSeeder extends Seeder
|
|||
'flux_icon_name' => 'flower',
|
||||
]
|
||||
);
|
||||
|
||||
Plugin::updateOrCreate(
|
||||
['uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90'],
|
||||
[
|
||||
'name' => 'Holidays (iCal)',
|
||||
'user_id' => $user_id,
|
||||
'data_payload' => null,
|
||||
'data_stale_minutes' => 720,
|
||||
'data_strategy' => 'polling',
|
||||
'configuration_template' => [
|
||||
'custom_fields' => [
|
||||
[
|
||||
'keyname' => 'calendar',
|
||||
'field_type' => 'select',
|
||||
'name' => 'Public Holidays Calendar',
|
||||
'options' => [
|
||||
['USA' => 'usa'],
|
||||
['Austria' => 'austria'],
|
||||
['Australia' => 'australia'],
|
||||
['Canada' => 'canada'],
|
||||
['Germany' => 'germany'],
|
||||
['UK' => 'united-kingdom'],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'configuration' => ['calendar' => 'usa'],
|
||||
'polling_url' => 'https://www.officeholidays.com/ics-clean/{{calendar}}',
|
||||
'polling_verb' => 'get',
|
||||
'polling_header' => null,
|
||||
'render_markup' => null,
|
||||
'render_markup_view' => 'recipes.holidays-ical',
|
||||
'detail_view_route' => null,
|
||||
'icon_url' => null,
|
||||
'flux_icon_name' => 'calendar',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1398
package-lock.json
generated
21
package.json
|
|
@ -6,14 +6,27 @@
|
|||
"dev": "vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"@codemirror/commands": "^6.9.0",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-liquid": "^6.3.0",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.38.5",
|
||||
"@fsegurai/codemirror-theme-github-light": "^6.2.2",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.8.2",
|
||||
"codemirror": "^6.0.2",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^1.0",
|
||||
"puppeteer": "^24.3.0",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"puppeteer": "24.30.0",
|
||||
"tailwindcss": "^4.0.7",
|
||||
"vite": "^6.3"
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
|
|
|||
BIN
public/mirror/assets/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/mirror/assets/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/mirror/assets/apple-touch-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/mirror/assets/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/mirror/assets/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/mirror/assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 401 B |
BIN
public/mirror/assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 518 B |
BIN
public/mirror/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
139
public/mirror/assets/logo--brand.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
521
public/mirror/index.html
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<title>TRMNL BYOS Laravel Mirror</title>
|
||||
<link rel="manifest" href="/mirror/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-76x76.png" sizes="76x76" />
|
||||
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-120x120.png" sizes="120x120" />
|
||||
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-152x152.png" sizes="152x152" />
|
||||
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-167x167.png" sizes="167x167" />
|
||||
<link rel="apple-touch-icon" type="image/png" href="/mirror/assets/apple-touch-icon-180x180.png" sizes="180x180" />
|
||||
|
||||
<link rel="icon" type="image/png" href="/mirror/assets/favicon-16x16.png" sizes="16x16" />
|
||||
<link rel="icon" type="image/png" href="/mirror/assets/favicon-32x32.png" sizes="32x32" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<script>
|
||||
var trmnl = {
|
||||
STORAGE_KEY: "byos_laravel_mirror_settings",
|
||||
refreshTimer: null,
|
||||
renderedAt: 0,
|
||||
ui: {},
|
||||
|
||||
showStatus: function (message) {
|
||||
trmnl.ui.img.style.display = "none";
|
||||
trmnl.ui.errorContainer.style.display = "flex";
|
||||
trmnl.ui.errorMessage.textContent = message;
|
||||
},
|
||||
|
||||
showScreen: function (src) {
|
||||
trmnl.ui.img.src = src;
|
||||
trmnl.ui.img.style.display = "block";
|
||||
trmnl.ui.errorContainer.style.display = "none";
|
||||
},
|
||||
|
||||
showSetupForm: function () {
|
||||
var data = trmnl.getSettings();
|
||||
trmnl.ui.apiKeyInput.value = data.api_key || "";
|
||||
trmnl.ui.baseURLInput.value = data.base_url || "";
|
||||
trmnl.ui.macAddressInput.value = data.mac_address || "";
|
||||
trmnl.ui.displayModeSelect.value = data.display_mode || "";
|
||||
|
||||
trmnl.ui.setup.style.display = "flex";
|
||||
},
|
||||
|
||||
saveSetup: function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var apiKey = trmnl.ui.apiKeyInput.value;
|
||||
var baseURL = trmnl.ui.baseURLInput.value;
|
||||
var macAddress = trmnl.ui.macAddressInput.value;
|
||||
var displayMode = trmnl.ui.displayModeSelect.value;
|
||||
|
||||
if (!apiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
trmnl.saveSettings({
|
||||
api_key: apiKey,
|
||||
base_url: baseURL,
|
||||
mac_address: macAddress,
|
||||
display_mode: displayMode
|
||||
});
|
||||
|
||||
trmnl.fetchDisplay();
|
||||
},
|
||||
|
||||
hideSetupForm: function () {
|
||||
trmnl.ui.setup.style.display = "none";
|
||||
},
|
||||
|
||||
fetchDisplay: function (opts) {
|
||||
opts = opts || {};
|
||||
clearTimeout(trmnl.refreshTimer);
|
||||
|
||||
if (!opts.quiet) {
|
||||
trmnl.hideSetupForm();
|
||||
trmnl.showStatus("Loading...");
|
||||
}
|
||||
|
||||
var setup = trmnl.getSettings();
|
||||
var apiKey = setup.api_key;
|
||||
var displayMode = setup.display_mode;
|
||||
var baseURL = setup.base_url || "https://your-byos-trmnl.com";
|
||||
var macAddress = setup.mac_address || "00:00:00:00:00:01";
|
||||
|
||||
document.body.classList.remove("dark", "night")
|
||||
if (displayMode) {
|
||||
document.body.classList.add(displayMode)
|
||||
}
|
||||
|
||||
var headers = {
|
||||
"Access-Token": apiKey,
|
||||
"id": macAddress
|
||||
};
|
||||
|
||||
var url = baseURL + "/api/display";
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", url, true);
|
||||
|
||||
for (var headerName in headers) {
|
||||
if (headers.hasOwnProperty(headerName)) {
|
||||
xhr.setRequestHeader(headerName, headers[headerName]);
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
var data = JSON.parse(xhr.responseText);
|
||||
console.log("Display response:", data);
|
||||
|
||||
if (data.status !== 0) {
|
||||
trmnl.showStatus(
|
||||
"Error: " + (data.error || data.message || data.status)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
trmnl.showScreen(data.image_url);
|
||||
trmnl.renderedAt = new Date();
|
||||
|
||||
if (data.refresh_rate) {
|
||||
var refreshRate = 30;
|
||||
refreshRate = data.refresh_rate;
|
||||
|
||||
console.log("Refreshing in " + refreshRate + " seconds...");
|
||||
trmnl.refreshTimer = setTimeout(
|
||||
function () { trmnl.fetchDisplay({ quiet: true }); },
|
||||
1000 * refreshRate
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
trmnl.showStatus("Error processing response: " + e.message);
|
||||
}
|
||||
} else {
|
||||
trmnl.showStatus(
|
||||
"Failed to fetch screen: " + xhr.status + " " + xhr.statusText
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
trmnl.showStatus("Network error: Failed to connect or request was blocked.");
|
||||
};
|
||||
|
||||
xhr.send();
|
||||
},
|
||||
|
||||
getSettings: function () {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(trmnl.STORAGE_KEY)) || {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
saveSettings: function (data) {
|
||||
var settings = trmnl.getSettings();
|
||||
|
||||
for (var key in data) {
|
||||
if (data.hasOwnProperty(key)) {
|
||||
settings[key] = data[key];
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(trmnl.STORAGE_KEY, JSON.stringify(settings));
|
||||
console.log("Settings saved:", settings);
|
||||
},
|
||||
|
||||
cleanUrl: function () {
|
||||
if (window.history && window.history.replaceState) {
|
||||
try {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname
|
||||
);
|
||||
} catch (e) {
|
||||
// iOS 9 / UIWebView: silent ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
applySettingsFromUrl: function () {
|
||||
var query = window.location.search.substring(1);
|
||||
if (!query) return;
|
||||
|
||||
var pairs = query.split("&");
|
||||
var newSettings = {};
|
||||
var hasOverrides = false;
|
||||
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var parts = pairs[i].split("=");
|
||||
if (parts.length !== 2) continue;
|
||||
|
||||
var key = decodeURIComponent(parts[0]);
|
||||
var value = decodeURIComponent(parts[1]);
|
||||
|
||||
if (key === "api_key" && value) {
|
||||
newSettings.api_key = value;
|
||||
hasOverrides = true;
|
||||
}
|
||||
|
||||
if (key === "base_url" && value) {
|
||||
newSettings.base_url = value;
|
||||
hasOverrides = true;
|
||||
}
|
||||
|
||||
if (key === "mac_address" && value) {
|
||||
newSettings.mac_address = value;
|
||||
hasOverrides = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOverrides) {
|
||||
trmnl.saveSettings(newSettings);
|
||||
console.log("Settings overridden from URL:", newSettings);
|
||||
}
|
||||
},
|
||||
|
||||
setDefaultBaseUrlIfMissing: function () {
|
||||
var settings = trmnl.getSettings();
|
||||
|
||||
if (settings && settings.base_url) {
|
||||
return;
|
||||
}
|
||||
|
||||
var protocol = window.location.protocol;
|
||||
var host = window.location.hostname;
|
||||
var port = window.location.port;
|
||||
|
||||
var origin = protocol + "//" + host;
|
||||
if (port) {
|
||||
origin += ":" + port;
|
||||
}
|
||||
|
||||
trmnl.saveSettings({
|
||||
base_url: origin
|
||||
});
|
||||
|
||||
console.log("Default base_url set to:", origin);
|
||||
},
|
||||
|
||||
clearSettings: function () {
|
||||
try {
|
||||
localStorage.removeItem(trmnl.STORAGE_KEY);
|
||||
} catch (e) {
|
||||
// fallback ultra-safe
|
||||
localStorage.setItem(trmnl.STORAGE_KEY, "{}");
|
||||
}
|
||||
console.log("Settings cleared");
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
init: function () {
|
||||
|
||||
// override settings from GET params
|
||||
trmnl.applySettingsFromUrl();
|
||||
|
||||
trmnl.cleanUrl();
|
||||
|
||||
// default base_url
|
||||
trmnl.setDefaultBaseUrlIfMissing();
|
||||
|
||||
// screen
|
||||
trmnl.ui.img = document.getElementById("screen");
|
||||
trmnl.ui.errorContainer = document.getElementById("error-container");
|
||||
trmnl.ui.errorMessage = document.getElementById("error-message");
|
||||
|
||||
// settings
|
||||
trmnl.ui.apiKeyInput = document.getElementById("api_key");
|
||||
trmnl.ui.baseURLInput = document.getElementById("base_url");
|
||||
trmnl.ui.macAddressInput = document.getElementById("mac_address");
|
||||
trmnl.ui.displayModeSelect = document.getElementById("display_mode");
|
||||
trmnl.ui.setup = document.getElementById("setup");
|
||||
|
||||
var settings = trmnl.getSettings();
|
||||
if (!settings || !settings.api_key) {
|
||||
trmnl.showSetupForm();
|
||||
} else {
|
||||
trmnl.fetchDisplay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
trmnl.init();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
overflow: hidden;
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #f54900;
|
||||
}
|
||||
|
||||
#screen-container,
|
||||
#setup {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
#setup {
|
||||
background-color: #3d3d3e;
|
||||
}
|
||||
|
||||
#setup-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
padding: 2em;
|
||||
margin: 1em;
|
||||
border-radius: 1em;
|
||||
box-shadow: 0 0.5em 2em rgba(0, 0, 0, 1);
|
||||
}
|
||||
|
||||
#setup-panel img {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
#screen {
|
||||
cursor: pointer;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: contain;
|
||||
background-color: #000000;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
body.dark #screen,
|
||||
body.night #screen {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
#red-overlay {
|
||||
background-color: #ff0000;
|
||||
mix-blend-mode: darken;
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.night #red-overlay {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark #error-container,
|
||||
.dark #screen-container,
|
||||
.night #error-container,
|
||||
.night #screen-container {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
#error-message {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
#setup {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
font-size: 1.25em;
|
||||
width: 14em;
|
||||
padding: 0.5em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
label,
|
||||
summary {
|
||||
font-size: 1.25em;
|
||||
margin-bottom: 0.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 1.25em;
|
||||
padding: 0.5em 1em;
|
||||
background-color: #f54900;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5em;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
margin-top: 1em;
|
||||
background-color: #777;
|
||||
}
|
||||
|
||||
#error-container .btn {
|
||||
margin-left: 0.5em;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.night #error-container .btn {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 1.25em;
|
||||
padding: 0.5em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
#unsupported {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="setup" style="display: none;">
|
||||
<div id="setup-panel">
|
||||
<img src="/mirror/assets/logo--brand.svg" alt="TRMNL Logo" />
|
||||
|
||||
<form onsubmit="return trmnl.saveSetup(event)">
|
||||
<fieldset>
|
||||
<label for="mac_address">Device MAC Address</label>
|
||||
<input name="mac_address" id="mac_address" type="text" placeholder="00:00:00:00:00:01" class="form-control"
|
||||
required />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label for="api_key">Device API Key</label>
|
||||
<input name="api_key" id="api_key" type="text" placeholder="API Key" class="form-control" required />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<select id="display_mode" name="display_mode">
|
||||
<option value="" selected="selected">Light Mode</option>
|
||||
<option value="dark">Dark Mode</option>
|
||||
<option value="night">Night Mode</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<label for="base_url">Custom Server URL</label>
|
||||
<input name="base_url" id="base_url" type="text" placeholder="https://your-byos-trmnl.com"
|
||||
class="form-control" value="" />
|
||||
</fieldset>
|
||||
|
||||
<button class="btn">Save</button>
|
||||
|
||||
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()">
|
||||
Clear settings
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="screen-container">
|
||||
<div id="red-overlay"></div>
|
||||
<img id="screen" onclick="trmnl.showSetupForm()" style="display: none;">
|
||||
|
||||
<div id="error-container" style="display: none">
|
||||
<div id="error-message"></div>
|
||||
<div style="display: flex; margin-top: 1em">
|
||||
<button class="btn" onclick="trmnl.showSetupForm()">Setup</button>
|
||||
<button class="btn" onclick="trmnl.fetchDisplay()">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
7
public/mirror/manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "TRMNL BYOS Laravel Mirror",
|
||||
"short_name": "TRMNL BYOS",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff"
|
||||
}
|
||||
26
rector.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Rector\Config\RectorConfig;
|
||||
use Rector\Set\ValueObject\LevelSetList;
|
||||
use Rector\Set\ValueObject\SetList;
|
||||
|
||||
return static function (RectorConfig $rectorConfig): void {
|
||||
$rectorConfig->paths([
|
||||
__DIR__.'/app',
|
||||
__DIR__.'/tests',
|
||||
]);
|
||||
|
||||
$rectorConfig->sets([
|
||||
LevelSetList::UP_TO_PHP_82,
|
||||
SetList::CODE_QUALITY,
|
||||
SetList::DEAD_CODE,
|
||||
SetList::EARLY_RETURN,
|
||||
SetList::TYPE_DECLARATION,
|
||||
]);
|
||||
|
||||
$rectorConfig->skip([
|
||||
// Skip any specific rules if needed
|
||||
]);
|
||||
};
|
||||
|
|
@ -59,6 +59,10 @@
|
|||
@apply !mb-0 !leading-tight;
|
||||
}
|
||||
|
||||
[data-flux-description] a {
|
||||
@apply text-accent underline hover:opacity-80;
|
||||
}
|
||||
|
||||
input:focus[data-flux-control],
|
||||
textarea:focus[data-flux-control],
|
||||
select:focus[data-flux-control] {
|
||||
|
|
@ -68,3 +72,39 @@ select:focus[data-flux-control] {
|
|||
/* \[:where(&)\]:size-4 {
|
||||
@apply size-4;
|
||||
} */
|
||||
|
||||
@layer components {
|
||||
/* standard container for app */
|
||||
.styled-container,
|
||||
.tab-button {
|
||||
@apply rounded-xl border bg-white dark:bg-stone-950 dark:border-zinc-700 text-stone-800 shadow-xs;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@apply flex items-center gap-2 px-4 py-2 text-sm font-medium;
|
||||
@apply rounded-b-none shadow-none bg-inherit;
|
||||
|
||||
/* This makes the button sit slightly over the box border */
|
||||
margin-bottom: -1px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tab-button.is-active {
|
||||
@apply text-zinc-700 dark:text-zinc-300;
|
||||
@apply border-b-white dark:border-b-zinc-800;
|
||||
|
||||
/* Z-index 10 ensures the bottom border of the tab hides the top border of the box */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tab-button:not(.is-active) {
|
||||
@apply text-zinc-500 border-transparent;
|
||||
}
|
||||
|
||||
.tab-button:not(.is-active):hover {
|
||||
@apply text-zinc-700 dark:text-zinc-300;
|
||||
@apply border-zinc-300 dark:border-zinc-700;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { codeEditorFormComponent } from './codemirror-alpine.js';
|
||||
|
||||
window.codeEditorFormComponent = codeEditorFormComponent;
|
||||
198
resources/js/codemirror-alpine.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { createCodeMirror, getSystemTheme, watchThemeChange } from './codemirror-core.js';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
* Alpine.js component for CodeMirror that integrates with textarea and Livewire
|
||||
* Inspired by Filament's approach with proper state entanglement
|
||||
* @param {Object} config - Configuration object
|
||||
* @returns {Object} Alpine.js component object
|
||||
*/
|
||||
export function codeEditorFormComponent(config) {
|
||||
return {
|
||||
editor: null,
|
||||
textarea: null,
|
||||
isLoading: false,
|
||||
unwatchTheme: null,
|
||||
|
||||
// Configuration
|
||||
isDisabled: config.isDisabled || false,
|
||||
language: config.language || 'html',
|
||||
state: config.state || '',
|
||||
textareaId: config.textareaId || null,
|
||||
|
||||
/**
|
||||
* Initialize the component
|
||||
*/
|
||||
async init() {
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
// Wait for textarea if provided
|
||||
if (this.textareaId) {
|
||||
await this.waitForTextarea();
|
||||
}
|
||||
|
||||
await this.$nextTick();
|
||||
this.createEditor();
|
||||
this.setupEventListeners();
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Wait for textarea to be available in the DOM
|
||||
*/
|
||||
async waitForTextarea() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds max wait
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
this.textarea = document.getElementById(this.textareaId);
|
||||
if (this.textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait 100ms before trying again
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
console.error(`Textarea with ID "${this.textareaId}" not found after ${maxAttempts} attempts`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Update both Livewire state and textarea with new value
|
||||
*/
|
||||
updateState(value) {
|
||||
this.state = value;
|
||||
if (this.textarea) {
|
||||
this.textarea.value = value;
|
||||
this.textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create the CodeMirror editor instance
|
||||
*/
|
||||
createEditor() {
|
||||
// Clean up any existing editor first
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
}
|
||||
|
||||
const effectiveTheme = this.getEffectiveTheme();
|
||||
const initialValue = this.textarea ? this.textarea.value : this.state;
|
||||
|
||||
this.editor = createCodeMirror(this.$refs.editor, {
|
||||
value: initialValue || '',
|
||||
language: this.language,
|
||||
theme: effectiveTheme,
|
||||
readOnly: this.isDisabled,
|
||||
onChange: (value) => this.updateState(value),
|
||||
onUpdate: (value) => this.updateState(value),
|
||||
onBlur: () => {
|
||||
if (this.editor) {
|
||||
this.updateState(this.editor.state.doc.toString());
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get effective theme
|
||||
*/
|
||||
getEffectiveTheme() {
|
||||
return getSystemTheme();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update editor content with new value
|
||||
*/
|
||||
updateEditorContent(value) {
|
||||
if (this.editor && value !== this.editor.state.doc.toString()) {
|
||||
this.editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: this.editor.state.doc.length,
|
||||
insert: value
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Setup event listeners for theme changes and state synchronization
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Watch for state changes from Livewire
|
||||
this.$watch('state', (newValue) => {
|
||||
this.updateEditorContent(newValue);
|
||||
});
|
||||
|
||||
// Watch for disabled state changes
|
||||
this.$watch('isDisabled', (newValue) => {
|
||||
if (this.editor) {
|
||||
this.editor.dispatch({
|
||||
effects: EditorView.editable.reconfigure(!newValue)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for textarea changes (from Livewire updates)
|
||||
if (this.textarea) {
|
||||
this.textarea.addEventListener('input', (event) => {
|
||||
this.updateEditorContent(event.target.value);
|
||||
this.state = event.target.value;
|
||||
});
|
||||
|
||||
// Listen for Livewire updates that might change the textarea value
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
|
||||
this.updateEditorContent(this.textarea.value);
|
||||
this.state = this.textarea.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(this.textarea, {
|
||||
attributes: true,
|
||||
attributeFilter: ['value']
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
this.unwatchTheme = watchThemeChange(() => {
|
||||
this.recreateEditor();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Recreate the editor (useful for theme changes)
|
||||
*/
|
||||
async recreateEditor() {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.editor = null;
|
||||
await this.$nextTick();
|
||||
this.createEditor();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Clean up resources when component is destroyed
|
||||
*/
|
||||
destroy() {
|
||||
if (this.editor) {
|
||||
this.editor.destroy();
|
||||
this.editor = null;
|
||||
}
|
||||
if (this.unwatchTheme) {
|
||||
this.unwatchTheme();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
265
resources/js/codemirror-core.js
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
||||
import { ViewPlugin } from '@codemirror/view';
|
||||
import { indentWithTab, selectAll } from '@codemirror/commands';
|
||||
import { foldGutter, foldKeymap } from '@codemirror/language';
|
||||
import { history, historyKeymap } from '@codemirror/commands';
|
||||
import { searchKeymap } from '@codemirror/search';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { liquid } from '@codemirror/lang-liquid';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { githubLight } from '@fsegurai/codemirror-theme-github-light';
|
||||
|
||||
// Language support mapping
|
||||
const LANGUAGE_MAP = {
|
||||
'javascript': javascript,
|
||||
'js': javascript,
|
||||
'json': json,
|
||||
'css': css,
|
||||
'liquid': liquid,
|
||||
'html': html,
|
||||
};
|
||||
|
||||
// Theme support mapping
|
||||
const THEME_MAP = {
|
||||
'light': githubLight,
|
||||
'dark': oneDark,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get language support based on language parameter
|
||||
* @param {string} language - Language name or comma-separated list
|
||||
* @returns {Array|Extension} Language extension(s)
|
||||
*/
|
||||
function getLanguageSupport(language) {
|
||||
// Handle comma-separated languages
|
||||
if (language.includes(',')) {
|
||||
const languages = language.split(',').map(lang => lang.trim().toLowerCase());
|
||||
const languageExtensions = [];
|
||||
|
||||
languages.forEach(lang => {
|
||||
const languageFn = LANGUAGE_MAP[lang];
|
||||
if (languageFn) {
|
||||
languageExtensions.push(languageFn());
|
||||
}
|
||||
});
|
||||
|
||||
return languageExtensions;
|
||||
}
|
||||
|
||||
// Handle single language
|
||||
const languageFn = LANGUAGE_MAP[language.toLowerCase()] || LANGUAGE_MAP.html;
|
||||
return languageFn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme support
|
||||
* @param {string} theme - Theme name
|
||||
* @returns {Array} Theme extensions
|
||||
*/
|
||||
function getThemeSupport(theme) {
|
||||
const themeFn = THEME_MAP[theme] || THEME_MAP.light;
|
||||
return [themeFn];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resize plugin that handles container resizing
|
||||
* @returns {ViewPlugin} Resize plugin
|
||||
*/
|
||||
function createResizePlugin() {
|
||||
return ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
this.view = view;
|
||||
this.resizeObserver = null;
|
||||
this.setupResizeObserver();
|
||||
}
|
||||
|
||||
setupResizeObserver() {
|
||||
const container = this.view.dom.parentElement;
|
||||
if (container) {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
// Use requestAnimationFrame to ensure proper timing
|
||||
requestAnimationFrame(() => {
|
||||
this.view.requestMeasure();
|
||||
});
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Flux-like theme styling based on theme
|
||||
* @param {string} theme - Theme name ('light', 'dark', or 'auto')
|
||||
* @returns {Object} Theme-specific styling
|
||||
*/
|
||||
function getFluxThemeStyling(theme) {
|
||||
const isDark = theme === 'dark' || (theme === 'auto' && getSystemTheme() === 'dark');
|
||||
|
||||
if (isDark) {
|
||||
return {
|
||||
backgroundColor: 'oklab(0.999994 0.0000455678 0.0000200868 / 0.1)',
|
||||
gutterBackgroundColor: 'oklch(26.9% 0 0)',
|
||||
borderColor: '#374151',
|
||||
focusBorderColor: 'rgb(224 91 68)',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
backgroundColor: '#fff', // zinc-50
|
||||
gutterBackgroundColor: '#fafafa', // zinc-50
|
||||
borderColor: '#e5e7eb', // gray-200
|
||||
focusBorderColor: 'rgb(224 91 68)', // red-500
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CodeMirror editor instance
|
||||
* @param {HTMLElement} element - DOM element to mount editor
|
||||
* @param {Object} options - Editor options
|
||||
* @returns {EditorView} CodeMirror editor instance
|
||||
*/
|
||||
export function createCodeMirror(element, options = {}) {
|
||||
const {
|
||||
value = '',
|
||||
language = 'html',
|
||||
theme = 'light',
|
||||
readOnly = false,
|
||||
onChange = () => {},
|
||||
onUpdate = () => {},
|
||||
onBlur = () => {}
|
||||
} = options;
|
||||
|
||||
// Get language and theme support
|
||||
const languageSupport = getLanguageSupport(language);
|
||||
const themeSupport = getThemeSupport(theme);
|
||||
const fluxStyling = getFluxThemeStyling(theme);
|
||||
|
||||
// Create editor
|
||||
const editor = new EditorView({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
foldGutter(),
|
||||
history(),
|
||||
EditorView.lineWrapping,
|
||||
createResizePlugin(),
|
||||
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
||||
...themeSupport,
|
||||
keymap.of([
|
||||
indentWithTab,
|
||||
...foldKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
{
|
||||
key: 'Mod-a',
|
||||
run: selectAll,
|
||||
},
|
||||
]),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '14px',
|
||||
border: `1px solid ${fluxStyling.borderColor}`,
|
||||
borderRadius: '0.375rem',
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: fluxStyling.backgroundColor + ' !important',
|
||||
resize: 'vertical',
|
||||
minHeight: '200px',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
borderTopLeftRadius: '0.375rem',
|
||||
backgroundColor: fluxStyling.gutterBackgroundColor + ' !important',
|
||||
},
|
||||
'.cm-gutter': {
|
||||
backgroundColor: fluxStyling.gutterBackgroundColor + ' !important',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
borderColor: fluxStyling.focusBorderColor,
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '12px',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-editor': {
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-editor .cm-scroller': {
|
||||
height: '100%',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-foldGutter': {
|
||||
width: '12px',
|
||||
},
|
||||
'.cm-foldGutter .cm-gutterElement': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
},
|
||||
'.cm-foldGutter .cm-gutterElement:hover': {
|
||||
color: '#374151',
|
||||
},
|
||||
'.cm-foldGutter .cm-gutterElement.cm-folded': {
|
||||
color: '#3b82f6',
|
||||
}
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const newValue = update.state.doc.toString();
|
||||
onChange(newValue);
|
||||
onUpdate(newValue);
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
blur: onBlur
|
||||
}),
|
||||
EditorView.editable.of(!readOnly),
|
||||
],
|
||||
parent: element
|
||||
});
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect system theme preference
|
||||
* @returns {string} 'dark' or 'light'
|
||||
*/
|
||||
export function getSystemTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for system theme changes
|
||||
* @param {Function} callback - Callback function when theme changes
|
||||
* @returns {Function} Unwatch function
|
||||
*/
|
||||
export function watchThemeChange(callback) {
|
||||
if (window.matchMedia) {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', callback);
|
||||
return () => mediaQuery.removeEventListener('change', callback);
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||
<div class="styled-container">
|
||||
<div class="px-10 py-8">{{ $slot }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
23
resources/views/default-screens/error.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
@props([
|
||||
'noBleed' => false,
|
||||
'darkMode' => false,
|
||||
'deviceVariant' => 'og',
|
||||
'deviceOrientation' => null,
|
||||
'colorDepth' => '1bit',
|
||||
'scaleLevel' => null,
|
||||
'pluginName' => 'Recipe',
|
||||
])
|
||||
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||
scale-level="{{$scaleLevel}}">
|
||||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
<x-trmnl::title>Error on {{ $pluginName }}</x-trmnl::title>
|
||||
<x-trmnl::content>Unable to render content. Please check server logs.</x-trmnl::content>
|
||||
</x-trmnl::richtext>
|
||||
</x-trmnl::layout>
|
||||
<x-trmnl::title-bar/>
|
||||
</x-trmnl::view>
|
||||
</x-trmnl::screen>
|
||||
22
resources/views/default-screens/setup.blade.php
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
@props([
|
||||
'noBleed' => false,
|
||||
'darkMode' => false,
|
||||
'deviceVariant' => 'og',
|
||||
'deviceOrientation' => null,
|
||||
'colorDepth' => '1bit',
|
||||
'scaleLevel' => null,
|
||||
])
|
||||
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||
scale-level="{{$scaleLevel}}">
|
||||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
<x-trmnl::title>Welcome to BYOS Laravel!</x-trmnl::title>
|
||||
<x-trmnl::content>Your device is connected.</x-trmnl::content>
|
||||
</x-trmnl::richtext>
|
||||
</x-trmnl::layout>
|
||||
<x-trmnl::title-bar title="byos_laravel"/>
|
||||
</x-trmnl::view>
|
||||
</x-trmnl::screen>
|
||||
28
resources/views/default-screens/sleep.blade.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
@props([
|
||||
'noBleed' => false,
|
||||
'darkMode' => true,
|
||||
'deviceVariant' => 'og',
|
||||
'deviceOrientation' => null,
|
||||
'colorDepth' => '1bit',
|
||||
'scaleLevel' => null,
|
||||
])
|
||||
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||
scale-level="{{$scaleLevel}}">
|
||||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
<div class="image image-dither" alt="sleep">
|
||||
<svg class="w-64 h-64" fill="#000" xmlns="http://www.w3.org/2000/svg" id="mdi-sleep"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M23,12H17V10L20.39,6H17V4H23V6L19.62,10H23V12M15,16H9V14L12.39,10H9V8H15V10L11.62,14H15V16M7,20H1V18L4.39,14H1V12H7V14L3.62,18H7V20Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<x-trmnl::title>Sleep Mode</x-trmnl::title>
|
||||
</x-trmnl::richtext>
|
||||
</x-trmnl::layout>
|
||||
<x-trmnl::title-bar title="byos_laravel"/>
|
||||
</x-trmnl::view>
|
||||
</x-trmnl::screen>
|
||||
|
|
@ -1,29 +1,52 @@
|
|||
<?php
|
||||
|
||||
use App\Services\PluginImportService;
|
||||
use Livewire\Volt\Component;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Volt\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
new class extends Component {
|
||||
new
|
||||
#[Lazy]
|
||||
class extends Component
|
||||
{
|
||||
public array $catalogPlugins = [];
|
||||
|
||||
public string $installingPlugin = '';
|
||||
|
||||
public string $previewingPlugin = '';
|
||||
|
||||
public array $previewData = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadCatalogPlugins();
|
||||
}
|
||||
|
||||
public function placeholder()
|
||||
{
|
||||
return <<<'HTML'
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center space-x-2">
|
||||
<flux:icon.loading />
|
||||
<flux:text>Loading recipes...</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function loadCatalogPlugins(): void
|
||||
{
|
||||
$catalogUrl = config('app.catalog_url');
|
||||
|
||||
$this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) {
|
||||
try {
|
||||
$response = Http::get($catalogUrl);
|
||||
$response = Http::timeout(10)->get($catalogUrl);
|
||||
$catalogContent = $response->body();
|
||||
$catalog = Yaml::parse($catalogContent);
|
||||
|
||||
|
|
@ -32,7 +55,7 @@ new class extends Component {
|
|||
return collect($catalog)
|
||||
->filter(function ($plugin) use ($currentVersion) {
|
||||
// Check if Laravel compatibility is true
|
||||
if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
||||
if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +76,7 @@ new class extends Component {
|
|||
'github' => Arr::get($plugin, 'author.github'),
|
||||
'license' => Arr::get($plugin, 'license'),
|
||||
'zip_url' => Arr::get($plugin, 'trmnlp.zip_url'),
|
||||
'zip_entry_path' => Arr::get($plugin, 'trmnlp.zip_entry_path'),
|
||||
'repo_url' => Arr::get($plugin, 'trmnlp.repo'),
|
||||
'logo_url' => Arr::get($plugin, 'logo_url'),
|
||||
'screenshot_url' => Arr::get($plugin, 'screenshot_url'),
|
||||
|
|
@ -61,8 +85,9 @@ new class extends Component {
|
|||
})
|
||||
->sortBy('name')
|
||||
->toArray();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Failed to load catalog from URL: ' . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to load catalog from URL: '.$e->getMessage());
|
||||
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
|
@ -74,31 +99,59 @@ new class extends Component {
|
|||
|
||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||
|
||||
if (!$plugin || !$plugin['zip_url']) {
|
||||
if (! $plugin || ! $plugin['zip_url']) {
|
||||
$this->addError('installation', 'Plugin not found or no download URL available.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->installingPlugin = $pluginId;
|
||||
|
||||
try {
|
||||
$importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user());
|
||||
$importedPlugin = $pluginImportService->importFromUrl(
|
||||
$plugin['zip_url'],
|
||||
auth()->user(),
|
||||
$plugin['zip_entry_path'] ?? null,
|
||||
null,
|
||||
$plugin['logo_url'] ?? null,
|
||||
allowDuplicate: true
|
||||
);
|
||||
|
||||
$this->dispatch('plugin-installed');
|
||||
Flux::modal('import-from-catalog')->close();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
||||
} finally {
|
||||
$this->installingPlugin = '';
|
||||
}
|
||||
}
|
||||
|
||||
public function previewPlugin(string $pluginId): void
|
||||
{
|
||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||
|
||||
if (! $plugin) {
|
||||
$this->addError('preview', 'Plugin not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->previewingPlugin = $pluginId;
|
||||
$this->previewData = $plugin;
|
||||
}
|
||||
|
||||
public function closePreview(): void
|
||||
{
|
||||
$this->previewingPlugin = '';
|
||||
$this->previewData = [];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-4">
|
||||
@if(empty($catalogPlugins))
|
||||
<div class="text-center py-8">
|
||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<flux:heading class="mt-2">No plugins available</flux:heading>
|
||||
<flux:subheading>Catalog is empty</flux:subheading>
|
||||
</div>
|
||||
|
|
@ -109,30 +162,30 @@ new class extends Component {
|
|||
@enderror
|
||||
|
||||
@foreach($catalogPlugins as $plugin)
|
||||
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||
<div wire:key="plugin-{{ $plugin['id'] }}" class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
@if($plugin['logo_url'])
|
||||
<img src="{{ $plugin['logo_url'] }}" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||
@else
|
||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
||||
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
|
||||
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
|
||||
@if ($plugin['github'])
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
|
||||
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($plugin['license'])
|
||||
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||
@endif
|
||||
@if($plugin['repo_url'])
|
||||
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
||||
<flux:icon name="github" class="w-5 h-5" />
|
||||
</a>
|
||||
@endif
|
||||
|
|
@ -140,7 +193,7 @@ new class extends Component {
|
|||
</div>
|
||||
|
||||
@if($plugin['description'])
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
|
||||
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-center space-x-3">
|
||||
|
|
@ -150,6 +203,19 @@ new class extends Component {
|
|||
Install
|
||||
</flux:button>
|
||||
|
||||
@if($plugin['screenshot_url'])
|
||||
<flux:modal.trigger name="catalog-preview">
|
||||
<flux:button
|
||||
wire:click="previewPlugin('{{ $plugin['id'] }}')"
|
||||
variant="subtle"
|
||||
icon="eye">
|
||||
Preview
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
|
||||
|
||||
|
||||
@if($plugin['learn_more_url'])
|
||||
<flux:button
|
||||
href="{{ $plugin['learn_more_url'] }}"
|
||||
|
|
@ -165,4 +231,38 @@ new class extends Component {
|
|||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<flux:modal name="catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
|
||||
@if($previewingPlugin && !empty($previewData))
|
||||
<div>
|
||||
<flux:heading size="lg">Preview {{ $previewData['name'] ?? 'Plugin' }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<img src="{{ $previewData['screenshot_url'] }}"
|
||||
alt="Preview of {{ $previewData['name'] }}"
|
||||
class="w-full h-auto max-h-[480px] object-contain">
|
||||
</div>
|
||||
|
||||
@if($previewData['description'])
|
||||
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
|
||||
<flux:heading size="sm" class="mb-2">Description</flux:heading>
|
||||
<flux:text size="sm">{{ $previewData['description'] }}</flux:text>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
|
||||
<flux:modal.close>
|
||||
<flux:button
|
||||
wire:click="installPlugin('{{ $previewingPlugin }}')"
|
||||
variant="primary">
|
||||
Install Plugin
|
||||
</flux:button>
|
||||
</flux:modal.close>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
|
|
|||
407
resources/views/livewire/catalog/trmnl.blade.php
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
<?php
|
||||
|
||||
use App\Services\PluginImportService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new
|
||||
#[Lazy]
|
||||
class extends Component
|
||||
{
|
||||
public array $recipes = [];
|
||||
|
||||
public int $page = 1;
|
||||
|
||||
public bool $hasMore = false;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public bool $isSearching = false;
|
||||
|
||||
public string $previewingRecipe = '';
|
||||
|
||||
public array $previewData = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadNewest();
|
||||
}
|
||||
|
||||
public function placeholder()
|
||||
{
|
||||
return <<<'HTML'
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center space-x-2">
|
||||
<flux:icon.loading />
|
||||
<flux:text>Loading recipes...</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private function loadNewest(): void
|
||||
{
|
||||
try {
|
||||
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
|
||||
$response = Cache::remember($cacheKey, 43200, function () {
|
||||
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||
'sort-by' => 'newest',
|
||||
'page' => $this->page,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Failed to fetch TRMNL recipes');
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
});
|
||||
|
||||
$data = $response['data'] ?? [];
|
||||
$mapped = $this->mapRecipes($data);
|
||||
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = $mapped;
|
||||
} else {
|
||||
$this->recipes = array_merge($this->recipes, $mapped);
|
||||
}
|
||||
|
||||
$this->hasMore = ! empty($response['next_page_url']);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('TRMNL catalog load error: '.$e->getMessage());
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = [];
|
||||
}
|
||||
$this->hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
private function searchRecipes(string $term): void
|
||||
{
|
||||
$this->isSearching = true;
|
||||
try {
|
||||
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
|
||||
$response = Cache::remember($cacheKey, 300, function () use ($term) {
|
||||
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
||||
'search' => $term,
|
||||
'sort-by' => 'newest',
|
||||
'page' => $this->page,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Failed to search TRMNL recipes');
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
});
|
||||
|
||||
$data = $response['data'] ?? [];
|
||||
$mapped = $this->mapRecipes($data);
|
||||
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = $mapped;
|
||||
} else {
|
||||
$this->recipes = array_merge($this->recipes, $mapped);
|
||||
}
|
||||
|
||||
$this->hasMore = ! empty($response['next_page_url']);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('TRMNL catalog search error: '.$e->getMessage());
|
||||
if ($this->page === 1) {
|
||||
$this->recipes = [];
|
||||
}
|
||||
$this->hasMore = false;
|
||||
} finally {
|
||||
$this->isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function loadMore(): void
|
||||
{
|
||||
$this->page++;
|
||||
|
||||
$term = mb_trim($this->search);
|
||||
if ($term === '' || mb_strlen($term) < 2) {
|
||||
$this->loadNewest();
|
||||
} else {
|
||||
$this->searchRecipes($term);
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSearch(): void
|
||||
{
|
||||
$this->page = 1;
|
||||
$term = mb_trim($this->search);
|
||||
if ($term === '') {
|
||||
$this->loadNewest();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mb_strlen($term) < 2) {
|
||||
// Require at least 2 chars to avoid noisy calls
|
||||
return;
|
||||
}
|
||||
|
||||
$this->searchRecipes($term);
|
||||
}
|
||||
|
||||
public function installPlugin(string $recipeId, PluginImportService $pluginImportService): void
|
||||
{
|
||||
abort_unless(auth()->user() !== null, 403);
|
||||
|
||||
try {
|
||||
$zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive";
|
||||
|
||||
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
|
||||
|
||||
$plugin = $pluginImportService->importFromUrl(
|
||||
$zipUrl,
|
||||
auth()->user(),
|
||||
null,
|
||||
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
|
||||
$recipe['icon_url'] ?? null,
|
||||
allowDuplicate: true
|
||||
);
|
||||
|
||||
$this->dispatch('plugin-installed');
|
||||
Flux::modal('import-from-trmnl-catalog')->close();
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Plugin installation failed: '.$e->getMessage());
|
||||
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function previewRecipe(string $recipeId): void
|
||||
{
|
||||
$this->previewingRecipe = $recipeId;
|
||||
$this->previewData = [];
|
||||
|
||||
try {
|
||||
$response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json");
|
||||
|
||||
if ($response->successful()) {
|
||||
$item = $response->json()['data'] ?? [];
|
||||
$this->previewData = $this->mapRecipe($item);
|
||||
} else {
|
||||
// Fallback to searching for the specific recipe if single endpoint doesn't exist
|
||||
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||
'search' => $recipeId,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json()['data'] ?? [];
|
||||
$item = collect($data)->firstWhere('id', $recipeId);
|
||||
if ($item) {
|
||||
$this->previewData = $this->mapRecipe($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
Log::error('TRMNL catalog preview fetch error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
if (empty($this->previewData)) {
|
||||
$this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function mapRecipes(array $items): array
|
||||
{
|
||||
return collect($items)
|
||||
->map(fn (array $item) => $this->mapRecipe($item))
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $item
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapRecipe(array $item): array
|
||||
{
|
||||
return [
|
||||
'id' => $item['id'] ?? null,
|
||||
'name' => $item['name'] ?? 'Untitled',
|
||||
'icon_url' => $item['icon_url'] ?? null,
|
||||
'screenshot_url' => $item['screenshot_url'] ?? null,
|
||||
'author_bio' => is_array($item['author_bio'] ?? null)
|
||||
? strip_tags($item['author_bio']['description'] ?? null)
|
||||
: null,
|
||||
'stats' => [
|
||||
'installs' => data_get($item, 'stats.installs'),
|
||||
'forks' => data_get($item, 'stats.forks'),
|
||||
],
|
||||
'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null,
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<flux:input
|
||||
wire:model.live.debounce.400ms="search"
|
||||
placeholder="Search TRMNL recipes (min 2 chars)..."
|
||||
icon="magnifying-glass"
|
||||
/>
|
||||
</div>
|
||||
<flux:badge color="zinc">Newest</flux:badge>
|
||||
</div>
|
||||
|
||||
@error('installation')
|
||||
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}" />
|
||||
@enderror
|
||||
|
||||
@if(empty($recipes))
|
||||
<div class="text-center py-8">
|
||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||
<flux:heading class="mt-2">No recipes found</flux:heading>
|
||||
<flux:subheading>Try a different search term</flux:subheading>
|
||||
</div>
|
||||
@else
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
@foreach($recipes as $recipe)
|
||||
<div wire:key="recipe-{{ $recipe['id'] }}" class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||
<div class="px-10 py-8 space-y-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
|
||||
@if($thumb)
|
||||
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||
@else
|
||||
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
|
||||
@if(data_get($recipe, 'stats.installs'))
|
||||
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</flux:text>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
@if($recipe['detail_url'])
|
||||
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
||||
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($recipe['author_bio'])
|
||||
<flux:text class="mt-2" size="sm">{{ $recipe['author_bio'] }}</flux:text>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex items-center space-x-3">
|
||||
@if($recipe['id'])
|
||||
<flux:button
|
||||
wire:click="installPlugin('{{ $recipe['id'] }}')"
|
||||
variant="primary">
|
||||
Install
|
||||
</flux:button>
|
||||
@endif
|
||||
|
||||
@if($recipe['id'] && ($recipe['screenshot_url'] ?? null))
|
||||
<flux:modal.trigger name="trmnl-catalog-preview">
|
||||
<flux:button
|
||||
wire:click="previewRecipe('{{ $recipe['id'] }}')"
|
||||
variant="subtle"
|
||||
icon="eye">
|
||||
Preview
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if($hasMore)
|
||||
<div class="flex justify-center mt-6">
|
||||
<flux:button wire:click="loadMore" variant="subtle" wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="loadMore">Load next page</span>
|
||||
<span wire:loading wire:target="loadMore">Loading...</span>
|
||||
</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<flux:modal name="trmnl-catalog-preview" class="min-w-[850px] min-h-[480px] space-y-6">
|
||||
<div wire:loading wire:target="previewRecipe" class="flex items-center justify-center py-12">
|
||||
<div class="flex items-center space-x-2">
|
||||
<flux:icon.loading />
|
||||
<flux:text>Fetching recipe details...</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove wire:target="previewRecipe">
|
||||
@if($previewingRecipe && !empty($previewData))
|
||||
<div>
|
||||
<flux:heading size="lg" class="mb-2">Preview {{ $previewData['name'] ?? 'Recipe' }}</flux:heading>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||
<img src="{{ $previewData['screenshot_url'] }}"
|
||||
alt="Preview of {{ $previewData['name'] }}"
|
||||
class="w-full h-auto max-h-[480px] object-contain">
|
||||
</div>
|
||||
|
||||
@if($previewData['author_bio'])
|
||||
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||
<div class="px-10 py-8">
|
||||
<flux:heading size="sm" class="mb-2">Description</flux:heading>
|
||||
<flux:text size="sm">{{ $previewData['author_bio'] }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(data_get($previewData, 'stats.installs'))
|
||||
<div class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||
<div class="px-10 py-8">
|
||||
<flux:heading size="sm" class="mb-2">Statistics</flux:heading>
|
||||
<flux:text size="sm">
|
||||
Installs: {{ data_get($previewData, 'stats.installs') }} ·
|
||||
Forks: {{ data_get($previewData, 'stats.forks') }}
|
||||
</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center justify-end pt-4 border-t border-zinc-200 dark:border-zinc-700 space-x-3">
|
||||
@if($previewData['detail_url'])
|
||||
<flux:button
|
||||
href="{{ $previewData['detail_url'] }}"
|
||||
target="_blank"
|
||||
variant="subtle">
|
||||
View on TRMNL
|
||||
</flux:button>
|
||||
@endif
|
||||
<flux:modal.close>
|
||||
<flux:button
|
||||
wire:click="installPlugin('{{ $previewingRecipe }}')"
|
||||
variant="primary">
|
||||
Install Recipe
|
||||
</flux:button>
|
||||
</flux:modal.close>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
64
resources/views/livewire/codemirror.blade.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\Attributes\Modelable;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
#[Modelable]
|
||||
public $model = '';
|
||||
public $language = 'html';
|
||||
public $theme = 'auto';
|
||||
public $readonly = false;
|
||||
public $placeholder = '';
|
||||
public $height = '200px';
|
||||
public $id = '';
|
||||
|
||||
public function mount($language = 'html', $theme = 'auto', $readonly = false, $placeholder = '', $height = '200px', $id = '')
|
||||
{
|
||||
$this->language = $language;
|
||||
$this->theme = $theme;
|
||||
$this->readonly = $readonly;
|
||||
$this->placeholder = $placeholder;
|
||||
$this->height = $height;
|
||||
$this->id = $id;
|
||||
}
|
||||
|
||||
|
||||
public function toJSON()
|
||||
{
|
||||
return json_encode([
|
||||
'model' => $this->model,
|
||||
'language' => $this->language,
|
||||
'theme' => $this->theme,
|
||||
'readonly' => $this->readonly,
|
||||
'placeholder' => $this->placeholder,
|
||||
'height' => $this->height,
|
||||
'id' => $this->id,
|
||||
]);
|
||||
}
|
||||
} ?>
|
||||
|
||||
|
||||
<div
|
||||
x-data="codeMirrorComponent(@js($language), @js($theme), @js($readonly), @js($placeholder), @js($height), @js($id ?: uniqid()))"
|
||||
x-init="init()"
|
||||
wire:ignore
|
||||
class="codemirror-container"
|
||||
@if($id) id="{{ $id }}" @endif
|
||||
autocomplete="off"
|
||||
>
|
||||
<!-- Loading state -->
|
||||
<div x-show="isLoading" class="flex items-center justify-center p-4 border border-gray-300 rounded-md" style="height: {{ $height }};">
|
||||
<div class="flex items-center space-x-2 text-gray-500">
|
||||
<svg class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Loading editor...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor container -->
|
||||
<div x-show="!isLoading" x-ref="editor" style="height: {{ $height }};"></div>
|
||||
</div>
|
||||
|
|
@ -16,7 +16,7 @@ new class extends Component {
|
|||
@if($devices->isEmpty())
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||
class="styled-container">
|
||||
<div class="px-10 py-8">
|
||||
<h1 class="text-xl font-medium dark:text-zinc-200">Add your first device</h1>
|
||||
<flux:button href="{{ route('devices') }}" class="mt-4" icon="plus-circle" variant="primary"
|
||||
|
|
@ -30,7 +30,7 @@ new class extends Component {
|
|||
@foreach($devices as $device)
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||
class="styled-container">
|
||||
<div class="px-10 py-8">
|
||||
@php
|
||||
$current_image_uuid =$device->current_screen_image;
|
||||
|
|
|
|||
|
|
@ -1,26 +1,43 @@
|
|||
<?php
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\DevicePalette;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public $deviceModels;
|
||||
|
||||
public $devicePalettes;
|
||||
|
||||
public $name;
|
||||
|
||||
public $label;
|
||||
|
||||
public $description;
|
||||
|
||||
public $width;
|
||||
|
||||
public $height;
|
||||
|
||||
public $colors;
|
||||
|
||||
public $bit_depth;
|
||||
|
||||
public $scale_factor = 1.0;
|
||||
|
||||
public $rotation = 0;
|
||||
|
||||
public $mime_type = 'image/png';
|
||||
|
||||
public $offset_x = 0;
|
||||
|
||||
public $offset_y = 0;
|
||||
|
||||
public $published_at;
|
||||
|
||||
public $palette_id;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255|unique:device_models,name',
|
||||
'label' => 'required|string|max:255',
|
||||
|
|
@ -40,62 +57,58 @@ new class extends Component {
|
|||
public function mount()
|
||||
{
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
$this->devicePalettes = DevicePalette::all();
|
||||
|
||||
return view('livewire.device-models.index');
|
||||
}
|
||||
|
||||
public function createDeviceModel(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
DeviceModel::create([
|
||||
'name' => $this->name,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'colors' => $this->colors,
|
||||
'bit_depth' => $this->bit_depth,
|
||||
'scale_factor' => $this->scale_factor,
|
||||
'rotation' => $this->rotation,
|
||||
'mime_type' => $this->mime_type,
|
||||
'offset_x' => $this->offset_x,
|
||||
'offset_y' => $this->offset_y,
|
||||
'published_at' => $this->published_at,
|
||||
]);
|
||||
|
||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at']);
|
||||
\Flux::modal('create-device-model')->close();
|
||||
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
session()->flash('message', 'Device model created successfully.');
|
||||
}
|
||||
|
||||
public $editingDeviceModelId;
|
||||
|
||||
public function editDeviceModel(DeviceModel $deviceModel): void
|
||||
public $viewingDeviceModelId;
|
||||
|
||||
public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void
|
||||
{
|
||||
$this->editingDeviceModelId = $deviceModel->id;
|
||||
$this->name = $deviceModel->name;
|
||||
$this->label = $deviceModel->label;
|
||||
$this->description = $deviceModel->description;
|
||||
$this->width = $deviceModel->width;
|
||||
$this->height = $deviceModel->height;
|
||||
$this->colors = $deviceModel->colors;
|
||||
$this->bit_depth = $deviceModel->bit_depth;
|
||||
$this->scale_factor = $deviceModel->scale_factor;
|
||||
$this->rotation = $deviceModel->rotation;
|
||||
$this->mime_type = $deviceModel->mime_type;
|
||||
$this->offset_x = $deviceModel->offset_x;
|
||||
$this->offset_y = $deviceModel->offset_y;
|
||||
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
||||
if ($deviceModelId) {
|
||||
$deviceModel = DeviceModel::findOrFail($deviceModelId);
|
||||
|
||||
if ($viewOnly) {
|
||||
$this->viewingDeviceModelId = $deviceModel->id;
|
||||
$this->editingDeviceModelId = null;
|
||||
} else {
|
||||
$this->editingDeviceModelId = $deviceModel->id;
|
||||
$this->viewingDeviceModelId = null;
|
||||
}
|
||||
|
||||
$this->name = $deviceModel->name;
|
||||
$this->label = $deviceModel->label;
|
||||
$this->description = $deviceModel->description;
|
||||
$this->width = $deviceModel->width;
|
||||
$this->height = $deviceModel->height;
|
||||
$this->colors = $deviceModel->colors;
|
||||
$this->bit_depth = $deviceModel->bit_depth;
|
||||
$this->scale_factor = $deviceModel->scale_factor;
|
||||
$this->rotation = $deviceModel->rotation;
|
||||
$this->mime_type = $deviceModel->mime_type;
|
||||
$this->offset_x = $deviceModel->offset_x;
|
||||
$this->offset_y = $deviceModel->offset_y;
|
||||
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
||||
$this->palette_id = $deviceModel->palette_id;
|
||||
} else {
|
||||
$this->editingDeviceModelId = null;
|
||||
$this->viewingDeviceModelId = null;
|
||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id']);
|
||||
$this->mime_type = 'image/png';
|
||||
$this->scale_factor = 1.0;
|
||||
$this->rotation = 0;
|
||||
$this->offset_x = 0;
|
||||
$this->offset_y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateDeviceModel(): void
|
||||
public function saveDeviceModel(): void
|
||||
{
|
||||
$deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:255|unique:device_models,name,' . $deviceModel->id,
|
||||
$rules = [
|
||||
'name' => 'required|string|max:255',
|
||||
'label' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'width' => 'required|integer|min:1',
|
||||
|
|
@ -108,38 +121,96 @@ new class extends Component {
|
|||
'offset_x' => 'required|integer',
|
||||
'offset_y' => 'required|integer',
|
||||
'published_at' => 'nullable|date',
|
||||
]);
|
||||
'palette_id' => 'nullable|exists:device_palettes,id',
|
||||
];
|
||||
|
||||
$deviceModel->update([
|
||||
'name' => $this->name,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'colors' => $this->colors,
|
||||
'bit_depth' => $this->bit_depth,
|
||||
'scale_factor' => $this->scale_factor,
|
||||
'rotation' => $this->rotation,
|
||||
'mime_type' => $this->mime_type,
|
||||
'offset_x' => $this->offset_x,
|
||||
'offset_y' => $this->offset_y,
|
||||
'published_at' => $this->published_at,
|
||||
]);
|
||||
if ($this->editingDeviceModelId) {
|
||||
$rules['name'] = 'required|string|max:255|unique:device_models,name,'.$this->editingDeviceModelId;
|
||||
} else {
|
||||
$rules['name'] = 'required|string|max:255|unique:device_models,name';
|
||||
}
|
||||
|
||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'editingDeviceModelId']);
|
||||
\Flux::modal('edit-device-model-' . $deviceModel->id)->close();
|
||||
$this->validate($rules);
|
||||
|
||||
if ($this->editingDeviceModelId) {
|
||||
$deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId);
|
||||
$deviceModel->update([
|
||||
'name' => $this->name,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'colors' => $this->colors,
|
||||
'bit_depth' => $this->bit_depth,
|
||||
'scale_factor' => $this->scale_factor,
|
||||
'rotation' => $this->rotation,
|
||||
'mime_type' => $this->mime_type,
|
||||
'offset_x' => $this->offset_x,
|
||||
'offset_y' => $this->offset_y,
|
||||
'published_at' => $this->published_at,
|
||||
'palette_id' => $this->palette_id ?: null,
|
||||
]);
|
||||
$message = 'Device model updated successfully.';
|
||||
} else {
|
||||
DeviceModel::create([
|
||||
'name' => $this->name,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'colors' => $this->colors,
|
||||
'bit_depth' => $this->bit_depth,
|
||||
'scale_factor' => $this->scale_factor,
|
||||
'rotation' => $this->rotation,
|
||||
'mime_type' => $this->mime_type,
|
||||
'offset_x' => $this->offset_x,
|
||||
'offset_y' => $this->offset_y,
|
||||
'published_at' => $this->published_at,
|
||||
'palette_id' => $this->palette_id ?: null,
|
||||
'source' => 'manual',
|
||||
]);
|
||||
$message = 'Device model created successfully.';
|
||||
}
|
||||
|
||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'editingDeviceModelId', 'viewingDeviceModelId']);
|
||||
Flux::modal('device-model-modal')->close();
|
||||
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
session()->flash('message', 'Device model updated successfully.');
|
||||
session()->flash('message', $message);
|
||||
}
|
||||
|
||||
public function deleteDeviceModel(DeviceModel $deviceModel): void
|
||||
public function deleteDeviceModel(string $deviceModelId): void
|
||||
{
|
||||
$deviceModel = DeviceModel::findOrFail($deviceModelId);
|
||||
$deviceModel->delete();
|
||||
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
session()->flash('message', 'Device model deleted successfully.');
|
||||
}
|
||||
|
||||
public function duplicateDeviceModel(string $deviceModelId): void
|
||||
{
|
||||
$deviceModel = DeviceModel::findOrFail($deviceModelId);
|
||||
|
||||
$this->editingDeviceModelId = null;
|
||||
$this->viewingDeviceModelId = null;
|
||||
$this->name = $deviceModel->name.' (Copy)';
|
||||
$this->label = $deviceModel->label;
|
||||
$this->description = $deviceModel->description;
|
||||
$this->width = $deviceModel->width;
|
||||
$this->height = $deviceModel->height;
|
||||
$this->colors = $deviceModel->colors;
|
||||
$this->bit_depth = $deviceModel->bit_depth;
|
||||
$this->scale_factor = $deviceModel->scale_factor;
|
||||
$this->rotation = $deviceModel->rotation;
|
||||
$this->mime_type = $deviceModel->mime_type;
|
||||
$this->offset_x = $deviceModel->offset_x;
|
||||
$this->offset_y = $deviceModel->offset_y;
|
||||
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
||||
$this->palette_id = $deviceModel->palette_id;
|
||||
|
||||
$this->js('Flux.modal("device-model-modal").show()');
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
|
@ -148,10 +219,19 @@ new class extends Component {
|
|||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Models</h2>
|
||||
{{-- <flux:modal.trigger name="create-device-model">--}}
|
||||
{{-- <flux:button icon="plus" variant="primary">Add Device Model</flux:button>--}}
|
||||
{{-- </flux:modal.trigger>--}}
|
||||
<div class="flex items-center space-x-2">
|
||||
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Models</h2>
|
||||
<flux:dropdown>
|
||||
<flux:button icon="chevron-down" variant="ghost"></flux:button>
|
||||
<flux:menu>
|
||||
<flux:menu.item href="{{ route('devices') }}">Devices</flux:menu.item>
|
||||
<flux:menu.item href="{{ route('device-palettes.index') }}">Device Palettes</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</div>
|
||||
<flux:modal.trigger name="device-model-modal">
|
||||
<flux:button wire:click="openDeviceModelModal()" icon="plus" variant="primary">Add Device Model</flux:button>
|
||||
</flux:modal.trigger>
|
||||
</div>
|
||||
@if (session()->has('message'))
|
||||
<div class="mb-4">
|
||||
|
|
@ -164,157 +244,104 @@ new class extends Component {
|
|||
</div>
|
||||
@endif
|
||||
|
||||
<flux:modal name="create-device-model" class="md:w-96">
|
||||
<flux:modal name="device-model-modal" class="md:w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Add Device Model</flux:heading>
|
||||
<flux:heading size="lg">
|
||||
@if ($viewingDeviceModelId)
|
||||
View Device Model
|
||||
@elseif ($editingDeviceModelId)
|
||||
Edit Device Model
|
||||
@else
|
||||
Add Device Model
|
||||
@endif
|
||||
</flux:heading>
|
||||
</div>
|
||||
|
||||
<form wire:submit="createDeviceModel">
|
||||
<form wire:submit="saveDeviceModel">
|
||||
<div class="mb-4">
|
||||
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
||||
name="name" autofocus/>
|
||||
name="name" autofocus :disabled="(bool) $viewingDeviceModelId"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Label" wire:model="label" id="label" class="block mt-1 w-full"
|
||||
type="text"
|
||||
name="label"/>
|
||||
name="label" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Description" wire:model="description" id="description"
|
||||
class="block mt-1 w-full" name="description"/>
|
||||
class="block mt-1 w-full" name="description" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Width" wire:model="width" id="width" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="width"/>
|
||||
name="width" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
<flux:input label="Height" wire:model="height" id="height" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="height"/>
|
||||
name="height" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Colors" wire:model="colors" id="colors" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="colors"/>
|
||||
name="colors" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
<flux:input label="Bit Depth" wire:model="bit_depth" id="bit_depth"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="bit_depth"/>
|
||||
name="bit_depth" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Scale Factor" wire:model="scale_factor" id="scale_factor"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="scale_factor" step="0.1"/>
|
||||
name="scale_factor" step="0.1" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
<flux:input label="Rotation" wire:model="rotation" id="rotation" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="rotation"/>
|
||||
name="rotation" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="MIME Type" wire:model="mime_type" id="mime_type"
|
||||
class="block mt-1 w-full" type="text"
|
||||
name="mime_type"/>
|
||||
<flux:select label="MIME Type" wire:model="mime_type" id="mime_type" name="mime_type" :disabled="(bool) $viewingDeviceModelId">
|
||||
<flux:select.option>image/png</flux:select.option>
|
||||
<flux:select.option>image/bmp</flux:select.option>
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Offset X" wire:model="offset_x" id="offset_x" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="offset_x"/>
|
||||
name="offset_x" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
<flux:input label="Offset Y" wire:model="offset_y" id="offset_y" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="offset_y"/>
|
||||
name="offset_y" :disabled="(bool) $viewingDeviceModelId"/>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary">Create Device Model</flux:button>
|
||||
<div class="mb-4">
|
||||
<flux:select label="Color Palette" wire:model="palette_id" id="palette_id" name="palette_id" :disabled="(bool) $viewingDeviceModelId">
|
||||
<flux:select.option value="">None</flux:select.option>
|
||||
@foreach ($devicePalettes as $palette)
|
||||
<flux:select.option value="{{ $palette->id }}">{{ $palette->description ?? $palette->name }} ({{ $palette->name }})</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
@if (!$viewingDeviceModelId)
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary">{{ $editingDeviceModelId ? 'Update' : 'Create' }} Device Model</flux:button>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="button" wire:click="duplicateDeviceModel({{ $viewingDeviceModelId }})" variant="primary">Duplicate</flux:button>
|
||||
</div>
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
@foreach ($deviceModels as $deviceModel)
|
||||
<flux:modal name="edit-device-model-{{ $deviceModel->id }}" class="md:w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Edit Device Model</flux:heading>
|
||||
</div>
|
||||
|
||||
<form wire:submit="updateDeviceModel">
|
||||
<div class="mb-4">
|
||||
<flux:input label="Name" wire:model="name" id="edit_name" class="block mt-1 w-full"
|
||||
type="text"
|
||||
name="edit_name"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Label" wire:model="label" id="edit_label" class="block mt-1 w-full"
|
||||
type="text"
|
||||
name="edit_label"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Description" wire:model="description" id="edit_description"
|
||||
class="block mt-1 w-full" name="edit_description"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Width" wire:model="width" id="edit_width" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="edit_width"/>
|
||||
<flux:input label="Height" wire:model="height" id="edit_height"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_height"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Colors" wire:model="colors" id="edit_colors"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_colors"/>
|
||||
<flux:input label="Bit Depth" wire:model="bit_depth" id="edit_bit_depth"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_bit_depth"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Scale Factor" wire:model="scale_factor" id="edit_scale_factor"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_scale_factor" step="0.1"/>
|
||||
<flux:input label="Rotation" wire:model="rotation" id="edit_rotation"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_rotation"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:select label="MIME Type" wire:model="mime_type" id="edit_mime_type" name="edit_mime_type">
|
||||
<flux:select.option>image/png</flux:select.option>
|
||||
<flux:select.option>image/bmp</flux:select.option>
|
||||
</flux:select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Offset X" wire:model="offset_x" id="edit_offset_x"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_offset_x"/>
|
||||
<flux:input label="Offset Y" wire:model="offset_y" id="edit_offset_y"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_offset_y"/>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary">Update Device Model</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endforeach
|
||||
|
||||
<table
|
||||
class="min-w-full table-fixed text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20 text-zinc-800"
|
||||
data-flux-table>
|
||||
|
|
@ -369,14 +396,25 @@ new class extends Component {
|
|||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button.group>
|
||||
<flux:modal.trigger name="edit-device-model-{{ $deviceModel->id }}">
|
||||
<flux:button wire:click="editDeviceModel({{ $deviceModel->id }})" icon="pencil"
|
||||
@if ($deviceModel->source === 'api')
|
||||
<flux:modal.trigger name="device-model-modal">
|
||||
<flux:button wire:click="openDeviceModelModal('{{ $deviceModel->id }}', true)" icon="eye"
|
||||
iconVariant="outline">
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<flux:button wire:click="duplicateDeviceModel('{{ $deviceModel->id }}')" icon="document-duplicate"
|
||||
iconVariant="outline">
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<flux:button wire:click="deleteDeviceModel({{ $deviceModel->id }})" icon="trash"
|
||||
iconVariant="outline">
|
||||
</flux:button>
|
||||
@else
|
||||
<flux:modal.trigger name="device-model-modal">
|
||||
<flux:button wire:click="openDeviceModelModal('{{ $deviceModel->id }}')" icon="pencil"
|
||||
iconVariant="outline">
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<flux:button wire:click="deleteDeviceModel('{{ $deviceModel->id }}')" icon="trash"
|
||||
iconVariant="outline">
|
||||
</flux:button>
|
||||
@endif
|
||||
</flux:button.group>
|
||||
</div>
|
||||
</td>
|
||||
|
|
|
|||