feat: add tests, chore: update readme

This commit is contained in:
Benjamin Nussbaum 2025-03-03 22:20:52 +01:00
parent 715e6a2562
commit e6a2bdb3bc
27 changed files with 1179 additions and 299 deletions

51
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: tests
on:
push:
branches:
- develop
- main
pull_request:
branches:
- develop
- main
jobs:
ci:
runs-on: ubuntu-latest
environment: Testing
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.4
tools: composer:v2
coverage: xdebug
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install Node Dependencies
run: npm i
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
- name: Copy Environment File
run: cp .env.example .env
- name: Generate Application Key
run: php artisan key:generate
- name: Build Assets
run: npm run build
- name: Run Tests
run: ./vendor/bin/pest

191
README.md
View file

@ -1,10 +1,24 @@
## Laravel Trmnl Server
This is a PoC of a TRMNL server, written in Laravel. Inspired by https://github.com/usetrmnl/byos_sinatra
Laravel Trmnl Server 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 TRMNL API (native plugin system).
Inspired by [usetrmnl/byos_sinatra](https://github.com/usetrmnl/byos_sinatra).
![Screenshot](README_byos-screenshot.png)
[More Screenshots](screenshots/SCREENSHOTS.md)
### Key Features
* 📡 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 Markup, API, or update via Code.
* 🔄 TRMNL API Proxy Can act as a proxy for TRMNL API (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.
* 🐳 Deployment Dockerized setup for easier hosting (Dockerfile, docker-compose).
### Requirements
* PHP >= 8.2
* ext-imagick
* puppeteer [see Browsershot docs](https://spatie.be/docs/browsershot/v4/requirements)
@ -45,31 +59,190 @@ To make your server accessible in the network, you can run the following command
php artisan serve --host=0.0.0.0 --port 4567
```
### Docker
Use the provided Dockerfile, or docker-compose file to run the server in a container.
You can persist the database file by mounting a volume to `/var/www/html/database/database.sqlite`.
```Dockerfile
# docker-compose.yaml
volumes:
- ./database/database.sqlite:/var/www/html/database/database.sqlite
```
### Usage
#### Environment Variables
| environment | description | default |
|-------------------------------|------------------------------------------------------------------|-------------------|
| `TRMNL_PROXY_BASE_URL` | Base URL of the native TRMNL service | https://trmnl.app |
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
#### Login
If your environment is local, you can access the server at `http://localhost:4567` and login with user / password
admin@example.com / admin@example.com
`admin@example.com` / `admin@example.com`, otherwise register. With environment variable `REGISTRATION_ENABLED` you can control, if registration is allowed.
#### Add your TRMNL Device
#### Add Your TRMNL Device
http://localhost:4567/devices
##### Auto-Join (Local Network)
* Add new device
* You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard. Or debug the incoming request to `/api/setup` to determine.
1. Switch on the “Permit Auto-Join” toggle in the header. For that to work only one user can be registered.
2. New devices on your local network will be detected and added automatically when connecting to the server.
✅ This is the easiest way to connect your devices with minimal effort.
##### Manually
1. Open the Devices page:
👉 http://localhost:4567/devices
2. Click “Add New Device”.
3. Retrieve your TRMNL MAC Address and API Key:
- You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard
- Alternatively, debug incoming requests to /api/setup to determine them
### ⚙️ Configure Server for Device
#### 📌 Firmware Version 1.4.6 or Newer
* Setup device
* After entering Wifi credentials, choose "Custom Server"
* Point to the URL of your server
#### Firmware Older Than 1.4.6
If your device firmware is older than 1.4.6, you need to flash a new firmware version to point it to your server.
#### Flash Firmware to point Device to your server
See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://www.youtube.com/watch?v=3xehPW-PCOM)
#### Generate Screen
### 🖥️ Generate Screens
* Edit resources/views/trmnl.blade.php
#### 🎨 Blade View
* Edit `resources/views/trmnl.blade.php`
* Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components)
* To generate the screen, run
```bash
php artisan trmnl:screen:generate
```
#### Generate via API
You can dynamically update screens by sending a POST request.
* Send a POST request to `/api/screen` with the following payload
##### Header
`Authorization` `Bearer <TOKEN>`
##### Body
```json
{
"markup": "<h1>Hello World</h1>"
}
```
Token can be retrieved under Plugins > API in the Web Interface.
#### Markup via Web Interface
1. Navigate to Plugins > Markup in the Web Interface.
2. Enter your markup manually or select from the available templates.
3. Save and apply the changes.
* Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components)
#### 🛠️ Generate Screens Programmatically
You can fetch external data, process it, and generate screens dynamically.
* Fetch data from an external source.
* Either render it in a Blade view or directly insert markup.
* Use Laravels scheduler to automate updates.
#### 📌 Example: Fetch Train Monitor Data
This example retrieves data from [trmnl-train-monitor](https://github.com/bnussbau/trmnl-train-monitor) and updates the screen periodically.
##### Step 1: Create a new Artisan Command
```bash
php artisan make:command PluginTrainMonitorFetch
```
##### Step 2: Edit PluginTrainMonitorFetch.php
```php
class PluginTrainMonitorFetch extends Command
{
protected $signature = 'plugin:train:fetch';
protected $description = 'Command description';
public function handle(): void
{
$markup = Http::get('https://oebb.trmnl.yourserver.at/markup')->json('markup');
GenerateScreenJob::dispatchSync(1, $markup);
}
}
```
##### Step 3: Schedule the Command in console.php
```php
Schedule::command('plugin:train:fetch')
->everyFiveMinutes()
->timezone('Europe/Vienna')
->between('5:00', '18:00');
```
This will automatically update the screen every 5 minutes between 5:00 AM and 6:00 PM local time.
### 🤝 Contribution
Contributions are welcome! If youd like to improve the project, follow these steps:
1. Open an Issue
- Before submitting a pull request, create an issue to discuss your idea.
- Clearly describe the feature or bug fix you want to work on.
2. Fork the Repository & Create a Branch
3. Make Your Changes & Add Tests
- Ensure your code follows best practices.
- Add Pest tests to cover your changes.
4. Run Tests
- `php artisan test`
5. Submit a Pull Request (PR)
- Push your branch and create a PR.
- Provide a clear description of your changes.
🚀 Thank you for contributing! Every contribution helps improve the project.
### 🏗️ Roadmap
Here are some features and improvements that are open for contribution:
##### 🔌 Plugin System
- Enable configurable plugins via the Web Interface.
- Ensure compatibility with the trmnl-laravel package.
- Implement auto-discovery for plugins.
##### ⏳ Scheduling
- Move task scheduling from console.php to a Web Interface.
- Allow users to configure custom schedule intervals.
##### 🖥️ “Native” Plugins
- Add built-in plugins such as (as an example):
- ☁️ Weather
- 💬 Quotes
- 🏡 Home Assistant integration
- Provide Web UI controls to enable/disable plugins.
##### Improve Code Coverage
- Expand Pest tests to cover more functionality.
- Increase code coverage (currently at 86.9%).
### License
MIT

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Before After
Before After

View file

@ -19,7 +19,8 @@
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0",
"livewire/volt": "^1.6.7",
"spatie/browsershot": "^5.0"
"spatie/browsershot": "^5.0",
"spatie/pest-expectations": "^1.3"
},
"require-dev": {
"fakerphp/faker": "^1.23",

155
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "2820695c16f3a51b2464c1ed739338ba",
"content-hash": "a37113926b52744df508a5619719b4dc",
"packages": [
{
"name": "bnussbau/laravel-trmnl",
@ -87,16 +87,16 @@
},
{
"name": "brick/math",
"version": "0.12.2",
"version": "0.12.3",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "901eddb1e45a8e0f689302e40af871c181ecbe40"
"reference": "866551da34e9a618e64a819ee1e01c20d8a588ba"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/901eddb1e45a8e0f689302e40af871c181ecbe40",
"reference": "901eddb1e45a8e0f689302e40af871c181ecbe40",
"url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba",
"reference": "866551da34e9a618e64a819ee1e01c20d8a588ba",
"shasum": ""
},
"require": {
@ -135,7 +135,7 @@
],
"support": {
"issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.12.2"
"source": "https://github.com/brick/math/tree/0.12.3"
},
"funding": [
{
@ -143,7 +143,7 @@
"type": "github"
}
],
"time": "2025-02-26T10:21:45+00:00"
"time": "2025-02-28T13:11:00+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
@ -1203,16 +1203,16 @@
},
{
"name": "intervention/image",
"version": "3.11.1",
"version": "3.11.2",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "0f87254688e480fbb521e2a1ac6c11c784ca41af"
"reference": "ebbb711871fb261c064cf4c422f5f3c124fe1842"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/0f87254688e480fbb521e2a1ac6c11c784ca41af",
"reference": "0f87254688e480fbb521e2a1ac6c11c784ca41af",
"url": "https://api.github.com/repos/Intervention/image/zipball/ebbb711871fb261c064cf4c422f5f3c124fe1842",
"reference": "ebbb711871fb261c064cf4c422f5f3c124fe1842",
"shasum": ""
},
"require": {
@ -1223,7 +1223,7 @@
"require-dev": {
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.0 || ^11.0",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
"slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8"
},
@ -1259,7 +1259,7 @@
],
"support": {
"issues": "https://github.com/Intervention/image/issues",
"source": "https://github.com/Intervention/image/tree/3.11.1"
"source": "https://github.com/Intervention/image/tree/3.11.2"
},
"funding": [
{
@ -1275,7 +1275,7 @@
"type": "ko_fi"
}
],
"time": "2025-02-01T07:28:26+00:00"
"time": "2025-02-27T13:08:55+00:00"
},
{
"name": "laravel/framework",
@ -2295,16 +2295,16 @@
},
{
"name": "livewire/flux",
"version": "v2.0.3",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/livewire/flux.git",
"reference": "dec010f09419cd9d9930abc4b304802c379be57e"
"reference": "6b0d59040715f072982bfc92fe71414b44d45a0c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/flux/zipball/dec010f09419cd9d9930abc4b304802c379be57e",
"reference": "dec010f09419cd9d9930abc4b304802c379be57e",
"url": "https://api.github.com/repos/livewire/flux/zipball/6b0d59040715f072982bfc92fe71414b44d45a0c",
"reference": "6b0d59040715f072982bfc92fe71414b44d45a0c",
"shasum": ""
},
"require": {
@ -2352,9 +2352,9 @@
],
"support": {
"issues": "https://github.com/livewire/flux/issues",
"source": "https://github.com/livewire/flux/tree/v2.0.3"
"source": "https://github.com/livewire/flux/tree/v2.0.4"
},
"time": "2025-02-26T00:29:58+00:00"
"time": "2025-02-28T16:35:28+00:00"
},
{
"name": "livewire/livewire",
@ -3618,16 +3618,16 @@
},
{
"name": "ramsey/collection",
"version": "2.0.0",
"version": "2.1.0",
"source": {
"type": "git",
"url": "https://github.com/ramsey/collection.git",
"reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5"
"reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
"reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
"url": "https://api.github.com/repos/ramsey/collection/zipball/3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109",
"reference": "3c5990b8a5e0b79cd1cf11c2dc1229e58e93f109",
"shasum": ""
},
"require": {
@ -3635,25 +3635,22 @@
},
"require-dev": {
"captainhook/plugin-composer": "^5.3",
"ergebnis/composer-normalize": "^2.28.3",
"fakerphp/faker": "^1.21",
"ergebnis/composer-normalize": "^2.45",
"fakerphp/faker": "^1.24",
"hamcrest/hamcrest-php": "^2.0",
"jangregor/phpstan-prophecy": "^1.0",
"mockery/mockery": "^1.5",
"jangregor/phpstan-prophecy": "^2.1",
"mockery/mockery": "^1.6",
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpcsstandards/phpcsutils": "^1.0.0-rc1",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.9",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-phpunit": "^1.3",
"phpunit/phpunit": "^9.5",
"psalm/plugin-mockery": "^1.1",
"psalm/plugin-phpunit": "^0.18.4",
"ramsey/coding-standard": "^2.0.3",
"ramsey/conventional-commits": "^1.3",
"vimeo/psalm": "^5.4"
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpspec/prophecy-phpunit": "^2.3",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-mockery": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5",
"ramsey/coding-standard": "^2.3",
"ramsey/conventional-commits": "^1.6",
"roave/security-advisories": "dev-latest"
},
"type": "library",
"extra": {
@ -3691,19 +3688,9 @@
],
"support": {
"issues": "https://github.com/ramsey/collection/issues",
"source": "https://github.com/ramsey/collection/tree/2.0.0"
"source": "https://github.com/ramsey/collection/tree/2.1.0"
},
"funding": [
{
"url": "https://github.com/ramsey",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/ramsey/collection",
"type": "tidelift"
}
],
"time": "2022-12-31T21:50:55+00:00"
"time": "2025-03-02T04:48:29+00:00"
},
{
"name": "ramsey/uuid",
@ -3925,6 +3912,68 @@
],
"time": "2025-02-06T14:58:20+00:00"
},
{
"name": "spatie/pest-expectations",
"version": "1.3.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/pest-expectations.git",
"reference": "e7e7be733f315157da97a44988099374edeffc23"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/pest-expectations/zipball/e7e7be733f315157da97a44988099374edeffc23",
"reference": "e7e7be733f315157da97a44988099374edeffc23",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"illuminate/contracts": "^9.47|^10.0",
"laravel/pint": "^1.2",
"pestphp/pest": "^1.20|^2.0",
"spatie/ray": "^1.28"
},
"type": "library",
"autoload": {
"files": [
"src/PestExpectations.php",
"src/Helpers.php"
],
"psr-4": {
"Spatie\\PestExpectations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"role": "Developer"
}
],
"description": "A collection of handy custom Pest customisations",
"homepage": "https://github.com/spatie/pest-expectations",
"keywords": [
"pest-expectations",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/pest-expectations/issues",
"source": "https://github.com/spatie/pest-expectations/tree/1.3.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2023-04-05T17:19:05+00:00"
},
{
"name": "spatie/temporary-directory",
"version": "2.3.0",

View file

@ -38,6 +38,7 @@ return [
'trmnl' => [
'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'),
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false),
],
];

View file

@ -14,12 +14,17 @@ class DeviceFactory extends Factory
public function definition(): array
{
return [
'name' => $this->faker->firstName().' TRMNL',
'name' => $this->faker->firstName().'\'s TRMNL',
'mac_address' => $this->faker->macAddress(),
'default_refresh_interval' => '900',
'friendly_id' => Str::random(6),
'api_key' => 'tD-'.Str::random(19),
'user_id' => 1,
'last_battery_voltage' => $this->faker->randomFloat(2, 3.0, 4.2),
'last_rssi_level' => $this->faker->numberBetween(-100, 0),
'last_firmware_version' => '1.6.0',
'proxy_cloud' => $this->faker->boolean(),
'last_log_request' => ['status' => 'success', 'timestamp' => Carbon::now()->toDateTimeString()],
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];

View file

@ -29,6 +29,7 @@ class UserFactory extends Factory
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'assign_new_devices' => false,
];
}

View file

@ -2,10 +2,12 @@
namespace Database\Seeders;
use App\Models\Device;
use App\Models\User;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
class DatabaseSeeder extends Seeder
{
/**
@ -13,12 +15,14 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// User::factory(10)->create();
if (app()->isLocal()) {
User::factory()->create([
'name' => 'Test User',
'email' => 'admin@example.com',
'password' => bcrypt('admin@example.com'),
]);
User::factory()->create([
'name' => 'Test User',
'email' => 'admin@example.com',
'password' => bcrypt('admin@example.com'),
]);
// Device::factory(5)->create();
}
}
}

340
package-lock.json generated
View file

@ -465,9 +465,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz",
"integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.9.tgz",
"integrity": "sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==",
"cpu": [
"arm"
],
@ -478,9 +478,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz",
"integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.9.tgz",
"integrity": "sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==",
"cpu": [
"arm64"
],
@ -491,9 +491,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz",
"integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz",
"integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==",
"cpu": [
"arm64"
],
@ -504,9 +504,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz",
"integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz",
"integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==",
"cpu": [
"x64"
],
@ -517,9 +517,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz",
"integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.9.tgz",
"integrity": "sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==",
"cpu": [
"arm64"
],
@ -530,9 +530,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz",
"integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.9.tgz",
"integrity": "sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==",
"cpu": [
"x64"
],
@ -543,9 +543,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz",
"integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.9.tgz",
"integrity": "sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==",
"cpu": [
"arm"
],
@ -556,9 +556,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz",
"integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.9.tgz",
"integrity": "sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==",
"cpu": [
"arm"
],
@ -569,9 +569,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz",
"integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz",
"integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==",
"cpu": [
"arm64"
],
@ -582,9 +582,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz",
"integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz",
"integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==",
"cpu": [
"arm64"
],
@ -595,9 +595,9 @@
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz",
"integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.9.tgz",
"integrity": "sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==",
"cpu": [
"loong64"
],
@ -608,9 +608,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz",
"integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.9.tgz",
"integrity": "sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==",
"cpu": [
"ppc64"
],
@ -621,9 +621,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz",
"integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.9.tgz",
"integrity": "sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==",
"cpu": [
"riscv64"
],
@ -634,9 +634,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz",
"integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.9.tgz",
"integrity": "sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==",
"cpu": [
"s390x"
],
@ -660,9 +660,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.8.tgz",
"integrity": "sha512-SCXcP0ZpGFIe7Ge+McxY5zKxiEI5ra+GT3QRxL0pMMtxPfpyLAKleZODi1zdRHkz5/BhueUrYtYVgubqe9JBNQ==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz",
"integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==",
"cpu": [
"x64"
],
@ -673,9 +673,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz",
"integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz",
"integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==",
"cpu": [
"arm64"
],
@ -686,9 +686,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz",
"integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.9.tgz",
"integrity": "sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==",
"cpu": [
"ia32"
],
@ -699,9 +699,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz",
"integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz",
"integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==",
"cpu": [
"x64"
],
@ -712,42 +712,42 @@
]
},
"node_modules/@tailwindcss/node": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.8.tgz",
"integrity": "sha512-FKArQpbrbwv08TNT0k7ejYXpF+R8knZFAatNc0acOxbgeqLzwb86r+P3LGOjIeI3Idqe9CVkZrh4GlsJLJKkkw==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.0.9.tgz",
"integrity": "sha512-tOJvdI7XfJbARYhxX+0RArAhmuDcczTC46DGCEziqxzzbIaPnfYaIyRT31n4u8lROrsO7Q6u/K9bmQHL2uL1bQ==",
"license": "MIT",
"dependencies": {
"enhanced-resolve": "^5.18.1",
"jiti": "^2.4.2",
"tailwindcss": "4.0.8"
"tailwindcss": "4.0.9"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.8.tgz",
"integrity": "sha512-KfMcuAu/Iw+DcV1e8twrFyr2yN8/ZDC/odIGta4wuuJOGkrkHZbvJvRNIbQNhGh7erZTYV6Ie0IeD6WC9Y8Hcw==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.0.9.tgz",
"integrity": "sha512-eLizHmXFqHswJONwfqi/WZjtmWZpIalpvMlNhTM99/bkHtUs6IqgI1XQ0/W5eO2HiRQcIlXUogI2ycvKhVLNcA==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.0.8",
"@tailwindcss/oxide-darwin-arm64": "4.0.8",
"@tailwindcss/oxide-darwin-x64": "4.0.8",
"@tailwindcss/oxide-freebsd-x64": "4.0.8",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.8",
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.8",
"@tailwindcss/oxide-linux-arm64-musl": "4.0.8",
"@tailwindcss/oxide-linux-x64-gnu": "4.0.8",
"@tailwindcss/oxide-linux-x64-musl": "4.0.8",
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.8",
"@tailwindcss/oxide-win32-x64-msvc": "4.0.8"
"@tailwindcss/oxide-android-arm64": "4.0.9",
"@tailwindcss/oxide-darwin-arm64": "4.0.9",
"@tailwindcss/oxide-darwin-x64": "4.0.9",
"@tailwindcss/oxide-freebsd-x64": "4.0.9",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.0.9",
"@tailwindcss/oxide-linux-arm64-gnu": "4.0.9",
"@tailwindcss/oxide-linux-arm64-musl": "4.0.9",
"@tailwindcss/oxide-linux-x64-gnu": "4.0.9",
"@tailwindcss/oxide-linux-x64-musl": "4.0.9",
"@tailwindcss/oxide-win32-arm64-msvc": "4.0.9",
"@tailwindcss/oxide-win32-x64-msvc": "4.0.9"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.8.tgz",
"integrity": "sha512-We7K79+Sm4mwJHk26Yzu/GAj7C7myemm7PeXvpgMxyxO70SSFSL3uCcqFbz9JA5M5UPkrl7N9fkBe/Y0iazqpA==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.0.9.tgz",
"integrity": "sha512-YBgy6+2flE/8dbtrdotVInhMVIxnHJPbAwa7U1gX4l2ThUIaPUp18LjB9wEH8wAGMBZUb//SzLtdXXNBHPUl6Q==",
"cpu": [
"arm64"
],
@ -761,9 +761,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.8.tgz",
"integrity": "sha512-Lv9Isi2EwkCTG1sRHNDi0uRNN1UGFdEThUAGFrydRmQZnraGLMjN8gahzg2FFnOizDl7LB2TykLUuiw833DSNg==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.0.9.tgz",
"integrity": "sha512-pWdl4J2dIHXALgy2jVkwKBmtEb73kqIfMpYmcgESr7oPQ+lbcQ4+tlPeVXaSAmang+vglAfFpXQCOvs/aGSqlw==",
"cpu": [
"arm64"
],
@ -777,9 +777,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.8.tgz",
"integrity": "sha512-fWfywfYIlSWtKoqWTjukTHLWV3ARaBRjXCC2Eo0l6KVpaqGY4c2y8snUjp1xpxUtpqwMvCvFWFaleMoz1Vhzlw==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.0.9.tgz",
"integrity": "sha512-4Dq3lKp0/C7vrRSkNPtBGVebEyWt9QPPlQctxJ0H3MDyiQYvzVYf8jKow7h5QkWNe8hbatEqljMj/Y0M+ERYJg==",
"cpu": [
"x64"
],
@ -793,9 +793,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.8.tgz",
"integrity": "sha512-SO+dyvjJV9G94bnmq2288Ke0BIdvrbSbvtPLaQdqjqHR83v5L2fWADyFO+1oecHo9Owsk8MxcXh1agGVPIKIqw==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.0.9.tgz",
"integrity": "sha512-k7U1RwRODta8x0uealtVt3RoWAWqA+D5FAOsvVGpYoI6ObgmnzqWW6pnVwz70tL8UZ/QXjeMyiICXyjzB6OGtQ==",
"cpu": [
"x64"
],
@ -809,9 +809,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.8.tgz",
"integrity": "sha512-ZSHggWiEblQNV69V0qUK5vuAtHP+I+S2eGrKGJ5lPgwgJeAd6GjLsVBN+Mqn2SPVfYM3BOpS9jX/zVg9RWQVDQ==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.0.9.tgz",
"integrity": "sha512-NDDjVweHz2zo4j+oS8y3KwKL5wGCZoXGA9ruJM982uVJLdsF8/1AeKvUwKRlMBpxHt1EdWJSAh8a0Mfhl28GlQ==",
"cpu": [
"arm"
],
@ -825,9 +825,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.8.tgz",
"integrity": "sha512-xWpr6M0OZLDNsr7+bQz+3X7zcnDJZJ1N9gtBWCtfhkEtDjjxYEp+Lr5L5nc/yXlL4MyCHnn0uonGVXy3fhxaVA==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.0.9.tgz",
"integrity": "sha512-jk90UZ0jzJl3Dy1BhuFfRZ2KP9wVKMXPjmCtY4U6fF2LvrjP5gWFJj5VHzfzHonJexjrGe1lMzgtjriuZkxagg==",
"cpu": [
"arm64"
],
@ -841,9 +841,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.8.tgz",
"integrity": "sha512-5tz2IL7LN58ssGEq7h/staD7pu/izF/KeMWdlJ86WDe2Ah46LF3ET6ZGKTr5eZMrnEA0M9cVFuSPprKRHNgjeg==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.0.9.tgz",
"integrity": "sha512-3eMjyTC6HBxh9nRgOHzrc96PYh1/jWOwHZ3Kk0JN0Kl25BJ80Lj9HEvvwVDNTgPg154LdICwuFLuhfgH9DULmg==",
"cpu": [
"arm64"
],
@ -857,9 +857,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.8.tgz",
"integrity": "sha512-KSzMkhyrxAQyY2o194NKVKU9j/c+NFSoMvnHWFaNHKi3P1lb+Vq1UC19tLHrmxSkKapcMMu69D7+G1+FVGNDXQ==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.0.9.tgz",
"integrity": "sha512-v0D8WqI/c3WpWH1kq/HP0J899ATLdGZmENa2/emmNjubT0sWtEke9W9+wXeEoACuGAhF9i3PO5MeyditpDCiWQ==",
"cpu": [
"x64"
],
@ -873,9 +873,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.8.tgz",
"integrity": "sha512-yFYKG5UtHTRimjtqxUWXBgI4Tc6NJe3USjRIVdlTczpLRxq/SFwgzGl5JbatCxgSRDPBFwRrNPxq+ukfQFGdrw==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.0.9.tgz",
"integrity": "sha512-Kvp0TCkfeXyeehqLJr7otsc4hd/BUPfcIGrQiwsTVCfaMfjQZCG7DjI+9/QqPZha8YapLA9UoIcUILRYO7NE1Q==",
"cpu": [
"x64"
],
@ -889,9 +889,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.8.tgz",
"integrity": "sha512-tndGujmCSba85cRCnQzXgpA2jx5gXimyspsUYae5jlPyLRG0RjXbDshFKOheVXU4TLflo7FSG8EHCBJ0EHTKdQ==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.0.9.tgz",
"integrity": "sha512-m3+60T/7YvWekajNq/eexjhV8z10rswcz4BC9bioJ7YaN+7K8W2AmLmG0B79H14m6UHE571qB0XsPus4n0QVgQ==",
"cpu": [
"arm64"
],
@ -905,9 +905,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.8.tgz",
"integrity": "sha512-T77jroAc0p4EHVVgTUiNeFn6Nj3jtD3IeNId2X+0k+N1XxfNipy81BEkYErpKLiOkNhpNFjPee8/ZVas29b2OQ==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.0.9.tgz",
"integrity": "sha512-dpc05mSlqkwVNOUjGu/ZXd5U1XNch1kHFJ4/cHkZFvaW1RzbHmRt24gvM8/HC6IirMxNarzVw4IXVtvrOoZtxA==",
"cpu": [
"x64"
],
@ -921,15 +921,15 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.8.tgz",
"integrity": "sha512-+SAq44yLzYlzyrb7QTcFCdU8Xa7FOA0jp+Xby7fPMUie+MY9HhJysM7Vp+vL8qIp8ceQJfLD+FjgJuJ4lL6nyg==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.9.tgz",
"integrity": "sha512-BIKJO+hwdIsN7V6I7SziMZIVHWWMsV/uCQKYEbeiGRDRld+TkqyRRl9+dQ0MCXbhcVr+D9T/qX2E84kT7V281g==",
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.0.8",
"@tailwindcss/oxide": "4.0.8",
"@tailwindcss/node": "4.0.9",
"@tailwindcss/oxide": "4.0.9",
"lightningcss": "^1.29.1",
"tailwindcss": "4.0.8"
"tailwindcss": "4.0.9"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6"
@ -948,9 +948,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==",
"version": "22.13.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz",
"integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -1062,9 +1062,9 @@
}
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@ -1101,13 +1101,13 @@
}
},
"node_modules/bare-os": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.4.0.tgz",
"integrity": "sha512-9Ous7UlnKbe3fMi7Y+qh0DwAup6A1JkYgPnjvMDNOlmnxNRQvQ/7Nst+OnUQKzk0iAT0m9BisbDVp9gCv8+ETA==",
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.5.1.tgz",
"integrity": "sha512-LvfVNDcWLw2AnIw5f2mWUgumW3I3N/WYGiWeimhQC1Ybt71n2FjlS9GJKeCnFeg1MKZHxzIFmpFnBXDI+sBeFg==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"bare": ">=1.6.0"
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
@ -1215,9 +1215,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001700",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
"version": "1.0.30001702",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz",
"integrity": "sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==",
"funding": [
{
"type": "opencollective",
@ -1263,9 +1263,9 @@
}
},
"node_modules/chromium-bidi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-2.0.0.tgz",
"integrity": "sha512-8VmyVj0ewSY4pstZV0Y3rCUUwpomam8uWgHZf1XavRxJEP4vU9/dcpNuoyB+u4AQxPo96CASXz5CHPvdH+dSeQ==",
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-2.1.2.tgz",
"integrity": "sha512-vtRWBK2uImo5/W2oG6/cDkkHSm+2t6VHgnj+Rcwhb0pP74OoUb4GipyRX/T/y39gYQPhioP0DPShn+A7P6CHNw==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
@ -1452,9 +1452,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.104",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.104.tgz",
"integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==",
"version": "1.5.112",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.112.tgz",
"integrity": "sha512-oen93kVyqSb3l+ziUgzIOlWt/oOuy4zRmpwestMn4rhFWAoFJeFuCVte9F2fASjeZZo7l/Cif9TiyrdW4CwEMA==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@ -2531,17 +2531,17 @@
}
},
"node_modules/puppeteer": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.3.0.tgz",
"integrity": "sha512-wYEx+NnEM1T6ncHB+IsTovUgx+JlZ0pv0sRGTb8IzoTeOILvyUcdU2h34bYEQ1iG5maz1VQA5eI4kzIyAVh90A==",
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.3.1.tgz",
"integrity": "sha512-k0OJ7itRwkr06owp0CP3f/PsRD7Pdw4DjoCUZvjGr+aNgS1z6n/61VajIp0uBjl+V5XAQO1v/3k9bzeZLWs9OQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.7.1",
"chromium-bidi": "2.0.0",
"chromium-bidi": "2.1.2",
"cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1402036",
"puppeteer-core": "24.3.0",
"puppeteer-core": "24.3.1",
"typed-query-selector": "^2.12.0"
},
"bin": {
@ -2552,13 +2552,13 @@
}
},
"node_modules/puppeteer-core": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.3.0.tgz",
"integrity": "sha512-x8kQRP/xxtiFav6wWuLzrctO0HWRpSQy+JjaHbqIl+d5U2lmRh2pY9vh5AzDFN0EtOXW2pzngi9RrryY1vZGig==",
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.3.1.tgz",
"integrity": "sha512-585ccfcTav4KmlSmYbwwOSeC8VdutQHn2Fuk0id/y/9OoeO7Gg5PK1aUGdZjEmos0TAq+pCpChqFurFbpNd3wA==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.7.1",
"chromium-bidi": "2.0.0",
"chromium-bidi": "2.1.2",
"debug": "^4.4.0",
"devtools-protocol": "0.0.1402036",
"typed-query-selector": "^2.12.0",
@ -2587,9 +2587,9 @@
}
},
"node_modules/rollup": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.8.tgz",
"integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.9.tgz",
"integrity": "sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.6"
@ -2602,32 +2602,32 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.34.8",
"@rollup/rollup-android-arm64": "4.34.8",
"@rollup/rollup-darwin-arm64": "4.34.8",
"@rollup/rollup-darwin-x64": "4.34.8",
"@rollup/rollup-freebsd-arm64": "4.34.8",
"@rollup/rollup-freebsd-x64": "4.34.8",
"@rollup/rollup-linux-arm-gnueabihf": "4.34.8",
"@rollup/rollup-linux-arm-musleabihf": "4.34.8",
"@rollup/rollup-linux-arm64-gnu": "4.34.8",
"@rollup/rollup-linux-arm64-musl": "4.34.8",
"@rollup/rollup-linux-loongarch64-gnu": "4.34.8",
"@rollup/rollup-linux-powerpc64le-gnu": "4.34.8",
"@rollup/rollup-linux-riscv64-gnu": "4.34.8",
"@rollup/rollup-linux-s390x-gnu": "4.34.8",
"@rollup/rollup-linux-x64-gnu": "4.34.8",
"@rollup/rollup-linux-x64-musl": "4.34.8",
"@rollup/rollup-win32-arm64-msvc": "4.34.8",
"@rollup/rollup-win32-ia32-msvc": "4.34.8",
"@rollup/rollup-win32-x64-msvc": "4.34.8",
"@rollup/rollup-android-arm-eabi": "4.34.9",
"@rollup/rollup-android-arm64": "4.34.9",
"@rollup/rollup-darwin-arm64": "4.34.9",
"@rollup/rollup-darwin-x64": "4.34.9",
"@rollup/rollup-freebsd-arm64": "4.34.9",
"@rollup/rollup-freebsd-x64": "4.34.9",
"@rollup/rollup-linux-arm-gnueabihf": "4.34.9",
"@rollup/rollup-linux-arm-musleabihf": "4.34.9",
"@rollup/rollup-linux-arm64-gnu": "4.34.9",
"@rollup/rollup-linux-arm64-musl": "4.34.9",
"@rollup/rollup-linux-loongarch64-gnu": "4.34.9",
"@rollup/rollup-linux-powerpc64le-gnu": "4.34.9",
"@rollup/rollup-linux-riscv64-gnu": "4.34.9",
"@rollup/rollup-linux-s390x-gnu": "4.34.9",
"@rollup/rollup-linux-x64-gnu": "4.34.9",
"@rollup/rollup-linux-x64-musl": "4.34.9",
"@rollup/rollup-win32-arm64-msvc": "4.34.9",
"@rollup/rollup-win32-ia32-msvc": "4.34.9",
"@rollup/rollup-win32-x64-msvc": "4.34.9",
"fsevents": "~2.3.2"
}
},
"node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.34.8",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz",
"integrity": "sha512-8y7ED8gjxITUltTUEJLQdgpbPh1sUQ0kMTmufRF/Ns5tI9TNMNlhWtmPKKHCU0SilX+3MJkZ0zERYYGIVBYHIA==",
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz",
"integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==",
"cpu": [
"x64"
],
@ -2788,9 +2788,9 @@
}
},
"node_modules/tailwindcss": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.8.tgz",
"integrity": "sha512-Me7N5CKR+D2A1xdWA5t5+kjjT7bwnxZOE6/yDI/ixJdJokszsn2n++mdU5yJwrsTpqFX2B9ZNMBJDwcqk9C9lw==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.0.9.tgz",
"integrity": "sha512-12laZu+fv1ONDRoNR9ipTOpUD7RN9essRVkX36sjxuRUInpN7hIiHN4lBd/SIFjbISvnXzp8h/hXzmU8SQQYhw==",
"license": "MIT"
},
"node_modules/tapable": {
@ -2865,9 +2865,9 @@
"optional": true
},
"node_modules/update-browserslist-db": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"funding": [
{
"type": "opencollective",

View file

@ -1,16 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" {{ $attributes }}>
<g clip-path="url(#clip0_870_1047)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.02194 2.11328L15.8863 5.07445L14.4259 8.98481L6.56152 6.02364L8.02194 2.11328Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.6315 1.14647L23.2291 9.16642L19.2738 10.458L16.6761 2.43807L20.6315 1.14647Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.2441 10.4452L24.619 17.4848L21.1471 15.185L25.7723 8.14549L29.2441 10.4452Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.3739 23.0034L19.0088 23.7616L18.6349 19.6023L27 18.8441L27.3739 23.0034Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4296 29.3638L10.6236 23.2697L13.6292 20.3828L19.4351 26.4769L16.4296 29.3638Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.65232 24.7423L5.77753 16.3849L9.89932 16.9443L8.77411 25.3017L4.65232 24.7423Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.910558 12.6074L8.11961 8.28L10.2539 11.8645L3.04481 16.192L0.910558 12.6074Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_870_1047">
<rect width="30" height="30" fill="currentColor"/>
</clipPath>
</defs>
</svg>
@if(config('services.trmnl.override_orig_icon'))
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30" {{ $attributes }}>
<g clip-path="url(#clip0_870_1047)">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8.02194 2.11328L15.8863 5.07445L14.4259 8.98481L6.56152 6.02364L8.02194 2.11328Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M20.6315 1.14647L23.2291 9.16642L19.2738 10.458L16.6761 2.43807L20.6315 1.14647Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M29.2441 10.4452L24.619 17.4848L21.1471 15.185L25.7723 8.14549L29.2441 10.4452Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M27.3739 23.0034L19.0088 23.7616L18.6349 19.6023L27 18.8441L27.3739 23.0034Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M16.4296 29.3638L10.6236 23.2697L13.6292 20.3828L19.4351 26.4769L16.4296 29.3638Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M4.65232 24.7423L5.77753 16.3849L9.89932 16.9443L8.77411 25.3017L4.65232 24.7423Z"
fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0.910558 12.6074L8.11961 8.28L10.2539 11.8645L3.04481 16.192L0.910558 12.6074Z"
fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_870_1047">
<rect width="30" height="30" fill="currentColor"/>
</clipPath>
</defs>
</svg>
@else
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trello"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><rect width="3" height="9" x="7" y="7"/><rect width="3" height="5" x="14" y="7"/></svg>
@endif

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

View file

@ -11,42 +11,44 @@ new class extends Component {
?>
<div>
<div class="flex w-full max-w-3xl flex-col gap-6">
@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">
<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"
class="w-full mt-4">Add Device
</flux:button>
<div class="bg-muted flex flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-3xl flex-col gap-6">
@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">
<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"
class="w-full mt-4">Add Device
</flux:button>
</div>
</div>
</div>
</div>
@endif
@endif
@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">
<div class="px-10 py-8">
@php
$current_image_uuid =$device->current_screen_image;
file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp';
$current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension;
@endphp
@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">
<div class="px-10 py-8">
@php
$current_image_uuid =$device->current_screen_image;
file_exists('storage/images/generated/' . $current_image_uuid . '.png') ? $file_extension = 'png' : $file_extension = 'bmp';
$current_image_path = 'storage/images/generated/' . $current_image_uuid . '.' . $file_extension;
@endphp
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $device->name }}</h1>
<p class="text-sm dark:text-zinc-400">{{$device->mac_address}}</p>
@if($current_image_uuid)
<flux:separator class="mt-2 mb-4"/>
<img src="{{ asset($current_image_path) }}" alt="Current Image"/>
@endif
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $device->name }}</h1>
<p class="text-sm dark:text-zinc-400">{{$device->mac_address}}</p>
@if($current_image_uuid)
<flux:separator class="mt-2 mb-4"/>
<img src="{{ asset($current_image_path) }}" alt="Current Image"/>
@endif
</div>
</div>
</div>
</div>
@endforeach
@endforeach
</div>
</div>
{{-- @php--}}

View file

@ -2,8 +2,4 @@
use App\Jobs\FetchProxyCloudResponses;
// Artisan::command('inspire', function () {
// $this->comment(Inspiring::quote());
// })->purpose('Display an inspiring quote')->hourly();
Schedule::job(new FetchProxyCloudResponses)->everyFifteenMinutes();
Schedule::job(FetchProxyCloudResponses::class, [])->cron(sprintf('*/%s * * * *', intval(config('services.trmnl.proxy_refresh_minutes', 15))));

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

View file

@ -0,0 +1,12 @@
## Sceenshots
![Screenshot](README_byos-screenshot2.png)
![Screenshot](README_byos-screenshot3.png)
![Screenshot](README_byos-screenshot4.png)
![Screenshot](README_byos-screenshot5.png)
![Screenshot](README_byos-screenshot6.png)

View file

@ -0,0 +1,158 @@
<?php
use App\Models\Device;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Storage::fake('public');
});
test('device can fetch display data with valid credentials', function () {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'status' => '0',
'filename' => 'test-image.bmp',
'refresh_rate' => 900,
'reset_firmware' => false,
'update_firmware' => false,
'firmware_url' => null,
'special_function' => 'sleep',
]);
expect($device->fresh())
->last_rssi_level->toBe(-70)
->last_battery_voltage->toBe(3.8)
->last_firmware_version->toBe('1.0.0');
});
test('new device is auto-assigned to user with auto-assign enabled', function () {
$user = User::factory()->create(['assign_new_devices' => true]);
$response = $this->withHeaders([
'id' => '00:11:22:33:44:55',
'access-token' => 'new-device-key',
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
$device = Device::where('mac_address', '00:11:22:33:44:55')->first();
expect($device)
->not->toBeNull()
->user_id->toBe($user->id)
->api_key->toBe('new-device-key');
});
test('device setup endpoint returns correct data', function () {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
])->get('/api/setup');
$response->assertOk()
->assertJson([
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
'message' => 'Welcome to TRMNL BYOS',
]);
});
test('device can submit logs', function () {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
]);
$logData = [
'log' => [
'logs_array' => [
['message' => 'Test log message', 'level' => 'info'],
],
],
];
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
])->postJson('/api/log', $logData);
$response->assertOk()
->assertJson(['status' => '0']);
expect($device->fresh()->last_log_request)
->toBe($logData);
});
// test('authenticated user can update device display', function () {
// $user = User::factory()->create();
// $device = Device::factory()->create(['user_id' => $user->id]);
//
// Sanctum::actingAs($user, ['update-screen']);
//
// $response = $this->postJson('/api/display/update', [
// 'device_id' => $device->id,
// 'markup' => '<div>Test markup</div>'
// ]);
//
// $response->assertOk();
// });
test('user cannot update display for devices they do not own', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$device = Device::factory()->create(['user_id' => $otherUser->id]);
Sanctum::actingAs($user, ['update-screen']);
$response = $this->postJson('/api/display/update', [
'device_id' => $device->id,
'markup' => '<div>Test markup</div>',
]);
$response->assertForbidden();
});
test('invalid device credentials return error', function () {
$response = $this->withHeaders([
'id' => 'invalid-mac',
'access-token' => 'invalid-token',
])->get('/api/display');
$response->assertNotFound()
->assertJson(['message' => 'MAC Address not registered or invalid access token']);
});
test('log endpoint requires valid device credentials', function () {
$response = $this->withHeaders([
'id' => 'invalid-mac',
'access-token' => 'invalid-token',
])->postJson('/api/log', ['log' => []]);
$response->assertNotFound()
->assertJson(['message' => 'Device not found or invalid access token']);
});

View file

@ -0,0 +1,15 @@
<?php
use App\Jobs\FetchProxyCloudResponses;
use Illuminate\Support\Facades\Bus;
test('it dispatches fetch proxy cloud responses job', function () {
// Prevent the job from actually running
Bus::fake();
// Run the command
$this->artisan('trmnl:cloud:proxy')->assertSuccessful();
// Assert that the job was dispatched
Bus::assertDispatched(FetchProxyCloudResponses::class);
});

View file

@ -0,0 +1,13 @@
<?php
use App\Jobs\GenerateScreenJob;
use Illuminate\Support\Facades\Bus;
test('it generates screen with default parameters', function () {
Bus::fake();
$this->artisan('trmnl:screen:generate')
->assertSuccessful();
Bus::assertDispatched(GenerateScreenJob::class);
});

View file

@ -0,0 +1,76 @@
<?php
use App\Models\Device;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('device can be created with basic attributes', function () {
$device = Device::factory()->create([
'name' => 'Test Device',
]);
expect($device)->toBeInstanceOf(Device::class)
->and($device->name)->toBe('Test Device');
});
test('battery percentage is calculated correctly', function () {
$cases = [
['voltage' => 3.0, 'expected' => 0], // Min voltage
['voltage' => 4.2, 'expected' => 100], // Max voltage
['voltage' => 2.9, 'expected' => 0], // Below min
['voltage' => 4.3, 'expected' => 100], // Above max
['voltage' => 3.6, 'expected' => 50.0], // Middle voltage
['voltage' => 3.3, 'expected' => 25.0], // Quarter voltage
];
foreach ($cases as $case) {
$device = Device::factory()->create([
'last_battery_voltage' => $case['voltage'],
]);
expect($device->battery_percent)->toBe($case['expected'])
->and($device->last_battery_voltage)->toBe($case['voltage']);
}
});
test('wifi strength is determined correctly', function () {
$cases = [
['rssi' => 0, 'expected' => 0], // No signal
['rssi' => -90, 'expected' => 1], // Weak signal
['rssi' => -70, 'expected' => 2], // Moderate signal
['rssi' => -50, 'expected' => 3], // Strong signal
];
foreach ($cases as $case) {
$device = Device::factory()->create([
'last_rssi_level' => $case['rssi'],
]);
expect($device->wifi_strengh)->toBe($case['expected'])
->and($device->last_rssi_level)->toBe($case['rssi']);
}
});
test('proxy cloud attribute is properly cast to boolean', function () {
$device = Device::factory()->create([
'proxy_cloud' => true,
]);
expect($device->proxy_cloud)->toBeTrue();
$device->update(['proxy_cloud' => false]);
expect($device->proxy_cloud)->toBeFalse();
});
test('last log request is properly cast to json', function () {
$logData = ['status' => 'success', 'timestamp' => '2024-03-04 12:00:00'];
$device = Device::factory()->create([
'last_log_request' => $logData,
]);
expect($device->last_log_request)
->toBeArray()
->toHaveKey('status')
->toHaveKey('timestamp');
});

View file

@ -0,0 +1,106 @@
<?php
use App\Models\Device;
use App\Models\User;
use Livewire\Volt\Volt;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
test('device management page can be rendered', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->get('/devices');
$response->assertOk();
});
test('user can create a new device', function () {
$user = User::factory()->create();
$this->actingAs($user);
$deviceData = [
'name' => 'Test Device',
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'default_refresh_interval' => 900,
'friendly_id' => 'test-device-1',
];
$response = Volt::test('devices.manage')
->set('name', $deviceData['name'])
->set('mac_address', $deviceData['mac_address'])
->set('api_key', $deviceData['api_key'])
->set('default_refresh_interval', $deviceData['default_refresh_interval'])
->set('friendly_id', $deviceData['friendly_id'])
->call('createDevice');
$response->assertHasNoErrors();
expect(Device::count())->toBe(1);
$device = Device::first();
expect($device->name)->toBe($deviceData['name']);
expect($device->mac_address)->toBe($deviceData['mac_address']);
expect($device->api_key)->toBe($deviceData['api_key']);
expect($device->default_refresh_interval)->toBe($deviceData['default_refresh_interval']);
expect($device->friendly_id)->toBe($deviceData['friendly_id']);
expect($device->user_id)->toBe($user->id);
});
test('device creation requires required fields', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = Volt::test('devices.manage')
->set('name', '')
->set('mac_address', '')
->set('api_key', '')
->set('default_refresh_interval', '')
->set('friendly_id', '')
->call('createDevice');
$response->assertHasErrors([
'mac_address',
'api_key',
'default_refresh_interval',
]);
});
test('user can toggle proxy cloud for their device', function () {
$user = User::factory()->create();
$this->actingAs($user);
$device = Device::factory()->create([
'user_id' => $user->id,
'proxy_cloud' => false,
]);
$response = Volt::test('devices.manage')
->call('toggleProxyCloud', $device);
$response->assertHasNoErrors();
expect($device->fresh()->proxy_cloud)->toBeTrue();
// Toggle back to false
$response = Volt::test('devices.manage')
->call('toggleProxyCloud', $device);
expect($device->fresh()->proxy_cloud)->toBeFalse();
});
test('user cannot toggle proxy cloud for other users devices', function () {
$user = User::factory()->create();
$this->actingAs($user);
$otherUser = User::factory()->create();
$device = Device::factory()->create([
'user_id' => $otherUser->id,
'proxy_cloud' => false,
]);
$response = Volt::test('devices.manage')
->call('toggleProxyCloud', $device);
$response->assertStatus(403);
expect($device->fresh()->proxy_cloud)->toBeFalse();
});

View file

@ -0,0 +1,140 @@
<?php
use App\Jobs\FetchProxyCloudResponses;
use App\Models\Device;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/generated');
});
test('it fetches and processes proxy cloud responses for devices', function () {
config(['services.trmnl.proxy_base_url' => 'https://example.com']);
// Create a test device with proxy cloud enabled
$device = Device::factory()->create([
'proxy_cloud' => true,
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'last_rssi_level' => -70,
'last_battery_voltage' => 3.7,
'default_refresh_interval' => 300,
'last_firmware_version' => '1.0.0',
]);
// Mock the API response
Http::fake([
config('services.trmnl.proxy_base_url').'/api/display' => Http::response([
'image_url' => 'https://example.com/test-image.bmp',
'filename' => 'test-image',
]),
'https://example.com/test-image.bmp' => Http::response('fake-image-content'),
]);
Http::withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'width' => 800,
'height' => 480,
'rssi' => $device->last_rssi_level,
'battery_voltage' => $device->last_battery_voltage,
'refresh-rate' => $device->default_refresh_interval,
'fw-version' => $device->last_firmware_version,
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
'user-agent' => 'ESP32HTTPClient',
])->get(config('services.trmnl.proxy_base_url').'/api/display');
// Run the job
$job = new FetchProxyCloudResponses;
$job->handle();
// Assert HTTP requests were made with correct headers
Http::assertSent(function ($request) use ($device) {
return $request->hasHeader('id', $device->mac_address) &&
$request->hasHeader('access-token', $device->api_key) &&
$request->hasHeader('width', 800) &&
$request->hasHeader('height', 480) &&
$request->hasHeader('rssi', $device->last_rssi_level) &&
$request->hasHeader('battery_voltage', $device->last_battery_voltage) &&
$request->hasHeader('refresh-rate', $device->default_refresh_interval) &&
$request->hasHeader('fw-version', $device->last_firmware_version);
});
// Assert the device was updated
$device->refresh();
expect($device->current_screen_image)->toBe('test-image')
->and($device->proxy_cloud_response)->toBe('{"image_url":"https:\\/\\/example.com\\/test-image.bmp","filename":"test-image"}');
// Assert the image was saved
Storage::disk('public')->assertExists('images/generated/test-image.bmp');
});
test('it handles log requests when present', function () {
$device = Device::factory()->create([
'proxy_cloud' => true,
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'last_log_request' => ['message' => 'test log'],
]);
Http::fake([
config('services.trmnl.proxy_base_url').'/api/display' => Http::response([
'image_url' => 'https://example.com/test-image.bmp',
'filename' => 'test-image',
]),
'https://example.com/test-image.bmp' => Http::response('fake-image-content'),
config('services.trmnl.proxy_base_url').'/api/log' => Http::response(null, 200),
]);
$job = new FetchProxyCloudResponses;
$job->handle();
// Assert log request was sent
Http::assertSent(function ($request) use ($device) {
return $request->url() === config('services.trmnl.proxy_base_url').'/api/log' &&
$request->hasHeader('id', $device->mac_address) &&
$request->body() === json_encode(['message' => 'test log']);
});
// Assert log request was cleared
$device->refresh();
expect($device->last_log_request)->toBeNull();
});
test('it handles API errors gracefully', function () {
$device = Device::factory()->create([
'proxy_cloud' => true,
'mac_address' => '00:11:22:33:44:55',
]);
Http::fake([
config('services.trmnl.proxy_base_url').'/api/display' => Http::response(null, 500),
]);
$job = new FetchProxyCloudResponses;
// Job should not throw exception but log error
expect(fn () => $job->handle())->not->toThrow(Exception::class);
});
test('it only processes proxy cloud enabled devices', function () {
Http::fake();
$enabledDevice = Device::factory()->create(['proxy_cloud' => true]);
$disabledDevice = Device::factory()->create(['proxy_cloud' => false]);
$job = new FetchProxyCloudResponses;
$job->handle();
// Assert request was only made for enabled device
Http::assertSent(function ($request) use ($enabledDevice) {
return $request->hasHeader('id', $enabledDevice->mac_address);
});
Http::assertNotSent(function ($request) use ($disabledDevice) {
return $request->hasHeader('id', $disabledDevice->mac_address);
});
});

View file

@ -0,0 +1,59 @@
<?php
use App\Jobs\GenerateScreenJob;
use App\Models\Device;
use Illuminate\Support\Facades\Storage;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/generated');
});
test('it generates screen images and updates device', function () {
$device = Device::factory()->create();
$job = new GenerateScreenJob($device->id, view('trmnl')->render());
$job->handle();
// Assert the device was updated with a new image UUID
$device->refresh();
expect($device->current_screen_image)->not->toBeNull();
// Assert both PNG and BMP files were created
$uuid = $device->current_screen_image;
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp");
})->skipOnGitHubActions();
test('it cleans up unused images', function () {
// Create some test devices with images
$activeDevice = Device::factory()->create([
'current_screen_image' => 'uuid-to-be-replaced',
]);
// Create some test files
Storage::disk('public')->put('/images/generated/uuid-to-be-replaced.png', 'test');
Storage::disk('public')->put('/images/generated/uuid-to-be-replaced.bmp', 'test');
Storage::disk('public')->put('/images/generated/inactive-uuid.png', 'test');
Storage::disk('public')->put('/images/generated/inactive-uuid.bmp', 'test');
// Run a job which will trigger cleanup
$job = new GenerateScreenJob($activeDevice->id, '<div>Test</div>');
$job->handle();
Storage::disk('public')->assertMissing('/images/generated/uuid-to-be-replaced.png');
Storage::disk('public')->assertMissing('/images/generated/uuid-to-be-replaced.bmp');
Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.png');
Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.bmp');
})->skipOnGitHubActions();
test('it preserves gitignore file during cleanup', function () {
Storage::disk('public')->put('/images/generated/.gitignore', '*');
$device = Device::factory()->create();
$job = new GenerateScreenJob($device->id, '<div>Test</div>');
$job->handle();
Storage::disk('public')->assertExists('/images/generated/.gitignore');
})->skipOnGitHubActions();

View file

@ -15,6 +15,7 @@ pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
registerSpatiePestHelpers();
/*
|--------------------------------------------------------------------------
| Expectations
@ -26,9 +27,9 @@ pest()->extend(Tests\TestCase::class)
|
*/
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
// expect()->extend('toBeOne', function () {
// return $this->toBe(1);
// });
/*
|--------------------------------------------------------------------------
@ -41,7 +42,7 @@ expect()->extend('toBeOne', function () {
|
*/
function something()
{
// ..
}
// function something()
// {
// // ..
// }